mirror of
https://github.com/SigNoz/signoz.git
synced 2026-10-26 19:11:00 +00:00
Compare commits
22 Commits
test/uplot
...
test/dashb
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d8b9c136ca | ||
|
|
ffe2600423 | ||
|
|
9a2eb4f242 | ||
|
|
4f89b577d5 | ||
|
|
f12319f35e | ||
|
|
2141d349eb | ||
|
|
706a95c793 | ||
|
|
c39b3ff578 | ||
|
|
4aebec8cab | ||
|
|
2fa39a85bc | ||
|
|
3f0eea7a3d | ||
|
|
aaf35323c3 | ||
|
|
9d35c60906 | ||
|
|
ed6289f7ba | ||
|
|
e761e07f74 | ||
|
|
8b0bbf54f7 | ||
|
|
a5c1a3f1eb | ||
|
|
c3ec2f8320 | ||
|
|
33473551e6 | ||
|
|
04de506a9d | ||
|
|
d052063398 | ||
|
|
59f227d368 |
@@ -88,7 +88,10 @@ tests/
|
||||
│ └── setup.py # test_setup / test_teardown — pytest lifecycle
|
||||
├── drivers/ # the driver kit and one driver per feature
|
||||
│ ├── kit.ts
|
||||
│ └── routing.ts
|
||||
│ ├── routing.ts
|
||||
│ ├── dashboards.ts # dashboard seeding + query_range mocks; panels → panel-editor → panel-config extend it
|
||||
│ ├── panels.ts
|
||||
│ └── panel-kinds.ts # one row per panel kind; drives tests/dashboards/contract
|
||||
├── fixtures/ # Playwright test fixtures (test.extend) only
|
||||
│ └── auth.ts
|
||||
├── helpers/ # function helpers + the constants they share with tests
|
||||
@@ -128,7 +131,8 @@ test('TC-09 RouteTab switches the url', async ({ routing }) => {
|
||||
});
|
||||
```
|
||||
|
||||
- `driver(name, build)` adds a test-scoped fixture built on `authedPage`.
|
||||
- `driver(name, build, dispose?)` adds a test-scoped fixture built on `authedPage`. `dispose` runs after the test on the same page, so a driver can delete what it seeded.
|
||||
- Drivers layer: `drivers/panels.ts` exports `withPanels(dashboardsTest)` and re-exports `drivers/dashboards.ts`, so a spec still imports one module.
|
||||
- `seeded(name, open, close)` adds a worker-scoped seed. `open` and `close` run on a throwaway admin page.
|
||||
- A driver reuses helpers by calling them, never by reaching for another fixture.
|
||||
- `drivers/` is AI-owned: coding agents generate and maintain it with the specs, and `.gitattributes` collapses it in diffs.
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -87,7 +87,7 @@ function AddColumnDropdown({
|
||||
value={field.name}
|
||||
isSelected={selectedNames.has(field.name)}
|
||||
onSelect={(): void => onToggle(field)}
|
||||
data-testid="list-columns-suggestion"
|
||||
data-testid={`list-columns-suggestion-${field.name}`}
|
||||
>
|
||||
{field.name}
|
||||
</ComboboxItem>
|
||||
|
||||
@@ -37,7 +37,12 @@ function SortableColumnChip({
|
||||
};
|
||||
|
||||
return (
|
||||
<div ref={setNodeRef} style={style} className={styles.chip}>
|
||||
<div
|
||||
ref={setNodeRef}
|
||||
style={style}
|
||||
className={styles.chip}
|
||||
data-testid={`list-column-chip-${name}`}
|
||||
>
|
||||
<span {...listeners} className={styles.grip}>
|
||||
<Button
|
||||
type="button"
|
||||
@@ -61,7 +66,7 @@ function SortableColumnChip({
|
||||
size="sm"
|
||||
icon
|
||||
aria-label={`Remove ${name}`}
|
||||
testId="list-column-remove"
|
||||
testId={`list-column-remove-${name}`}
|
||||
onClick={(): void => onRemove(name)}
|
||||
>
|
||||
<X size={12} />
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -99,7 +99,7 @@ export const ListPanelAddColumnOpen: Story = {
|
||||
timeout: 10_000,
|
||||
}),
|
||||
);
|
||||
await screen.findAllByTestId('list-columns-suggestion');
|
||||
await screen.findAllByTestId(/^list-columns-suggestion-/);
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
141
tests/e2e/drivers/dashboard-list.ts
generated
Normal file
141
tests/e2e/drivers/dashboard-list.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 { BASE_PATH } from '../helpers/base-path';
|
||||
|
||||
import {
|
||||
DASHBOARDS_API,
|
||||
removeTrackedDashboards,
|
||||
trackDashboard,
|
||||
} from './dashboards';
|
||||
import { driver } from './kit';
|
||||
import { test as editorTest } from './panel-editor';
|
||||
|
||||
// The dashboards list page (`/dashboard`): the New dashboard modal's Blank tab
|
||||
// and the per-row actions menu.
|
||||
|
||||
const LIST_PATH = '/dashboard';
|
||||
|
||||
/** A dashboard route, not the list or an editor route. */
|
||||
const DASHBOARD_ROUTE = /\/dashboard\/([^/?#]+)(?:[?#]|$)/;
|
||||
|
||||
export interface NewDashboardFields {
|
||||
name: string;
|
||||
description?: string;
|
||||
/** `key:value`, each committed with Enter. */
|
||||
tags?: string[];
|
||||
}
|
||||
|
||||
export interface DashboardList {
|
||||
/** Narrowed by the `query` param to one name, since the list is paged. */
|
||||
open(name?: string): Promise<void>;
|
||||
newDashboardButton(): Locator;
|
||||
nameInput(): Locator;
|
||||
descriptionInput(): Locator;
|
||||
tagsInput(): Locator;
|
||||
tagChips(): Locator;
|
||||
submit(): Locator;
|
||||
/**
|
||||
* Fills and submits the Blank tab, then returns the id from the route it
|
||||
* lands on. The dashboard is deleted when the test ends.
|
||||
*/
|
||||
create(fields: NewDashboardFields): Promise<string>;
|
||||
/** Rows render titles in `dashboard-title-<index>`, not by id. */
|
||||
rowTitle(name: string): Locator;
|
||||
/** Opens the row's actions menu and confirms Delete. */
|
||||
delete(name: string): Promise<void>;
|
||||
}
|
||||
|
||||
function dashboardListDriver(page: Page): DashboardList {
|
||||
const rowTitle = (name: string): Locator =>
|
||||
page
|
||||
.locator('[data-testid^="dashboard-title-"]')
|
||||
.filter({ hasText: new RegExp(`^${escapeRegExp(name)}$`) });
|
||||
|
||||
const list: DashboardList = {
|
||||
async open(name) {
|
||||
const search = name
|
||||
? `?${new URLSearchParams({ query: `name CONTAINS '${name}'` }).toString()}`
|
||||
: '';
|
||||
await page.goto(`${LIST_PATH}${search}`);
|
||||
await expect(list.newDashboardButton()).toBeVisible();
|
||||
},
|
||||
newDashboardButton: () => page.getByTestId('new-dashboard-cta'),
|
||||
nameInput: () => page.getByTestId('create-dashboard-name'),
|
||||
descriptionInput: () => page.getByTestId('create-dashboard-description'),
|
||||
tagsInput: () => page.getByTestId('create-dashboard-tags'),
|
||||
tagChips: () => page.getByTestId('create-dashboard-tags-chip'),
|
||||
submit: () => page.getByTestId('create-dashboard-submit'),
|
||||
async create({ name, description, tags = [] }) {
|
||||
await list.newDashboardButton().click();
|
||||
await list.nameInput().fill(name);
|
||||
if (description !== undefined) {
|
||||
await list.descriptionInput().fill(description);
|
||||
}
|
||||
for (const tag of tags) {
|
||||
await list.tagsInput().fill(tag);
|
||||
await list.tagsInput().press('Enter');
|
||||
}
|
||||
await expect(list.tagChips()).toHaveCount(tags.length);
|
||||
|
||||
const [response] = await Promise.all([
|
||||
page.waitForResponse(
|
||||
(r) =>
|
||||
r.request().method() === 'POST' &&
|
||||
// The app is served under the base path, and so is its API.
|
||||
new URL(r.url()).pathname === `${BASE_PATH}${DASHBOARDS_API}`,
|
||||
),
|
||||
list.submit().click(),
|
||||
]);
|
||||
// Tracked from the response, before the route settles, so a failed
|
||||
// navigation still cleans up.
|
||||
expect(response.ok(), `POST ${DASHBOARDS_API} failed`).toBe(true);
|
||||
const created = ((await response.json()) as { data: { id: string } }).data
|
||||
.id;
|
||||
trackDashboard(page, created);
|
||||
|
||||
await page.waitForURL(DASHBOARD_ROUTE);
|
||||
const id = DASHBOARD_ROUTE.exec(new URL(page.url()).pathname)?.[1];
|
||||
expect(id).toBe(created);
|
||||
return created;
|
||||
},
|
||||
rowTitle,
|
||||
async delete(name) {
|
||||
const row = rowTitle(name).locator(
|
||||
'xpath=ancestor::div[.//*[@data-testid="dashboard-action-icon"]][1]',
|
||||
);
|
||||
await row.getByTestId('dashboard-action-icon').click();
|
||||
await page.getByTestId('dashboard-action-delete').click();
|
||||
// The confirm is an antd `modal.confirm` with no testid.
|
||||
const confirm = page.locator('.ant-modal-confirm');
|
||||
await expect(confirm).toContainText(name);
|
||||
await Promise.all([
|
||||
page.waitForResponse(
|
||||
(r) =>
|
||||
r.request().method() === 'DELETE' &&
|
||||
r.url().includes(`${DASHBOARDS_API}/`),
|
||||
),
|
||||
confirm.getByRole('button', { name: 'Delete', exact: true }).click(),
|
||||
]);
|
||||
},
|
||||
};
|
||||
|
||||
return list;
|
||||
}
|
||||
|
||||
function escapeRegExp(text: string): string {
|
||||
return text.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
||||
}
|
||||
|
||||
const withDashboardList = driver(
|
||||
'dashboardList',
|
||||
({ page }) => dashboardListDriver(page),
|
||||
(_, { page }) => removeTrackedDashboards(page),
|
||||
);
|
||||
|
||||
export const test = withDashboardList(editorTest);
|
||||
|
||||
export * from './panel-editor';
|
||||
312
tests/e2e/drivers/dashboard-page.ts
generated
Normal file
312
tests/e2e/drivers/dashboard-page.ts
generated
Normal file
@@ -0,0 +1,312 @@
|
||||
/**
|
||||
* AI-owned. Generated and maintained by coding agents alongside the specs that
|
||||
* use it. Do not hand-edit: regenerate it with the spec that needs the change.
|
||||
*/
|
||||
import { expect, type Locator, type Page } from '@playwright/test';
|
||||
|
||||
import { driver } from './kit';
|
||||
import { test as panelsTest } from './panels';
|
||||
|
||||
// The dashboard page chrome around the grid: breadcrumb, title, toolbar, the
|
||||
// Actions menu, the JSON drawer and the section headers.
|
||||
|
||||
/** Suffixes of the `dashboard-action-<key>` testids on the Actions menu items. */
|
||||
export const DashboardAction = {
|
||||
rename: 'rename',
|
||||
clone: 'clone',
|
||||
lock: 'lock',
|
||||
fullScreen: 'fullscreen',
|
||||
newSection: 'new-section',
|
||||
delete: 'delete',
|
||||
} as const;
|
||||
|
||||
export type DashboardAction =
|
||||
(typeof DashboardAction)[keyof typeof DashboardAction];
|
||||
|
||||
/** Section menu items carry no testid; their label is the contract. */
|
||||
export const SectionAction = {
|
||||
addPanel: 'Add panel',
|
||||
rename: 'Rename section',
|
||||
clone: 'Clone section',
|
||||
delete: 'Delete section',
|
||||
} as const;
|
||||
|
||||
export interface SectionHandle {
|
||||
root(): Locator;
|
||||
/** The header button, named by the title; it toggles collapse. */
|
||||
toggle(): Locator;
|
||||
/** Panel roots mounted in the section: none while collapsed. */
|
||||
panels(): Locator;
|
||||
collapse(): Promise<void>;
|
||||
expand(): Promise<void>;
|
||||
openMenu(): Promise<Locator>;
|
||||
menuItem(label: string): Locator;
|
||||
/** Opens the rename modal and leaves it open. */
|
||||
startRename(): Promise<void>;
|
||||
rename(title: string): Promise<void>;
|
||||
delete(): Promise<void>;
|
||||
}
|
||||
|
||||
export interface DashboardPage {
|
||||
breadcrumb(): Locator;
|
||||
/** The breadcrumb's link back to the list. */
|
||||
breadcrumbHome(): Locator;
|
||||
title(): Locator;
|
||||
documentTitle(): Promise<string>;
|
||||
tags(): Locator;
|
||||
tagsOverflow(): Locator;
|
||||
toolbar: {
|
||||
actions(): Locator;
|
||||
configure(): Locator;
|
||||
json(): Locator;
|
||||
newPanel(): Locator;
|
||||
timePicker(): Locator;
|
||||
/** No testid or accessible name: matched by its wrapper class. */
|
||||
refresh(): Locator;
|
||||
autoRefresh(): Locator;
|
||||
feedback(): Locator;
|
||||
share(): Locator;
|
||||
};
|
||||
actions: {
|
||||
open(): Promise<Locator>;
|
||||
close(): Promise<void>;
|
||||
item(action: DashboardAction): Locator;
|
||||
run(action: DashboardAction): Promise<void>;
|
||||
};
|
||||
rename: {
|
||||
input(): Locator;
|
||||
save(): Locator;
|
||||
cancel(): Locator;
|
||||
/** Through Actions → Rename, saved with the check button. */
|
||||
to(name: string): Promise<void>;
|
||||
};
|
||||
/** Header icon, rendered once the dashboard has been locked. */
|
||||
lockToggle(): Locator;
|
||||
lock(): Promise<void>;
|
||||
unlock(): Promise<void>;
|
||||
isFullScreen(): Promise<boolean>;
|
||||
exitFullScreen(): Promise<void>;
|
||||
json: {
|
||||
drawer(): Locator;
|
||||
open(): Promise<void>;
|
||||
/** Returns what the Copy button put on the clipboard. */
|
||||
copy(): Promise<string>;
|
||||
/** Resolves with the suggested filename of the downloaded file. */
|
||||
download(): Promise<string>;
|
||||
};
|
||||
section(title: string): SectionHandle;
|
||||
sectionTitles(): Locator;
|
||||
sectionModal: {
|
||||
input(): Locator;
|
||||
submit(): Locator;
|
||||
cancel(): Locator;
|
||||
};
|
||||
/** Through Actions → New section. */
|
||||
addSection(title: string): Promise<void>;
|
||||
/** A variable's selector, which shows its current value. */
|
||||
variableValue(name: string): Locator;
|
||||
/** Shown in place of the page when the dashboard fetch fails. No testid. */
|
||||
loadError(): Locator;
|
||||
navigateSideNav(label: string): Promise<void>;
|
||||
/** The dashboards list, identified by its create button. */
|
||||
listPage(): Locator;
|
||||
/** Collects `pageerror`s from now on. */
|
||||
collectPageErrors(): { readonly errors: Error[] };
|
||||
}
|
||||
|
||||
function sectionHandle(page: Page, title: string): SectionHandle {
|
||||
const toggle = (): Locator =>
|
||||
page.getByRole('button', { name: title, exact: true });
|
||||
const root = (): Locator =>
|
||||
page.locator('[data-section-layout-index]').filter({ has: toggle() });
|
||||
const menu = (): Locator => page.getByRole('menu');
|
||||
const menuItem = (label: string): Locator =>
|
||||
menu().getByRole('menuitem', { name: label, exact: true });
|
||||
const modal = (): Locator => page.getByRole('dialog');
|
||||
|
||||
const section: SectionHandle = {
|
||||
root,
|
||||
toggle,
|
||||
panels: () => root().locator('[data-panel-root]'),
|
||||
async collapse() {
|
||||
await expect(section.panels().first()).toBeAttached();
|
||||
await toggle().click();
|
||||
await expect(section.panels()).toHaveCount(0);
|
||||
},
|
||||
async expand() {
|
||||
await expect(section.panels()).toHaveCount(0);
|
||||
await toggle().click();
|
||||
await expect(section.panels().first()).toBeAttached();
|
||||
},
|
||||
async openMenu() {
|
||||
await root().locator('[data-testid^="dashboard-section-actions-"]').click();
|
||||
await expect(menu()).toBeVisible();
|
||||
return menu();
|
||||
},
|
||||
menuItem,
|
||||
async startRename() {
|
||||
await section.openMenu();
|
||||
await menuItem(SectionAction.rename).click();
|
||||
await expect(modal()).toBeVisible();
|
||||
},
|
||||
async rename(next) {
|
||||
await section.startRename();
|
||||
await page.getByTestId('section-title-input').fill(next);
|
||||
await page.getByTestId('section-title-submit').click();
|
||||
await expect(modal()).toBeHidden();
|
||||
},
|
||||
async delete() {
|
||||
await section.openMenu();
|
||||
await menuItem(SectionAction.delete).click();
|
||||
await page.getByTestId('confirm-delete').click();
|
||||
await expect(root()).toHaveCount(0);
|
||||
},
|
||||
};
|
||||
return section;
|
||||
}
|
||||
|
||||
function dashboardPageDriver(page: Page): DashboardPage {
|
||||
const breadcrumb = (): Locator =>
|
||||
page.getByRole('navigation', { name: 'breadcrumb' });
|
||||
const actionsMenu = (): Locator => page.getByRole('menu');
|
||||
const actionItem = (action: DashboardAction): Locator =>
|
||||
page.getByTestId(`dashboard-action-${action}`);
|
||||
const jsonDrawer = (): Locator =>
|
||||
page.getByRole('dialog').filter({ hasText: 'Dashboard JSON' });
|
||||
|
||||
const dashboardPage: DashboardPage = {
|
||||
breadcrumb,
|
||||
breadcrumbHome: () =>
|
||||
breadcrumb().getByRole('link', { name: 'Dashboard', exact: true }),
|
||||
title: () => page.getByTestId('dashboard-title'),
|
||||
documentTitle: () => page.title(),
|
||||
tags: () => page.getByTestId('dashboard-tags'),
|
||||
tagsOverflow: () => page.getByTestId('dashboard-tags-overflow'),
|
||||
toolbar: {
|
||||
actions: () => page.getByTestId('options'),
|
||||
configure: () => page.getByTestId('show-drawer'),
|
||||
json: () => page.getByTestId('edit-json'),
|
||||
newPanel: () => page.getByTestId('add-panel-header'),
|
||||
timePicker: () => page.getByTestId('dropDown'),
|
||||
refresh: () => page.locator('.refresh-btn button'),
|
||||
autoRefresh: () => page.getByRole('button', { name: 'Set auto refresh' }),
|
||||
feedback: () => page.getByRole('button', { name: 'Feedback' }),
|
||||
share: () => page.getByRole('button', { name: 'Share' }),
|
||||
},
|
||||
actions: {
|
||||
async open() {
|
||||
await dashboardPage.actions.close();
|
||||
await dashboardPage.toolbar.actions().click();
|
||||
await expect(actionsMenu()).toBeVisible();
|
||||
return actionsMenu();
|
||||
},
|
||||
async close() {
|
||||
if ((await actionsMenu().count()) === 0) {
|
||||
return;
|
||||
}
|
||||
await page.keyboard.press('Escape');
|
||||
await expect(actionsMenu()).toHaveCount(0);
|
||||
},
|
||||
item: actionItem,
|
||||
async run(action) {
|
||||
await dashboardPage.actions.open();
|
||||
await actionItem(action).click();
|
||||
},
|
||||
},
|
||||
rename: {
|
||||
input: () => page.getByTestId('dashboard-title-input'),
|
||||
save: () => page.getByTestId('dashboard-title-save'),
|
||||
cancel: () => page.getByTestId('dashboard-title-cancel'),
|
||||
async to(name) {
|
||||
await dashboardPage.actions.run(DashboardAction.rename);
|
||||
await dashboardPage.rename.input().fill(name);
|
||||
await dashboardPage.rename.save().click();
|
||||
await expect(dashboardPage.title()).toHaveText(name);
|
||||
},
|
||||
},
|
||||
lockToggle: () => page.getByTestId('dashboard-lock'),
|
||||
async lock() {
|
||||
await dashboardPage.actions.run(DashboardAction.lock);
|
||||
await expect(dashboardPage.lockToggle()).toHaveAccessibleName(
|
||||
'Unlock dashboard',
|
||||
);
|
||||
},
|
||||
async unlock() {
|
||||
await dashboardPage.actions.run(DashboardAction.lock);
|
||||
await expect(dashboardPage.lockToggle()).toHaveAccessibleName(
|
||||
'Lock dashboard',
|
||||
);
|
||||
},
|
||||
isFullScreen: () => page.evaluate(() => !!document.fullscreenElement),
|
||||
async exitFullScreen() {
|
||||
await page.evaluate(() => document.exitFullscreen());
|
||||
},
|
||||
json: {
|
||||
drawer: jsonDrawer,
|
||||
async open() {
|
||||
await dashboardPage.toolbar.json().click();
|
||||
await expect(jsonDrawer()).toBeVisible();
|
||||
},
|
||||
async copy() {
|
||||
await page
|
||||
.context()
|
||||
.grantPermissions(['clipboard-read', 'clipboard-write']);
|
||||
await page.getByTestId('json-editor-copy').click();
|
||||
await expect(page.getByText('JSON copied to clipboard')).toBeVisible();
|
||||
return page.evaluate(() =>
|
||||
(
|
||||
globalThis as unknown as {
|
||||
navigator: { clipboard: { readText(): Promise<string> } };
|
||||
}
|
||||
).navigator.clipboard.readText(),
|
||||
);
|
||||
},
|
||||
async download() {
|
||||
const [file] = await Promise.all([
|
||||
page.waitForEvent('download'),
|
||||
page.getByTestId('json-editor-download').click(),
|
||||
]);
|
||||
return file.suggestedFilename();
|
||||
},
|
||||
},
|
||||
section: (title) => sectionHandle(page, title),
|
||||
sectionTitles: () =>
|
||||
page.locator('[data-testid^="dashboard-section-toggle-"]'),
|
||||
sectionModal: {
|
||||
input: () => page.getByTestId('section-title-input'),
|
||||
submit: () => page.getByTestId('section-title-submit'),
|
||||
cancel: () => page.getByTestId('section-title-cancel'),
|
||||
},
|
||||
async addSection(title) {
|
||||
await dashboardPage.actions.run(DashboardAction.newSection);
|
||||
await dashboardPage.sectionModal.input().fill(title);
|
||||
await dashboardPage.sectionModal.submit().click();
|
||||
await expect(dashboardPage.section(title).toggle()).toBeVisible();
|
||||
},
|
||||
variableValue: (name) => page.getByTestId(`variable-select-${name}`),
|
||||
loadError: () =>
|
||||
page.getByRole('heading', { name: 'Failed to load dashboard' }),
|
||||
async navigateSideNav(label) {
|
||||
await page
|
||||
.locator('.nav-item')
|
||||
.filter({ hasText: new RegExp(`^${label}$`) })
|
||||
.click();
|
||||
},
|
||||
listPage: () => page.getByTestId('new-dashboard-cta'),
|
||||
collectPageErrors() {
|
||||
const collected = { errors: [] as Error[] };
|
||||
page.on('pageerror', (error) => collected.errors.push(error));
|
||||
return collected;
|
||||
},
|
||||
};
|
||||
|
||||
return dashboardPage;
|
||||
}
|
||||
|
||||
const withDashboardPage = driver('dashboardPage', ({ page }) =>
|
||||
dashboardPageDriver(page),
|
||||
);
|
||||
|
||||
export const test = withDashboardPage(panelsTest);
|
||||
|
||||
export * from './panels';
|
||||
571
tests/e2e/drivers/dashboard-settings.ts
generated
Normal file
571
tests/e2e/drivers/dashboard-settings.ts
generated
Normal file
@@ -0,0 +1,571 @@
|
||||
/**
|
||||
* AI-owned. Generated and maintained by coding agents alongside the specs that
|
||||
* use it. Do not hand-edit: regenerate it with the spec that needs the change.
|
||||
*/
|
||||
import { expect, type Locator, type Page } from '@playwright/test';
|
||||
|
||||
import {
|
||||
buildDashboard,
|
||||
panel,
|
||||
PanelKind,
|
||||
type PostableDashboard,
|
||||
type Signal,
|
||||
type Variable,
|
||||
} from '../helpers/dashboard-spec';
|
||||
|
||||
import { seedMetricsViaSeeder } from '../helpers/dashboards';
|
||||
|
||||
import { PATH } from './dashboards';
|
||||
import { driver } from './kit';
|
||||
import { test as panelsTest } from './panels';
|
||||
|
||||
// The dashboard toolbar: time picker, variables bar and the Configure drawer.
|
||||
|
||||
// ─── Variable fixtures ───────────────────────────────────────────────────
|
||||
|
||||
export function textVariable(name: string, value: string): Variable {
|
||||
return { kind: 'TextVariable', spec: { name, display: { name }, value } };
|
||||
}
|
||||
|
||||
export function customVariable(
|
||||
name: string,
|
||||
values: string[],
|
||||
options: { multi?: boolean; all?: boolean } = {},
|
||||
): Variable {
|
||||
return listVariable(name, options, {
|
||||
kind: 'signoz/CustomVariable',
|
||||
spec: { customValue: values.join(',') },
|
||||
});
|
||||
}
|
||||
|
||||
/** `queryValue` is ClickHouse SQL; `{{.name}}` references another variable. */
|
||||
export function queryVariable(
|
||||
name: string,
|
||||
queryValue: string,
|
||||
options: { multi?: boolean; all?: boolean } = {},
|
||||
): Variable {
|
||||
return listVariable(name, options, {
|
||||
kind: 'signoz/QueryVariable',
|
||||
spec: { queryValue },
|
||||
});
|
||||
}
|
||||
|
||||
export function dynamicVariable(
|
||||
name: string,
|
||||
attribute: string,
|
||||
signal: Signal,
|
||||
): Variable {
|
||||
return listVariable(
|
||||
name,
|
||||
{ multi: true, all: true },
|
||||
{ kind: 'signoz/DynamicVariable', spec: { name: attribute, signal } },
|
||||
'alphabetical-asc',
|
||||
);
|
||||
}
|
||||
|
||||
function listVariable(
|
||||
name: string,
|
||||
options: { multi?: boolean; all?: boolean },
|
||||
plugin: Extract<Variable, { kind: 'ListVariable' }>['spec']['plugin'],
|
||||
sort: 'none' | 'alphabetical-asc' = 'none',
|
||||
): Variable {
|
||||
return {
|
||||
kind: 'ListVariable',
|
||||
spec: {
|
||||
name,
|
||||
display: { name },
|
||||
allowAllValue: options.all ?? false,
|
||||
allowMultiple: options.multi ?? false,
|
||||
sort,
|
||||
plugin,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export const BAR_VARIABLES = {
|
||||
textEnv: 'tb_env',
|
||||
textService: 'tb_service',
|
||||
single: 'cu_single',
|
||||
all: 'cu_all',
|
||||
multi: 'cu_multi',
|
||||
queryParent: 'q_env',
|
||||
queryChild: 'q_service',
|
||||
queryEmpty: 'q_empty',
|
||||
dynamic: 'd_service',
|
||||
} as const;
|
||||
|
||||
export const BAR_VALUES = {
|
||||
textEnv: 'otel-demo',
|
||||
textService: 'frontend',
|
||||
envs: ['otel-demo', 'mq-kafka', 'production'],
|
||||
services: ['adservice', 'frontend', 'checkout'],
|
||||
// Short enough that the child's chip is not truncated.
|
||||
queryEnvs: ['prod', 'stage'],
|
||||
} as const;
|
||||
|
||||
/** Each child option is its parent's value plus this suffix. */
|
||||
export const QUERY_CHILD_SUFFIX = '-svc';
|
||||
|
||||
/** One variable per control the bar renders, in spec order. */
|
||||
export function variablesBarDashboard(
|
||||
options: { dynamicAttribute?: string } = {},
|
||||
): PostableDashboard {
|
||||
const v = BAR_VARIABLES;
|
||||
return buildDashboard({
|
||||
title: `e2e-variables-bar-${Date.now()}-${process.pid}`,
|
||||
variables: [
|
||||
textVariable(v.textEnv, BAR_VALUES.textEnv),
|
||||
textVariable(v.textService, BAR_VALUES.textService),
|
||||
customVariable(v.single, [...BAR_VALUES.envs]),
|
||||
customVariable(v.all, [...BAR_VALUES.envs], { multi: true, all: true }),
|
||||
customVariable(v.multi, [...BAR_VALUES.services], { multi: true }),
|
||||
queryVariable(
|
||||
v.queryParent,
|
||||
`SELECT arrayJoin(['${BAR_VALUES.queryEnvs.join("', '")}'])`,
|
||||
),
|
||||
queryVariable(
|
||||
v.queryChild,
|
||||
`SELECT concat({{.${v.queryParent}}}, '${QUERY_CHILD_SUFFIX}')`,
|
||||
{ multi: true },
|
||||
),
|
||||
queryVariable(v.queryEmpty, "SELECT 'none' WHERE 1 = 0", { multi: true }),
|
||||
dynamicVariable(
|
||||
v.dynamic,
|
||||
options.dynamicAttribute ?? 'service.name',
|
||||
'metrics',
|
||||
),
|
||||
],
|
||||
// Queryless, so opening the dashboard issues no panel query.
|
||||
sections: [
|
||||
{
|
||||
title: 'Notes',
|
||||
panels: { notes: panel(PanelKind.Text, { name: 'Notes' }) },
|
||||
},
|
||||
],
|
||||
});
|
||||
}
|
||||
|
||||
// ─── Time picker ─────────────────────────────────────────────────────────
|
||||
|
||||
export const REFRESH_INTERVALS = [
|
||||
'5 seconds',
|
||||
'10 seconds',
|
||||
'30 seconds',
|
||||
'1 minute',
|
||||
'5 minutes',
|
||||
'10 minutes',
|
||||
'30 minutes',
|
||||
'1 hour',
|
||||
'2 hours',
|
||||
'1 day',
|
||||
] as const;
|
||||
|
||||
export type RefreshInterval = (typeof REFRESH_INTERVALS)[number];
|
||||
|
||||
export interface TimePicker {
|
||||
/** Its accessible name is the selected range's label. */
|
||||
input(): Locator;
|
||||
/** The offset badge, e.g. 'UTC + 9:00'. */
|
||||
timezoneBadge(): Locator;
|
||||
open(): Promise<void>;
|
||||
/** `value` is the preset's key, e.g. '1h'. */
|
||||
selectPreset(value: string): Promise<void>;
|
||||
/** Picks two days of the previous month, then applies. */
|
||||
selectPreviousMonthRange(fromDay: number, toDay: number): Promise<void>;
|
||||
/** Picks the first zone matching `search`. */
|
||||
selectTimezone(search: string, name: string): Promise<void>;
|
||||
refresh(): Locator;
|
||||
autoRefresh: {
|
||||
open(): Promise<void>;
|
||||
checkbox(): Locator;
|
||||
interval(label: RefreshInterval): Locator;
|
||||
/** The interval carrying the check mark. */
|
||||
selected(): Locator;
|
||||
};
|
||||
}
|
||||
|
||||
function ordinal(day: number): string {
|
||||
const tens = day % 100;
|
||||
if (tens >= 11 && tens <= 13) {
|
||||
return `${day}th`;
|
||||
}
|
||||
const suffix = { 1: 'st', 2: 'nd', 3: 'rd' }[day % 10] ?? 'th';
|
||||
return `${day}${suffix}`;
|
||||
}
|
||||
|
||||
function timePickerDriver(page: Page): TimePicker {
|
||||
const input = (): Locator => page.getByTestId('dropDown');
|
||||
const options = (): Locator => page.locator('.date-time-popover');
|
||||
// The timezone footer sits outside `.date-time-popover`.
|
||||
const popover = (): Locator => page.locator('.date-time-root');
|
||||
const autoRefreshMenu = (): Locator => page.locator('.auto-refresh-menu');
|
||||
|
||||
const picker: TimePicker = {
|
||||
input,
|
||||
timezoneBadge: () => page.locator('.timezone-badge'),
|
||||
async open() {
|
||||
// A click before the picker is interactive is dropped silently.
|
||||
await expect(async () => {
|
||||
if (!(await options().isVisible())) {
|
||||
await input().click();
|
||||
}
|
||||
await expect(options()).toBeVisible({ timeout: 3_000 });
|
||||
}).toPass({ timeout: 30_000 });
|
||||
},
|
||||
async selectPreset(value) {
|
||||
const option = page.getByTestId(`time-option-${value}`);
|
||||
// A click on a popover still animating closed is dropped, so retry.
|
||||
await expect(async () => {
|
||||
await picker.open();
|
||||
await option.click();
|
||||
await expect(option).toBeHidden({ timeout: 3_000 });
|
||||
}).toPass({ timeout: 30_000 });
|
||||
},
|
||||
async selectPreviousMonthRange(fromDay, toDay) {
|
||||
await picker.open();
|
||||
await page.getByTestId('time-option-custom').click();
|
||||
await popover()
|
||||
.getByRole('button', { name: 'Go to the Previous Month' })
|
||||
.click();
|
||||
const day = (n: number): Locator =>
|
||||
popover()
|
||||
.getByRole('gridcell', { name: new RegExp(`\\b${ordinal(n)}\\b`) })
|
||||
.first();
|
||||
await day(fromDay).click();
|
||||
await day(toDay).click();
|
||||
await popover().getByRole('button', { name: 'Apply' }).click();
|
||||
await expect(options()).toBeHidden();
|
||||
},
|
||||
async selectTimezone(search, name) {
|
||||
await picker.open();
|
||||
await popover().getByRole('button', { name: 'Change Timezone' }).click();
|
||||
await popover().getByPlaceholder('Search timezones...').fill(search);
|
||||
await popover()
|
||||
.getByRole('button', { name: new RegExp(`^${name}\\b`) })
|
||||
.click();
|
||||
},
|
||||
// The refresh button has neither a test id nor an accessible name.
|
||||
refresh: () => page.locator('.refresh-btn button'),
|
||||
autoRefresh: {
|
||||
async open() {
|
||||
await page.getByRole('button', { name: 'Set auto refresh' }).click();
|
||||
await expect(autoRefreshMenu()).toBeVisible();
|
||||
},
|
||||
checkbox: () =>
|
||||
autoRefreshMenu().getByRole('checkbox', { name: 'Auto Refresh' }),
|
||||
interval: (label) =>
|
||||
autoRefreshMenu().getByRole('button', { name: label, exact: true }),
|
||||
selected: () =>
|
||||
autoRefreshMenu()
|
||||
.locator('.refresh-interval-btns')
|
||||
.filter({
|
||||
has: page.locator('svg'),
|
||||
}),
|
||||
},
|
||||
};
|
||||
return picker;
|
||||
}
|
||||
|
||||
// ─── Variables bar ───────────────────────────────────────────────────────
|
||||
|
||||
export type SharedSelection = Record<string, string | string[]>;
|
||||
|
||||
export interface VariablesBar {
|
||||
root(): Locator;
|
||||
/** Overflowing variables stay hidden until the bar is expanded. */
|
||||
expand(): Promise<void>;
|
||||
/**
|
||||
* Opens the dashboard through a share link's `?variables=`, as a returning
|
||||
* visitor: on a first visit the auto-selected defaults overwrite the link's
|
||||
* values (see `firstVisitWith`).
|
||||
*/
|
||||
openWith(id: string, selection: SharedSelection): Promise<void>;
|
||||
/** The share link as a first-time visitor opens it. */
|
||||
firstVisitWith(id: string, selection: SharedSelection): Promise<void>;
|
||||
/** The `$name` labels in render order. */
|
||||
names(): Promise<string[]>;
|
||||
label(name: string): Locator;
|
||||
variable(name: string): Locator;
|
||||
textbox(name: string): Locator;
|
||||
/** Commits on Enter. */
|
||||
type(name: string, value: string): Promise<void>;
|
||||
select(name: string): Locator;
|
||||
open(name: string): Promise<void>;
|
||||
/** Multi-selects commit when the dropdown closes. */
|
||||
close(name: string): Promise<void>;
|
||||
listbox(): Locator;
|
||||
option(label: string): Locator;
|
||||
/** Single-select: picks one option. */
|
||||
pick(name: string, value: string): Promise<void>;
|
||||
/** Multi-select: toggles each value, then closes to commit. */
|
||||
toggle(name: string, values: string[]): Promise<void>;
|
||||
chip(name: string, value: string): Locator;
|
||||
chips(name: string): Locator;
|
||||
/** The selection a share link carries when variables are included. */
|
||||
shared(): Promise<SharedSelection>;
|
||||
addVariable(): Locator;
|
||||
/**
|
||||
* Writes one sample per value under a metric label, for a dynamic variable
|
||||
* to resolve. The golden dataset leaves the attribute metadata empty.
|
||||
*/
|
||||
seedMetricLabel(label: string, values: string[]): Promise<void>;
|
||||
}
|
||||
|
||||
function variablesBarDriver(page: Page): VariablesBar {
|
||||
const root = (): Locator => page.getByTestId('dashboard-variables-bar');
|
||||
const variable = (name: string): Locator =>
|
||||
page.getByTestId(`variable-${name}`);
|
||||
const select = (name: string): Locator =>
|
||||
page.getByTestId(`variable-select-${name}`);
|
||||
const listbox = (): Locator =>
|
||||
page.getByRole('listbox').locator('visible=true');
|
||||
const more = (): Locator => page.getByTestId('dashboard-variables-more');
|
||||
|
||||
const bar: VariablesBar = {
|
||||
root,
|
||||
async expand() {
|
||||
await expect(root()).toBeVisible();
|
||||
if ((await more().getAttribute('aria-expanded')) !== 'true') {
|
||||
await more().click();
|
||||
}
|
||||
await expect(more()).toHaveAttribute('aria-expanded', 'true');
|
||||
},
|
||||
async openWith(id, selection) {
|
||||
await page.goto(PATH.dashboard(id));
|
||||
await expect(root()).toBeVisible();
|
||||
await bar.firstVisitWith(id, selection);
|
||||
},
|
||||
async firstVisitWith(id, selection) {
|
||||
const search = new URLSearchParams({
|
||||
relativeTime: '30m',
|
||||
variables: JSON.stringify(selection),
|
||||
});
|
||||
await page.goto(`${PATH.dashboard(id)}?${search.toString()}`);
|
||||
await expect(root()).toBeVisible();
|
||||
},
|
||||
async names() {
|
||||
await expect(root()).toBeVisible();
|
||||
return root().locator('[title^="$"]').allTextContents();
|
||||
},
|
||||
label: (name) => variable(name).getByTitle(`$${name}`, { exact: true }),
|
||||
variable,
|
||||
textbox: (name) => page.getByTestId(`variable-input-${name}`),
|
||||
async type(name, value) {
|
||||
const input = bar.textbox(name);
|
||||
await input.fill(value);
|
||||
await input.press('Enter');
|
||||
},
|
||||
select,
|
||||
async open(name) {
|
||||
await select(name).click();
|
||||
await expect(listbox()).toBeVisible();
|
||||
},
|
||||
async close(name) {
|
||||
// The multi-select swallows Escape, so click outside it.
|
||||
await bar.label(name).click();
|
||||
await expect(listbox()).toBeHidden();
|
||||
},
|
||||
listbox,
|
||||
option: (label) =>
|
||||
listbox().getByRole('option', { name: label, exact: true }),
|
||||
async pick(name, value) {
|
||||
await bar.open(name);
|
||||
await bar.option(value).click();
|
||||
await expect(listbox()).toBeHidden();
|
||||
},
|
||||
async toggle(name, values) {
|
||||
await bar.open(name);
|
||||
for (const value of values) {
|
||||
await bar.option(value).click();
|
||||
}
|
||||
await bar.close(name);
|
||||
},
|
||||
chip: (name, value) =>
|
||||
variable(name).getByRole('button', {
|
||||
name: `Remove tag ${value}`,
|
||||
exact: true,
|
||||
}),
|
||||
chips: (name) => variable(name).getByRole('button', { name: /^Remove tag / }),
|
||||
async shared() {
|
||||
// The link is copied through a transient selection, never shown.
|
||||
await page.evaluate(() => {
|
||||
document.addEventListener(
|
||||
'copy',
|
||||
() => {
|
||||
(globalThis as { e2eCopied?: string }).e2eCopied = String(
|
||||
globalThis.getSelection(),
|
||||
);
|
||||
},
|
||||
{ capture: true, once: true },
|
||||
);
|
||||
});
|
||||
await page.getByRole('button', { name: 'Share' }).click();
|
||||
const modal = page.locator('.share-modal-content');
|
||||
const include = modal
|
||||
.locator('.absolute-relative-time-toggler-container')
|
||||
.filter({ hasText: 'Include variables' })
|
||||
.getByRole('switch');
|
||||
await include.click();
|
||||
await expect(include).toBeChecked();
|
||||
await modal.getByRole('button', { name: 'Copy page link' }).click();
|
||||
const link = await page.evaluate(
|
||||
() => (globalThis as { e2eCopied?: string }).e2eCopied ?? '',
|
||||
);
|
||||
const raw = new URL(link).searchParams.get('variables');
|
||||
expect(raw, `no variables in the shared link ${link}`).not.toBeNull();
|
||||
return JSON.parse(raw as string) as SharedSelection;
|
||||
},
|
||||
addVariable: () => root().getByRole('button', { name: 'Add variable' }),
|
||||
async seedMetricLabel(label, values) {
|
||||
const timestamp = new Date(Date.now() - 60_000).toISOString();
|
||||
await seedMetricsViaSeeder(
|
||||
page,
|
||||
values.map((value) => ({
|
||||
metric_name: 'e2e_dynamic_variable',
|
||||
labels: { [label]: value },
|
||||
timestamp,
|
||||
value: 1,
|
||||
temporality: 'Cumulative',
|
||||
type_: 'Sum',
|
||||
is_monotonic: true,
|
||||
})),
|
||||
);
|
||||
},
|
||||
};
|
||||
return bar;
|
||||
}
|
||||
|
||||
// ─── Configure drawer ────────────────────────────────────────────────────
|
||||
|
||||
export type SettingsTab = 'Overview' | 'Variables' | 'Publish';
|
||||
export type VariableTypeTab = 'dynamic' | 'textbox' | 'custom' | 'query';
|
||||
|
||||
export interface DashboardSettings {
|
||||
configure(): Locator;
|
||||
open(): Promise<void>;
|
||||
close(): Promise<void>;
|
||||
dialog(): Locator;
|
||||
heading(): Locator;
|
||||
tab(name: SettingsTab): Locator;
|
||||
openTab(name: SettingsTab): Promise<void>;
|
||||
panel(): Locator;
|
||||
overview: {
|
||||
name(): Locator;
|
||||
description(): Locator;
|
||||
tagInput(): Locator;
|
||||
addTag(tag: string): Promise<void>;
|
||||
unsaved(): Locator;
|
||||
save(): Locator;
|
||||
discard(): Locator;
|
||||
};
|
||||
variables: {
|
||||
list(): Locator;
|
||||
row(name: string): Locator;
|
||||
add(): Locator;
|
||||
edit(name: string): Promise<void>;
|
||||
/** Confirms inline. */
|
||||
remove(name: string): Promise<void>;
|
||||
};
|
||||
form: {
|
||||
name(): Locator;
|
||||
type(type: VariableTypeTab): Locator;
|
||||
customValues(): Locator;
|
||||
textDefault(): Locator;
|
||||
queryEditor(): Locator;
|
||||
error(text: string): Locator;
|
||||
save(): Locator;
|
||||
discard(): Locator;
|
||||
};
|
||||
publish: {
|
||||
text(text: string): Locator;
|
||||
timeRange(): Locator;
|
||||
publish(): Locator;
|
||||
};
|
||||
}
|
||||
|
||||
function settingsDriver(page: Page): DashboardSettings {
|
||||
const dialog = (): Locator =>
|
||||
page.getByRole('dialog').filter({ hasText: 'Dashboard Configuration' });
|
||||
const panelLocator = (): Locator => dialog().getByRole('tabpanel').first();
|
||||
|
||||
const settings: DashboardSettings = {
|
||||
configure: () => page.getByTestId('show-drawer'),
|
||||
async open() {
|
||||
await settings.configure().click();
|
||||
await expect(dialog()).toBeVisible();
|
||||
},
|
||||
async close() {
|
||||
await dialog().getByRole('button', { name: 'Close' }).click();
|
||||
await expect(dialog()).toBeHidden();
|
||||
},
|
||||
dialog,
|
||||
heading: () => dialog().getByText('Dashboard Configuration'),
|
||||
tab: (name) => dialog().getByRole('tab', { name, exact: true }),
|
||||
async openTab(name) {
|
||||
await settings.tab(name).click();
|
||||
await expect(settings.tab(name)).toHaveAttribute('aria-selected', 'true');
|
||||
},
|
||||
panel: panelLocator,
|
||||
overview: {
|
||||
name: () => dialog().getByTestId('dashboard-name'),
|
||||
description: () => dialog().getByTestId('dashboard-desc'),
|
||||
tagInput: () => dialog().getByTestId('tag-key-value-input'),
|
||||
async addTag(tag) {
|
||||
const input = settings.overview.tagInput();
|
||||
await input.fill(tag);
|
||||
await input.press('Enter');
|
||||
await expect(
|
||||
dialog().getByTestId('tag-key-value-input-chip').filter({ hasText: tag }),
|
||||
).toBeVisible();
|
||||
},
|
||||
unsaved: () => dialog().getByText(/unsaved changes?$/),
|
||||
save: () => dialog().getByTestId('save-dashboard-config'),
|
||||
discard: () => dialog().getByRole('button', { name: 'Discard' }),
|
||||
},
|
||||
variables: {
|
||||
list: () => dialog().getByTestId('variables-list'),
|
||||
row: (name) => dialog().getByTestId(`variable-row-${name}`),
|
||||
add: () => dialog().getByTestId('add-variable'),
|
||||
async edit(name) {
|
||||
await dialog().getByTestId(`variable-edit-${name}`).click();
|
||||
await expect(settings.form.name()).toBeVisible();
|
||||
},
|
||||
async remove(name) {
|
||||
await dialog().getByTestId(`variable-delete-${name}`).click();
|
||||
await dialog().getByTestId(`variable-delete-confirm-${name}`).click();
|
||||
await expect(settings.variables.row(name)).toHaveCount(0);
|
||||
},
|
||||
},
|
||||
form: {
|
||||
name: () => dialog().getByTestId('variable-name'),
|
||||
type: (type) => dialog().getByTestId(`variable-type-${type}`),
|
||||
customValues: () => dialog().getByTestId('variable-custom-input'),
|
||||
textDefault: () => dialog().getByTestId('variable-text-input'),
|
||||
// Monaco renders a textbox named "Editor content" with no test id.
|
||||
queryEditor: () => dialog().locator('.monaco-editor'),
|
||||
error: (text) => dialog().getByText(text),
|
||||
save: () => dialog().getByTestId('variable-save'),
|
||||
discard: () => dialog().getByRole('button', { name: 'Discard' }),
|
||||
},
|
||||
publish: {
|
||||
text: (text) => panelLocator().getByText(text),
|
||||
timeRange: () =>
|
||||
panelLocator().getByRole('switch', { name: 'Enable time range' }),
|
||||
publish: () =>
|
||||
panelLocator().getByRole('button', { name: 'Publish Dashboard' }),
|
||||
},
|
||||
};
|
||||
return settings;
|
||||
}
|
||||
|
||||
const withTimePicker = driver('timePicker', ({ page }) =>
|
||||
timePickerDriver(page),
|
||||
);
|
||||
const withVariablesBar = driver('variablesBar', ({ page }) =>
|
||||
variablesBarDriver(page),
|
||||
);
|
||||
const withSettings = driver('settings', ({ page }) => settingsDriver(page));
|
||||
|
||||
export const test = withSettings(withVariablesBar(withTimePicker(panelsTest)));
|
||||
|
||||
export * from './panels';
|
||||
355
tests/e2e/drivers/dashboards.ts
generated
Normal file
355
tests/e2e/drivers/dashboards.ts
generated
Normal file
@@ -0,0 +1,355 @@
|
||||
/**
|
||||
* AI-owned. Generated and maintained by coding agents alongside the specs that
|
||||
* use it. Do not hand-edit: regenerate it with the spec that needs the change.
|
||||
*/
|
||||
import {
|
||||
expect,
|
||||
type Locator,
|
||||
type Page,
|
||||
type Request,
|
||||
type Route,
|
||||
} from '@playwright/test';
|
||||
|
||||
import { authToken } from '../helpers/common';
|
||||
import type { PanelKind, PostableDashboard } from '../helpers/dashboard-spec';
|
||||
import {
|
||||
mockQueryRange,
|
||||
mockQueryRangeError,
|
||||
mockQueryRangeSequence,
|
||||
type MockOptions,
|
||||
type QueryRangeBody,
|
||||
unmockQueryRange,
|
||||
} from '../helpers/query-range-mock';
|
||||
|
||||
import { driver, kit } from './kit';
|
||||
|
||||
// `POST /api/v2/dashboards` takes the full perses spec in one call.
|
||||
|
||||
export const DASHBOARDS_API = '/api/v2/dashboards';
|
||||
|
||||
const QUERY_RANGE_GLOB = '**/api/v5/query_range';
|
||||
|
||||
export const PATH = {
|
||||
dashboard: (id: string): string => `/dashboard/${id}`,
|
||||
/** `panelId` is 'new' for creation. */
|
||||
editor: (id: string, panelId: string): string =>
|
||||
`/dashboard/${id}/panel/${panelId}`,
|
||||
/** Matches the dashboard route itself, with or without a query string. */
|
||||
onDashboard: (id: string): RegExp => new RegExp(`/dashboard/${id}(\\?|$)`),
|
||||
/** Matches the dashboard route once the View modal has written its params. */
|
||||
onDashboardWithParams: (id: string): RegExp =>
|
||||
new RegExp(`/dashboard/${id}\\?`),
|
||||
newPanel: /\/panel\/new\?/,
|
||||
} as const;
|
||||
|
||||
/**
|
||||
* `panel-editor-v2` is not unique: the ResizablePanelGroup derives the same
|
||||
* testid from its `id`, which is also its localStorage layout key.
|
||||
*/
|
||||
export const EDITOR_ROOT = '[data-testid="panel-editor-v2"]:not([data-group])';
|
||||
|
||||
export interface GettableDashboard {
|
||||
id: string;
|
||||
name: string;
|
||||
locked: boolean;
|
||||
tags: { key: string; value: string }[];
|
||||
spec: PostableDashboard['spec'];
|
||||
}
|
||||
|
||||
export interface Dashboards {
|
||||
/** Created for this test and deleted when it ends. */
|
||||
seed(dashboard: PostableDashboard): Promise<string>;
|
||||
seedAndOpen(dashboard: PostableDashboard): Promise<string>;
|
||||
seedAndEdit(dashboard: PostableDashboard, panelId: string): Promise<string>;
|
||||
/** Deletes a dashboard the UI created when the test ends. */
|
||||
track(id: string): void;
|
||||
/** The open dashboard's heading. */
|
||||
title(): Locator;
|
||||
/** Waits for the grid, not just the shell. */
|
||||
open(id: string): Promise<void>;
|
||||
/** For a dashboard with no panels: waits for the empty state instead. */
|
||||
openEmpty(id: string): Promise<void>;
|
||||
openEditor(id: string, panelId: string): Promise<void>;
|
||||
/** `kind` is required: without it the route bounces to the dashboard. */
|
||||
openNewPanel(id: string, kind: PanelKind, layoutIndex?: number): Promise<void>;
|
||||
/** Navigates without waiting for anything, for routes expected to redirect. */
|
||||
visit(path: string): Promise<void>;
|
||||
reload(): Promise<void>;
|
||||
get(id: string): Promise<GettableDashboard>;
|
||||
setLocked(id: string, locked: boolean): Promise<void>;
|
||||
/** A panel's grid geometry as persisted, not as rendered. */
|
||||
gridItem(id: string, panelId: string): Promise<GridGeometry>;
|
||||
/** A query param of the current URL, where the time window lives. */
|
||||
param(name: string): string | null;
|
||||
emptyAddPanel(): Locator;
|
||||
headerAddPanel(): Locator;
|
||||
zoomOut(): Locator;
|
||||
variable(name: string): Locator;
|
||||
confirmDelete(): Locator;
|
||||
/** Free text anywhere on the page, e.g. a confirm dialog's title. */
|
||||
text(text: string): Locator;
|
||||
}
|
||||
|
||||
export interface GridGeometry {
|
||||
x: number;
|
||||
y: number;
|
||||
width: number;
|
||||
height: number;
|
||||
}
|
||||
|
||||
export interface QueryRangeMock {
|
||||
/** Fulfils every call with one body. Install before navigating. */
|
||||
respond(body: QueryRangeBody, options?: MockOptions): Promise<void>;
|
||||
fail(
|
||||
message?: string,
|
||||
options?: MockOptions & { status?: number },
|
||||
): Promise<void>;
|
||||
/** One body per call, repeating the last. */
|
||||
sequence(bodies: QueryRangeBody[]): Promise<void>;
|
||||
/** Holds every call until `release` is called, then serves `body`. */
|
||||
hold(body: QueryRangeBody): Promise<{ release: () => void }>;
|
||||
/** Drops interception so later calls reach the backend. */
|
||||
reset(): Promise<void>;
|
||||
/** Resolves with the next query_range request. */
|
||||
nextRequest(): Promise<Request>;
|
||||
/** The paging of the first query in the next query_range request. */
|
||||
nextPaging(): Promise<{ offset?: number; limit?: number }>;
|
||||
/** Counts query_range requests from now on. */
|
||||
countRequests(): { readonly count: number };
|
||||
}
|
||||
|
||||
async function bearer(page: Page): Promise<{ Authorization: string }> {
|
||||
return { Authorization: `Bearer ${await authToken(page)}` };
|
||||
}
|
||||
|
||||
/**
|
||||
* A 400 is almost always the strict decoder rejecting a stray key; the body
|
||||
* names the field, so it is surfaced verbatim.
|
||||
*/
|
||||
async function createViaApi(
|
||||
page: Page,
|
||||
dashboard: PostableDashboard,
|
||||
): Promise<string> {
|
||||
const res = await page.request.post(DASHBOARDS_API, {
|
||||
data: dashboard,
|
||||
headers: await bearer(page),
|
||||
});
|
||||
if (!res.ok()) {
|
||||
throw new Error(
|
||||
`POST ${DASHBOARDS_API} ${res.status()}: ${await res.text()}`,
|
||||
);
|
||||
}
|
||||
return ((await res.json()) as { data: GettableDashboard }).data.id;
|
||||
}
|
||||
|
||||
export async function getViaApi(
|
||||
page: Page,
|
||||
id: string,
|
||||
): Promise<GettableDashboard> {
|
||||
const res = await page.request.get(`${DASHBOARDS_API}/${id}`, {
|
||||
headers: await bearer(page),
|
||||
});
|
||||
if (!res.ok()) {
|
||||
throw new Error(
|
||||
`GET ${DASHBOARDS_API}/${id} ${res.status()}: ${await res.text()}`,
|
||||
);
|
||||
}
|
||||
return ((await res.json()) as { data: GettableDashboard }).data;
|
||||
}
|
||||
|
||||
/** Same path, no body: PUT locks, DELETE unlocks. */
|
||||
async function setLockedViaApi(
|
||||
page: Page,
|
||||
id: string,
|
||||
locked: boolean,
|
||||
): Promise<void> {
|
||||
const url = `${DASHBOARDS_API}/${id}/lock`;
|
||||
const headers = await bearer(page);
|
||||
const res = locked
|
||||
? await page.request.put(url, { headers })
|
||||
: await page.request.delete(url, { headers });
|
||||
if (!res.ok()) {
|
||||
throw new Error(
|
||||
`${locked ? 'PUT' : 'DELETE'} ${url} ${res.status()}: ${await res.text()}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const trackedByPage = new WeakMap<Page, string[]>();
|
||||
|
||||
/** Registers a dashboard for deletion when the test owning `page` ends. */
|
||||
export function trackDashboard(page: Page, id: string): void {
|
||||
trackedByPage.set(page, [...(trackedByPage.get(page) ?? []), id]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Not asserted: a test that deleted its own dashboard, or left a closed page
|
||||
* behind, must not fail on cleanup.
|
||||
*/
|
||||
export async function removeTrackedDashboards(page: Page): Promise<void> {
|
||||
const ids = trackedByPage.get(page) ?? [];
|
||||
trackedByPage.delete(page);
|
||||
if (ids.length === 0) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const headers = await bearer(page);
|
||||
for (const id of ids) {
|
||||
await page.request
|
||||
.delete(`${DASHBOARDS_API}/${id}`, { headers })
|
||||
.catch(() => undefined);
|
||||
}
|
||||
} catch {
|
||||
/* the dashboards outlive the run rather than hiding why it failed */
|
||||
}
|
||||
}
|
||||
|
||||
function dashboardsDriver(page: Page): Dashboards {
|
||||
const editorRoot = (): Locator => page.locator(EDITOR_ROOT);
|
||||
|
||||
const dashboards: Dashboards = {
|
||||
async seed(dashboard) {
|
||||
const id = await createViaApi(page, dashboard);
|
||||
trackDashboard(page, id);
|
||||
return id;
|
||||
},
|
||||
track: (id) => trackDashboard(page, id),
|
||||
title: () => page.getByTestId('dashboard-title'),
|
||||
async seedAndOpen(dashboard) {
|
||||
const id = await dashboards.seed(dashboard);
|
||||
await dashboards.open(id);
|
||||
return id;
|
||||
},
|
||||
async seedAndEdit(dashboard, panelId) {
|
||||
const id = await dashboards.seed(dashboard);
|
||||
await dashboards.openEditor(id, panelId);
|
||||
return id;
|
||||
},
|
||||
async open(id) {
|
||||
await page.goto(PATH.dashboard(id));
|
||||
await page
|
||||
.locator('[data-panel-root]')
|
||||
.first()
|
||||
.waitFor({ state: 'attached' });
|
||||
},
|
||||
async openEmpty(id) {
|
||||
await page.goto(PATH.dashboard(id));
|
||||
await expect(dashboards.emptyAddPanel()).toBeVisible();
|
||||
},
|
||||
async openEditor(id, panelId) {
|
||||
await page.goto(PATH.editor(id, panelId));
|
||||
await expect(editorRoot()).toBeVisible();
|
||||
},
|
||||
async openNewPanel(id, kind, layoutIndex = 0) {
|
||||
const search = new URLSearchParams({
|
||||
panelKind: kind,
|
||||
layoutIndex: String(layoutIndex),
|
||||
});
|
||||
await page.goto(`${PATH.editor(id, 'new')}?${search.toString()}`);
|
||||
await expect(editorRoot()).toBeVisible();
|
||||
},
|
||||
async visit(path) {
|
||||
await page.goto(path);
|
||||
},
|
||||
async reload() {
|
||||
await page.reload();
|
||||
},
|
||||
get: (id) => getViaApi(page, id),
|
||||
setLocked: (id, locked) => setLockedViaApi(page, id, locked),
|
||||
async gridItem(id, panelId) {
|
||||
const { spec } = await getViaApi(page, id);
|
||||
for (const layout of spec.layouts) {
|
||||
const item = layout.spec.items.find(
|
||||
(candidate) => candidate.content.$ref === `#/spec/panels/${panelId}`,
|
||||
);
|
||||
if (item) {
|
||||
return { x: item.x, y: item.y, width: item.width, height: item.height };
|
||||
}
|
||||
}
|
||||
throw new Error(`no grid item for panel ${panelId}`);
|
||||
},
|
||||
param: (name) => new URL(page.url()).searchParams.get(name),
|
||||
emptyAddPanel: () => page.getByTestId('add-panel'),
|
||||
headerAddPanel: () => page.getByTestId('add-panel-header'),
|
||||
zoomOut: () => page.getByTestId('zoom-out-btn'),
|
||||
variable: (name) => page.getByTestId(`variable-${name}`),
|
||||
confirmDelete: () => page.getByTestId('confirm-delete'),
|
||||
text: (text) => page.getByText(text),
|
||||
};
|
||||
|
||||
return dashboards;
|
||||
}
|
||||
|
||||
function queryRangeDriver(page: Page): QueryRangeMock {
|
||||
const isQueryRange = (url: string): boolean => url.includes('/query_range');
|
||||
|
||||
return {
|
||||
respond: (body, options) => mockQueryRange(page, body, options),
|
||||
fail: (message, options) => mockQueryRangeError(page, message, options),
|
||||
sequence: (bodies) => mockQueryRangeSequence(page, bodies),
|
||||
async hold(body) {
|
||||
let release: () => void = () => undefined;
|
||||
const gate = new Promise<void>((resolve) => {
|
||||
release = resolve;
|
||||
});
|
||||
await page.route(QUERY_RANGE_GLOB, async (route: Route) => {
|
||||
await gate;
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
});
|
||||
return { release: () => release() };
|
||||
},
|
||||
reset: () => unmockQueryRange(page),
|
||||
nextRequest: () => page.waitForRequest((r) => isQueryRange(r.url())),
|
||||
async nextPaging() {
|
||||
const request = await page.waitForRequest((r) => isQueryRange(r.url()));
|
||||
const body = request.postDataJSON() as {
|
||||
compositeQuery?: {
|
||||
queries?: { spec?: { offset?: number; limit?: number } }[];
|
||||
};
|
||||
} | null;
|
||||
const spec = body?.compositeQuery?.queries?.[0]?.spec;
|
||||
return { offset: spec?.offset, limit: spec?.limit };
|
||||
},
|
||||
countRequests() {
|
||||
const counter = { count: 0 };
|
||||
page.on('request', (request) => {
|
||||
if (isQueryRange(request.url())) {
|
||||
counter.count += 1;
|
||||
}
|
||||
});
|
||||
return counter;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const withDashboards = driver(
|
||||
'dashboards',
|
||||
({ page }) => dashboardsDriver(page),
|
||||
(_, { page }) => removeTrackedDashboards(page),
|
||||
);
|
||||
|
||||
const withQueryRange = driver('queryRange', ({ page }) =>
|
||||
queryRangeDriver(page),
|
||||
);
|
||||
|
||||
export const test = withQueryRange(withDashboards(kit));
|
||||
|
||||
export { expect } from './kit';
|
||||
export * from '../helpers/dashboard-spec';
|
||||
export {
|
||||
flat,
|
||||
type HeatmapPoint,
|
||||
heatmapPoints,
|
||||
type HeatmapSeriesSpec,
|
||||
QueryRange,
|
||||
type QueryRangeBody,
|
||||
ramp,
|
||||
type SeriesSpec,
|
||||
timestamps,
|
||||
type TimeSeriesPoint,
|
||||
} from '../helpers/query-range-mock';
|
||||
export * from '../testdata/dashboards/panels';
|
||||
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>) =>
|
||||
|
||||
278
tests/e2e/drivers/panel-config.ts
generated
Normal file
278
tests/e2e/drivers/panel-config.ts
generated
Normal file
@@ -0,0 +1,278 @@
|
||||
/**
|
||||
* AI-owned. Generated and maintained by coding agents alongside the specs that
|
||||
* use it. Do not hand-edit: regenerate it with the spec that needs the change.
|
||||
*/
|
||||
import { expect, type Locator, type Page } from '@playwright/test';
|
||||
|
||||
import type {
|
||||
ContextLink,
|
||||
PanelPluginSpec,
|
||||
Threshold,
|
||||
} from '../helpers/dashboard-spec';
|
||||
import { SINGLE_PANEL_ID } from '../testdata/dashboards/panels';
|
||||
|
||||
import { getViaApi } from './dashboards';
|
||||
import { driver } from './kit';
|
||||
import { test as editorTest } from './panel-editor';
|
||||
|
||||
// The panel editor's per-kind config editors: thresholds, context links,
|
||||
// per-column units and the List columns editor.
|
||||
|
||||
/**
|
||||
* Variant follows panel kind, not a user control: label → TimeSeries/Bar,
|
||||
* comparison → Number, table → Table. Each has its own add button and row
|
||||
* testid prefix.
|
||||
*/
|
||||
export type ThresholdVariant = 'label' | 'comparison' | 'table';
|
||||
|
||||
const THRESHOLD_ADD_TESTID: Record<ThresholdVariant, string> = {
|
||||
label: 'panel-editor-v2-add-threshold',
|
||||
comparison: 'panel-editor-v2-add-comparison-threshold',
|
||||
table: 'panel-editor-v2-add-table-threshold',
|
||||
};
|
||||
|
||||
const THRESHOLD_ROW_PREFIX: Record<ThresholdVariant, string> = {
|
||||
label: 'threshold',
|
||||
comparison: 'comparison-threshold',
|
||||
table: 'table-threshold',
|
||||
};
|
||||
|
||||
export type ThresholdOperator =
|
||||
| 'above'
|
||||
| 'above_or_equal'
|
||||
| 'below'
|
||||
| 'below_or_equal'
|
||||
| 'equal'
|
||||
| 'not_equal';
|
||||
|
||||
export type ThresholdFormat = 'background' | 'text';
|
||||
|
||||
export interface ThresholdRow {
|
||||
value(): Locator;
|
||||
/** Label variant only. */
|
||||
label(): Locator;
|
||||
edit(): Locator;
|
||||
save(): Locator;
|
||||
discard(): Locator;
|
||||
remove(): Locator;
|
||||
/** Operator and format are radio tiles keyed by the persisted value. */
|
||||
setOperator(operator: ThresholdOperator): Promise<void>;
|
||||
setFormat(format: ThresholdFormat): Promise<void>;
|
||||
/** Table variant only: the column keys come from the live result. */
|
||||
pickFirstColumn(): Promise<void>;
|
||||
}
|
||||
|
||||
export interface Thresholds {
|
||||
addButton(variant: ThresholdVariant): Locator;
|
||||
/** The section header's quick-add, which also expands a collapsed section. */
|
||||
headerAdd(): Locator;
|
||||
row(variant: ThresholdVariant, index: number): ThresholdRow;
|
||||
}
|
||||
|
||||
export interface ContextLinks {
|
||||
addButton(): Locator;
|
||||
headerAdd(): Locator;
|
||||
dialog(): Locator;
|
||||
label(): Locator;
|
||||
url(): Locator;
|
||||
save(): Locator;
|
||||
cancel(): Locator;
|
||||
addParam(): Locator;
|
||||
paramKey(index: number): Locator;
|
||||
paramValue(index: number): Locator;
|
||||
item(index: number): Locator;
|
||||
edit(index: number): Locator;
|
||||
remove(index: number): Locator;
|
||||
/**
|
||||
* Closes the URL field's suggestion popover, which otherwise keeps the
|
||||
* dialog reflowing so buttons never settle. Escape would dismiss the whole
|
||||
* dialog.
|
||||
*/
|
||||
blurFields(): Promise<void>;
|
||||
}
|
||||
|
||||
export interface ListColumns {
|
||||
editor(): Locator;
|
||||
chip(name: string): Locator;
|
||||
remove(name: string): Locator;
|
||||
addButton(): Locator;
|
||||
search(): Locator;
|
||||
/** The explicit "Add …" entry, so a typo can't be committed by blurring. */
|
||||
addCustom(): Locator;
|
||||
/** Opens the dropdown and types `term`. */
|
||||
open(term: string): Promise<void>;
|
||||
/**
|
||||
* Clicks the first suggestion once the results for `term` have landed;
|
||||
* returns its field name.
|
||||
*/
|
||||
pickFirstSuggestion(term: string): Promise<string>;
|
||||
}
|
||||
|
||||
export interface TableCells {
|
||||
/** The last cell of the first row, where the aggregated value lands. */
|
||||
valueCell(): Locator;
|
||||
/** Inline background, set only while a background threshold matches. */
|
||||
background(cell: Locator): Promise<string>;
|
||||
/** Spans the text format recolours. */
|
||||
coloredText(cell: Locator): Locator;
|
||||
}
|
||||
|
||||
export interface SavedPanel {
|
||||
plugin: PanelPluginSpec;
|
||||
links: ContextLink[];
|
||||
}
|
||||
|
||||
export interface PanelConfig {
|
||||
thresholds: Thresholds;
|
||||
contextLinks: ContextLinks;
|
||||
listColumns: ListColumns;
|
||||
columnUnits: {
|
||||
any(): Locator;
|
||||
/** The first per-column unit select, with the column key it targets. */
|
||||
first(): Promise<{ testId: string; key: string }>;
|
||||
};
|
||||
/** The plugin spec and links as persisted, straight from the API. */
|
||||
saved(dashboardId: string, panelId?: string): Promise<SavedPanel>;
|
||||
savedThresholds(dashboardId: string, panelId?: string): Promise<Threshold[]>;
|
||||
numberValue(): Locator;
|
||||
/** Inline colour on the rendered Number value, set only while a threshold matches. */
|
||||
numberValueColor(): Promise<string>;
|
||||
table: TableCells;
|
||||
text(text: string): Locator;
|
||||
}
|
||||
|
||||
const COLUMN_UNIT_PREFIX = 'panel-editor-v2-column-unit-';
|
||||
const SUGGESTION_PREFIX = 'list-columns-suggestion-';
|
||||
|
||||
function thresholdRow(
|
||||
page: Page,
|
||||
variant: ThresholdVariant,
|
||||
index: number,
|
||||
): ThresholdRow {
|
||||
const prefix = THRESHOLD_ROW_PREFIX[variant];
|
||||
const byTestId = (part: string): Locator =>
|
||||
page.getByTestId(`${prefix}-${part}-${index}`);
|
||||
return {
|
||||
value: () => byTestId('value'),
|
||||
label: () => byTestId('label'),
|
||||
edit: () => byTestId('edit'),
|
||||
save: () => byTestId('save'),
|
||||
discard: () => byTestId('discard'),
|
||||
remove: () => byTestId('remove'),
|
||||
async setOperator(operator) {
|
||||
await page.getByTestId(`${prefix}-operator-${index}-${operator}`).click();
|
||||
},
|
||||
async setFormat(format) {
|
||||
await page.getByTestId(`${prefix}-format-${index}-${format}`).click();
|
||||
},
|
||||
async pickFirstColumn() {
|
||||
await byTestId('column').click();
|
||||
await page
|
||||
.locator('.ant-select-dropdown:not(.ant-select-dropdown-hidden)')
|
||||
.locator('.ant-select-item-option')
|
||||
.first()
|
||||
.click();
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function panelConfigDriver(page: Page): PanelConfig {
|
||||
const numberValue = (): Locator => page.getByTestId('number-panel-value');
|
||||
const contextLinkLabel = (): Locator => page.getByTestId('context-link-label');
|
||||
const columnUnits = (): Locator =>
|
||||
page.locator(`[data-testid^="${COLUMN_UNIT_PREFIX}"]`);
|
||||
|
||||
const config: PanelConfig = {
|
||||
thresholds: {
|
||||
addButton: (variant) => page.getByTestId(THRESHOLD_ADD_TESTID[variant]),
|
||||
headerAdd: () => page.getByTestId('panel-editor-v2-add-threshold-header'),
|
||||
row: (variant, index) => thresholdRow(page, variant, index),
|
||||
},
|
||||
contextLinks: {
|
||||
addButton: () => page.getByTestId('panel-editor-v2-add-link'),
|
||||
headerAdd: () => page.getByTestId('panel-editor-v2-add-link-header'),
|
||||
dialog: () => page.getByTestId('context-link-dialog'),
|
||||
label: contextLinkLabel,
|
||||
url: () => page.getByTestId('context-link-url'),
|
||||
save: () => page.getByTestId('context-link-save'),
|
||||
cancel: () => page.getByTestId('context-link-cancel'),
|
||||
addParam: () => page.getByTestId('context-link-add-param'),
|
||||
paramKey: (index) => page.getByTestId(`context-link-param-key-${index}`),
|
||||
paramValue: (index) => page.getByTestId(`context-link-param-value-${index}`),
|
||||
item: (index) => page.getByTestId(`context-link-item-${index}`),
|
||||
edit: (index) => page.getByTestId(`context-link-edit-${index}`),
|
||||
remove: (index) => page.getByTestId(`context-link-remove-${index}`),
|
||||
async blurFields() {
|
||||
await contextLinkLabel().focus();
|
||||
},
|
||||
},
|
||||
listColumns: {
|
||||
editor: () => page.getByTestId('list-columns-editor'),
|
||||
chip: (name) => page.getByTestId(`list-column-chip-${name}`),
|
||||
remove: (name) => page.getByTestId(`list-column-remove-${name}`),
|
||||
addButton: () => page.getByTestId('list-columns-add'),
|
||||
search: () => page.getByTestId('list-columns-search'),
|
||||
addCustom: () => page.getByTestId('list-columns-add-custom'),
|
||||
async open(term) {
|
||||
await page.getByTestId('list-columns-add').click();
|
||||
await page.getByTestId('list-columns-search').fill(term);
|
||||
},
|
||||
async pickFirstSuggestion(term) {
|
||||
const first = page.locator(`[data-testid^="${SUGGESTION_PREFIX}"]`).first();
|
||||
const firstName = async (): Promise<string> =>
|
||||
((await first.getAttribute('data-testid')) ?? '').replace(
|
||||
SUGGESTION_PREFIX,
|
||||
'',
|
||||
);
|
||||
// Search is debounced, so the previous results stay up until this
|
||||
// term's land; and the list repaints as they do, so the row is
|
||||
// clicked by its own testid rather than by position.
|
||||
await expect(page.getByText('Loading…')).toHaveCount(0);
|
||||
await expect.poll(firstName).toContain(term);
|
||||
const name = await firstName();
|
||||
await page.getByTestId(`${SUGGESTION_PREFIX}${name}`).click();
|
||||
return name;
|
||||
},
|
||||
},
|
||||
columnUnits: {
|
||||
any: () => columnUnits().first(),
|
||||
async first() {
|
||||
const testId =
|
||||
(await columnUnits().first().getAttribute('data-testid')) ?? '';
|
||||
return { testId, key: testId.replace(COLUMN_UNIT_PREFIX, '') };
|
||||
},
|
||||
},
|
||||
async saved(dashboardId, panelId = SINGLE_PANEL_ID) {
|
||||
const { spec } = await getViaApi(page, dashboardId);
|
||||
const panel = spec.panels[panelId].spec;
|
||||
return { plugin: panel.plugin.spec, links: panel.links ?? [] };
|
||||
},
|
||||
async savedThresholds(dashboardId, panelId) {
|
||||
return (await config.saved(dashboardId, panelId)).plugin.thresholds ?? [];
|
||||
},
|
||||
numberValue,
|
||||
numberValueColor: () =>
|
||||
numberValue().evaluate((node) => (node as HTMLElement).style.color),
|
||||
table: {
|
||||
valueCell: () =>
|
||||
page
|
||||
.getByTestId('table-panel-renderer')
|
||||
.locator('tbody tr.ant-table-row')
|
||||
.first()
|
||||
.locator('td')
|
||||
.last(),
|
||||
background: (cell) =>
|
||||
cell.evaluate((node) => (node as HTMLElement).style.backgroundColor),
|
||||
coloredText: (cell) => cell.locator('span[style*="color"]'),
|
||||
},
|
||||
text: (text) => page.getByText(text),
|
||||
};
|
||||
|
||||
return config;
|
||||
}
|
||||
|
||||
const withPanelConfig = driver('config', ({ page }) => panelConfigDriver(page));
|
||||
|
||||
export const test = withPanelConfig(editorTest);
|
||||
|
||||
export * from './panel-editor';
|
||||
296
tests/e2e/drivers/panel-editor.ts
generated
Normal file
296
tests/e2e/drivers/panel-editor.ts
generated
Normal file
@@ -0,0 +1,296 @@
|
||||
/**
|
||||
* AI-owned. Generated and maintained by coding agents alongside the specs that
|
||||
* use it. Do not hand-edit: regenerate it with the spec that needs the change.
|
||||
*/
|
||||
import { expect, type Locator, type Page } from '@playwright/test';
|
||||
|
||||
import { PanelKind } from '../helpers/dashboard-spec';
|
||||
|
||||
import { EDITOR_ROOT } from './dashboards';
|
||||
import { driver } from './kit';
|
||||
import { newPanelDriver } from './panel-editor/picker';
|
||||
import { type QueryPane, queryPaneAt } from './panel-editor/query';
|
||||
import { type EditorSelects, selectsAt } from './panel-editor/select';
|
||||
import { type TextPane, textPaneAt } from './panel-editor/text';
|
||||
import {
|
||||
type TypeSwitcher,
|
||||
typeSwitcherAt,
|
||||
} from './panel-editor/type-switcher';
|
||||
import {
|
||||
type Chart,
|
||||
chartAt,
|
||||
type Markdown,
|
||||
markdownAt,
|
||||
RENDERER_TESTID,
|
||||
test as panelsTest,
|
||||
} from './panels';
|
||||
|
||||
// The panel editor; config sections only mount their editors while open.
|
||||
// Each pane lives in its own part under ./panel-editor.
|
||||
|
||||
export const EditorText = {
|
||||
title: 'Configure panel',
|
||||
unsavedBadge: 'Unsaved Changes',
|
||||
save: 'Save changes',
|
||||
switchToView: 'Switch to View Mode',
|
||||
discardTitle: 'Discard changes?',
|
||||
discardBody: 'Your unsaved edits to this panel will be lost.',
|
||||
savedToast: 'Panel saved',
|
||||
lockedReason: 'This dashboard is locked',
|
||||
runQuery: 'Run Query',
|
||||
} as const;
|
||||
|
||||
export const QueryTab = {
|
||||
builder: 'Query Builder',
|
||||
clickhouse: 'ClickHouse Query',
|
||||
promql: 'PromQL',
|
||||
ai: 'AI Query Builder',
|
||||
} as const;
|
||||
|
||||
/** SettingsSection titles as rendered. */
|
||||
export const Section = {
|
||||
visualization: 'Visualization',
|
||||
formatting: 'Formatting & Units',
|
||||
axes: 'Axes',
|
||||
legend: 'Legend',
|
||||
chartAppearance: 'Chart Appearance',
|
||||
buckets: 'Buckets',
|
||||
thresholds: 'Thresholds',
|
||||
contextLinks: 'Context Links',
|
||||
/** Text only. */
|
||||
panelAppearance: 'Panel appearance',
|
||||
} as const;
|
||||
|
||||
/** Config controls, each testid'd as `panel-editor-v2-<name>`. */
|
||||
export type EditorControl =
|
||||
| 'soft-min'
|
||||
| 'soft-max'
|
||||
| 'y-scale'
|
||||
| 'log-scale'
|
||||
| 'unit'
|
||||
| 'decimals'
|
||||
| 'time-preference'
|
||||
| 'stacked-bar-chart'
|
||||
| 'stack-mode'
|
||||
| 'fill-spans'
|
||||
| 'fill-mode'
|
||||
| 'fill-opacity'
|
||||
| 'span-gaps'
|
||||
| 'span-gaps-value'
|
||||
| 'show-points'
|
||||
| 'line-style'
|
||||
| 'line-interpolation'
|
||||
| 'legend-position'
|
||||
| 'legend-colors'
|
||||
| 'legend-search'
|
||||
| 'bucket-count'
|
||||
| 'bucket-width'
|
||||
| 'merge-queries'
|
||||
| 'heatmap-color-mode'
|
||||
| 'heatmap-palette'
|
||||
| 'heatmap-color-scale'
|
||||
| 'heatmap-color-steps'
|
||||
| 'heatmap-min-count'
|
||||
| 'heatmap-max-count'
|
||||
| 'heatmap-fill'
|
||||
| 'heatmap-preview'
|
||||
| 'type-revert';
|
||||
|
||||
export interface PatchOperation {
|
||||
op: string;
|
||||
path: string;
|
||||
value?: unknown;
|
||||
}
|
||||
|
||||
export interface PanelEditor extends EditorSelects, TypeSwitcher {
|
||||
root(): Locator;
|
||||
title(): Locator;
|
||||
description(): Locator;
|
||||
saveButton(): Locator;
|
||||
closeButton(): Locator;
|
||||
unsavedBadge(): Locator;
|
||||
switchToView(): Locator;
|
||||
/** Save is not gated on dirty state; fails the test on a rejected PATCH. */
|
||||
save(): Promise<void>;
|
||||
/** Clicks Save and returns whether the PATCH was accepted, for rejection paths. */
|
||||
trySave(): Promise<{ ok: boolean; body: string }>;
|
||||
/**
|
||||
* A dirty panel raises the discard dialog; `expectDirty` answers it, with
|
||||
* `keepEditing` choosing Cancel over Discard.
|
||||
*/
|
||||
close(options?: {
|
||||
expectDirty?: boolean;
|
||||
keepEditing?: boolean;
|
||||
}): Promise<void>;
|
||||
discardModal(): Locator;
|
||||
/** Records the RFC-6902 ops each save sends. */
|
||||
capturePatches(): PatchOperation[][];
|
||||
section: {
|
||||
toggle(title: string): Locator;
|
||||
/** Idempotent: a blind click on an open section collapses it. */
|
||||
expand(title: string): Promise<void>;
|
||||
collapse(title: string): Promise<void>;
|
||||
};
|
||||
control(name: EditorControl): Locator;
|
||||
/**
|
||||
* A ConfigTiles choice. The radio input is visually hidden, so the label
|
||||
* carrying `<group testid>-<value>` is the click target.
|
||||
*/
|
||||
tile(name: EditorControl, value: string): Locator;
|
||||
setTile(name: EditorControl, value: string): Promise<void>;
|
||||
/** The tile's radio input, for `toBeChecked`. */
|
||||
tileInput(name: EditorControl, value: string): Locator;
|
||||
/**
|
||||
* Whether a refresh or tab close would be held by the editor's
|
||||
* `beforeunload` prompt, checked without leaving the page.
|
||||
*/
|
||||
blocksUnload(): Promise<boolean>;
|
||||
/**
|
||||
* Drives a slider control from the keyboard ('Home', 'End', 'ArrowRight', …);
|
||||
* a key commits like a released drag.
|
||||
*/
|
||||
pressSlider(name: EditorControl, key: string): Promise<void>;
|
||||
text: TextPane;
|
||||
/** The heading text, which carries no testid. */
|
||||
heading(): Locator;
|
||||
/** A disabled Save swallows pointer events, so this hovers its tooltip trigger. */
|
||||
hoverSave(): Promise<void>;
|
||||
query: QueryPane;
|
||||
preview: {
|
||||
root(): Locator;
|
||||
renderer(kind: PanelKind): Locator;
|
||||
chart: Chart;
|
||||
markdown: Markdown;
|
||||
};
|
||||
}
|
||||
|
||||
function isDashboardPatch(method: string, url: string): boolean {
|
||||
return method === 'PATCH' && /\/api\/v2\/dashboards\//.test(url);
|
||||
}
|
||||
|
||||
function editorDriver(page: Page): PanelEditor {
|
||||
const byTestId = (suffix: string): Locator =>
|
||||
page.getByTestId(`panel-editor-v2-${suffix}`);
|
||||
const preview = (): Locator => page.getByTestId('preview-pane');
|
||||
const sectionToggle = (title: string): Locator =>
|
||||
page.getByTestId(
|
||||
`config-section-${title.toLowerCase().replace(/\s+/g, '-')}`,
|
||||
);
|
||||
|
||||
const editor: PanelEditor = {
|
||||
root: () => page.locator(EDITOR_ROOT),
|
||||
title: () => byTestId('title'),
|
||||
description: () => byTestId('description'),
|
||||
saveButton: () => byTestId('save'),
|
||||
closeButton: () => byTestId('close'),
|
||||
unsavedBadge: () => byTestId('unsaved-badge'),
|
||||
switchToView: () => byTestId('switch-to-view'),
|
||||
async save() {
|
||||
const { ok, body } = await editor.trySave();
|
||||
// A rejected patch leaves the editor open, which would otherwise surface
|
||||
// as an unrelated timeout several lines later.
|
||||
expect(ok, `dashboard PATCH failed: ${body}`).toBe(true);
|
||||
},
|
||||
async trySave() {
|
||||
const [response] = await Promise.all([
|
||||
page.waitForResponse((r) =>
|
||||
isDashboardPatch(r.request().method(), r.url()),
|
||||
),
|
||||
byTestId('save').click(),
|
||||
]);
|
||||
return { ok: response.ok(), body: await response.text() };
|
||||
},
|
||||
async close(options) {
|
||||
await byTestId('close').click();
|
||||
if (!options?.expectDirty) {
|
||||
return;
|
||||
}
|
||||
await expect(byTestId('discard-modal')).toBeVisible();
|
||||
await byTestId(
|
||||
options.keepEditing ? 'discard-cancel' : 'discard-confirm',
|
||||
).click();
|
||||
},
|
||||
discardModal: () => byTestId('discard-modal'),
|
||||
capturePatches() {
|
||||
const batches: PatchOperation[][] = [];
|
||||
page.on('request', (request) => {
|
||||
if (!isDashboardPatch(request.method(), request.url())) {
|
||||
return;
|
||||
}
|
||||
const body = request.postDataJSON() as PatchOperation[] | null;
|
||||
if (body) {
|
||||
batches.push(body);
|
||||
}
|
||||
});
|
||||
return batches;
|
||||
},
|
||||
section: {
|
||||
toggle: sectionToggle,
|
||||
async expand(title) {
|
||||
const toggle = sectionToggle(title);
|
||||
await expect(toggle).toBeVisible();
|
||||
if ((await toggle.getAttribute('aria-expanded')) !== 'true') {
|
||||
await toggle.click();
|
||||
}
|
||||
await expect(toggle).toHaveAttribute('aria-expanded', 'true');
|
||||
},
|
||||
async collapse(title) {
|
||||
const toggle = sectionToggle(title);
|
||||
if ((await toggle.getAttribute('aria-expanded')) === 'true') {
|
||||
await toggle.click();
|
||||
}
|
||||
await expect(toggle).toHaveAttribute('aria-expanded', 'false');
|
||||
},
|
||||
},
|
||||
control: (name) => byTestId(name),
|
||||
tile: (name, value) => byTestId(`${name}-${value}`),
|
||||
async setTile(name, value) {
|
||||
await editor.tile(name, value).click();
|
||||
},
|
||||
tileInput: (name, value) => editor.tile(name, value).locator('input'),
|
||||
blocksUnload: () =>
|
||||
page.evaluate(() => {
|
||||
const event = new Event('beforeunload', { cancelable: true });
|
||||
window.dispatchEvent(event);
|
||||
return event.defaultPrevented;
|
||||
}),
|
||||
async pressSlider(name, key) {
|
||||
const thumb = byTestId(name).getByRole('slider');
|
||||
await thumb.focus();
|
||||
await thumb.press(key);
|
||||
},
|
||||
text: textPaneAt(page),
|
||||
...selectsAt(page),
|
||||
heading: () => editor.root().getByText(EditorText.title),
|
||||
async hoverSave() {
|
||||
await page
|
||||
.locator('[data-slot="tooltip-trigger"]')
|
||||
.filter({ has: byTestId('save') })
|
||||
.hover();
|
||||
},
|
||||
...typeSwitcherAt(page),
|
||||
query: queryPaneAt(page, EditorText.runQuery),
|
||||
preview: {
|
||||
root: preview,
|
||||
renderer: (kind) => preview().getByTestId(RENDERER_TESTID[kind]),
|
||||
chart: chartAt(page, () => preview().getByTestId('uplot-main-div')),
|
||||
markdown: markdownAt(() =>
|
||||
preview().getByTestId(RENDERER_TESTID[PanelKind.Text]),
|
||||
),
|
||||
},
|
||||
};
|
||||
|
||||
return editor;
|
||||
}
|
||||
|
||||
const withEditor = driver('editor', ({ page }) => editorDriver(page));
|
||||
const withNewPanel = driver('newPanel', ({ page }) => newPanelDriver(page));
|
||||
|
||||
export const test = withNewPanel(withEditor(panelsTest));
|
||||
|
||||
export * from './panels';
|
||||
export * from './panel-editor/picker';
|
||||
export * from './panel-editor/query';
|
||||
export * from './panel-editor/select';
|
||||
export * from './panel-editor/text';
|
||||
export * from './panel-editor/type-switcher';
|
||||
131
tests/e2e/drivers/panel-editor/picker.ts
generated
Normal file
131
tests/e2e/drivers/panel-editor/picker.ts
generated
Normal file
@@ -0,0 +1,131 @@
|
||||
/**
|
||||
* AI-owned. Generated and maintained by coding agents alongside the specs that
|
||||
* use it. Do not hand-edit: regenerate it with the spec that needs the change.
|
||||
*/
|
||||
import { expect, type Locator, type Page } from '@playwright/test';
|
||||
|
||||
import { PanelKind } from '../../helpers/dashboard-spec';
|
||||
import { PATH } from '../dashboards';
|
||||
|
||||
const NEW_PANEL_DRAWER = 'panel-type-drawer';
|
||||
|
||||
/** The type browser's catalog, in render order. */
|
||||
export const PANEL_TYPE_TILES: {
|
||||
kind: PanelKind;
|
||||
label: string;
|
||||
category: string;
|
||||
}[] = [
|
||||
{
|
||||
kind: PanelKind.TimeSeries,
|
||||
label: 'Time Series',
|
||||
category: 'Trends over time',
|
||||
},
|
||||
{
|
||||
kind: PanelKind.AreaChart,
|
||||
label: 'Area Chart',
|
||||
category: 'Trends over time',
|
||||
},
|
||||
{
|
||||
kind: PanelKind.BarChart,
|
||||
label: 'Bar Chart',
|
||||
category: 'Compare & rank',
|
||||
},
|
||||
{
|
||||
kind: PanelKind.PieChart,
|
||||
label: 'Pie Chart',
|
||||
category: 'Compare & rank',
|
||||
},
|
||||
{
|
||||
kind: PanelKind.Histogram,
|
||||
label: 'Histogram',
|
||||
category: 'Distributions',
|
||||
},
|
||||
{ kind: PanelKind.Heatmap, label: 'Heatmap', category: 'Distributions' },
|
||||
{ kind: PanelKind.Number, label: 'Number', category: 'Single values' },
|
||||
{ kind: PanelKind.Table, label: 'Table', category: 'Raw records' },
|
||||
{ kind: PanelKind.List, label: 'List', category: 'Raw records' },
|
||||
{ kind: PanelKind.Text, label: 'Text', category: 'Documentation' },
|
||||
];
|
||||
|
||||
/**
|
||||
* The New panel drawer every Add panel trigger opens. A tile click only selects
|
||||
* (TimeSeries is preselected); the confirm button navigates to the editor.
|
||||
*/
|
||||
export interface NewPanelPicker {
|
||||
root(): Locator;
|
||||
tile(kind: PanelKind): Locator;
|
||||
/** Visible tiles, in render order. */
|
||||
tiles(): Locator;
|
||||
/** Visible group headings, in render order. */
|
||||
groups(): Locator;
|
||||
search(): Locator;
|
||||
/** A category chip; its accessible name carries the count. */
|
||||
category(label: string): Locator;
|
||||
emptyState(): Locator;
|
||||
/** Reads "Add panel", or "Add to <section>" once there is a choice. */
|
||||
confirmButton(): Locator;
|
||||
/** Selects without confirming. */
|
||||
pick(kind: PanelKind): Promise<void>;
|
||||
confirm(): Promise<void>;
|
||||
/** Selects and confirms, landing on the editor route. */
|
||||
choose(kind: PanelKind): Promise<void>;
|
||||
/** `value` is the layoutIndex, or 'root' for a root yet to be created. */
|
||||
chooseSection(value: string): Promise<void>;
|
||||
/** Switches the target to a new section named `title`. */
|
||||
newSection(title: string): Promise<void>;
|
||||
cancel(): Promise<void>;
|
||||
/** An empty section's own New Panel CTA. Should move to drivers/dashboards.ts. */
|
||||
sectionAddPanel(): Locator;
|
||||
}
|
||||
|
||||
export function newPanelDriver(page: Page): NewPanelPicker {
|
||||
const root = (): Locator => page.getByTestId(NEW_PANEL_DRAWER);
|
||||
const tiles = (): Locator =>
|
||||
root().locator('[data-testid^="panel-type-signoz/"]');
|
||||
|
||||
const picker: NewPanelPicker = {
|
||||
root,
|
||||
tile: (kind) => root().getByTestId(`panel-type-${kind}`),
|
||||
tiles,
|
||||
groups: () => root().locator('section > span:first-child'),
|
||||
search: () => root().getByTestId('panel-type-search'),
|
||||
category: (label) =>
|
||||
root()
|
||||
.locator('button[aria-pressed]:not([data-testid])')
|
||||
.filter({ hasText: new RegExp(`^${escapeRegExp(label)}\\d+$`) }),
|
||||
emptyState: () => root().getByText(/No panel types match/),
|
||||
confirmButton: () => root().getByTestId('panel-type-confirm'),
|
||||
async pick(kind) {
|
||||
await picker.tile(kind).click();
|
||||
await expect(picker.tile(kind)).toHaveAttribute('aria-pressed', 'true');
|
||||
},
|
||||
async confirm() {
|
||||
await picker.confirmButton().click();
|
||||
await page.waitForURL(PATH.newPanel);
|
||||
},
|
||||
async choose(kind) {
|
||||
await picker.pick(kind);
|
||||
await picker.confirm();
|
||||
},
|
||||
async chooseSection(value) {
|
||||
await root().getByRole('button', { name: 'Choose section' }).click();
|
||||
await page.getByTestId(`panel-section-option-${value}`).click();
|
||||
},
|
||||
async newSection(title) {
|
||||
await root().getByRole('button', { name: 'Choose section' }).click();
|
||||
await page.getByTestId('panel-section-create').click();
|
||||
await root().getByTestId('panel-section-name').fill(title);
|
||||
},
|
||||
async cancel() {
|
||||
await root().getByRole('button', { name: 'Cancel', exact: true }).click();
|
||||
await expect(root()).toHaveCount(0);
|
||||
},
|
||||
sectionAddPanel: () =>
|
||||
page.locator('[data-testid^="section-add-panel-"]').first(),
|
||||
};
|
||||
return picker;
|
||||
}
|
||||
|
||||
function escapeRegExp(text: string): string {
|
||||
return text.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
||||
}
|
||||
63
tests/e2e/drivers/panel-editor/query.ts
generated
Normal file
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'),
|
||||
})),
|
||||
);
|
||||
},
|
||||
};
|
||||
}
|
||||
50
tests/e2e/drivers/panel-editor/text.ts
generated
Normal file
50
tests/e2e/drivers/panel-editor/text.ts
generated
Normal file
@@ -0,0 +1,50 @@
|
||||
/**
|
||||
* AI-owned. Generated and maintained by coding agents alongside the specs that
|
||||
* use it. Do not hand-edit: regenerate it with the spec that needs the change.
|
||||
*/
|
||||
import { type Locator, type Page } from '@playwright/test';
|
||||
|
||||
/** The Text kind's editor pane and its own config controls. */
|
||||
export interface TextPane {
|
||||
pane(): Locator;
|
||||
/** The CodeMirror document. */
|
||||
input(): Locator;
|
||||
/**
|
||||
* Replaces the whole body. Inserted as text, not typed, so CodeMirror's
|
||||
* bracket auto-close cannot double `[` or `(`.
|
||||
*/
|
||||
setMarkdown(markdown: string): Promise<void>;
|
||||
toolbar(): Locator;
|
||||
/** Rendered only when the dashboard has variables. */
|
||||
insertVariableButton(): Locator;
|
||||
insertVariable(name: string): Promise<void>;
|
||||
/** The "Hide panel header" switch, inline under the title, not in a section. */
|
||||
hideHeader(): Locator;
|
||||
/** `vertical-horizontal`, e.g. 'center-center'. */
|
||||
alignment(vertical: string, horizontal: string): Locator;
|
||||
}
|
||||
|
||||
export function textPaneAt(page: Page): TextPane {
|
||||
const textPane = (): Locator => page.getByTestId('text-panel-editor-pane');
|
||||
const textInput = (): Locator => textPane().locator('.cm-content');
|
||||
return {
|
||||
pane: textPane,
|
||||
input: textInput,
|
||||
async setMarkdown(markdown) {
|
||||
await textInput().click();
|
||||
await page.keyboard.press('ControlOrMeta+a');
|
||||
await page.keyboard.press('Delete');
|
||||
await page.keyboard.insertText(markdown);
|
||||
},
|
||||
toolbar: () => textPane().getByTestId('markdown-editor-toolbar'),
|
||||
insertVariableButton: () =>
|
||||
textPane().getByTestId('markdown-insert-variable'),
|
||||
async insertVariable(name) {
|
||||
await textPane().getByTestId('markdown-insert-variable').click();
|
||||
await page.getByTestId(`markdown-variable-${name}`).click();
|
||||
},
|
||||
hideHeader: () => page.getByTestId('panel-header-hide'),
|
||||
alignment: (vertical, horizontal) =>
|
||||
page.getByTestId(`text-layout-align-${vertical}-${horizontal}`),
|
||||
};
|
||||
}
|
||||
69
tests/e2e/drivers/panel-editor/type-switcher.ts
generated
Normal file
69
tests/e2e/drivers/panel-editor/type-switcher.ts
generated
Normal file
@@ -0,0 +1,69 @@
|
||||
/**
|
||||
* AI-owned. Generated and maintained by coding agents alongside the specs that
|
||||
* use it. Do not hand-edit: regenerate it with the spec that needs the change.
|
||||
*/
|
||||
import { expect, type Locator, type Page } from '@playwright/test';
|
||||
|
||||
import { PanelKind } from '../../helpers/dashboard-spec';
|
||||
|
||||
import { type SelectOption } from './select';
|
||||
|
||||
const TYPE_SWITCHER_DRAWER = 'panel-type-switcher-drawer';
|
||||
|
||||
export interface TypeSwitcher {
|
||||
typeSwitcher(): Locator;
|
||||
/** Opens the kind picker drawer and picks one. */
|
||||
switchType(kind: PanelKind): Promise<void>;
|
||||
/** A tile in the open type-switcher drawer. */
|
||||
typeOption(kind: PanelKind): Locator;
|
||||
openTypeSwitcher(): Promise<Locator>;
|
||||
/** Every tile in the type switcher, opening it; `disabled` reads aria-disabled. */
|
||||
typeOptions(): Promise<SelectOption[]>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Tiles are aria-disabled, not disabled, so their reason tooltip still hovers.
|
||||
* The label is the display name alone: the tile also renders a preview and a
|
||||
* description.
|
||||
*/
|
||||
async function tileOptions(scope: Locator): Promise<SelectOption[]> {
|
||||
const tiles = scope.locator('[data-testid^="panel-type-signoz/"]');
|
||||
await expect(tiles.first()).toBeVisible();
|
||||
return tiles.evaluateAll((nodes) =>
|
||||
nodes.map((node) => ({
|
||||
label:
|
||||
node
|
||||
.querySelector(
|
||||
':scope > span:last-child > span:first-child > span:first-child',
|
||||
)
|
||||
?.textContent?.trim() ?? '',
|
||||
disabled: node.getAttribute('aria-disabled') === 'true',
|
||||
})),
|
||||
);
|
||||
}
|
||||
|
||||
export function typeSwitcherAt(page: Page): TypeSwitcher {
|
||||
const trigger = (): Locator =>
|
||||
page.getByTestId('panel-editor-v2-type-switcher');
|
||||
const switcher: TypeSwitcher = {
|
||||
typeSwitcher: trigger,
|
||||
typeOption: (kind) =>
|
||||
page.getByTestId(TYPE_SWITCHER_DRAWER).getByTestId(`panel-type-${kind}`),
|
||||
async openTypeSwitcher() {
|
||||
await trigger().click();
|
||||
const drawer = page.getByTestId(TYPE_SWITCHER_DRAWER);
|
||||
await expect(drawer).toBeVisible();
|
||||
return drawer;
|
||||
},
|
||||
async switchType(kind) {
|
||||
await switcher.openTypeSwitcher();
|
||||
await switcher.typeOption(kind).click();
|
||||
await expect(page.getByTestId(TYPE_SWITCHER_DRAWER)).toHaveCount(0);
|
||||
},
|
||||
async typeOptions() {
|
||||
const drawer = await switcher.openTypeSwitcher();
|
||||
return tileOptions(drawer);
|
||||
},
|
||||
};
|
||||
return switcher;
|
||||
}
|
||||
568
tests/e2e/drivers/panel-kinds.ts
generated
Normal file
568
tests/e2e/drivers/panel-kinds.ts
generated
Normal file
@@ -0,0 +1,568 @@
|
||||
/**
|
||||
* AI-owned. Generated and maintained by coding agents alongside the specs that
|
||||
* use it. Do not hand-edit: regenerate it with the spec that needs the change.
|
||||
*/
|
||||
import { expect, type Locator } from '@playwright/test';
|
||||
|
||||
import {
|
||||
buildDashboard,
|
||||
type EditorControl,
|
||||
GOLDEN,
|
||||
heatmapPoints,
|
||||
heatmapTooltipAt,
|
||||
heatmapQuery,
|
||||
metricsQuery,
|
||||
NEW_KINDS_MARKDOWN,
|
||||
type Panel,
|
||||
panel,
|
||||
type PanelHandle,
|
||||
PanelKind,
|
||||
type PostableDashboard,
|
||||
QueryRange,
|
||||
type QueryRangeBody,
|
||||
type QueryRangeMock,
|
||||
QueryTab,
|
||||
ramp,
|
||||
rawQuery,
|
||||
RENDERER_TESTID,
|
||||
Section,
|
||||
type Signal,
|
||||
SINGLE_PANEL_ID,
|
||||
type ThresholdVariant,
|
||||
} from './panel-config';
|
||||
|
||||
// One row per panel kind, every value read off the kind's definition.ts and
|
||||
// sections.ts (and capabilities.ts for the query matrix). The contract specs
|
||||
// generate one test per row, so a new kind is one row here.
|
||||
|
||||
export type QueryType = 'builder' | 'clickhouse' | 'promql';
|
||||
|
||||
export type DownloadFormat = 'CSV' | 'PNG' | 'SVG';
|
||||
|
||||
/**
|
||||
* Every collapsible section title. Text's PanelHeader section is left out: it
|
||||
* renders inline in Panel Details, with no toggle of its own.
|
||||
*/
|
||||
export const ALL_SECTIONS: string[] = Object.values(Section);
|
||||
|
||||
/** The axis controls a kind can declare; Heatmap has `y-scale` only. */
|
||||
export const AXIS_CONTROLS: EditorControl[] = [
|
||||
'soft-min',
|
||||
'soft-max',
|
||||
'log-scale',
|
||||
'y-scale',
|
||||
];
|
||||
|
||||
export interface PanelKindCase {
|
||||
kind: PanelKind;
|
||||
/** `displayName`: the picker tile, switcher option and tooltip wording. */
|
||||
label: string;
|
||||
renderer: string;
|
||||
/** Renders from its own spec: no query, so no data states. */
|
||||
queryless: boolean;
|
||||
/** The panel as seeded; renders against golden data. */
|
||||
panel(): Panel;
|
||||
/** A one-panel dashboard holding `panel()` under SINGLE_PANEL_ID. */
|
||||
seed(): PostableDashboard;
|
||||
/** A mocked result the kind renders; null when queryless. */
|
||||
body(): QueryRangeBody | null;
|
||||
/** A mocked successful result with nothing in it; null when queryless. */
|
||||
emptyBody(): QueryRangeBody | null;
|
||||
/** Number renders the shared empty state under its own testid. */
|
||||
noDataTestId: string;
|
||||
sections: string[];
|
||||
forbiddenSections: string[];
|
||||
/** Visualization declares the time-preference control. */
|
||||
timePreference: boolean;
|
||||
/** Axis controls shown once Axes opens; the rest of AXIS_CONTROLS are absent. */
|
||||
axisControls: EditorControl[];
|
||||
queryTabs: string[];
|
||||
/** Query types the switcher accepts, AI excluded. */
|
||||
queryTypes: QueryType[];
|
||||
/** Builder signals the switcher accepts. */
|
||||
signals: Signal[];
|
||||
downloads: DownloadFormat[];
|
||||
createAlert: boolean;
|
||||
drilldown: boolean;
|
||||
/** The kind's data gesture: what a drilldown would hang off. */
|
||||
click(target: PanelHandle): Promise<void>;
|
||||
headerSearch: boolean;
|
||||
thresholdVariant: ThresholdVariant | null;
|
||||
columnsEditor: boolean;
|
||||
}
|
||||
|
||||
const BUCKETS = [10, 50, 100, 500];
|
||||
|
||||
const ALL_SIGNALS: Signal[] = ['metrics', 'logs', 'traces'];
|
||||
const ALL_TABS = [
|
||||
QueryTab.builder,
|
||||
QueryTab.clickhouse,
|
||||
QueryTab.promql,
|
||||
QueryTab.ai,
|
||||
];
|
||||
const IMAGES: DownloadFormat[] = ['PNG', 'SVG'];
|
||||
|
||||
const CHART_SECTIONS = [
|
||||
Section.visualization,
|
||||
Section.formatting,
|
||||
Section.axes,
|
||||
Section.legend,
|
||||
Section.thresholds,
|
||||
Section.contextLinks,
|
||||
];
|
||||
|
||||
function oneSeries(): QueryRangeBody {
|
||||
return QueryRange.timeSeries([
|
||||
{ labels: { 'service.name': 'adservice' }, points: ramp(12, 2, 8) },
|
||||
]);
|
||||
}
|
||||
|
||||
function serviceScalar(rows: (string | number)[][]): QueryRangeBody {
|
||||
return QueryRange.scalar({
|
||||
groupColumns: ['service.name'],
|
||||
aggregationColumns: ['A'],
|
||||
rows,
|
||||
});
|
||||
}
|
||||
|
||||
function singlePanel(content: Panel): PostableDashboard {
|
||||
return buildDashboard({
|
||||
sections: [{ title: 'Section', panels: { [SINGLE_PANEL_ID]: content } }],
|
||||
});
|
||||
}
|
||||
|
||||
type RowInput = Omit<
|
||||
PanelKindCase,
|
||||
'renderer' | 'seed' | 'forbiddenSections' | 'noDataTestId'
|
||||
> &
|
||||
Partial<Pick<PanelKindCase, 'noDataTestId'>>;
|
||||
|
||||
function row(input: RowInput): PanelKindCase {
|
||||
return {
|
||||
noDataTestId: 'panel-no-data',
|
||||
...input,
|
||||
renderer: RENDERER_TESTID[input.kind],
|
||||
seed: () => singlePanel(input.panel()),
|
||||
forbiddenSections: ALL_SECTIONS.filter(
|
||||
(title) => !input.sections.includes(title),
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
const clickChartPoint = (target: PanelHandle): Promise<void> =>
|
||||
target.chart.clickPoint();
|
||||
const clickChart = (target: PanelHandle): Promise<void> => target.chart.click();
|
||||
|
||||
/**
|
||||
* A slice's bounding-box centre can fall in the donut hole, so this clicks the
|
||||
* centroid, where the slice's label leader line starts.
|
||||
*/
|
||||
async function clickPieSlice(target: PanelHandle): Promise<void> {
|
||||
const leader = target
|
||||
.renderer(PanelKind.PieChart)
|
||||
.locator('svg g line')
|
||||
.first();
|
||||
const { x, y } = await leader.evaluate((node) => {
|
||||
const line = node as SVGLineElement;
|
||||
const matrix = line.getScreenCTM();
|
||||
const point = new DOMPoint(
|
||||
line.x1.baseVal.value,
|
||||
line.y1.baseVal.value,
|
||||
).matrixTransform(matrix ?? undefined);
|
||||
return { x: point.x, y: point.y };
|
||||
});
|
||||
await target.root().page().mouse.click(x, y);
|
||||
}
|
||||
|
||||
/** Waits for the cell tooltip, so a missing menu is not a click that missed. */
|
||||
async function clickHeatmapCell(target: PanelHandle): Promise<void> {
|
||||
await target.chart.click();
|
||||
await expect(heatmapTooltipAt(target.root().page()).root()).toBeVisible();
|
||||
}
|
||||
|
||||
export const PANEL_KINDS: PanelKindCase[] = [
|
||||
row({
|
||||
kind: PanelKind.TimeSeries,
|
||||
label: 'Time Series',
|
||||
queryless: false,
|
||||
panel: () => panel(PanelKind.TimeSeries, { name: 'Calls by service' }),
|
||||
body: oneSeries,
|
||||
emptyBody: () => QueryRange.empty(),
|
||||
sections: [...CHART_SECTIONS, Section.chartAppearance],
|
||||
timePreference: true,
|
||||
axisControls: ['soft-min', 'soft-max', 'log-scale'],
|
||||
queryTabs: ALL_TABS,
|
||||
queryTypes: ['builder', 'clickhouse', 'promql'],
|
||||
signals: ALL_SIGNALS,
|
||||
downloads: IMAGES,
|
||||
createAlert: true,
|
||||
drilldown: true,
|
||||
click: clickChartPoint,
|
||||
headerSearch: false,
|
||||
thresholdVariant: 'label',
|
||||
columnsEditor: false,
|
||||
}),
|
||||
row({
|
||||
kind: PanelKind.BarChart,
|
||||
label: 'Bar Chart',
|
||||
queryless: false,
|
||||
panel: () => panel(PanelKind.BarChart, { name: 'Calls bar' }),
|
||||
body: oneSeries,
|
||||
emptyBody: () => QueryRange.empty(),
|
||||
sections: CHART_SECTIONS,
|
||||
timePreference: true,
|
||||
axisControls: ['soft-min', 'soft-max', 'log-scale'],
|
||||
queryTabs: ALL_TABS,
|
||||
queryTypes: ['builder', 'clickhouse', 'promql'],
|
||||
signals: ALL_SIGNALS,
|
||||
downloads: IMAGES,
|
||||
createAlert: true,
|
||||
drilldown: true,
|
||||
click: clickChartPoint,
|
||||
headerSearch: false,
|
||||
thresholdVariant: 'label',
|
||||
columnsEditor: false,
|
||||
}),
|
||||
row({
|
||||
kind: PanelKind.AreaChart,
|
||||
label: 'Area Chart',
|
||||
queryless: false,
|
||||
panel: () => panel(PanelKind.AreaChart, { name: 'Calls area' }),
|
||||
body: oneSeries,
|
||||
emptyBody: () => QueryRange.empty(),
|
||||
sections: [...CHART_SECTIONS, Section.chartAppearance],
|
||||
timePreference: true,
|
||||
axisControls: ['soft-min', 'soft-max', 'log-scale'],
|
||||
queryTabs: ALL_TABS,
|
||||
queryTypes: ['builder', 'clickhouse', 'promql'],
|
||||
signals: ALL_SIGNALS,
|
||||
downloads: IMAGES,
|
||||
createAlert: true,
|
||||
drilldown: true,
|
||||
click: clickChartPoint,
|
||||
headerSearch: false,
|
||||
thresholdVariant: 'label',
|
||||
columnsEditor: false,
|
||||
}),
|
||||
row({
|
||||
kind: PanelKind.Histogram,
|
||||
label: 'Histogram',
|
||||
queryless: false,
|
||||
panel: () =>
|
||||
panel(PanelKind.Histogram, {
|
||||
name: 'Latency distribution',
|
||||
query: metricsQuery({ metricName: GOLDEN.metrics.latencySum, groupBy: [] }),
|
||||
}),
|
||||
body: oneSeries,
|
||||
emptyBody: () => QueryRange.empty(),
|
||||
sections: [
|
||||
Section.visualization,
|
||||
Section.legend,
|
||||
Section.buckets,
|
||||
Section.contextLinks,
|
||||
],
|
||||
timePreference: false,
|
||||
axisControls: [],
|
||||
queryTabs: ALL_TABS,
|
||||
queryTypes: ['builder', 'clickhouse', 'promql'],
|
||||
signals: ALL_SIGNALS,
|
||||
downloads: IMAGES,
|
||||
createAlert: true,
|
||||
drilldown: false,
|
||||
click: clickChart,
|
||||
headerSearch: false,
|
||||
thresholdVariant: null,
|
||||
columnsEditor: false,
|
||||
}),
|
||||
row({
|
||||
kind: PanelKind.Number,
|
||||
label: 'Number',
|
||||
queryless: false,
|
||||
panel: () =>
|
||||
panel(PanelKind.Number, {
|
||||
name: 'Total calls',
|
||||
pluginSpec: { formatting: { unit: 'short', decimalPrecision: '2' } },
|
||||
query: metricsQuery({ groupBy: [] }),
|
||||
}),
|
||||
body: () => QueryRange.scalar({ aggregationColumns: ['A'], rows: [[1234]] }),
|
||||
emptyBody: () => QueryRange.scalar({ aggregationColumns: ['A'], rows: [] }),
|
||||
noDataTestId: 'number-panel-no-data',
|
||||
sections: [
|
||||
Section.visualization,
|
||||
Section.formatting,
|
||||
Section.thresholds,
|
||||
Section.contextLinks,
|
||||
],
|
||||
timePreference: true,
|
||||
axisControls: [],
|
||||
queryTabs: ALL_TABS,
|
||||
queryTypes: ['builder', 'clickhouse', 'promql'],
|
||||
signals: ALL_SIGNALS,
|
||||
downloads: IMAGES,
|
||||
createAlert: true,
|
||||
drilldown: true,
|
||||
// The whole renderer is the click target.
|
||||
click: (target) => target.renderer(PanelKind.Number).click(),
|
||||
headerSearch: false,
|
||||
thresholdVariant: 'comparison',
|
||||
columnsEditor: false,
|
||||
}),
|
||||
row({
|
||||
kind: PanelKind.PieChart,
|
||||
label: 'Pie Chart',
|
||||
queryless: false,
|
||||
panel: () => panel(PanelKind.PieChart, { name: 'Calls share' }),
|
||||
body: () =>
|
||||
serviceScalar([
|
||||
['adservice', 10],
|
||||
['cartservice', 20],
|
||||
]),
|
||||
emptyBody: () => serviceScalar([]),
|
||||
sections: [
|
||||
Section.visualization,
|
||||
Section.formatting,
|
||||
Section.legend,
|
||||
Section.contextLinks,
|
||||
],
|
||||
timePreference: true,
|
||||
axisControls: [],
|
||||
queryTabs: [QueryTab.builder, QueryTab.clickhouse, QueryTab.ai],
|
||||
queryTypes: ['builder', 'clickhouse'],
|
||||
signals: ALL_SIGNALS,
|
||||
downloads: IMAGES,
|
||||
createAlert: false,
|
||||
drilldown: true,
|
||||
click: clickPieSlice,
|
||||
headerSearch: false,
|
||||
thresholdVariant: null,
|
||||
columnsEditor: false,
|
||||
}),
|
||||
row({
|
||||
kind: PanelKind.Table,
|
||||
label: 'Table',
|
||||
queryless: false,
|
||||
panel: () => panel(PanelKind.Table, { name: 'Calls table' }),
|
||||
body: () =>
|
||||
serviceScalar([
|
||||
['adservice', 10],
|
||||
['cartservice', 20],
|
||||
]),
|
||||
emptyBody: () => serviceScalar([]),
|
||||
sections: [
|
||||
Section.visualization,
|
||||
Section.formatting,
|
||||
Section.thresholds,
|
||||
Section.contextLinks,
|
||||
],
|
||||
timePreference: true,
|
||||
axisControls: [],
|
||||
queryTabs: [QueryTab.builder, QueryTab.clickhouse, QueryTab.ai],
|
||||
queryTypes: ['builder', 'clickhouse'],
|
||||
signals: ALL_SIGNALS,
|
||||
downloads: ['CSV', ...IMAGES],
|
||||
createAlert: false,
|
||||
drilldown: true,
|
||||
click: (target) => target.table.rows().first().locator('td').last().click(),
|
||||
headerSearch: true,
|
||||
thresholdVariant: 'table',
|
||||
columnsEditor: false,
|
||||
}),
|
||||
row({
|
||||
kind: PanelKind.List,
|
||||
label: 'List',
|
||||
queryless: false,
|
||||
panel: () =>
|
||||
panel(PanelKind.List, {
|
||||
name: 'Recent logs',
|
||||
pluginSpec: {
|
||||
selectFields: [
|
||||
{ name: 'timestamp', signal: 'logs' },
|
||||
{ name: 'body', signal: 'logs' },
|
||||
],
|
||||
},
|
||||
query: rawQuery({ signal: 'logs' }),
|
||||
}),
|
||||
body: () =>
|
||||
QueryRange.raw(
|
||||
Array.from({ length: 3 }, (_, i) => ({
|
||||
timestamp: new Date(Date.UTC(2026, 0, 1, 0, i)).toISOString(),
|
||||
body: `log line ${i}`,
|
||||
'service.name': 'adservice',
|
||||
})),
|
||||
),
|
||||
emptyBody: () => QueryRange.raw([]),
|
||||
sections: [Section.visualization],
|
||||
timePreference: false,
|
||||
axisControls: [],
|
||||
queryTabs: [QueryTab.builder, QueryTab.ai],
|
||||
queryTypes: ['builder'],
|
||||
signals: ['logs', 'traces'],
|
||||
downloads: IMAGES,
|
||||
createAlert: false,
|
||||
drilldown: false,
|
||||
click: (target) =>
|
||||
target.renderer(PanelKind.List).getByText('log line 0').click(),
|
||||
headerSearch: true,
|
||||
thresholdVariant: null,
|
||||
columnsEditor: true,
|
||||
}),
|
||||
row({
|
||||
kind: PanelKind.Heatmap,
|
||||
label: 'Heatmap',
|
||||
queryless: false,
|
||||
panel: () =>
|
||||
panel(PanelKind.Heatmap, { name: 'Calls heatmap', query: heatmapQuery() }),
|
||||
body: () =>
|
||||
QueryRange.heatmap(BUCKETS, [
|
||||
{
|
||||
labels: { 'service.name': 'adservice' },
|
||||
points: heatmapPoints(12, BUCKETS.length + 1),
|
||||
},
|
||||
]),
|
||||
// Bucket bounds but no series: the heatmap's own empty body.
|
||||
emptyBody: () => QueryRange.heatmap(BUCKETS, []),
|
||||
sections: [
|
||||
Section.visualization,
|
||||
Section.chartAppearance,
|
||||
Section.formatting,
|
||||
Section.axes,
|
||||
Section.legend,
|
||||
],
|
||||
timePreference: true,
|
||||
axisControls: ['y-scale'],
|
||||
queryTabs: [QueryTab.builder, QueryTab.clickhouse, QueryTab.promql],
|
||||
queryTypes: ['builder', 'clickhouse', 'promql'],
|
||||
signals: ['metrics'],
|
||||
downloads: IMAGES,
|
||||
createAlert: false,
|
||||
drilldown: false,
|
||||
click: clickHeatmapCell,
|
||||
headerSearch: false,
|
||||
thresholdVariant: null,
|
||||
columnsEditor: false,
|
||||
}),
|
||||
row({
|
||||
kind: PanelKind.Text,
|
||||
label: 'Text',
|
||||
queryless: true,
|
||||
panel: () =>
|
||||
panel(PanelKind.Text, {
|
||||
name: 'Runbook',
|
||||
pluginSpec: { text: NEW_KINDS_MARKDOWN },
|
||||
}),
|
||||
body: () => null,
|
||||
emptyBody: () => null,
|
||||
sections: [Section.visualization, Section.panelAppearance],
|
||||
timePreference: false,
|
||||
axisControls: [],
|
||||
queryTabs: [],
|
||||
queryTypes: [],
|
||||
signals: [],
|
||||
downloads: [],
|
||||
createAlert: false,
|
||||
drilldown: false,
|
||||
click: (target) => target.markdown.root().click(),
|
||||
headerSearch: false,
|
||||
thresholdVariant: null,
|
||||
columnsEditor: false,
|
||||
}),
|
||||
];
|
||||
|
||||
export interface NoDataState {
|
||||
root(): Locator;
|
||||
action(): Locator;
|
||||
secondaryAction(): Locator;
|
||||
}
|
||||
|
||||
/** The kind's empty state; its actions take the state's testid as a prefix. */
|
||||
export function noDataOf(c: PanelKindCase, target: PanelHandle): NoDataState {
|
||||
const byTestId = (suffix: string): Locator =>
|
||||
target.root().getByTestId(`${c.noDataTestId}${suffix}`);
|
||||
return {
|
||||
root: () => byTestId(''),
|
||||
action: () => byTestId('-action'),
|
||||
secondaryAction: () => byTestId('-secondary-action'),
|
||||
};
|
||||
}
|
||||
|
||||
export const QUERYABLE_KINDS = PANEL_KINDS.filter((c) => !c.queryless);
|
||||
|
||||
export function kindCase(kind: PanelKind): PanelKindCase {
|
||||
const match = PANEL_KINDS.find((c) => c.kind === kind);
|
||||
if (!match) {
|
||||
throw new Error(`no PANEL_KINDS row for ${kind}`);
|
||||
}
|
||||
return match;
|
||||
}
|
||||
|
||||
/**
|
||||
* The kind's panel under SINGLE_PANEL_ID, below a tall queryless filler so it
|
||||
* starts outside the viewport.
|
||||
*/
|
||||
export function belowTheFold(c: PanelKindCase): PostableDashboard {
|
||||
const dashboard = buildDashboard({
|
||||
sections: [
|
||||
{
|
||||
title: 'Section',
|
||||
panels: {
|
||||
filler: panel(PanelKind.Text, { name: 'Filler' }),
|
||||
[SINGLE_PANEL_ID]: c.panel(),
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
const [layout] = dashboard.spec.layouts;
|
||||
layout.spec.items = [
|
||||
{ ...layout.spec.items[0], x: 0, y: 0, width: 12, height: 40 },
|
||||
{ ...layout.spec.items[1], x: 0, y: 40 },
|
||||
];
|
||||
return dashboard;
|
||||
}
|
||||
|
||||
/** Serves the kind's mocked result; a queryless kind sends no query to serve. */
|
||||
export async function respondWithData(
|
||||
queryRange: QueryRangeMock,
|
||||
c: PanelKindCase,
|
||||
): Promise<void> {
|
||||
const body = c.body();
|
||||
if (body) {
|
||||
await queryRange.respond(body);
|
||||
}
|
||||
}
|
||||
|
||||
/** The kind's panel plus an empty second section, so Move to section has a target. */
|
||||
export function withSpareSection(c: PanelKindCase): PostableDashboard {
|
||||
return buildDashboard({
|
||||
sections: [
|
||||
{ title: 'Section', panels: { [SINGLE_PANEL_ID]: c.panel() } },
|
||||
{ title: 'Spare', panels: {} },
|
||||
],
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* The switcher's reason for refusing `c`, or null when it accepts: the query
|
||||
* type is checked before the signal (getPanelTypeDisabledReason).
|
||||
*/
|
||||
export function disabledReason(
|
||||
c: PanelKindCase,
|
||||
source: { queryType: QueryType; signal?: Signal },
|
||||
): string | null {
|
||||
if (c.queryless) {
|
||||
return null;
|
||||
}
|
||||
if (!c.queryTypes.includes(source.queryType)) {
|
||||
const label = {
|
||||
builder: 'Query Builder',
|
||||
clickhouse: 'ClickHouse',
|
||||
promql: 'PromQL',
|
||||
}[source.queryType];
|
||||
return `${c.label} isn't available for ${label} queries`;
|
||||
}
|
||||
if (source.signal && !c.signals.includes(source.signal)) {
|
||||
return `${c.label} doesn't support ${source.signal} data`;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export * from './panel-config';
|
||||
274
tests/e2e/drivers/panels.ts
generated
Normal file
274
tests/e2e/drivers/panels.ts
generated
Normal file
@@ -0,0 +1,274 @@
|
||||
/**
|
||||
* AI-owned. Generated and maintained by coding agents alongside the specs that
|
||||
* use it. Do not hand-edit: regenerate it with the spec that needs the change.
|
||||
*/
|
||||
import { expect, type Locator, type Page } from '@playwright/test';
|
||||
|
||||
import { PanelKind } from '../helpers/dashboard-spec';
|
||||
|
||||
import { test as dashboardsTest } from './dashboards';
|
||||
import { driver } from './kit';
|
||||
import { actionsAt, PanelAction, type PanelActions } from './panels/actions';
|
||||
import { type Chart, chartAt } from './panels/chart';
|
||||
import { openSelectOption } from './panels/dom';
|
||||
import { gridAt, type PanelGrid } from './panels/grid';
|
||||
import {
|
||||
type HeatmapPanel,
|
||||
type HeatmapTooltip,
|
||||
heatmapAt,
|
||||
heatmapTooltipAt,
|
||||
} from './panels/heatmap';
|
||||
import { type Legend, legendAt } from './panels/legend';
|
||||
import { type Markdown, markdownAt } from './panels/markdown';
|
||||
import { type PiePanel, pieAt } from './panels/pie';
|
||||
import {
|
||||
type ListPager,
|
||||
pagerAt,
|
||||
type PanelSearch,
|
||||
searchAt,
|
||||
type TableColumns,
|
||||
tableAt,
|
||||
} from './panels/table';
|
||||
|
||||
// Rendered panels, on the dashboard grid and in the View modal. Each surface
|
||||
// lives in its own part under ./panels, and so does a kind with UI of its own.
|
||||
|
||||
export const PanelMessageText = {
|
||||
noQueryTitle: 'Nothing to visualize yet',
|
||||
// Curly apostrophe (U+2019): a straight quote will not match.
|
||||
errorTitle: 'Couldn’t load panel data',
|
||||
noDataTitle: 'No data in this time range',
|
||||
extendAction: 'Extend time range',
|
||||
retryAction: 'Retry',
|
||||
} as const;
|
||||
|
||||
/** Root testid each kind's renderer puts on its own subtree. */
|
||||
export const RENDERER_TESTID: Record<PanelKind, string> = {
|
||||
[PanelKind.TimeSeries]: 'time-series-renderer',
|
||||
[PanelKind.BarChart]: 'bar-panel-renderer',
|
||||
[PanelKind.AreaChart]: 'area-chart-renderer',
|
||||
[PanelKind.Histogram]: 'histogram-panel-renderer',
|
||||
[PanelKind.Number]: 'number-panel-renderer',
|
||||
[PanelKind.PieChart]: 'pie-panel-renderer',
|
||||
[PanelKind.Table]: 'table-panel-renderer',
|
||||
[PanelKind.List]: 'list-panel-renderer',
|
||||
[PanelKind.Heatmap]: 'heatmap-panel-renderer',
|
||||
[PanelKind.Text]: 'text-panel',
|
||||
};
|
||||
|
||||
export interface PanelHandle {
|
||||
root(): Locator;
|
||||
/** Panels fetch lazily behind an IntersectionObserver. */
|
||||
scrollIntoView(): Promise<void>;
|
||||
/** Scrolls in, then waits for the kind's own renderer. */
|
||||
waitRendered(kind: PanelKind): Promise<void>;
|
||||
renderer(kind: PanelKind): Locator;
|
||||
/** `data-panel-visible`, 'true' once the panel entered the viewport. */
|
||||
visibility(): Locator;
|
||||
title(): Locator;
|
||||
infoIcon(): Locator;
|
||||
timePreference(): Locator;
|
||||
statusWarning(): Locator;
|
||||
statusError(): Locator;
|
||||
loading(): Locator;
|
||||
noData(): Locator;
|
||||
noDataAction(): Locator;
|
||||
noDataSecondaryAction(): Locator;
|
||||
error(): Locator;
|
||||
errorAction(): Locator;
|
||||
text(text: string): Locator;
|
||||
numberValue(): Locator;
|
||||
numberNoData(): Locator;
|
||||
/** Replaces the header when a Text panel hides it; carries the drag class. */
|
||||
hiddenHeaderHandle(): Locator;
|
||||
markdown: Markdown;
|
||||
heatmap: HeatmapPanel;
|
||||
pie: PiePanel;
|
||||
chart: Chart;
|
||||
legend: Legend;
|
||||
actions: PanelActions;
|
||||
search: PanelSearch;
|
||||
pager: ListPager;
|
||||
table: TableColumns;
|
||||
grid: PanelGrid;
|
||||
}
|
||||
|
||||
export interface Panels {
|
||||
panel(id: string): PanelHandle;
|
||||
/** Every panel title on the grid. */
|
||||
titles(): Locator;
|
||||
tooltip: {
|
||||
container(): Locator;
|
||||
list(): Locator;
|
||||
unpin(): Locator;
|
||||
/** The pin key is 'p' (DEFAULT_PIN_TOOLTIP_KEY); its JSDoc still says 'l'. */
|
||||
pin(): Promise<void>;
|
||||
/** Hover tooltips render their content twice, so this scopes to the tooltip role. */
|
||||
statusContent(): Locator;
|
||||
/** Free text inside any open tooltip. */
|
||||
text(text: string): Locator;
|
||||
heatmap: HeatmapTooltip;
|
||||
};
|
||||
drilldown: {
|
||||
menu(): Locator;
|
||||
/** Testids sit on an inner span; this walks up so disabled states assert. */
|
||||
item(testId: string): Locator;
|
||||
back(menu: 'breakout' | 'var'): Locator;
|
||||
breakoutSearch(): Locator;
|
||||
/** A page-level Escape does nothing: the handler needs the backdrop. */
|
||||
dismiss(): Promise<void>;
|
||||
};
|
||||
view: {
|
||||
/** Opens the modal through the actions menu. */
|
||||
open(panelId: string): Promise<Locator>;
|
||||
content(): Locator;
|
||||
dialog(): Locator;
|
||||
refresh(): Locator;
|
||||
switchToEdit(): Locator;
|
||||
chart: Chart;
|
||||
renderer(kind: PanelKind): Locator;
|
||||
markdown: Markdown;
|
||||
/** Text only: the Markdown editor in the query builder's slot. */
|
||||
textEditor(): Locator;
|
||||
/** Changes the kind for this session only. */
|
||||
switchType(label: string): Promise<void>;
|
||||
};
|
||||
/** A visible option of the open antd Select. */
|
||||
openSelectOption(label: string): Locator;
|
||||
/** Fires the browser's Back. */
|
||||
back(): Promise<void>;
|
||||
pressEscape(): Promise<void>;
|
||||
/** Resolves with the tab a click opens. */
|
||||
nextTab(): Promise<Page>;
|
||||
/** Resolves with the suggested filename of the next download. */
|
||||
nextDownload(): Promise<{ suggestedFilename: () => string }>;
|
||||
}
|
||||
|
||||
function panelHandle(page: Page, id: string): PanelHandle {
|
||||
const root = (): Locator => page.locator(`[data-panel-root="${id}"]`);
|
||||
const byTestId = (testId: string): Locator => root().getByTestId(testId);
|
||||
|
||||
return {
|
||||
root,
|
||||
async scrollIntoView() {
|
||||
await root().scrollIntoViewIfNeeded();
|
||||
},
|
||||
async waitRendered(kind) {
|
||||
await root().scrollIntoViewIfNeeded();
|
||||
await expect(root()).toHaveAttribute('data-panel-visible', 'true');
|
||||
await expect(byTestId(RENDERER_TESTID[kind])).toBeVisible();
|
||||
},
|
||||
renderer: (kind) => byTestId(RENDERER_TESTID[kind]),
|
||||
visibility: root,
|
||||
title: () => byTestId('panel-title'),
|
||||
infoIcon: () => byTestId('panel-header-info-icon'),
|
||||
timePreference: () => byTestId('panel-time-preference'),
|
||||
statusWarning: () => byTestId('panel-status-warning'),
|
||||
statusError: () => byTestId('panel-status-error'),
|
||||
loading: () => byTestId('panel-loading'),
|
||||
noData: () => byTestId('panel-no-data'),
|
||||
noDataAction: () => byTestId('panel-no-data-action'),
|
||||
noDataSecondaryAction: () => byTestId('panel-no-data-secondary-action'),
|
||||
error: () => byTestId('panel-error'),
|
||||
errorAction: () => byTestId('panel-error-action'),
|
||||
text: (text) => root().getByText(text),
|
||||
numberValue: () => byTestId('number-panel-value'),
|
||||
numberNoData: () => byTestId('number-panel-no-data'),
|
||||
hiddenHeaderHandle: () => byTestId('hidden-header-drag-handle'),
|
||||
markdown: markdownAt(() => byTestId(RENDERER_TESTID[PanelKind.Text])),
|
||||
heatmap: heatmapAt(root),
|
||||
pie: pieAt(root),
|
||||
chart: chartAt(page, () => byTestId('uplot-main-div')),
|
||||
legend: legendAt(root),
|
||||
actions: actionsAt(page, id, root),
|
||||
search: searchAt(root),
|
||||
pager: pagerAt(page, root),
|
||||
table: tableAt(page, id, root),
|
||||
grid: gridAt(page, root),
|
||||
};
|
||||
}
|
||||
|
||||
function panelsDriver(page: Page): Panels {
|
||||
const menu = (): Locator => page.locator('.context-menu');
|
||||
const viewContent = (): Locator =>
|
||||
page.getByTestId('view-panel-modal-content');
|
||||
|
||||
return {
|
||||
panel: (id) => panelHandle(page, id),
|
||||
titles: () => page.getByTestId('panel-title'),
|
||||
tooltip: {
|
||||
container: () => page.getByTestId('uplot-tooltip-container'),
|
||||
list: () => page.getByTestId('uplot-tooltip-list'),
|
||||
unpin: () => page.getByTestId('uplot-tooltip-unpin'),
|
||||
async pin() {
|
||||
await page.keyboard.press('p');
|
||||
},
|
||||
statusContent: () =>
|
||||
page.getByRole('tooltip').getByTestId('panel-status-content'),
|
||||
text: (text) => page.getByText(text),
|
||||
heatmap: heatmapTooltipAt(page),
|
||||
},
|
||||
drilldown: {
|
||||
menu,
|
||||
item: (testId) =>
|
||||
page
|
||||
.getByTestId(testId)
|
||||
.locator(
|
||||
'xpath=ancestor-or-self::button[contains(@class,"context-menu-item")][1]',
|
||||
),
|
||||
back: (submenu) => page.getByTestId(`drilldown-${submenu}-back`),
|
||||
breakoutSearch: () => page.getByPlaceholder('Search breakout options...'),
|
||||
async dismiss() {
|
||||
// A corner, since the menu opens over the clicked point.
|
||||
await page
|
||||
.locator('.context-menu-backdrop')
|
||||
.click({ position: { x: 5, y: 5 } });
|
||||
},
|
||||
},
|
||||
view: {
|
||||
async open(panelId) {
|
||||
await panelHandle(page, panelId).actions.run(PanelAction.view);
|
||||
await expect(viewContent()).toBeVisible();
|
||||
return viewContent();
|
||||
},
|
||||
content: viewContent,
|
||||
dialog: () => page.getByRole('dialog'),
|
||||
refresh: () => page.getByTestId('view-panel-refresh'),
|
||||
switchToEdit: () => page.getByTestId('view-panel-switch-to-edit'),
|
||||
chart: chartAt(page, () => viewContent().getByTestId('uplot-main-div')),
|
||||
renderer: (kind) => viewContent().getByTestId(RENDERER_TESTID[kind]),
|
||||
markdown: markdownAt(() =>
|
||||
viewContent().getByTestId(RENDERER_TESTID[PanelKind.Text]),
|
||||
),
|
||||
textEditor: () => viewContent().getByTestId('text-panel-editor-pane'),
|
||||
async switchType(label) {
|
||||
await page.getByTestId('view-panel-type-selector').click();
|
||||
await openSelectOption(page, label).click();
|
||||
},
|
||||
},
|
||||
openSelectOption: (label) => openSelectOption(page, label),
|
||||
async back() {
|
||||
await page.goBack();
|
||||
},
|
||||
async pressEscape() {
|
||||
await page.keyboard.press('Escape');
|
||||
},
|
||||
nextTab: () => page.context().waitForEvent('page'),
|
||||
nextDownload: () => page.waitForEvent('download'),
|
||||
};
|
||||
}
|
||||
|
||||
const withPanels = driver('panels', ({ page }) => panelsDriver(page));
|
||||
|
||||
export const test = withPanels(dashboardsTest);
|
||||
|
||||
export * from './dashboards';
|
||||
export * from './panels/actions';
|
||||
export * from './panels/chart';
|
||||
export * from './panels/dom';
|
||||
export * from './panels/grid';
|
||||
export * from './panels/heatmap';
|
||||
export * from './panels/legend';
|
||||
export * from './panels/markdown';
|
||||
export * from './panels/pie';
|
||||
export * from './panels/table';
|
||||
88
tests/e2e/drivers/panels/actions.ts
generated
Normal file
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;
|
||||
}
|
||||
287
tests/e2e/drivers/panels/chart.ts
generated
Normal file
287
tests/e2e/drivers/panels/chart.ts
generated
Normal file
@@ -0,0 +1,287 @@
|
||||
/**
|
||||
* AI-owned. Generated and maintained by coding agents alongside the specs that
|
||||
* use it. Do not hand-edit: regenerate it with the spec that needs the change.
|
||||
*/
|
||||
import { expect, type Locator, type Page } from '@playwright/test';
|
||||
|
||||
import { boxOf, dragHorizontally } from './dom';
|
||||
|
||||
/** uPlot encodes a log scale as `distr: 3`. */
|
||||
export const LOG_DISTR = 3;
|
||||
|
||||
export interface UPlotSeriesState {
|
||||
label?: string;
|
||||
/** false once a series is hidden via the legend. */
|
||||
show: boolean;
|
||||
/** Non-empty for a dashed line style. */
|
||||
dash?: number[];
|
||||
/** uPlot only sets a fill when the panel asks for one. */
|
||||
hasFill: boolean;
|
||||
/** The resolved fill: a colour (`#rrggbbaa` when solid) or 'gradient'. */
|
||||
fill: string | null;
|
||||
/** Largest finite value plotted, after any stacking; null when none. */
|
||||
max: number | null;
|
||||
/** A predicate cannot cross `evaluate`, so it reports as 'fn'. */
|
||||
pointsShow?: boolean | 'fn' | null;
|
||||
width?: number;
|
||||
/** uPlot's own gap bridging; false when a threshold breaks gaps in data-prep. */
|
||||
spanGaps?: boolean | number;
|
||||
/** Nulls in the plotted data: each one is a break in the line. */
|
||||
nullCount: number;
|
||||
}
|
||||
|
||||
export interface UPlotScaleState {
|
||||
min: number | null;
|
||||
max: number | null;
|
||||
/** 1 linear, 3 logarithmic. */
|
||||
distr?: number;
|
||||
}
|
||||
|
||||
export interface UPlotState {
|
||||
/** Excludes the x series at index 0. */
|
||||
series: UPlotSeriesState[];
|
||||
scales: Record<string, UPlotScaleState>;
|
||||
/** Points in the x series. */
|
||||
pointCount: number;
|
||||
/** Fill bands between series; a stacked chart has one per adjacent pair. */
|
||||
bandCount: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Real chart state, read from the `__uplot` handle UPlotChart hangs off its
|
||||
* container. The DOM only says a chart exists; this catches a renderer that
|
||||
* ignores a setting.
|
||||
*/
|
||||
export interface Chart {
|
||||
host(): Locator;
|
||||
/** uPlot binds its cursor to `.u-over`, so this is the pointer target. */
|
||||
plotArea(): Locator;
|
||||
/** Throws when there is no plot, usually because the panel shows No Data. */
|
||||
state(): Promise<UPlotState>;
|
||||
/** The left scale, where axis bounds and log mode land. */
|
||||
yScale(): Promise<UPlotScaleState>;
|
||||
expectSeriesCount(count: number): Promise<void>;
|
||||
/**
|
||||
* Share (0–1) of the plot-area row at `value` on the left scale painted in
|
||||
* `color`. For lines drawn straight onto the canvas, such as thresholds,
|
||||
* which leave no state on the uPlot instance.
|
||||
*/
|
||||
lineCoverage(value: number, color: string): Promise<number>;
|
||||
/** Moves the pointer to the middle of the plot. */
|
||||
hover(): Promise<void>;
|
||||
/** Moves the pointer just outside the plot's top-left corner. */
|
||||
leave(): Promise<void>;
|
||||
click(): Promise<void>;
|
||||
/**
|
||||
* Clicks the first series' data point at `index`, by default the middle of
|
||||
* those inside the visible window, placed through uPlot's own scales so it
|
||||
* lands on data wherever it sits.
|
||||
*/
|
||||
clickPoint(index?: number): Promise<void>;
|
||||
/** Fractions are of the plot area's own box, so panel size does not matter. */
|
||||
drag(fromFraction: number, toFraction: number): Promise<void>;
|
||||
}
|
||||
|
||||
async function readUplot(host: Locator): Promise<UPlotState> {
|
||||
await expect(host).toBeVisible();
|
||||
return host.evaluate((node) => {
|
||||
const plot = (node as { __uplot?: unknown }).__uplot as
|
||||
| {
|
||||
series: {
|
||||
label?: string;
|
||||
show?: boolean;
|
||||
dash?: number[];
|
||||
fill?: unknown;
|
||||
width?: number;
|
||||
spanGaps?: boolean | number;
|
||||
points?: { show?: boolean | ((...args: unknown[]) => boolean) };
|
||||
}[];
|
||||
scales: Record<string, { min?: number; max?: number; distr?: number }>;
|
||||
data: unknown[][];
|
||||
bands?: unknown[];
|
||||
}
|
||||
| undefined;
|
||||
|
||||
if (!plot) {
|
||||
throw new Error(
|
||||
'no uPlot instance on the chart container — the panel probably rendered "No Data" instead of a plot',
|
||||
);
|
||||
}
|
||||
|
||||
// uPlot wraps every fill in a function; calling it yields the colour or gradient.
|
||||
const resolveFill = (fill: unknown, seriesIndex: number): string | null => {
|
||||
const value =
|
||||
typeof fill === 'function'
|
||||
? (fill as (u: unknown, i: number) => unknown)(plot, seriesIndex)
|
||||
: fill;
|
||||
if (typeof value === 'string') {
|
||||
return value;
|
||||
}
|
||||
return value instanceof CanvasGradient ? 'gradient' : null;
|
||||
};
|
||||
|
||||
const maxOf = (values: unknown[] | undefined): number | null => {
|
||||
const finite = (values ?? []).filter(
|
||||
(value): value is number =>
|
||||
typeof value === 'number' && Number.isFinite(value),
|
||||
);
|
||||
return finite.length > 0 ? Math.max(...finite) : null;
|
||||
};
|
||||
|
||||
return {
|
||||
series: plot.series.slice(1).map((series, index) => ({
|
||||
label: series.label,
|
||||
show: series.show !== false,
|
||||
dash: series.dash,
|
||||
hasFill: series.fill != null,
|
||||
fill: resolveFill(series.fill, index + 1),
|
||||
max: maxOf(plot.data?.[index + 1]),
|
||||
pointsShow:
|
||||
typeof series.points?.show === 'function'
|
||||
? ('fn' as const)
|
||||
: (series.points?.show ?? null),
|
||||
width: series.width,
|
||||
spanGaps: series.spanGaps,
|
||||
nullCount: (plot.data?.[index + 1] ?? []).filter((v) => v === null).length,
|
||||
})),
|
||||
scales: Object.fromEntries(
|
||||
Object.entries(plot.scales).map(([key, scale]) => [
|
||||
key,
|
||||
{ min: scale.min ?? null, max: scale.max ?? null, distr: scale.distr },
|
||||
]),
|
||||
),
|
||||
pointCount: plot.data?.[0]?.length ?? 0,
|
||||
bandCount: plot.bands?.length ?? 0,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
export function chartAt(page: Page, host: () => Locator): Chart {
|
||||
const plotArea = (): Locator => host().locator('.u-over');
|
||||
const chart: Chart = {
|
||||
host,
|
||||
plotArea,
|
||||
state: () => readUplot(host()),
|
||||
async yScale() {
|
||||
const { scales } = await chart.state();
|
||||
// Panels name the left scale 'y'; fall back to the first non-x scale.
|
||||
return (
|
||||
scales.y ??
|
||||
Object.entries(scales).find(([key]) => key !== 'x')?.[1] ?? {
|
||||
min: null,
|
||||
max: null,
|
||||
}
|
||||
);
|
||||
},
|
||||
async lineCoverage(value, color) {
|
||||
await expect(host()).toBeVisible();
|
||||
return host().evaluate(
|
||||
(node, { value, color }) => {
|
||||
const plot = (node as { __uplot?: unknown }).__uplot as
|
||||
| {
|
||||
ctx: CanvasRenderingContext2D;
|
||||
bbox: { left: number; width: number };
|
||||
scales: Record<string, unknown>;
|
||||
valToPos: (v: number, scale: string, canvas: boolean) => number;
|
||||
}
|
||||
| undefined;
|
||||
if (!plot) {
|
||||
throw new Error('no uPlot instance on the chart container');
|
||||
}
|
||||
const scale =
|
||||
'y' in plot.scales
|
||||
? 'y'
|
||||
: (Object.keys(plot.scales).find((key) => key !== 'x') ?? 'y');
|
||||
|
||||
const probe = document.createElement('canvas').getContext('2d');
|
||||
if (!probe) {
|
||||
throw new Error('no 2d context to resolve the colour');
|
||||
}
|
||||
probe.fillStyle = color;
|
||||
probe.fillRect(0, 0, 1, 1);
|
||||
const [r, g, b] = probe.getImageData(0, 0, 1, 1).data;
|
||||
|
||||
// The line is 2px wide and anti-aliased: read the rows around it and
|
||||
// count a column when any of them is close to the colour.
|
||||
const y = Math.round(plot.valToPos(value, scale, true));
|
||||
const x = Math.round(plot.bbox.left);
|
||||
const width = Math.round(plot.bbox.width);
|
||||
const { data } = plot.ctx.getImageData(x, y - 1, width, 3);
|
||||
let hits = 0;
|
||||
for (let column = 0; column < width; column++) {
|
||||
for (let row = 0; row < 3; row++) {
|
||||
const i = (row * width + column) * 4;
|
||||
const close =
|
||||
data[i + 3] > 0 &&
|
||||
Math.abs(data[i] - r) < 60 &&
|
||||
Math.abs(data[i + 1] - g) < 60 &&
|
||||
Math.abs(data[i + 2] - b) < 60;
|
||||
if (close) {
|
||||
hits += 1;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
return width > 0 ? hits / width : 0;
|
||||
},
|
||||
{ value, color },
|
||||
);
|
||||
},
|
||||
async expectSeriesCount(count) {
|
||||
await expect
|
||||
.poll(async () => (await chart.state()).series.length)
|
||||
.toBe(count);
|
||||
},
|
||||
async hover() {
|
||||
const box = await boxOf(host(), 'the chart');
|
||||
await page.mouse.move(box.x + box.width / 2, box.y + box.height / 2);
|
||||
},
|
||||
async leave() {
|
||||
const box = await boxOf(host(), 'the chart');
|
||||
await page.mouse.move(box.x - 20, box.y - 20);
|
||||
},
|
||||
async click() {
|
||||
const box = await boxOf(host(), 'the chart');
|
||||
await page.mouse.click(box.x + box.width / 2, box.y + box.height / 2);
|
||||
},
|
||||
async clickPoint(index) {
|
||||
await expect(host()).toBeVisible();
|
||||
const point = await host().evaluate((node, at) => {
|
||||
const plot = (node as { __uplot?: unknown }).__uplot as
|
||||
| {
|
||||
data: [number[], ...(number | null)[][]];
|
||||
scales: { x: { min?: number; max?: number } };
|
||||
series: { scale?: string }[];
|
||||
over: HTMLElement;
|
||||
valToPos: (value: number, scale: string) => number;
|
||||
}
|
||||
| undefined;
|
||||
if (!plot) {
|
||||
throw new Error('no uPlot instance on the chart container');
|
||||
}
|
||||
const xs = plot.data[0];
|
||||
const { min = -Infinity, max = Infinity } = plot.scales.x;
|
||||
const visible = xs
|
||||
.map((x, i) => ({ x, i }))
|
||||
.filter(({ x }) => x >= min && x <= max);
|
||||
const i = at ?? visible[Math.floor(visible.length / 2)]?.i ?? 0;
|
||||
const y = plot.data[1]?.[i];
|
||||
if (y == null) {
|
||||
throw new Error(`no value at index ${i} of the first series`);
|
||||
}
|
||||
const over = plot.over.getBoundingClientRect();
|
||||
return {
|
||||
x: over.left + plot.valToPos(xs[i], 'x'),
|
||||
y: over.top + plot.valToPos(y, plot.series[1].scale ?? 'y'),
|
||||
};
|
||||
}, index);
|
||||
await page.mouse.move(point.x, point.y);
|
||||
await page.mouse.click(point.x, point.y);
|
||||
},
|
||||
async drag(fromFraction, toFraction) {
|
||||
const box = await boxOf(plotArea(), 'the plot area');
|
||||
await dragHorizontally(page, box, fromFraction, toFraction);
|
||||
},
|
||||
};
|
||||
return chart;
|
||||
}
|
||||
57
tests/e2e/drivers/panels/dom.ts
generated
Normal file
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,
|
||||
);
|
||||
},
|
||||
};
|
||||
}
|
||||
34
tests/e2e/drivers/panels/heatmap.ts
generated
Normal file
34
tests/e2e/drivers/panels/heatmap.ts
generated
Normal file
@@ -0,0 +1,34 @@
|
||||
/**
|
||||
* AI-owned. Generated and maintained by coding agents alongside the specs that
|
||||
* use it. Do not hand-edit: regenerate it with the spec that needs the change.
|
||||
*/
|
||||
import { type Locator, type Page } from '@playwright/test';
|
||||
|
||||
export interface HeatmapPanel {
|
||||
/** The count colour bar under the grid: caption, min label, ramp, max label. */
|
||||
colorBar(): Locator;
|
||||
/** Marks the hovered cell's count on the bar. */
|
||||
colorBarMarker(): Locator;
|
||||
}
|
||||
|
||||
export interface HeatmapTooltip {
|
||||
root(): Locator;
|
||||
bucket(): Locator;
|
||||
count(): Locator;
|
||||
}
|
||||
|
||||
export function heatmapAt(root: () => Locator): HeatmapPanel {
|
||||
const byTestId = (testId: string): Locator => root().getByTestId(testId);
|
||||
return {
|
||||
colorBar: () => byTestId('color-bar'),
|
||||
colorBarMarker: () => byTestId('color-bar-marker'),
|
||||
};
|
||||
}
|
||||
|
||||
export function heatmapTooltipAt(page: Page): HeatmapTooltip {
|
||||
return {
|
||||
root: () => page.getByTestId('heatmap-tooltip'),
|
||||
bucket: () => page.getByTestId('heatmap-tooltip-bucket'),
|
||||
count: () => page.getByTestId('heatmap-tooltip-count'),
|
||||
};
|
||||
}
|
||||
52
tests/e2e/drivers/panels/legend.ts
generated
Normal file
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'),
|
||||
};
|
||||
}
|
||||
27
tests/e2e/drivers/panels/markdown.ts
generated
Normal file
27
tests/e2e/drivers/panels/markdown.ts
generated
Normal file
@@ -0,0 +1,27 @@
|
||||
/**
|
||||
* AI-owned. Generated and maintained by coding agents alongside the specs that
|
||||
* use it. Do not hand-edit: regenerate it with the spec that needs the change.
|
||||
*/
|
||||
import { type Locator } from '@playwright/test';
|
||||
|
||||
/** A rendered Text panel body. */
|
||||
export interface Markdown {
|
||||
root(): Locator;
|
||||
heading(name: string): Locator;
|
||||
listItems(): Locator;
|
||||
link(name: string): Locator;
|
||||
codeBlock(): Locator;
|
||||
/** "Nothing written yet", for a blank body. */
|
||||
empty(): Locator;
|
||||
}
|
||||
|
||||
export function markdownAt(root: () => Locator): Markdown {
|
||||
return {
|
||||
root,
|
||||
heading: (name) => root().getByRole('heading', { name }),
|
||||
listItems: () => root().getByRole('listitem'),
|
||||
link: (name) => root().getByRole('link', { name }),
|
||||
codeBlock: () => root().locator('pre code'),
|
||||
empty: () => root().getByTestId('text-panel-empty'),
|
||||
};
|
||||
}
|
||||
22
tests/e2e/drivers/panels/pie.ts
generated
Normal file
22
tests/e2e/drivers/panels/pie.ts
generated
Normal file
@@ -0,0 +1,22 @@
|
||||
/**
|
||||
* AI-owned. Generated and maintained by coding agents alongside the specs that
|
||||
* use it. Do not hand-edit: regenerate it with the spec that needs the change.
|
||||
*/
|
||||
import { type Locator } from '@playwright/test';
|
||||
|
||||
/** The donut @visx draws: one `<path>` per visible slice, the total in the hole. */
|
||||
export interface PiePanel {
|
||||
arcs(): Locator;
|
||||
/** The total of the visible slices, centred in the donut hole. */
|
||||
total(): Locator;
|
||||
}
|
||||
|
||||
export function pieAt(root: () => Locator): PiePanel {
|
||||
// The legend's icons are SVGs too; the donut is the first one.
|
||||
const chart = (): Locator =>
|
||||
root().getByTestId('pie-chart').locator('svg').first();
|
||||
return {
|
||||
arcs: () => chart().locator('g > path'),
|
||||
total: () => chart().locator('text[text-anchor="middle"]'),
|
||||
};
|
||||
}
|
||||
105
tests/e2e/drivers/panels/table.ts
generated
Normal file
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 },
|
||||
),
|
||||
};
|
||||
}
|
||||
539
tests/e2e/helpers/dashboard-spec.ts
Normal file
539
tests/e2e/helpers/dashboard-spec.ts
Normal file
@@ -0,0 +1,539 @@
|
||||
// Typed builders for the (Perses-shaped) dashboard spec — pure data, no
|
||||
// Playwright. Mirrors Go's `dashboardtypes.DashboardSpec`.
|
||||
//
|
||||
// Three server rules that surface as an opaque 400:
|
||||
// 1. POST decodes with DisallowUnknownFields — any stray key rejects it all.
|
||||
// 2. Every panel needs EXACTLY ONE query; Text needs `queries: []`.
|
||||
// 3. Panel keys match /^[a-zA-Z0-9_.-]+$/, so readable ids double as test
|
||||
// handles (`panel-actions-${id}`).
|
||||
|
||||
// ─── Kinds ───────────────────────────────────────────────────────────────
|
||||
|
||||
export const PanelKind = {
|
||||
TimeSeries: 'signoz/TimeSeriesPanel',
|
||||
BarChart: 'signoz/BarChartPanel',
|
||||
AreaChart: 'signoz/AreaChartPanel',
|
||||
Histogram: 'signoz/HistogramPanel',
|
||||
Number: 'signoz/NumberPanel',
|
||||
PieChart: 'signoz/PieChartPanel',
|
||||
Table: 'signoz/TablePanel',
|
||||
List: 'signoz/ListPanel',
|
||||
Heatmap: 'signoz/HeatmapPanel',
|
||||
Text: 'signoz/TextPanel',
|
||||
} as const;
|
||||
|
||||
export type PanelKind = (typeof PanelKind)[keyof typeof PanelKind];
|
||||
|
||||
export type Signal = 'metrics' | 'logs' | 'traces';
|
||||
|
||||
// ─── Queries ─────────────────────────────────────────────────────────────
|
||||
|
||||
export interface MetricAggregation {
|
||||
metricName: string;
|
||||
timeAggregation: string;
|
||||
spaceAggregation: string;
|
||||
reduceTo: string;
|
||||
}
|
||||
|
||||
/** Logs/traces aggregations are expression-shaped ("count()"), not metric-shaped. */
|
||||
export interface ExpressionAggregation {
|
||||
expression: string;
|
||||
}
|
||||
|
||||
export interface OrderBy {
|
||||
key: { name: string };
|
||||
direction: 'asc' | 'desc';
|
||||
}
|
||||
|
||||
export interface BuilderQuerySpec {
|
||||
name: string;
|
||||
signal: Signal;
|
||||
aggregations: (MetricAggregation | ExpressionAggregation)[];
|
||||
filter?: { expression: string };
|
||||
groupBy?: { name: string }[];
|
||||
order?: OrderBy[];
|
||||
limit?: number;
|
||||
}
|
||||
|
||||
export type QueryPlugin =
|
||||
| { kind: 'signoz/BuilderQuery'; spec: BuilderQuerySpec }
|
||||
| { kind: 'signoz/PromQLQuery'; spec: { name: string; query: string } }
|
||||
| { kind: 'signoz/ClickHouseSQL'; spec: { name: string; query: string } };
|
||||
|
||||
/**
|
||||
* List reads `raw`, Heatmap `heatmap`; every other kind reads `time_series`.
|
||||
* Mixing them renders empty.
|
||||
*/
|
||||
export type QueryResultKind = 'time_series' | 'raw' | 'heatmap';
|
||||
|
||||
export interface Query {
|
||||
kind: QueryResultKind;
|
||||
spec: { plugin: QueryPlugin };
|
||||
}
|
||||
|
||||
// ─── Panel plugin specs ──────────────────────────────────────────────────
|
||||
|
||||
export interface Threshold {
|
||||
value: number;
|
||||
color: string;
|
||||
operator?:
|
||||
| 'above'
|
||||
| 'aboveOrEqual'
|
||||
| 'below'
|
||||
| 'belowOrEqual'
|
||||
| 'equal'
|
||||
| 'notEqual';
|
||||
format?: 'background' | 'text';
|
||||
unit?: string;
|
||||
label?: string;
|
||||
columnName?: string;
|
||||
}
|
||||
|
||||
export type StackMode = 'none' | 'normal' | 'percent';
|
||||
|
||||
export type HeatmapPalette =
|
||||
| 'ice'
|
||||
| 'moss'
|
||||
| 'rust'
|
||||
| 'graphite'
|
||||
| 'ember'
|
||||
| 'lagoon'
|
||||
| 'orchid'
|
||||
| 'verdant'
|
||||
| 'lava'
|
||||
| 'beacon';
|
||||
|
||||
export interface HeatmapColors {
|
||||
mode?: 'palette' | 'opacity';
|
||||
palette?: HeatmapPalette;
|
||||
scale?: 'log' | 'sqrt' | 'linear';
|
||||
/** 2–128. */
|
||||
steps?: number;
|
||||
minCount?: number | null;
|
||||
maxCount?: number | null;
|
||||
/** Opacity mode only. */
|
||||
fill?: string;
|
||||
}
|
||||
|
||||
export interface TextPresentation {
|
||||
textAlign?: 'left' | 'center' | 'right';
|
||||
verticalAlign?: 'top' | 'center' | 'bottom';
|
||||
/** A hex colour. */
|
||||
background?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* One bag for every kind; the server's strict decoder rejects a key the kind
|
||||
* does not declare, so a mismatch fails the seed rather than rendering wrong.
|
||||
*/
|
||||
export interface PanelPluginSpec {
|
||||
visualization?: {
|
||||
timePreference?: string;
|
||||
fillSpans?: boolean;
|
||||
stackedBarChart?: boolean;
|
||||
/** Area only. */
|
||||
stack?: StackMode;
|
||||
};
|
||||
formatting?: {
|
||||
unit?: string;
|
||||
decimalPrecision?: string;
|
||||
columnUnits?: Record<string, string>;
|
||||
};
|
||||
axes?: {
|
||||
softMin?: number | null;
|
||||
softMax?: number | null;
|
||||
isLogScale?: boolean;
|
||||
/** Heatmap only, and its only axis key. */
|
||||
y?: { scale: 'auto' | 'linear' | 'log' | 'symlog' };
|
||||
};
|
||||
legend?: {
|
||||
position?: 'bottom' | 'right';
|
||||
customColors?: Record<string, string>;
|
||||
};
|
||||
chartAppearance?: {
|
||||
lineStyle?: 'solid' | 'dashed';
|
||||
lineInterpolation?: 'linear' | 'spline' | 'step_before' | 'step_after';
|
||||
/** Area drops `none`. */
|
||||
fillMode?: 'none' | 'solid' | 'gradient';
|
||||
/** Area only, 0–1. */
|
||||
fillOpacity?: number;
|
||||
showPoints?: boolean;
|
||||
spanGaps?: { fillOnlyBelow?: boolean; fillLessThan?: string };
|
||||
/** Heatmap only, and its only appearance key. */
|
||||
colors?: HeatmapColors;
|
||||
};
|
||||
histogramBuckets?: {
|
||||
bucketCount?: number;
|
||||
bucketWidth?: number;
|
||||
mergeAllActiveQueries?: boolean;
|
||||
};
|
||||
selectFields?: { name: string; signal?: Signal; fieldDataType?: string }[];
|
||||
thresholds?: Threshold[];
|
||||
/** Text only. */
|
||||
mode?: 'markdown';
|
||||
text?: string;
|
||||
presentation?: TextPresentation;
|
||||
headerOptions?: { hide: boolean };
|
||||
}
|
||||
|
||||
/**
|
||||
* Perses' link model — NOT the editor dialog's field names. The dialog labels
|
||||
* its first field "Label", but it persists as `name`; there is no `label` key
|
||||
* and the strict decoder rejects one.
|
||||
*/
|
||||
export interface ContextLink {
|
||||
name: string;
|
||||
url: string;
|
||||
tooltip?: string;
|
||||
renderVariables?: boolean;
|
||||
targetBlank?: boolean;
|
||||
}
|
||||
|
||||
export interface Panel {
|
||||
kind: 'Panel';
|
||||
spec: {
|
||||
display: { name: string; description?: string };
|
||||
links: ContextLink[];
|
||||
plugin: { kind: PanelKind; spec: PanelPluginSpec };
|
||||
/** Empty for Text, exactly one query for every other kind. */
|
||||
queries: Query[];
|
||||
};
|
||||
}
|
||||
|
||||
// ─── Layout ──────────────────────────────────────────────────────────────
|
||||
|
||||
export interface GridItem {
|
||||
x: number;
|
||||
y: number;
|
||||
width: number;
|
||||
height: number;
|
||||
content: { $ref: string };
|
||||
}
|
||||
|
||||
export interface Layout {
|
||||
kind: 'Grid';
|
||||
spec: { display: { title: string }; items: GridItem[] };
|
||||
}
|
||||
|
||||
// ─── Variables ───────────────────────────────────────────────────────────
|
||||
|
||||
export type Variable =
|
||||
| {
|
||||
kind: 'ListVariable';
|
||||
spec: {
|
||||
name: string;
|
||||
display: { name: string };
|
||||
allowAllValue: boolean;
|
||||
allowMultiple: boolean;
|
||||
sort: 'none' | 'alphabetical-asc' | 'alphabetical-desc';
|
||||
plugin:
|
||||
| { kind: 'signoz/CustomVariable'; spec: { customValue: string } }
|
||||
| { kind: 'signoz/QueryVariable'; spec: { queryValue: string } }
|
||||
| {
|
||||
kind: 'signoz/DynamicVariable';
|
||||
spec: { name: string; signal: Signal };
|
||||
};
|
||||
};
|
||||
}
|
||||
| {
|
||||
kind: 'TextVariable';
|
||||
spec: { name: string; display: { name: string }; value: string };
|
||||
};
|
||||
|
||||
// ─── Dashboard ───────────────────────────────────────────────────────────
|
||||
|
||||
export interface DashboardSpec {
|
||||
display: { name: string; description?: string };
|
||||
variables: Variable[];
|
||||
panels: Record<string, Panel>;
|
||||
layouts: Layout[];
|
||||
links: ContextLink[];
|
||||
duration?: string;
|
||||
}
|
||||
|
||||
export interface Tag {
|
||||
key: string;
|
||||
value: string;
|
||||
}
|
||||
|
||||
export interface PostableDashboard {
|
||||
schemaVersion: 'v6';
|
||||
/** Must be EMPTY when `generateName` is true; the server derives it. */
|
||||
name: string;
|
||||
generateName: boolean;
|
||||
tags: Tag[];
|
||||
spec: DashboardSpec;
|
||||
}
|
||||
|
||||
/** The backend pins this; a mismatch fails validation before anything else. */
|
||||
export const SCHEMA_VERSION = 'v6' as const;
|
||||
|
||||
// ─── Golden-dataset constants ────────────────────────────────────────────
|
||||
//
|
||||
// What `seed/golden` writes: four metrics over 8 services, 6h of 5-min buckets.
|
||||
|
||||
export const GOLDEN = {
|
||||
metrics: {
|
||||
calls: 'signoz_calls_total',
|
||||
latencyCount: 'signoz_latency_count',
|
||||
latencySum: 'signoz_latency_sum',
|
||||
dbLatencyCount: 'signoz_db_latency_count',
|
||||
},
|
||||
services: [
|
||||
'adservice',
|
||||
'cartservice',
|
||||
'checkoutservice',
|
||||
'currencyservice',
|
||||
'frontend',
|
||||
'paymentservice',
|
||||
'productcatalogservice',
|
||||
'shippingservice',
|
||||
],
|
||||
environment: 'production',
|
||||
/** Golden telemetry spans the last 6 hours, so windows wider than this add nothing. */
|
||||
windowHours: 6,
|
||||
} as const;
|
||||
|
||||
// ─── Query builders ──────────────────────────────────────────────────────
|
||||
|
||||
/** A rate-over-counter metrics query grouped by service — the default fixture query. */
|
||||
export function metricsQuery(options?: {
|
||||
name?: string;
|
||||
metricName?: string;
|
||||
timeAggregation?: string;
|
||||
spaceAggregation?: string;
|
||||
reduceTo?: string;
|
||||
filter?: string;
|
||||
groupBy?: string[];
|
||||
}): Query {
|
||||
return {
|
||||
kind: 'time_series',
|
||||
spec: {
|
||||
plugin: {
|
||||
kind: 'signoz/BuilderQuery',
|
||||
spec: {
|
||||
name: options?.name ?? 'A',
|
||||
signal: 'metrics',
|
||||
aggregations: [
|
||||
{
|
||||
metricName: options?.metricName ?? GOLDEN.metrics.calls,
|
||||
timeAggregation: options?.timeAggregation ?? 'rate',
|
||||
spaceAggregation: options?.spaceAggregation ?? 'sum',
|
||||
reduceTo: options?.reduceTo ?? 'sum',
|
||||
},
|
||||
],
|
||||
filter: { expression: options?.filter ?? '' },
|
||||
groupBy: (options?.groupBy ?? ['service.name']).map((name) => ({
|
||||
name,
|
||||
})),
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/** A `raw` logs/traces query for List panels — ordered newest-first like the UI seeds it. */
|
||||
export function rawQuery(options?: {
|
||||
name?: string;
|
||||
signal?: Extract<Signal, 'logs' | 'traces'>;
|
||||
filter?: string;
|
||||
limit?: number;
|
||||
}): Query {
|
||||
const signal = options?.signal ?? 'logs';
|
||||
return {
|
||||
kind: 'raw',
|
||||
spec: {
|
||||
plugin: {
|
||||
kind: 'signoz/BuilderQuery',
|
||||
spec: {
|
||||
name: options?.name ?? 'A',
|
||||
signal,
|
||||
aggregations: [{ expression: 'count()' }],
|
||||
filter: { expression: options?.filter ?? '' },
|
||||
groupBy: [],
|
||||
order: [
|
||||
{ key: { name: 'timestamp' }, direction: 'desc' },
|
||||
{ key: { name: 'id' }, direction: 'desc' },
|
||||
],
|
||||
...(options?.limit === undefined ? {} : { limit: options.limit }),
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* A logs/traces `count()` shaped as `time_series` — what Table and the chart
|
||||
* kinds read (unlike `rawQuery`, which only List consumes). Also the only
|
||||
* non-metrics seed that saves without picking a metric.
|
||||
*/
|
||||
export function logsCountQuery(options?: {
|
||||
name?: string;
|
||||
signal?: Extract<Signal, 'logs' | 'traces'>;
|
||||
filter?: string;
|
||||
groupBy?: string[];
|
||||
}): Query {
|
||||
return {
|
||||
kind: 'time_series',
|
||||
spec: {
|
||||
plugin: {
|
||||
kind: 'signoz/BuilderQuery',
|
||||
spec: {
|
||||
name: options?.name ?? 'A',
|
||||
signal: options?.signal ?? 'logs',
|
||||
aggregations: [{ expression: 'count()' }],
|
||||
filter: { expression: options?.filter ?? '' },
|
||||
groupBy: (options?.groupBy ?? ['service.name']).map((name) => ({
|
||||
name,
|
||||
})),
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/** A metrics builder query shaped as `heatmap`, the only result kind Heatmap reads. */
|
||||
export function heatmapQuery(
|
||||
options?: Parameters<typeof metricsQuery>[0],
|
||||
): Query {
|
||||
return { ...metricsQuery(options), kind: 'heatmap' };
|
||||
}
|
||||
|
||||
export function promqlQuery(query: string, name = 'A'): Query {
|
||||
return {
|
||||
kind: 'time_series',
|
||||
spec: { plugin: { kind: 'signoz/PromQLQuery', spec: { name, query } } },
|
||||
};
|
||||
}
|
||||
|
||||
export function clickhouseQuery(query: string, name = 'A'): Query {
|
||||
return {
|
||||
kind: 'time_series',
|
||||
spec: { plugin: { kind: 'signoz/ClickHouseSQL', spec: { name, query } } },
|
||||
};
|
||||
}
|
||||
|
||||
// ─── Panel builder ───────────────────────────────────────────────────────
|
||||
|
||||
export interface PanelOptions {
|
||||
name: string;
|
||||
description?: string;
|
||||
pluginSpec?: PanelPluginSpec;
|
||||
/**
|
||||
* Defaults to a metrics query shaped for the kind: `raw` for List, `heatmap`
|
||||
* for Heatmap. Ignored for Text, which carries none.
|
||||
*/
|
||||
query?: Query;
|
||||
links?: ContextLink[];
|
||||
}
|
||||
|
||||
function defaultQueryFor(kind: PanelKind): Query {
|
||||
if (kind === PanelKind.List) {
|
||||
return rawQuery();
|
||||
}
|
||||
if (kind === PanelKind.Heatmap) {
|
||||
return heatmapQuery();
|
||||
}
|
||||
return metricsQuery();
|
||||
}
|
||||
|
||||
export function panel(kind: PanelKind, options: PanelOptions): Panel {
|
||||
return {
|
||||
kind: 'Panel',
|
||||
spec: {
|
||||
display: {
|
||||
name: options.name,
|
||||
...(options.description === undefined
|
||||
? {}
|
||||
: { description: options.description }),
|
||||
},
|
||||
links: options.links ?? [],
|
||||
plugin: { kind, spec: options.pluginSpec ?? {} },
|
||||
queries:
|
||||
kind === PanelKind.Text ? [] : [options.query ?? defaultQueryFor(kind)],
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// ─── Dashboard composer ──────────────────────────────────────────────────
|
||||
|
||||
export interface SectionFixture {
|
||||
title: string;
|
||||
/** Keys become panel ids AND test handles, so keep them readable and unique. */
|
||||
panels: Record<string, Panel>;
|
||||
}
|
||||
|
||||
export interface DashboardFixtureOptions {
|
||||
/** Defaults to a unique value; the server derives the internal name. */
|
||||
title?: string;
|
||||
description?: string;
|
||||
sections: SectionFixture[];
|
||||
variables?: Variable[];
|
||||
duration?: string;
|
||||
tags?: Tag[];
|
||||
}
|
||||
|
||||
// pid distinguishes workers; the counter distinguishes seeds within one.
|
||||
let fixtureSeq = 0;
|
||||
function nextFixtureId(): string {
|
||||
fixtureSeq += 1;
|
||||
return `${process.pid}-${fixtureSeq}`;
|
||||
}
|
||||
|
||||
/** Grid is 12 columns wide (SectionGrid `cols`), so 6×6 tiles two per row. */
|
||||
const PANEL_WIDTH = 6;
|
||||
const PANEL_HEIGHT = 6;
|
||||
const GRID_COLS = 12;
|
||||
const PER_ROW = GRID_COLS / PANEL_WIDTH;
|
||||
|
||||
/** Two panels per row. Duplicate ids across sections are rejected loudly. */
|
||||
export function buildDashboard(
|
||||
options: DashboardFixtureOptions,
|
||||
): PostableDashboard {
|
||||
const panels: Record<string, Panel> = {};
|
||||
const layouts: Layout[] = [];
|
||||
|
||||
for (const section of options.sections) {
|
||||
const items: GridItem[] = [];
|
||||
let index = 0;
|
||||
for (const [id, panelSpec] of Object.entries(section.panels)) {
|
||||
if (panels[id]) {
|
||||
throw new Error(`duplicate panel id ${id} in dashboard fixture`);
|
||||
}
|
||||
panels[id] = panelSpec;
|
||||
items.push({
|
||||
x: (index % PER_ROW) * PANEL_WIDTH,
|
||||
y: Math.floor(index / PER_ROW) * PANEL_HEIGHT,
|
||||
width: PANEL_WIDTH,
|
||||
height: PANEL_HEIGHT,
|
||||
content: { $ref: `#/spec/panels/${id}` },
|
||||
});
|
||||
index += 1;
|
||||
}
|
||||
layouts.push({
|
||||
kind: 'Grid',
|
||||
spec: { display: { title: section.title }, items },
|
||||
});
|
||||
}
|
||||
|
||||
const title = options.title ?? `e2e-dashboard-${nextFixtureId()}`;
|
||||
return {
|
||||
schemaVersion: SCHEMA_VERSION,
|
||||
name: '',
|
||||
generateName: true,
|
||||
tags: options.tags ?? [],
|
||||
spec: {
|
||||
display: {
|
||||
name: title,
|
||||
...(options.description === undefined
|
||||
? {}
|
||||
: { description: options.description }),
|
||||
},
|
||||
variables: options.variables ?? [],
|
||||
panels,
|
||||
layouts,
|
||||
links: [],
|
||||
...(options.duration === undefined ? {} : { duration: options.duration }),
|
||||
},
|
||||
};
|
||||
}
|
||||
343
tests/e2e/helpers/query-range-mock.ts
Normal file
343
tests/e2e/helpers/query-range-mock.ts
Normal file
@@ -0,0 +1,343 @@
|
||||
import type { Page, Route } from '@playwright/test';
|
||||
|
||||
// Pinned `/api/v5/query_range` payloads. Install BEFORE `page.goto`.
|
||||
//
|
||||
// Prefer golden data and assert STRUCTURE: the dataset's content is
|
||||
// deterministic but `seed/golden` rebases timestamps to `now`, so labels and
|
||||
// counts are stable while exact numbers are not. Reach for this only when the
|
||||
// backend can't promise what the test needs — error/no-data/warning states,
|
||||
// exact numbers for thresholds and units, a known series count, or stable
|
||||
// pagination.
|
||||
//
|
||||
// Envelope (double-nested, easy to get wrong), mirroring `QueryRangeV5200`:
|
||||
// { status, data: { type, meta, warning, data: { results: [...] } } }
|
||||
// `data.type` is the discriminator — a body under the wrong type renders as an
|
||||
// empty panel rather than failing loudly.
|
||||
|
||||
const QUERY_RANGE_GLOB = '**/api/v5/query_range';
|
||||
|
||||
type RequestType = 'time_series' | 'scalar' | 'raw' | 'trace' | 'heatmap';
|
||||
|
||||
export interface QueryRangeBody {
|
||||
status: string;
|
||||
data: {
|
||||
type: RequestType;
|
||||
data: { results: unknown[] };
|
||||
meta?: Record<string, unknown>;
|
||||
warning?: Record<string, unknown>;
|
||||
};
|
||||
}
|
||||
|
||||
function envelope(type: RequestType, results: unknown[]): QueryRangeBody {
|
||||
return { status: 'success', data: { type, data: { results } } };
|
||||
}
|
||||
|
||||
// ─── Time helpers ────────────────────────────────────────────────────────
|
||||
|
||||
const MINUTE_MS = 60_000;
|
||||
|
||||
/**
|
||||
* The default end of a generated series: now, floored to the minute, so series
|
||||
* built by separate calls share their timestamps instead of straddling a
|
||||
* millisecond.
|
||||
*/
|
||||
function defaultEndMs(): number {
|
||||
return Math.floor(Date.now() / MINUTE_MS) * MINUTE_MS;
|
||||
}
|
||||
|
||||
export interface TimeSeriesPoint {
|
||||
timestamp: number;
|
||||
value: number;
|
||||
}
|
||||
|
||||
export interface SeriesSpec {
|
||||
labels: Record<string, string>;
|
||||
points: TimeSeriesPoint[];
|
||||
}
|
||||
|
||||
export interface HeatmapPoint {
|
||||
timestamp: number;
|
||||
/** One count per bucket bound, plus a trailing open-above overflow. */
|
||||
counts: number[];
|
||||
}
|
||||
|
||||
export interface HeatmapSeriesSpec {
|
||||
labels: Record<string, string>;
|
||||
points: HeatmapPoint[];
|
||||
}
|
||||
|
||||
/** Evenly spaced epoch-ms timestamps ending at `endMs`. */
|
||||
export function timestamps(
|
||||
count: number,
|
||||
stepMinutes = 5,
|
||||
endMs = defaultEndMs(),
|
||||
): number[] {
|
||||
return Array.from(
|
||||
{ length: count },
|
||||
(_, i) => endMs - (count - 1 - i) * stepMinutes * MINUTE_MS,
|
||||
);
|
||||
}
|
||||
|
||||
/** A ramp of `count` points from `from` to `to` — readable, non-flat test data. */
|
||||
export function ramp(
|
||||
count: number,
|
||||
from: number,
|
||||
to: number,
|
||||
endMs = defaultEndMs(),
|
||||
): TimeSeriesPoint[] {
|
||||
const step = count > 1 ? (to - from) / (count - 1) : 0;
|
||||
return timestamps(count, 5, endMs).map((timestamp, i) => ({
|
||||
timestamp,
|
||||
value: from + step * i,
|
||||
}));
|
||||
}
|
||||
|
||||
/** A flat line — use when the assertion is about a specific value, not a shape. */
|
||||
export function flat(
|
||||
count: number,
|
||||
value: number,
|
||||
endMs = defaultEndMs(),
|
||||
): TimeSeriesPoint[] {
|
||||
return timestamps(count, 5, endMs).map((timestamp) => ({
|
||||
timestamp,
|
||||
value,
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* `count` columns of `width` cells, every count non-zero and growing with both
|
||||
* time and bucket, so any hovered cell has a value and the colour domain is known:
|
||||
* 1 … count × width.
|
||||
*/
|
||||
export function heatmapPoints(
|
||||
count: number,
|
||||
width: number,
|
||||
endMs = defaultEndMs(),
|
||||
): HeatmapPoint[] {
|
||||
return timestamps(count, 5, endMs).map((timestamp, i) => ({
|
||||
timestamp,
|
||||
counts: Array.from({ length: width }, (_, j) => (i + 1) * (j + 1)),
|
||||
}));
|
||||
}
|
||||
|
||||
// ─── Payload builders ────────────────────────────────────────────────────
|
||||
|
||||
export const QueryRange = {
|
||||
/** Series must nest at `results[].aggregations[].series[]`. */
|
||||
timeSeries(series: SeriesSpec[], queryName = 'A'): QueryRangeBody {
|
||||
return envelope('time_series', [
|
||||
{
|
||||
queryName,
|
||||
aggregations: [
|
||||
{
|
||||
index: 0,
|
||||
alias: '',
|
||||
series: series.map((s) => ({
|
||||
labels: Object.entries(s.labels).map(([name, value]) => ({
|
||||
key: { name },
|
||||
value,
|
||||
})),
|
||||
values: s.points.map((p) => ({
|
||||
timestamp: p.timestamp,
|
||||
value: p.value,
|
||||
})),
|
||||
})),
|
||||
},
|
||||
],
|
||||
},
|
||||
]);
|
||||
},
|
||||
|
||||
/** Rows are positional across `[...groupColumns, ...aggregationColumns]`. */
|
||||
scalar(options: {
|
||||
groupColumns?: string[];
|
||||
aggregationColumns?: string[];
|
||||
rows: (string | number)[][];
|
||||
queryName?: string;
|
||||
units?: Record<string, string>;
|
||||
}): QueryRangeBody {
|
||||
const queryName = options.queryName ?? 'A';
|
||||
const group = (options.groupColumns ?? []).map((name) => ({
|
||||
name,
|
||||
queryName,
|
||||
columnType: 'group',
|
||||
...(options.units?.[name] ? { meta: { unit: options.units[name] } } : {}),
|
||||
}));
|
||||
const aggregation = (options.aggregationColumns ?? []).map((name, index) => ({
|
||||
name,
|
||||
queryName,
|
||||
columnType: 'aggregation',
|
||||
aggregationIndex: index,
|
||||
...(options.units?.[name] ? { meta: { unit: options.units[name] } } : {}),
|
||||
}));
|
||||
return envelope('scalar', [
|
||||
{ queryName, columns: [...group, ...aggregation], data: options.rows },
|
||||
]);
|
||||
},
|
||||
|
||||
/** List rows. `timestamp` is RFC-3339 here, not epoch ms — per the contract. */
|
||||
raw(
|
||||
rows: Record<string, unknown>[],
|
||||
options?: { queryName?: string; nextCursor?: string },
|
||||
): QueryRangeBody {
|
||||
return envelope('raw', [
|
||||
{
|
||||
queryName: options?.queryName ?? 'A',
|
||||
...(options?.nextCursor ? { nextCursor: options.nextCursor } : {}),
|
||||
rows: rows.map((data) => ({
|
||||
timestamp:
|
||||
typeof data.timestamp === 'string'
|
||||
? data.timestamp
|
||||
: new Date(Number(data.timestamp ?? 0)).toISOString(),
|
||||
data,
|
||||
})),
|
||||
},
|
||||
]);
|
||||
},
|
||||
|
||||
/**
|
||||
* Bucket bounds sit on the aggregation's `meta`; without them the panel
|
||||
* draws no grid. `series: []` is the heatmap's own no-data body.
|
||||
*/
|
||||
heatmap(
|
||||
buckets: number[],
|
||||
series: HeatmapSeriesSpec[],
|
||||
queryName = 'A',
|
||||
): QueryRangeBody {
|
||||
return envelope('heatmap', [
|
||||
{
|
||||
queryName,
|
||||
aggregations: [
|
||||
{
|
||||
index: 0,
|
||||
alias: '',
|
||||
meta: { buckets },
|
||||
series: series.map((s) => ({
|
||||
labels: Object.entries(s.labels).map(([name, value]) => ({
|
||||
key: { name },
|
||||
value,
|
||||
})),
|
||||
values: s.points.map((p) => ({
|
||||
timestamp: p.timestamp,
|
||||
values: p.counts,
|
||||
})),
|
||||
})),
|
||||
},
|
||||
],
|
||||
},
|
||||
]);
|
||||
},
|
||||
|
||||
/** Successful but empty — drives `panel-no-data`. */
|
||||
empty(queryName = 'A'): QueryRangeBody {
|
||||
return envelope('time_series', [{ queryName, aggregations: [] }]);
|
||||
},
|
||||
|
||||
/** Successful with a warning — drives the `panel-status-warning` indicator. */
|
||||
withWarning(body: QueryRangeBody, message: string): QueryRangeBody {
|
||||
return {
|
||||
...body,
|
||||
data: {
|
||||
...body.data,
|
||||
warning: { warnings: [{ message }], message },
|
||||
},
|
||||
};
|
||||
},
|
||||
};
|
||||
|
||||
// ─── Route installers ────────────────────────────────────────────────────
|
||||
|
||||
export interface MockOptions {
|
||||
/** Stop intercepting after N calls, letting later requests hit the backend. */
|
||||
times?: number;
|
||||
}
|
||||
|
||||
/** Fulfil every query_range call with one pinned body. */
|
||||
export async function mockQueryRange(
|
||||
page: Page,
|
||||
body: QueryRangeBody,
|
||||
options?: MockOptions,
|
||||
): Promise<void> {
|
||||
let served = 0;
|
||||
await page.route(QUERY_RANGE_GLOB, async (route: Route) => {
|
||||
if (options?.times !== undefined && served >= options.times) {
|
||||
await route.fallback();
|
||||
return;
|
||||
}
|
||||
served += 1;
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/** Fail every query_range call — drives `panel-error`. */
|
||||
export async function mockQueryRangeError(
|
||||
page: Page,
|
||||
message = 'mocked query failure',
|
||||
options?: MockOptions & { status?: number },
|
||||
): Promise<void> {
|
||||
let served = 0;
|
||||
await page.route(QUERY_RANGE_GLOB, async (route: Route) => {
|
||||
if (options?.times !== undefined && served >= options.times) {
|
||||
await route.fallback();
|
||||
return;
|
||||
}
|
||||
served += 1;
|
||||
await route.fulfill({
|
||||
status: options?.status ?? 500,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify({
|
||||
status: 'error',
|
||||
error: { code: 'internal', message },
|
||||
}),
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/** One body per call, repeating the last — for pagination and retry flows. */
|
||||
export async function mockQueryRangeSequence(
|
||||
page: Page,
|
||||
bodies: QueryRangeBody[],
|
||||
): Promise<void> {
|
||||
let call = 0;
|
||||
await page.route(QUERY_RANGE_GLOB, async (route: Route) => {
|
||||
const body = bodies[Math.min(call, bodies.length - 1)];
|
||||
call += 1;
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/** Record request bodies without changing responses. */
|
||||
export function recordQueryRange(page: Page): {
|
||||
requests: Record<string, unknown>[];
|
||||
install: () => Promise<void>;
|
||||
} {
|
||||
const requests: Record<string, unknown>[] = [];
|
||||
return {
|
||||
requests,
|
||||
install: async () => {
|
||||
await page.route(QUERY_RANGE_GLOB, async (route: Route) => {
|
||||
const payload = route.request().postDataJSON() as Record<
|
||||
string,
|
||||
unknown
|
||||
> | null;
|
||||
if (payload) {
|
||||
requests.push(payload);
|
||||
}
|
||||
await route.fallback();
|
||||
});
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/** Drop the interception so later navigation reaches the real backend again. */
|
||||
export async function unmockQueryRange(page: Page): Promise<void> {
|
||||
await page.unroute(QUERY_RANGE_GLOB);
|
||||
}
|
||||
@@ -36,11 +36,6 @@ for (const file of ['.env', '.env.local']) {
|
||||
export default defineConfig({
|
||||
testDir: './tests',
|
||||
|
||||
// Temporarily excluded: the V1 -> V2 dashboard migration changes the
|
||||
// behaviour the dashboards specs assert against, so they fail as written.
|
||||
// Remove this once they are updated for the V2 dashboard.
|
||||
testIgnore: ['**/tests/dashboards/**'],
|
||||
|
||||
// All Playwright output lands under artifacts/. One subdir per reporter
|
||||
// plus results/ for per-test artifacts (traces/screenshots/videos).
|
||||
// CI can archive the whole dir with `tar czf artifacts.tgz tests/e2e/artifacts`.
|
||||
|
||||
359
tests/e2e/testdata/dashboards/panels.ts
vendored
Normal file
359
tests/e2e/testdata/dashboards/panels.ts
vendored
Normal file
@@ -0,0 +1,359 @@
|
||||
import {
|
||||
GOLDEN,
|
||||
PanelKind,
|
||||
clickhouseQuery,
|
||||
buildDashboard,
|
||||
heatmapQuery,
|
||||
metricsQuery,
|
||||
panel,
|
||||
promqlQuery,
|
||||
rawQuery,
|
||||
type ContextLink,
|
||||
type PanelPluginSpec,
|
||||
type PostableDashboard,
|
||||
type Query,
|
||||
type Variable,
|
||||
} from '../../helpers/dashboard-spec';
|
||||
|
||||
// Dashboard fixtures for the dashboard suites. TypeScript rather than frozen JSON so
|
||||
// a contract change is a compile error, and panel ids stay visible to the specs
|
||||
// that use them as test handles.
|
||||
let seq = 0;
|
||||
function uniqueSuffix(): string {
|
||||
seq += 1;
|
||||
return `${process.pid}-${seq}`;
|
||||
}
|
||||
|
||||
// ─── All kinds ───────────────────────────────────────────────────────────
|
||||
|
||||
export const ALL_KINDS_PANELS = {
|
||||
timeseries: 'ts-panel',
|
||||
bar: 'bar-panel',
|
||||
histogram: 'histogram-panel',
|
||||
number: 'number-panel',
|
||||
pie: 'pie-panel',
|
||||
table: 'table-panel',
|
||||
list: 'list-panel',
|
||||
} as const;
|
||||
|
||||
/**
|
||||
* One panel of every kind. Only for "every kind renders" and lazy-load — use
|
||||
* `compactDashboard` otherwise; seven panels means seven real queries.
|
||||
*/
|
||||
export function allKindsDashboard(title?: string): PostableDashboard {
|
||||
return buildDashboard({
|
||||
title: title ?? `e2e-all-kinds-${Date.now()}-${uniqueSuffix()}`,
|
||||
sections: [
|
||||
{
|
||||
title: 'Charts',
|
||||
panels: {
|
||||
[ALL_KINDS_PANELS.timeseries]: panel(PanelKind.TimeSeries, {
|
||||
name: 'Calls by service',
|
||||
description: 'Rate of signoz_calls_total grouped by service.name',
|
||||
}),
|
||||
[ALL_KINDS_PANELS.bar]: panel(PanelKind.BarChart, {
|
||||
name: 'Calls bar',
|
||||
}),
|
||||
[ALL_KINDS_PANELS.histogram]: panel(PanelKind.Histogram, {
|
||||
name: 'Latency distribution',
|
||||
query: metricsQuery({
|
||||
metricName: GOLDEN.metrics.latencySum,
|
||||
groupBy: [],
|
||||
}),
|
||||
}),
|
||||
[ALL_KINDS_PANELS.pie]: panel(PanelKind.PieChart, {
|
||||
name: 'Calls share',
|
||||
}),
|
||||
},
|
||||
},
|
||||
{
|
||||
title: 'Tabular',
|
||||
panels: {
|
||||
[ALL_KINDS_PANELS.number]: panel(PanelKind.Number, {
|
||||
name: 'Total calls',
|
||||
pluginSpec: { formatting: { unit: 'short', decimalPrecision: '2' } },
|
||||
query: metricsQuery({ groupBy: [] }),
|
||||
}),
|
||||
[ALL_KINDS_PANELS.table]: panel(PanelKind.Table, {
|
||||
name: 'Calls table',
|
||||
}),
|
||||
[ALL_KINDS_PANELS.list]: panel(PanelKind.List, {
|
||||
name: 'Recent logs',
|
||||
pluginSpec: {
|
||||
selectFields: [
|
||||
{ name: 'timestamp', signal: 'logs' },
|
||||
{ name: 'body', signal: 'logs' },
|
||||
],
|
||||
},
|
||||
query: rawQuery({ signal: 'logs' }),
|
||||
}),
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
}
|
||||
|
||||
// ─── Compact (default multi-panel fixture) ───────────────────────────────
|
||||
|
||||
export const COMPACT_PANELS = {
|
||||
timeseries: ALL_KINDS_PANELS.timeseries,
|
||||
table: ALL_KINDS_PANELS.table,
|
||||
list: ALL_KINDS_PANELS.list,
|
||||
} as const;
|
||||
|
||||
/**
|
||||
* The default multi-panel fixture: three panels, two sections.
|
||||
*
|
||||
* Every panel fires its own `query_range`, and over-seeding was the dominant
|
||||
* load on the single-container stack. These three keep every distinction the
|
||||
* specs assert — TimeSeries (charts, zoom, drilldown, createAlert), Table (CSV
|
||||
* download, search), List (search, no createAlert) — and two sections keep
|
||||
* Move-to-section and the modal's select-then-confirm branch reachable.
|
||||
*/
|
||||
export function compactDashboard(title?: string): PostableDashboard {
|
||||
return buildDashboard({
|
||||
title: title ?? `e2e-compact-${Date.now()}-${uniqueSuffix()}`,
|
||||
sections: [
|
||||
{
|
||||
title: 'Charts',
|
||||
panels: {
|
||||
[COMPACT_PANELS.timeseries]: panel(PanelKind.TimeSeries, {
|
||||
name: 'Calls by service',
|
||||
description: 'Rate of signoz_calls_total grouped by service.name',
|
||||
}),
|
||||
},
|
||||
},
|
||||
{
|
||||
title: 'Tabular',
|
||||
panels: {
|
||||
[COMPACT_PANELS.table]: panel(PanelKind.Table, {
|
||||
name: 'Calls table',
|
||||
}),
|
||||
[COMPACT_PANELS.list]: panel(PanelKind.List, {
|
||||
name: 'Recent logs',
|
||||
pluginSpec: {
|
||||
selectFields: [
|
||||
{ name: 'timestamp', signal: 'logs' },
|
||||
{ name: 'body', signal: 'logs' },
|
||||
],
|
||||
},
|
||||
query: rawQuery({ signal: 'logs' }),
|
||||
}),
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
}
|
||||
|
||||
// ─── Single panel ────────────────────────────────────────────────────────
|
||||
|
||||
export const SINGLE_PANEL_ID = 'solo-panel';
|
||||
|
||||
export interface SinglePanelOptions {
|
||||
title?: string;
|
||||
kind?: PanelKind;
|
||||
panelName?: string;
|
||||
/** Plugin config (legend, axes, formatting, thresholds, …) for the panel. */
|
||||
pluginSpec?: PanelPluginSpec;
|
||||
/** Replaces the kind's default query. */
|
||||
query?: Query;
|
||||
/** Context links attached to the panel. */
|
||||
links?: ContextLink[];
|
||||
/** Attaches the `VARIABLE_NAMES` variables, for interpolation cases. */
|
||||
withVariables?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* One panel, one section. Fully configurable so specs never reach into the
|
||||
* returned object — post-hoc mutation is four levels deep and breaks silently
|
||||
* when the spec shape moves.
|
||||
*/
|
||||
export function singlePanelDashboard(
|
||||
options: SinglePanelOptions = {},
|
||||
): PostableDashboard {
|
||||
return buildDashboard({
|
||||
title: options.title ?? `e2e-single-${Date.now()}-${uniqueSuffix()}`,
|
||||
...(options.withVariables ? { variables: VARIABLES } : {}),
|
||||
sections: [
|
||||
{
|
||||
title: 'Section',
|
||||
panels: {
|
||||
[SINGLE_PANEL_ID]: panel(options.kind ?? PanelKind.TimeSeries, {
|
||||
name: options.panelName ?? 'Solo panel',
|
||||
...(options.pluginSpec ? { pluginSpec: options.pluginSpec } : {}),
|
||||
...(options.query ? { query: options.query } : {}),
|
||||
...(options.links ? { links: options.links } : {}),
|
||||
}),
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
}
|
||||
|
||||
/** No panels at all — drives the dashboard empty state and its New Panel CTA. */
|
||||
export function emptyDashboard(title?: string): PostableDashboard {
|
||||
return buildDashboard({
|
||||
title: title ?? `e2e-empty-${Date.now()}-${uniqueSuffix()}`,
|
||||
sections: [],
|
||||
});
|
||||
}
|
||||
|
||||
// ─── Query-type coverage ─────────────────────────────────────────────────
|
||||
|
||||
export const QUERY_TYPE_PANELS = {
|
||||
builder: 'qb-panel',
|
||||
promql: 'promql-panel',
|
||||
clickhouse: 'chsql-panel',
|
||||
} as const;
|
||||
|
||||
/** One panel per query type, for the capability and drilldown-gating specs. */
|
||||
export function queryTypesDashboard(title?: string): PostableDashboard {
|
||||
return buildDashboard({
|
||||
title: title ?? `e2e-query-types-${Date.now()}-${uniqueSuffix()}`,
|
||||
sections: [
|
||||
{
|
||||
title: 'Query types',
|
||||
panels: {
|
||||
[QUERY_TYPE_PANELS.builder]: panel(PanelKind.TimeSeries, {
|
||||
name: 'Builder query',
|
||||
}),
|
||||
[QUERY_TYPE_PANELS.promql]: panel(PanelKind.TimeSeries, {
|
||||
name: 'PromQL query',
|
||||
query: promqlQuery(`sum(rate(${GOLDEN.metrics.calls}[5m]))`),
|
||||
}),
|
||||
[QUERY_TYPE_PANELS.clickhouse]: panel(PanelKind.Table, {
|
||||
name: 'ClickHouse query',
|
||||
query: clickhouseQuery(
|
||||
"SELECT now() AS ts, 'adservice' AS service, 1 AS A",
|
||||
),
|
||||
}),
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
}
|
||||
|
||||
// ─── Variables ───────────────────────────────────────────────────────────
|
||||
|
||||
export const VARIABLE_NAMES = {
|
||||
custom: 'serviceCustom',
|
||||
dynamic: 'serviceDynamic',
|
||||
} as const;
|
||||
|
||||
const VARIABLES: Variable[] = [
|
||||
{
|
||||
kind: 'ListVariable',
|
||||
spec: {
|
||||
name: VARIABLE_NAMES.custom,
|
||||
display: { name: VARIABLE_NAMES.custom },
|
||||
allowAllValue: false,
|
||||
allowMultiple: false,
|
||||
sort: 'none',
|
||||
plugin: {
|
||||
kind: 'signoz/CustomVariable',
|
||||
// Closed list: resolves instantly, no backend round-trip.
|
||||
spec: { customValue: GOLDEN.services.slice(0, 3).join(',') },
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
kind: 'ListVariable',
|
||||
spec: {
|
||||
name: VARIABLE_NAMES.dynamic,
|
||||
display: { name: VARIABLE_NAMES.dynamic },
|
||||
allowAllValue: true,
|
||||
allowMultiple: true,
|
||||
sort: 'alphabetical-asc',
|
||||
plugin: {
|
||||
kind: 'signoz/DynamicVariable',
|
||||
spec: { name: 'service.name', signal: 'metrics' },
|
||||
},
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
export const VARIABLE_PANEL_ID = 'var-panel';
|
||||
|
||||
/** A panel filtered by a dashboard variable — backs the drilldown variable cases. */
|
||||
export function variablesDashboard(
|
||||
title?: string,
|
||||
query?: Query,
|
||||
): PostableDashboard {
|
||||
return buildDashboard({
|
||||
title: title ?? `e2e-variables-${Date.now()}-${uniqueSuffix()}`,
|
||||
variables: VARIABLES,
|
||||
sections: [
|
||||
{
|
||||
title: 'Variables',
|
||||
panels: {
|
||||
[VARIABLE_PANEL_ID]: panel(PanelKind.TimeSeries, {
|
||||
name: 'Calls for $serviceCustom',
|
||||
query:
|
||||
query ??
|
||||
metricsQuery({
|
||||
filter: `service.name = $${VARIABLE_NAMES.custom}`,
|
||||
}),
|
||||
}),
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
}
|
||||
|
||||
// ─── Area / Heatmap / Text ───────────────────────────────────────────────
|
||||
|
||||
export const NEW_KINDS_PANELS = {
|
||||
area: 'area-panel',
|
||||
heatmap: 'heatmap-panel',
|
||||
text: 'md-panel',
|
||||
} as const;
|
||||
|
||||
export const NEW_KINDS_MARKDOWN =
|
||||
'## Runbook\n\nCheck the **checkout** path first.';
|
||||
|
||||
/**
|
||||
* One Area, one Heatmap and one Text panel — the kinds added after the
|
||||
* all-kinds fixture, kept apart so its seven queries don't grow.
|
||||
*/
|
||||
export function newKindsDashboard(title?: string): PostableDashboard {
|
||||
return buildDashboard({
|
||||
title: title ?? `e2e-new-kinds-${Date.now()}-${uniqueSuffix()}`,
|
||||
sections: [
|
||||
{
|
||||
title: 'New kinds',
|
||||
panels: {
|
||||
[NEW_KINDS_PANELS.area]: panel(PanelKind.AreaChart, {
|
||||
name: 'Calls area',
|
||||
}),
|
||||
[NEW_KINDS_PANELS.heatmap]: panel(PanelKind.Heatmap, {
|
||||
name: 'Calls heatmap',
|
||||
query: heatmapQuery(),
|
||||
}),
|
||||
[NEW_KINDS_PANELS.text]: panel(PanelKind.Text, {
|
||||
name: 'Runbook',
|
||||
pluginSpec: { text: NEW_KINDS_MARKDOWN },
|
||||
}),
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
}
|
||||
|
||||
/** Invalid on purpose: Text must carry `queries: []`; the server rejects this. */
|
||||
export function textPanelWithQueryDashboard(): PostableDashboard {
|
||||
const text = panel(PanelKind.Text, { name: 'Notes' });
|
||||
return buildDashboard({
|
||||
title: `e2e-text-with-query-${Date.now()}-${uniqueSuffix()}`,
|
||||
sections: [
|
||||
{
|
||||
title: 'Section',
|
||||
panels: {
|
||||
[SINGLE_PANEL_ID]: {
|
||||
...text,
|
||||
spec: { ...text.spec, queries: [metricsQuery()] },
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
}
|
||||
139
tests/e2e/tests/dashboards/contract/actions.spec.ts
Normal file
139
tests/e2e/tests/dashboards/contract/actions.spec.ts
Normal file
@@ -0,0 +1,139 @@
|
||||
import {
|
||||
type DownloadFormat,
|
||||
expect,
|
||||
PANEL_KINDS,
|
||||
PanelAction,
|
||||
respondWithData,
|
||||
SINGLE_PANEL_ID,
|
||||
test,
|
||||
withSpareSection,
|
||||
} from '../../../drivers/panel-kinds';
|
||||
|
||||
// Scope: what each kind's `actions` declaration turns into on the grid — the ⋮
|
||||
// menu, its downloads and Create Alerts, drilldown, and header search. Items
|
||||
// carry no testid, so the menu matches role + visible label.
|
||||
|
||||
const DOWNLOADS: DownloadFormat[] = ['CSV', 'PNG', 'SVG'];
|
||||
|
||||
const ALWAYS_OFFERED = [
|
||||
PanelAction.view,
|
||||
PanelAction.edit,
|
||||
PanelAction.clone,
|
||||
PanelAction.move,
|
||||
PanelAction.delete,
|
||||
];
|
||||
|
||||
test.describe('Dashboards contract — panel actions', () => {
|
||||
for (const c of PANEL_KINDS) {
|
||||
const declared = [
|
||||
...(c.createAlert ? [PanelAction.createAlert] : []),
|
||||
...(c.downloads.length > 0 ? [PanelAction.download] : []),
|
||||
];
|
||||
const missing = [PanelAction.createAlert, PanelAction.download].filter(
|
||||
(label) => !declared.includes(label),
|
||||
);
|
||||
|
||||
test(`TC-01 ${c.label}: the actions menu offers the full set its declaration allows`, async ({
|
||||
dashboards,
|
||||
panels,
|
||||
}) => {
|
||||
// A second section, so Move to section has somewhere to go.
|
||||
await dashboards.seedAndOpen(withSpareSection(c));
|
||||
const { actions } = panels.panel(SINGLE_PANEL_ID);
|
||||
await actions.open();
|
||||
|
||||
for (const label of [...ALWAYS_OFFERED, ...declared]) {
|
||||
await expect(actions.item(label)).toBeVisible();
|
||||
}
|
||||
for (const label of missing) {
|
||||
await expect(actions.item(label)).toHaveCount(0);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
for (const c of PANEL_KINDS.filter((k) => k.downloads.length > 0)) {
|
||||
const missing = DOWNLOADS.filter((format) => !c.downloads.includes(format));
|
||||
|
||||
test(`TC-02 ${c.label}: Download offers ${c.downloads.join(', ')} only`, async ({
|
||||
dashboards,
|
||||
panels,
|
||||
}) => {
|
||||
await dashboards.seedAndOpen(c.seed());
|
||||
const { actions } = panels.panel(SINGLE_PANEL_ID);
|
||||
await actions.open();
|
||||
await actions.hoverItem(PanelAction.download);
|
||||
|
||||
for (const format of c.downloads) {
|
||||
await expect(actions.item(`Download as ${format}`)).toBeVisible();
|
||||
}
|
||||
for (const format of missing) {
|
||||
await expect(actions.item(`Download as ${format}`)).toHaveCount(0);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
for (const c of PANEL_KINDS.filter((k) => k.drilldown)) {
|
||||
test(`TC-03 ${c.label}: clicking the data opens the drilldown menu`, async ({
|
||||
dashboards,
|
||||
queryRange,
|
||||
panels,
|
||||
}) => {
|
||||
await respondWithData(queryRange, c);
|
||||
await dashboards.seedAndOpen(c.seed());
|
||||
|
||||
const target = panels.panel(SINGLE_PANEL_ID);
|
||||
await target.waitRendered(c.kind);
|
||||
await c.click(target);
|
||||
|
||||
await expect(panels.drilldown.menu()).toBeVisible();
|
||||
await expect(panels.drilldown.item('drilldown-view-logs')).toBeVisible();
|
||||
await expect(panels.drilldown.item('drilldown-view-traces')).toBeVisible();
|
||||
await expect(panels.drilldown.item('drilldown-breakout')).toBeVisible();
|
||||
});
|
||||
}
|
||||
|
||||
for (const c of PANEL_KINDS.filter((k) => !k.drilldown)) {
|
||||
test(`TC-04 ${c.label}: clicking the data opens no menu`, async ({
|
||||
dashboards,
|
||||
queryRange,
|
||||
panels,
|
||||
}) => {
|
||||
await respondWithData(queryRange, c);
|
||||
await dashboards.seedAndOpen(c.seed());
|
||||
|
||||
const target = panels.panel(SINGLE_PANEL_ID);
|
||||
await target.waitRendered(c.kind);
|
||||
await c.click(target);
|
||||
|
||||
await expect(panels.drilldown.menu()).toHaveCount(0);
|
||||
});
|
||||
}
|
||||
|
||||
for (const c of PANEL_KINDS.filter((k) => k.headerSearch)) {
|
||||
test(`TC-05 ${c.label}: offers header search`, async ({
|
||||
dashboards,
|
||||
panels,
|
||||
}) => {
|
||||
await dashboards.seedAndOpen(c.seed());
|
||||
|
||||
const target = panels.panel(SINGLE_PANEL_ID);
|
||||
await target.waitRendered(c.kind);
|
||||
await target.root().hover();
|
||||
await expect(target.search.trigger()).toBeVisible();
|
||||
});
|
||||
}
|
||||
|
||||
for (const c of PANEL_KINDS.filter((k) => !k.headerSearch)) {
|
||||
test(`TC-06 ${c.label}: does not offer header search`, async ({
|
||||
dashboards,
|
||||
panels,
|
||||
}) => {
|
||||
await dashboards.seedAndOpen(c.seed());
|
||||
|
||||
const target = panels.panel(SINGLE_PANEL_ID);
|
||||
await target.waitRendered(c.kind);
|
||||
await target.root().hover();
|
||||
await expect(target.search.trigger()).toHaveCount(0);
|
||||
});
|
||||
}
|
||||
});
|
||||
108
tests/e2e/tests/dashboards/contract/capabilities.spec.ts
Normal file
108
tests/e2e/tests/dashboards/contract/capabilities.spec.ts
Normal file
@@ -0,0 +1,108 @@
|
||||
import {
|
||||
clickhouseQuery,
|
||||
disabledReason,
|
||||
expect,
|
||||
GOLDEN,
|
||||
logsCountQuery,
|
||||
metricsQuery,
|
||||
PANEL_KINDS,
|
||||
PanelKind,
|
||||
type PostableDashboard,
|
||||
promqlQuery,
|
||||
type QueryType,
|
||||
type SelectOption,
|
||||
type Signal,
|
||||
SINGLE_PANEL_ID,
|
||||
singlePanelDashboard,
|
||||
test,
|
||||
} from '../../../drivers/panel-kinds';
|
||||
|
||||
// Scope: the panelKind × queryType × signal matrix as the type switcher shows
|
||||
// it. Each kind is checked against each source query: selectable, or disabled
|
||||
// with the reason getPanelTypeDisabledReason gives.
|
||||
|
||||
interface SourceQuery {
|
||||
name: string;
|
||||
queryType: QueryType;
|
||||
signal?: Signal;
|
||||
seed(): PostableDashboard;
|
||||
}
|
||||
|
||||
const SOURCES: SourceQuery[] = [
|
||||
{
|
||||
name: 'a metrics builder query',
|
||||
queryType: 'builder',
|
||||
signal: 'metrics',
|
||||
seed: () => singlePanelDashboard({ query: metricsQuery() }),
|
||||
},
|
||||
{
|
||||
name: 'a logs builder query',
|
||||
queryType: 'builder',
|
||||
signal: 'logs',
|
||||
seed: () => singlePanelDashboard({ query: logsCountQuery() }),
|
||||
},
|
||||
{
|
||||
name: 'a PromQL query',
|
||||
queryType: 'promql',
|
||||
seed: () =>
|
||||
singlePanelDashboard({
|
||||
query: promqlQuery(`sum(rate(${GOLDEN.metrics.calls}[5m]))`),
|
||||
}),
|
||||
},
|
||||
{
|
||||
name: 'a ClickHouse query',
|
||||
queryType: 'clickhouse',
|
||||
seed: () =>
|
||||
singlePanelDashboard({
|
||||
kind: PanelKind.Table,
|
||||
query: clickhouseQuery(
|
||||
"SELECT now() AS ts, 'adservice' AS service, 1 AS A",
|
||||
),
|
||||
}),
|
||||
},
|
||||
];
|
||||
|
||||
function optionFor(options: SelectOption[], label: string): SelectOption {
|
||||
const match = options.find((option) => option.label.startsWith(label));
|
||||
expect(
|
||||
match,
|
||||
`expected a "${label}" option in the type switcher`,
|
||||
).toBeDefined();
|
||||
return match as SelectOption;
|
||||
}
|
||||
|
||||
test.describe('Dashboards contract — editor capabilities matrix', () => {
|
||||
SOURCES.forEach((source, index) => {
|
||||
const number = String(index + 1).padStart(2, '0');
|
||||
|
||||
for (const c of PANEL_KINDS) {
|
||||
const reason = disabledReason(c, source);
|
||||
if (reason === null) {
|
||||
test(`TC-${number} ${c.label}: ${source.name} keeps it selectable`, async ({
|
||||
dashboards,
|
||||
editor,
|
||||
}) => {
|
||||
await dashboards.seedAndEdit(source.seed(), SINGLE_PANEL_ID);
|
||||
|
||||
const options = await editor.typeOptions();
|
||||
expect(optionFor(options, c.label).disabled).toBe(false);
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
test(`TC-${number} ${c.label}: ${source.name} disables it with a reason`, async ({
|
||||
dashboards,
|
||||
editor,
|
||||
panels,
|
||||
}) => {
|
||||
await dashboards.seedAndEdit(source.seed(), SINGLE_PANEL_ID);
|
||||
|
||||
const options = await editor.typeOptions();
|
||||
expect(optionFor(options, c.label).disabled).toBe(true);
|
||||
|
||||
await editor.typeOption(c.kind).hover();
|
||||
await expect(panels.tooltip.text(reason)).toBeVisible();
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
221
tests/e2e/tests/dashboards/contract/editor.spec.ts
Normal file
221
tests/e2e/tests/dashboards/contract/editor.spec.ts
Normal file
@@ -0,0 +1,221 @@
|
||||
import {
|
||||
AXIS_CONTROLS,
|
||||
emptyDashboard,
|
||||
type EditorControl,
|
||||
expect,
|
||||
kindCase,
|
||||
logsCountQuery,
|
||||
metricsQuery,
|
||||
PANEL_KINDS,
|
||||
PanelKind,
|
||||
type PanelKindCase,
|
||||
type PostableDashboard,
|
||||
QUERYABLE_KINDS,
|
||||
QueryTab,
|
||||
Section,
|
||||
SINGLE_PANEL_ID,
|
||||
singlePanelDashboard,
|
||||
test,
|
||||
type ThresholdVariant,
|
||||
} from '../../../drivers/panel-kinds';
|
||||
|
||||
// Scope: what each kind declares in the editor — the picker tile, its config
|
||||
// sections, query tabs, threshold variant and the List columns editor. Tabs a
|
||||
// kind cannot use are hidden; the type switcher is the one that disables.
|
||||
|
||||
const THRESHOLD_VARIANTS: ThresholdVariant[] = ['label', 'comparison', 'table'];
|
||||
const QUERY_TABS: string[] = Object.values(QueryTab);
|
||||
|
||||
/** A kind other than `c`, so switching to `c` is a real switch. */
|
||||
function switchSourceKind(c: PanelKindCase): PanelKindCase {
|
||||
return kindCase(
|
||||
c.kind === PanelKind.TimeSeries ? PanelKind.BarChart : PanelKind.TimeSeries,
|
||||
);
|
||||
}
|
||||
|
||||
/** The source kind over a query `c` accepts: List takes no metrics. */
|
||||
function switchSource(
|
||||
c: PanelKindCase,
|
||||
source: PanelKindCase,
|
||||
): PostableDashboard {
|
||||
return singlePanelDashboard({
|
||||
kind: source.kind,
|
||||
query:
|
||||
c.signals.length === 0 || c.signals.includes('metrics')
|
||||
? metricsQuery()
|
||||
: logsCountQuery(),
|
||||
});
|
||||
}
|
||||
|
||||
function partition<T>(all: T[], shown: T[]): [T[], T[]] {
|
||||
return [shown, all.filter((item) => !shown.includes(item))];
|
||||
}
|
||||
|
||||
test.describe('Dashboards contract — panel editor', () => {
|
||||
for (const c of PANEL_KINDS) {
|
||||
test(`TC-01 ${c.label}: the New panel drawer offers it and opens its editor`, async ({
|
||||
dashboards,
|
||||
editor,
|
||||
newPanel,
|
||||
}) => {
|
||||
const id = await dashboards.seed(emptyDashboard());
|
||||
await dashboards.openEmpty(id);
|
||||
|
||||
await dashboards.emptyAddPanel().click();
|
||||
await expect(newPanel.root()).toBeVisible();
|
||||
await expect(newPanel.tiles()).toHaveCount(PANEL_KINDS.length);
|
||||
await expect(newPanel.tile(c.kind)).toBeVisible();
|
||||
await expect(newPanel.tile(c.kind)).toContainText(c.label);
|
||||
|
||||
await newPanel.choose(c.kind);
|
||||
expect(dashboards.param('panelKind')).toBe(c.kind);
|
||||
await expect(editor.root()).toBeVisible();
|
||||
});
|
||||
}
|
||||
|
||||
for (const c of PANEL_KINDS) {
|
||||
const [shownControls, hiddenControls] = partition<EditorControl>(
|
||||
['time-preference'],
|
||||
c.timePreference ? ['time-preference'] : [],
|
||||
);
|
||||
|
||||
test(`TC-02 ${c.label}: declares its sections and no others`, async ({
|
||||
dashboards,
|
||||
editor,
|
||||
}) => {
|
||||
await dashboards.seedAndEdit(c.seed(), SINGLE_PANEL_ID);
|
||||
|
||||
for (const title of c.sections) {
|
||||
await expect(editor.section.toggle(title)).toBeVisible();
|
||||
}
|
||||
for (const title of c.forbiddenSections) {
|
||||
await expect(editor.section.toggle(title)).toHaveCount(0);
|
||||
}
|
||||
|
||||
await editor.section.expand(Section.visualization);
|
||||
for (const control of shownControls) {
|
||||
await expect(editor.control(control)).toBeVisible();
|
||||
}
|
||||
for (const control of hiddenControls) {
|
||||
await expect(editor.control(control)).toHaveCount(0);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
for (const c of PANEL_KINDS.filter((k) => k.sections.includes(Section.axes))) {
|
||||
const [shown, hidden] = partition(AXIS_CONTROLS, c.axisControls);
|
||||
|
||||
test(`TC-03 ${c.label}: Axes offers ${shown.join(', ')} only`, async ({
|
||||
dashboards,
|
||||
editor,
|
||||
}) => {
|
||||
await dashboards.seedAndEdit(c.seed(), SINGLE_PANEL_ID);
|
||||
|
||||
await editor.section.expand(Section.axes);
|
||||
for (const control of shown) {
|
||||
await expect(editor.control(control)).toBeVisible();
|
||||
}
|
||||
for (const control of hidden) {
|
||||
await expect(editor.control(control)).toHaveCount(0);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
for (const c of PANEL_KINDS) {
|
||||
const from = switchSourceKind(c);
|
||||
|
||||
test(`TC-04 ${c.label}: switching to it from ${from.label} brings its sections`, async ({
|
||||
dashboards,
|
||||
editor,
|
||||
}) => {
|
||||
await dashboards.seedAndEdit(switchSource(c, from), SINGLE_PANEL_ID);
|
||||
for (const title of from.sections) {
|
||||
await expect(editor.section.toggle(title)).toBeVisible();
|
||||
}
|
||||
|
||||
await editor.switchType(c.kind);
|
||||
for (const title of c.sections) {
|
||||
await expect(editor.section.toggle(title)).toBeVisible();
|
||||
}
|
||||
for (const title of c.forbiddenSections) {
|
||||
await expect(editor.section.toggle(title)).toHaveCount(0);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
for (const c of QUERYABLE_KINDS) {
|
||||
const [shown, hidden] = partition(QUERY_TABS, c.queryTabs);
|
||||
|
||||
test(`TC-05 ${c.label}: offers only the query tabs it supports`, async ({
|
||||
dashboards,
|
||||
editor,
|
||||
}) => {
|
||||
await dashboards.seedAndEdit(c.seed(), SINGLE_PANEL_ID);
|
||||
|
||||
for (const tab of shown) {
|
||||
await expect(editor.query.tab(tab)).toBeVisible();
|
||||
}
|
||||
for (const tab of hidden) {
|
||||
await expect(editor.query.tab(tab)).toHaveCount(0);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
for (const c of PANEL_KINDS.filter((k) => k.queryless)) {
|
||||
test(`TC-06 ${c.label}: has no query builder, tabs or columns editor`, async ({
|
||||
dashboards,
|
||||
editor,
|
||||
config,
|
||||
}) => {
|
||||
await dashboards.seedAndEdit(c.seed(), SINGLE_PANEL_ID);
|
||||
|
||||
await expect(editor.text.pane()).toBeVisible();
|
||||
await expect(editor.query.builder()).toHaveCount(0);
|
||||
for (const tab of QUERY_TABS) {
|
||||
await expect(editor.query.tab(tab)).toHaveCount(0);
|
||||
}
|
||||
await expect(config.listColumns.editor()).toHaveCount(0);
|
||||
});
|
||||
}
|
||||
|
||||
for (const c of PANEL_KINDS.filter((k) => k.thresholdVariant !== null)) {
|
||||
const variant = c.thresholdVariant as ThresholdVariant;
|
||||
const others = THRESHOLD_VARIANTS.filter((other) => other !== variant);
|
||||
|
||||
test(`TC-07 ${c.label}: Thresholds uses the ${variant} variant`, async ({
|
||||
dashboards,
|
||||
editor,
|
||||
config,
|
||||
}) => {
|
||||
await dashboards.seedAndEdit(c.seed(), SINGLE_PANEL_ID);
|
||||
|
||||
await editor.section.expand(Section.thresholds);
|
||||
await expect(config.thresholds.addButton(variant)).toBeVisible();
|
||||
for (const other of others) {
|
||||
await expect(config.thresholds.addButton(other)).toHaveCount(0);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
for (const c of PANEL_KINDS.filter((k) => k.columnsEditor)) {
|
||||
test(`TC-08 ${c.label}: has the columns editor`, async ({
|
||||
dashboards,
|
||||
config,
|
||||
}) => {
|
||||
await dashboards.seedAndEdit(c.seed(), SINGLE_PANEL_ID);
|
||||
await expect(config.listColumns.editor()).toBeVisible();
|
||||
});
|
||||
}
|
||||
|
||||
for (const c of QUERYABLE_KINDS.filter((k) => !k.columnsEditor)) {
|
||||
test(`TC-09 ${c.label}: has no columns editor`, async ({
|
||||
dashboards,
|
||||
editor,
|
||||
config,
|
||||
}) => {
|
||||
await dashboards.seedAndEdit(c.seed(), SINGLE_PANEL_ID);
|
||||
await expect(editor.query.builder()).toBeVisible();
|
||||
await expect(config.listColumns.editor()).toHaveCount(0);
|
||||
});
|
||||
}
|
||||
});
|
||||
123
tests/e2e/tests/dashboards/contract/rendering.spec.ts
Normal file
123
tests/e2e/tests/dashboards/contract/rendering.spec.ts
Normal file
@@ -0,0 +1,123 @@
|
||||
import {
|
||||
belowTheFold,
|
||||
expect,
|
||||
noDataOf,
|
||||
PANEL_KINDS,
|
||||
PanelMessageText,
|
||||
QUERYABLE_KINDS,
|
||||
type QueryRangeBody,
|
||||
SINGLE_PANEL_ID,
|
||||
test,
|
||||
} from '../../../drivers/panel-kinds';
|
||||
|
||||
// Scope: every kind mounts its renderer, fetches lazily, and shows the shared
|
||||
// PanelBody states. States are mock-only: a healthy stack always returns data.
|
||||
|
||||
function required(body: QueryRangeBody | null): QueryRangeBody {
|
||||
expect(body, 'a queryable kind declares mock bodies').not.toBeNull();
|
||||
return body as QueryRangeBody;
|
||||
}
|
||||
|
||||
test.describe('Dashboards contract — rendering', () => {
|
||||
for (const c of PANEL_KINDS) {
|
||||
test(`TC-01 ${c.label}: mounts its own renderer`, async ({
|
||||
dashboards,
|
||||
panels,
|
||||
}) => {
|
||||
await dashboards.seedAndOpen(c.seed());
|
||||
|
||||
const target = panels.panel(SINGLE_PANEL_ID);
|
||||
await target.waitRendered(c.kind);
|
||||
await expect(target.renderer(c.kind)).toBeVisible();
|
||||
});
|
||||
}
|
||||
|
||||
for (const c of QUERYABLE_KINDS) {
|
||||
test(`TC-02 ${c.label}: does not query below the fold until scrolled into view`, async ({
|
||||
dashboards,
|
||||
panels,
|
||||
}) => {
|
||||
await dashboards.seedAndOpen(belowTheFold(c));
|
||||
|
||||
const target = panels.panel(SINGLE_PANEL_ID);
|
||||
await expect(target.visibility()).toHaveAttribute(
|
||||
'data-panel-visible',
|
||||
'false',
|
||||
);
|
||||
await expect(target.renderer(c.kind)).toHaveCount(0);
|
||||
|
||||
await target.scrollIntoView();
|
||||
await expect(target.visibility()).toHaveAttribute(
|
||||
'data-panel-visible',
|
||||
'true',
|
||||
);
|
||||
await expect(target.renderer(c.kind)).toBeVisible();
|
||||
});
|
||||
}
|
||||
|
||||
for (const c of QUERYABLE_KINDS) {
|
||||
test(`TC-03 ${c.label}: an empty result offers Extend time range as primary and Retry as secondary`, async ({
|
||||
dashboards,
|
||||
queryRange,
|
||||
panels,
|
||||
}) => {
|
||||
await queryRange.respond(required(c.emptyBody()));
|
||||
await dashboards.seedAndOpen(c.seed());
|
||||
|
||||
const target = panels.panel(SINGLE_PANEL_ID);
|
||||
const noData = noDataOf(c, target);
|
||||
await expect(noData.root()).toBeVisible();
|
||||
await expect(target.text(PanelMessageText.noDataTitle)).toBeVisible();
|
||||
await expect(noData.action()).toHaveText(PanelMessageText.extendAction);
|
||||
await expect(noData.secondaryAction()).toHaveText(
|
||||
PanelMessageText.retryAction,
|
||||
);
|
||||
await expect(target.heatmap.colorBar()).toHaveCount(0);
|
||||
});
|
||||
}
|
||||
|
||||
for (const c of QUERYABLE_KINDS) {
|
||||
test(`TC-04 ${c.label}: a failing query shows the error state and Retry refetches`, async ({
|
||||
dashboards,
|
||||
queryRange,
|
||||
panels,
|
||||
}) => {
|
||||
// Fail every call, then swap to success before Retry: react-query retries
|
||||
// on its own, so "fail once then succeed" never surfaces the error state.
|
||||
await queryRange.fail('querier exploded');
|
||||
await dashboards.seedAndOpen(c.seed());
|
||||
|
||||
const target = panels.panel(SINGLE_PANEL_ID);
|
||||
await expect(target.error()).toBeVisible();
|
||||
await expect(target.statusError()).toBeVisible();
|
||||
await expect(target.text(PanelMessageText.errorTitle)).toBeVisible();
|
||||
await expect(target.text('querier exploded')).toBeVisible();
|
||||
|
||||
await queryRange.reset();
|
||||
await queryRange.respond(required(c.body()));
|
||||
|
||||
await target.errorAction().click();
|
||||
await expect(target.error()).toHaveCount(0);
|
||||
await expect(target.renderer(c.kind)).toBeVisible();
|
||||
});
|
||||
}
|
||||
|
||||
for (const c of QUERYABLE_KINDS) {
|
||||
test(`TC-05 ${c.label}: an in-flight first query shows the loading state`, async ({
|
||||
dashboards,
|
||||
queryRange,
|
||||
panels,
|
||||
}) => {
|
||||
// LOADING, not `panel-refetching` — the refetch spinner needs prior data.
|
||||
const held = await queryRange.hold(required(c.body()));
|
||||
await dashboards.seedAndOpen(c.seed());
|
||||
|
||||
const target = panels.panel(SINGLE_PANEL_ID);
|
||||
await expect(target.loading()).toBeVisible();
|
||||
|
||||
held.release();
|
||||
await expect(target.renderer(c.kind)).toBeVisible();
|
||||
await expect(target.loading()).toHaveCount(0);
|
||||
});
|
||||
}
|
||||
});
|
||||
@@ -1,206 +0,0 @@
|
||||
import type { Page } from '@playwright/test';
|
||||
|
||||
import { expect, test } from '../../../fixtures/auth';
|
||||
import { newAdminContext } from '../../../helpers/auth';
|
||||
import {
|
||||
APM_METRICS_TITLE,
|
||||
authToken,
|
||||
createDashboardViaApi,
|
||||
deleteDashboardViaApi,
|
||||
createApmMetricsDashboardViaApi,
|
||||
} from '../../../helpers/dashboards';
|
||||
|
||||
const seedIds = new Set<string>();
|
||||
const BASE_TITLE = 'detail-viewing-base';
|
||||
let baseDashboardId = '';
|
||||
let apmDashboardId = '';
|
||||
|
||||
test.beforeAll(async ({ browser }) => {
|
||||
const ctx = await newAdminContext(browser);
|
||||
const page = await ctx.newPage();
|
||||
try {
|
||||
baseDashboardId = await createDashboardViaApi(page, BASE_TITLE);
|
||||
seedIds.add(baseDashboardId);
|
||||
apmDashboardId = await createApmMetricsDashboardViaApi(page);
|
||||
seedIds.add(apmDashboardId);
|
||||
} finally {
|
||||
await ctx.close();
|
||||
}
|
||||
});
|
||||
|
||||
test.afterAll(async ({ browser }) => {
|
||||
if (seedIds.size === 0) {
|
||||
return;
|
||||
}
|
||||
const ctx = await newAdminContext(browser);
|
||||
const page = await ctx.newPage();
|
||||
try {
|
||||
const token = await authToken(page);
|
||||
for (const id of seedIds) {
|
||||
await deleteDashboardViaApi(ctx.request, id, token);
|
||||
seedIds.delete(id);
|
||||
}
|
||||
} finally {
|
||||
await ctx.close();
|
||||
}
|
||||
});
|
||||
|
||||
async function gotoDetail(page: Page, id: string): Promise<void> {
|
||||
await page.goto(`/dashboard/${id}`);
|
||||
}
|
||||
|
||||
test.describe('Dashboard Detail Page — Viewing', () => {
|
||||
test('TC-01 page chrome — breadcrumb, title, toolbar buttons render', async ({
|
||||
authedPage: page,
|
||||
}) => {
|
||||
// Use the APM dashboard rather than the empty base — empty dashboards
|
||||
// render an onboarding canvas with its own Configure / New Panel
|
||||
// buttons, which duplicate the toolbar testids.
|
||||
await gotoDetail(page, apmDashboardId);
|
||||
|
||||
await expect(
|
||||
page.getByRole('button', { name: /Dashboard \// }),
|
||||
).toBeVisible();
|
||||
await expect(
|
||||
page.getByRole('button', {
|
||||
name: new RegExp(`dashboard-icon ${APM_METRICS_TITLE}`),
|
||||
}),
|
||||
).toBeVisible();
|
||||
|
||||
await expect(page).toHaveURL(/\/dashboard\/[0-9a-f-]+/);
|
||||
await expect(page).toHaveTitle(new RegExp(APM_METRICS_TITLE));
|
||||
|
||||
await expect(
|
||||
page.getByRole('textbox', { name: /Last \d+/ }).first(),
|
||||
).toBeVisible();
|
||||
await expect(page.locator('.refresh-btn button')).toBeVisible();
|
||||
await expect(
|
||||
page.getByRole('button', { name: 'Set auto refresh' }),
|
||||
).toBeVisible();
|
||||
await expect(page.getByTestId('options')).toBeVisible();
|
||||
await expect(page.getByTestId('show-drawer')).toBeVisible();
|
||||
await expect(page.getByTestId('add-panel-header')).toBeVisible();
|
||||
await expect(page.getByRole('button', { name: 'Feedback' })).toBeVisible();
|
||||
await expect(page.getByRole('button', { name: 'Share' })).toBeVisible();
|
||||
});
|
||||
|
||||
test('TC-02 breadcrumb returns to /dashboard', async ({
|
||||
authedPage: page,
|
||||
}) => {
|
||||
await gotoDetail(page, baseDashboardId);
|
||||
await expect(
|
||||
page.getByRole('button', { name: /Dashboard \// }),
|
||||
).toBeVisible();
|
||||
|
||||
// `dispatchEvent('click')` — the expanded sidenav intercepts pointer
|
||||
// events at the breadcrumb's center, defeating even `force: true`.
|
||||
// Dispatching the click directly on the DOM node bypasses hit testing.
|
||||
await page
|
||||
.getByRole('button', { name: 'Dashboard /' })
|
||||
.dispatchEvent('click');
|
||||
|
||||
await expect(page).toHaveURL(/\/dashboard$/);
|
||||
await expect(
|
||||
page.getByRole('heading', { name: 'Dashboards', level: 1 }),
|
||||
).toBeVisible();
|
||||
});
|
||||
|
||||
test('TC-03 tags bar renders for an imported dashboard', async ({
|
||||
authedPage: page,
|
||||
}) => {
|
||||
await gotoDetail(page, apmDashboardId);
|
||||
await expect(
|
||||
page.getByRole('button', {
|
||||
name: new RegExp(`dashboard-icon ${APM_METRICS_TITLE}`),
|
||||
}),
|
||||
).toBeVisible();
|
||||
|
||||
// `exact: true` is load-bearing — `apm` is a substring of the
|
||||
// breadcrumb title `APM Metrics`, so a loose match would collide.
|
||||
for (const tag of ['apm', 'latency', 'error rate', 'throughput']) {
|
||||
await expect(page.getByText(tag, { exact: true })).toBeVisible();
|
||||
}
|
||||
});
|
||||
|
||||
test('TC-04 section row headers render for APM Metrics', async ({
|
||||
authedPage: page,
|
||||
}) => {
|
||||
await gotoDetail(page, apmDashboardId);
|
||||
|
||||
// known behaviour: APM Metrics fixture has two sections both named
|
||||
// "Overview" — `.first()` deliberately matches whichever renders first.
|
||||
await expect(
|
||||
page.getByText('Overview', { exact: true }).first(),
|
||||
).toBeVisible();
|
||||
await expect(
|
||||
page.getByText('DB Metrics', { exact: true }).first(),
|
||||
).toBeVisible();
|
||||
await expect(
|
||||
page.getByText('External calls', { exact: true }).first(),
|
||||
).toBeVisible();
|
||||
});
|
||||
|
||||
test('TC-05 at least one panel container renders', async ({
|
||||
authedPage: page,
|
||||
}) => {
|
||||
await gotoDetail(page, apmDashboardId);
|
||||
await expect(
|
||||
page.getByRole('button', {
|
||||
name: new RegExp(`dashboard-icon ${APM_METRICS_TITLE}`),
|
||||
}),
|
||||
).toBeVisible();
|
||||
|
||||
await expect(
|
||||
page.getByText('Latency', { exact: true }).first(),
|
||||
).toBeVisible();
|
||||
});
|
||||
|
||||
test('TC-06 no JS pageerror during initial load', async ({
|
||||
authedPage: page,
|
||||
}) => {
|
||||
const errors: Error[] = [];
|
||||
page.on('pageerror', (err) => errors.push(err));
|
||||
|
||||
await gotoDetail(page, apmDashboardId);
|
||||
await expect(
|
||||
page.getByRole('button', {
|
||||
name: new RegExp(`dashboard-icon ${APM_METRICS_TITLE}`),
|
||||
}),
|
||||
).toBeVisible();
|
||||
await expect(
|
||||
page.getByText('Latency', { exact: true }).first(),
|
||||
).toBeVisible();
|
||||
|
||||
expect(errors).toHaveLength(0);
|
||||
});
|
||||
|
||||
// ─── Cross-spec: connection with the dashboards-list page ────────────────
|
||||
|
||||
test('TC-07 navigating from the dashboards list lands on the detail page', async ({
|
||||
authedPage: page,
|
||||
}) => {
|
||||
await page.goto('/dashboard');
|
||||
await expect(
|
||||
page.getByRole('heading', { name: 'Dashboards', level: 1 }),
|
||||
).toBeVisible();
|
||||
|
||||
await page
|
||||
.getByPlaceholder('Search by name, description, or tags...')
|
||||
.fill(APM_METRICS_TITLE);
|
||||
await expect(page.getByText(APM_METRICS_TITLE).first()).toBeVisible();
|
||||
const actionIcon = page.getByTestId('dashboard-action-icon').first();
|
||||
await actionIcon.scrollIntoViewIfNeeded();
|
||||
await actionIcon.click();
|
||||
await page.getByRole('tooltip').getByRole('button', { name: 'View' }).click();
|
||||
|
||||
await expect(page).toHaveURL(/\/dashboard\/[0-9a-f-]+/);
|
||||
await expect(
|
||||
page.getByRole('button', {
|
||||
name: new RegExp(`dashboard-icon ${APM_METRICS_TITLE}`),
|
||||
}),
|
||||
).toBeVisible();
|
||||
await expect(
|
||||
page.getByText('Latency', { exact: true }).first(),
|
||||
).toBeVisible();
|
||||
});
|
||||
});
|
||||
@@ -1,539 +0,0 @@
|
||||
import type { Page } from '@playwright/test';
|
||||
|
||||
import { expect, test } from '../../../fixtures/auth';
|
||||
import { newAdminContext } from '../../../helpers/auth';
|
||||
import {
|
||||
authToken,
|
||||
createApmMetricsDashboardViaApi,
|
||||
deleteDashboardViaApi,
|
||||
} from '../../../helpers/dashboards';
|
||||
|
||||
// ─── Per-test seed lifecycle ────────────────────────────────────────────
|
||||
//
|
||||
// Each test gets its own freshly-seeded APM Metrics dashboard (4 sections,
|
||||
// 16 panels — including the duplicate-named "Overview" sections, which the
|
||||
// fixture intentionally ships). Per-test seeding eliminates the "previous
|
||||
// test left the dashboard in a collapsed/renamed state" class of CI flakes
|
||||
// that bit us repeatedly with `beforeAll`-shared seed: it is no longer
|
||||
// possible for one test's restore PUT to race the next test's GET, because
|
||||
// the next test does not see the previous test's dashboard at all.
|
||||
//
|
||||
// `serial` mode is no longer required for correctness (tests are hermetic)
|
||||
// but we keep parallel runs intra-file because seed creation is the
|
||||
// per-test cost — running them concurrently inside the worker would just
|
||||
// pile up more concurrent dashboards without helping.
|
||||
let apmDashboardId: string;
|
||||
|
||||
test.beforeEach(async ({ browser }) => {
|
||||
const ctx = await newAdminContext(browser);
|
||||
const page = await ctx.newPage();
|
||||
try {
|
||||
apmDashboardId = await createApmMetricsDashboardViaApi(page);
|
||||
} finally {
|
||||
await ctx.close();
|
||||
}
|
||||
});
|
||||
|
||||
test.afterEach(async ({ browser }) => {
|
||||
if (!apmDashboardId) {
|
||||
return;
|
||||
}
|
||||
const ctx = await newAdminContext(browser);
|
||||
const page = await ctx.newPage();
|
||||
try {
|
||||
const token = await authToken(page);
|
||||
await deleteDashboardViaApi(ctx.request, apmDashboardId, token);
|
||||
} catch {
|
||||
// Best-effort cleanup — a failing delete should not mask test
|
||||
// failures the user actually needs to see.
|
||||
} finally {
|
||||
apmDashboardId = '';
|
||||
await ctx.close();
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* Resolve the `.row-panel` container for a section by traversing up from its
|
||||
* title text. The fixture ships two sections both literally named "Overview"
|
||||
* — pass `index` to disambiguate. Two `..` hops reach `.row-panel`, which
|
||||
* holds both the chevron and the settings-icon for that row.
|
||||
*/
|
||||
function sectionRow(
|
||||
page: Page,
|
||||
name: string | RegExp,
|
||||
index = 0,
|
||||
): ReturnType<Page['locator']> {
|
||||
return page
|
||||
.getByText(name, { exact: typeof name === 'string' })
|
||||
.nth(index)
|
||||
.locator('..')
|
||||
.locator('..');
|
||||
}
|
||||
|
||||
async function gotoApmDashboard(page: Page): Promise<void> {
|
||||
await page.goto(`/dashboard/${apmDashboardId}`);
|
||||
await page
|
||||
.getByRole('button', { name: /dashboard-icon APM Metrics/ })
|
||||
.waitFor({ state: 'visible' });
|
||||
|
||||
// `GridCardLayout`'s auto-save `useEffect` (line 226 of the source) is
|
||||
// gated on `!isDashboardFetching` but `isDashboardFetching` is NOT in the
|
||||
// effect's dep array. Concretely: if a chevron is clicked while any
|
||||
// `[REACT_QUERY_KEY.DASHBOARD_BY_ID]` query is in flight, the effect runs
|
||||
// once for the new `dashboardLayout`, sees `isDashboardFetching=true`, and
|
||||
// returns early — and never re-runs when the GET later completes, because
|
||||
// `dashboardLayout` didn't change again. The PUT is *never* fired and
|
||||
// `toggleSectionAndWaitForPut` blocks until the 30 s test timeout.
|
||||
//
|
||||
// Wait until the in-flight dashboard GETs settle so the effect's gate
|
||||
// evaluates to `false` on the next click. We assert this two ways: a panel
|
||||
// from each visible section must render (proves data is hydrated), and
|
||||
// `Latency` (the first panel of the first Overview section) must paint.
|
||||
await expect(page.getByText('Latency', { exact: true }).first()).toBeVisible({
|
||||
timeout: 20_000,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Click `.row-icon` (chevron) on a section row. The collapse/expand state is
|
||||
* driven by React local state — `setDashboardLayout` updates synchronously
|
||||
* and the (suffixed / unsuffixed) title appears on the next render. We do
|
||||
* NOT wait for the auto-save PUT here: it's gated on `!isDashboardFetching`
|
||||
* in `GridCardLayout.tsx` and can be skipped entirely under CI load.
|
||||
* Persistence does not matter because each test seeds a fresh dashboard.
|
||||
*
|
||||
* `dispatchEvent('click')` — under CI viewport the expanded sidenav's
|
||||
* `nav-item-data` subtree intercepts pointer events at the chevron's
|
||||
* position (verified in CI run #26162502354). `.click({ force: true })`
|
||||
* still lands the event at the visual centre and is swallowed by the
|
||||
* overlay; dispatching the click directly on the SVG node bypasses hit
|
||||
* testing entirely and triggers React's `onClick` handler.
|
||||
*/
|
||||
async function toggleSection(row: ReturnType<Page['locator']>): Promise<void> {
|
||||
const chevron = row.locator('.row-icon');
|
||||
await chevron.scrollIntoViewIfNeeded();
|
||||
await expect(chevron).toBeVisible();
|
||||
|
||||
const page = chevron.page();
|
||||
// Register a PUT listener BEFORE the click. The auto-save effect in
|
||||
// `GridCardLayout` fires a PUT when `!isDashboardFetching` — if the PUT
|
||||
// arrives, its `onSuccess` triggers a brief loading-state re-render that
|
||||
// unmounts every `.row-panel`. The next toggle's chevron lookup either
|
||||
// misses (locator times out) or grabs a transient node that detaches
|
||||
// during scroll. Sequencing: dispatch click → await PUT (3 s short
|
||||
// timeout in case auto-save was gated) → wait for the loading spinner
|
||||
// to be absent.
|
||||
const putSettled = page
|
||||
.waitForResponse(
|
||||
(r) => r.request().method() === 'PUT' && /\/dashboards\//.test(r.url()),
|
||||
{ timeout: 3_000 },
|
||||
)
|
||||
.catch(() => null);
|
||||
|
||||
await chevron.dispatchEvent('click');
|
||||
await putSettled;
|
||||
await expect(page.getByAltText('loading')).toHaveCount(0, {
|
||||
timeout: 20_000,
|
||||
});
|
||||
}
|
||||
|
||||
// Poll a section to the target collapsed/expanded state, re-clicking if a
|
||||
// toggle is dropped under CI load. `name` has no regex metacharacters.
|
||||
async function setSectionCollapsed(
|
||||
page: Page,
|
||||
name: string,
|
||||
collapsed: boolean,
|
||||
): Promise<void> {
|
||||
const collapsedTitle = new RegExp(`^${name} \\(\\d+ widgets?\\)$`);
|
||||
await expect(async () => {
|
||||
const alreadyCollapsed = (await page.getByText(collapsedTitle).count()) > 0;
|
||||
if (alreadyCollapsed === collapsed) {
|
||||
return;
|
||||
}
|
||||
await toggleSection(
|
||||
sectionRow(page, alreadyCollapsed ? collapsedTitle : name),
|
||||
);
|
||||
expect((await page.getByText(collapsedTitle).count()) > 0).toBe(collapsed);
|
||||
}).toPass({ timeout: 30_000 });
|
||||
}
|
||||
|
||||
/**
|
||||
* Click the settings (⋮) icon on a section header, bypassing the sidenav's
|
||||
* pointer-event interception via `dispatchEvent('click')` (same root cause
|
||||
* as `toggleSectionAndWaitForPut`). The settings popover (Rename / New Panel
|
||||
* / Remove Section) lives on the LEFT of the row at the same x-coordinate
|
||||
* as the chevron, so it suffers the same overlap.
|
||||
*/
|
||||
async function clickSectionSettings(
|
||||
row: ReturnType<Page['locator']>,
|
||||
): Promise<void> {
|
||||
const icon = row.locator('.settings-icon');
|
||||
await icon.scrollIntoViewIfNeeded();
|
||||
await expect(icon).toBeVisible();
|
||||
await icon.dispatchEvent('click');
|
||||
}
|
||||
|
||||
test.describe('Dashboard Detail — Sections', () => {
|
||||
// ─── Collapse / expand chevron and widget-count suffix ───────────────────
|
||||
|
||||
// TODO(e2e): re-enable once CI consistently passes. Passes locally
|
||||
// (including `STRESS=1 CI=1`) but flakes on GitHub Linux runner — the
|
||||
// chevron click intermittently fails to land its auto-save PUT despite
|
||||
// `dispatchEvent('click')` + `Latency` panel hydration gate. Suspect
|
||||
// remaining race lives in `GridCardLayout`'s auto-save `useEffect` not
|
||||
// listing `isDashboardFetching` in its deps. See CI-HARDENING.md item 5.
|
||||
test.skip('TC-01 collapsing a section hides panels and shows widget count', async ({
|
||||
authedPage: page,
|
||||
}) => {
|
||||
await gotoApmDashboard(page);
|
||||
|
||||
// "DB Metrics" is the third section in the APM fixture and lives below
|
||||
// the fold on the 1280×720 CI viewport. Scroll its title into view and
|
||||
// wait for visibility so the 14×14 chevron is actionable.
|
||||
const dbMetricsTitle = page.getByText('DB Metrics', { exact: true }).first();
|
||||
await dbMetricsTitle.scrollIntoViewIfNeeded();
|
||||
await expect(dbMetricsTitle).toBeVisible();
|
||||
await toggleSection(sectionRow(page, 'DB Metrics'));
|
||||
|
||||
// After collapse the section title is rewritten to include the count
|
||||
// suffix; assert with a regex so the test is robust to widget-count
|
||||
// drift in the fixture.
|
||||
await expect(
|
||||
page.getByText(/^DB Metrics \(\d+ widgets?\)$/).first(),
|
||||
).toBeVisible();
|
||||
|
||||
// Restore: chevron-down is the row-icon variant rendered for collapsed
|
||||
// sections. Re-resolve via the new (suffixed) title.
|
||||
await toggleSection(sectionRow(page, /^DB Metrics \(\d+ widgets?\)$/));
|
||||
await expect(page.getByText(/^DB Metrics \(\d+ widgets?\)$/)).toHaveCount(0);
|
||||
});
|
||||
|
||||
test('TC-02 widget count matches number of panels visible before collapse', async ({
|
||||
authedPage: page,
|
||||
}) => {
|
||||
await gotoApmDashboard(page);
|
||||
|
||||
// The first Overview section in the APM fixture holds these four
|
||||
// panels — they're our ground truth for the count assertion below.
|
||||
await expect(
|
||||
page.getByText('Latency', { exact: true }).first(),
|
||||
).toBeVisible();
|
||||
await expect(
|
||||
page.getByText('Request rate', { exact: true }).first(),
|
||||
).toBeVisible();
|
||||
await expect(
|
||||
page.getByText('Error percentage', { exact: true }).first(),
|
||||
).toBeVisible();
|
||||
await expect(
|
||||
page.getByText('Top operations', { exact: true }).first(),
|
||||
).toBeVisible();
|
||||
|
||||
await toggleSection(sectionRow(page, 'Overview', 0));
|
||||
|
||||
await expect(
|
||||
page.getByText('Overview (4 widgets)', { exact: true }).first(),
|
||||
).toBeVisible();
|
||||
|
||||
// Restore.
|
||||
await toggleSection(sectionRow(page, 'Overview (4 widgets)'));
|
||||
await expect(
|
||||
page.getByText('Overview (4 widgets)', { exact: true }),
|
||||
).toHaveCount(0);
|
||||
});
|
||||
|
||||
test('TC-03 expanding restores panels', async ({ authedPage: page }) => {
|
||||
await gotoApmDashboard(page);
|
||||
|
||||
// Collapse "DB Metrics" instead of the first Overview — its widgets
|
||||
// have unique titles ("DB Calls RPS" / "Database Calls Avg Duration")
|
||||
// so collapse/expand transitions can be asserted without colliding
|
||||
// with the duplicate-titled panels in the two Overview sections.
|
||||
// "DB Metrics" lives further down the canvas; scroll into view first
|
||||
// so the panels actually mount (the canvas virtualises off-screen).
|
||||
const dbCalls = page.getByText('DB Calls RPS', { exact: true }).first();
|
||||
await dbCalls.scrollIntoViewIfNeeded();
|
||||
await expect(dbCalls).toBeVisible({ timeout: 15_000 });
|
||||
await toggleSection(sectionRow(page, 'DB Metrics'));
|
||||
await expect(
|
||||
page.getByText(/^DB Metrics \(\d+ widgets?\)$/).first(),
|
||||
).toBeVisible();
|
||||
|
||||
// While collapsed, "DB Calls RPS" should fully unmount.
|
||||
await expect(page.getByText('DB Calls RPS', { exact: true })).toHaveCount(0);
|
||||
|
||||
await toggleSection(sectionRow(page, /^DB Metrics \(\d+ widgets?\)$/));
|
||||
|
||||
await expect(
|
||||
page.getByText('DB Calls RPS', { exact: true }).first(),
|
||||
).toBeVisible();
|
||||
await expect(page.getByText(/^DB Metrics \(\d+ widgets?\)$/)).toHaveCount(0);
|
||||
});
|
||||
|
||||
// ─── Section options menu (Rename / New Panel / Remove Section) ──────────
|
||||
|
||||
test('TC-04 section options menu shows Rename / New Panel / Remove Section', async ({
|
||||
authedPage: page,
|
||||
}) => {
|
||||
await gotoApmDashboard(page);
|
||||
|
||||
// Use DB Metrics — its settings popover is guaranteed to render all
|
||||
// three buttons when the section is expanded. WidgetRow.tsx hides
|
||||
// "Remove Section" while a section is collapsed.
|
||||
await clickSectionSettings(sectionRow(page, 'DB Metrics'));
|
||||
|
||||
const tooltip = page.getByRole('tooltip');
|
||||
await expect(tooltip).toBeVisible();
|
||||
await expect(tooltip.getByRole('button', { name: 'Rename' })).toBeVisible();
|
||||
await expect(
|
||||
tooltip.getByRole('button', { name: 'New Panel', exact: true }),
|
||||
).toBeVisible();
|
||||
await expect(
|
||||
tooltip.getByRole('button', { name: 'Remove Section' }),
|
||||
).toBeVisible();
|
||||
|
||||
await page.keyboard.press('Escape');
|
||||
});
|
||||
|
||||
test('TC-05 rename a section, restore original name', async ({
|
||||
authedPage: page,
|
||||
}) => {
|
||||
await gotoApmDashboard(page);
|
||||
|
||||
const renamed = `Renamed Section ${Date.now()}`;
|
||||
|
||||
// DB Metrics has a unique name, avoiding the duplicate-Overview snag.
|
||||
await clickSectionSettings(sectionRow(page, 'DB Metrics'));
|
||||
await page
|
||||
.getByRole('tooltip')
|
||||
.getByRole('button', { name: 'Rename' })
|
||||
.click();
|
||||
|
||||
const renameDialog = page.getByRole('dialog', { name: 'Rename Section' });
|
||||
await expect(renameDialog).toBeVisible();
|
||||
const nameInput = renameDialog.getByPlaceholder('Enter row name here...');
|
||||
await nameInput.click();
|
||||
await nameInput.fill(renamed);
|
||||
await renameDialog.getByRole('button', { name: 'Apply Changes' }).click();
|
||||
await expect(renameDialog).not.toBeVisible();
|
||||
|
||||
await expect(page.getByText(renamed, { exact: true }).first()).toBeVisible();
|
||||
|
||||
// Restore.
|
||||
await clickSectionSettings(sectionRow(page, renamed));
|
||||
await page
|
||||
.getByRole('tooltip')
|
||||
.getByRole('button', { name: 'Rename' })
|
||||
.click();
|
||||
const restoreDialog = page.getByRole('dialog', { name: 'Rename Section' });
|
||||
const restoreInput = restoreDialog.getByPlaceholder('Enter row name here...');
|
||||
await restoreInput.click();
|
||||
await restoreInput.fill('DB Metrics');
|
||||
await restoreDialog.getByRole('button', { name: 'Apply Changes' }).click();
|
||||
await expect(restoreDialog).not.toBeVisible();
|
||||
|
||||
await expect(
|
||||
page.getByText('DB Metrics', { exact: true }).first(),
|
||||
).toBeVisible();
|
||||
await expect(page.getByText(renamed, { exact: true })).toHaveCount(0);
|
||||
});
|
||||
|
||||
test('TC-06 cancel section rename leaves name unchanged', async ({
|
||||
authedPage: page,
|
||||
}) => {
|
||||
await gotoApmDashboard(page);
|
||||
|
||||
await clickSectionSettings(sectionRow(page, 'External calls'));
|
||||
await page
|
||||
.getByRole('tooltip')
|
||||
.getByRole('button', { name: 'Rename' })
|
||||
.click();
|
||||
|
||||
const dialog = page.getByRole('dialog', { name: 'Rename Section' });
|
||||
await expect(dialog).toBeVisible();
|
||||
const input = dialog.getByPlaceholder('Enter row name here...');
|
||||
await input.click();
|
||||
await input.fill('Should Not Be Applied');
|
||||
|
||||
await dialog.getByRole('button', { name: 'Cancel' }).click();
|
||||
await expect(dialog).not.toBeVisible();
|
||||
|
||||
await expect(
|
||||
page.getByText('External calls', { exact: true }).first(),
|
||||
).toBeVisible();
|
||||
await expect(page.getByText('Should Not Be Applied')).toHaveCount(0);
|
||||
});
|
||||
|
||||
// TODO(e2e): re-enable once CI consistently passes. Flaky because of hover interaction on menu, will be changing with new implementation with perses.
|
||||
test.skip('TC-07 add a new panel to a section, then delete it', async ({
|
||||
authedPage: page,
|
||||
}) => {
|
||||
await gotoApmDashboard(page);
|
||||
|
||||
const panelName = `Test Panel ${Date.now()}`;
|
||||
|
||||
await clickSectionSettings(sectionRow(page, 'DB Metrics'));
|
||||
await page
|
||||
.getByRole('tooltip')
|
||||
.getByRole('button', { name: 'New Panel', exact: true })
|
||||
.click();
|
||||
|
||||
const panelTypeDialog = page.getByRole('dialog', { name: 'New Panel' });
|
||||
await expect(panelTypeDialog).toBeVisible();
|
||||
await panelTypeDialog.getByTestId('panel-type-graph').click();
|
||||
|
||||
// We're now in the panel editor at /dashboard/:id/new?widgetId=…
|
||||
await page.waitForURL(/\/new/);
|
||||
await page.getByTestId('panel-name-input').fill(panelName);
|
||||
|
||||
// NewWidget renders TWO buttons with `data-testid="new-widget-save"` —
|
||||
// a disabled variant when `isSaveDisabled` is true and an enabled
|
||||
// variant when it is false. Under CI load the editor mounts with the
|
||||
// disabled variant first; without `toBeEnabled` the click can hit the
|
||||
// disabled button and the Save dialog never opens.
|
||||
const saveBtn = page.getByTestId('new-widget-save');
|
||||
await expect(saveBtn).toBeVisible();
|
||||
await expect(saveBtn).toBeEnabled({ timeout: 20_000 });
|
||||
// `dispatchEvent('click')` — sidenav overlap risk on CI; see the same
|
||||
// rationale on `toggleSectionAndWaitForPut` above.
|
||||
await saveBtn.dispatchEvent('click');
|
||||
const saveDialog = page.getByRole('dialog', { name: 'Save Widget' });
|
||||
await expect(saveDialog).toBeVisible();
|
||||
|
||||
// PUT confirms the panel persisted server-side — more reliable than
|
||||
// waiting on redux state to propagate before navigating back.
|
||||
const putResponse = page.waitForResponse(
|
||||
(r) => r.request().method() === 'PUT' && /\/dashboards\//.test(r.url()),
|
||||
);
|
||||
await saveDialog.getByRole('button', { name: 'OK' }).click();
|
||||
await putResponse;
|
||||
|
||||
await page.waitForURL((url) => !url.pathname.includes('/new'));
|
||||
await expect(
|
||||
page.getByText(panelName, { exact: true }).first(),
|
||||
).toBeVisible();
|
||||
|
||||
// The panel ⋮ menu is a Radix `DropdownMenuSimple` — it opens on click,
|
||||
// not hover (see `openPanelMoreMenu` in 21-panel-actions.spec.ts). The
|
||||
// container hover only reveals the kebab (it's `visibility: hidden`
|
||||
// until then); the click toggles the menu. Wait for the menu role to be
|
||||
// visible before clicking Delete.
|
||||
const panelTitle = page.getByText(panelName, { exact: true }).first();
|
||||
await panelTitle.hover();
|
||||
const panelContainer = panelTitle.locator('../..');
|
||||
await panelContainer.scrollIntoViewIfNeeded();
|
||||
await panelContainer.hover();
|
||||
await panelContainer.getByTestId('widget-header-options').click();
|
||||
const menu = page.getByRole('menu');
|
||||
await menu.waitFor({ state: 'visible' });
|
||||
await menu.getByRole('menuitem', { name: 'Delete', exact: true }).click();
|
||||
|
||||
const deleteDialog = page.getByRole('dialog', { name: 'Delete' });
|
||||
await expect(deleteDialog).toBeVisible();
|
||||
|
||||
const deletePut = page.waitForResponse(
|
||||
(r) => r.request().method() === 'PUT' && /\/dashboards\//.test(r.url()),
|
||||
);
|
||||
await deleteDialog.getByRole('button', { name: 'OK' }).click();
|
||||
await deletePut;
|
||||
await expect(deleteDialog).not.toBeVisible();
|
||||
await expect(page.getByText(panelName, { exact: true })).toHaveCount(0);
|
||||
});
|
||||
|
||||
// ─── New section in edit mode ────────────────────────────────────────────
|
||||
|
||||
test('TC-08 add a new section via edit mode, then remove it', async ({
|
||||
authedPage: page,
|
||||
}) => {
|
||||
await gotoApmDashboard(page);
|
||||
|
||||
const sectionName = `Temp Section ${Date.now()}`;
|
||||
|
||||
await page.getByTestId('options').click();
|
||||
await page.getByRole('menuitem', { name: 'New section' }).click();
|
||||
|
||||
const newSectionDialog = page.getByRole('dialog', { name: 'New Section' });
|
||||
await expect(newSectionDialog).toBeVisible();
|
||||
await newSectionDialog.getByTestId('section-name').fill(sectionName);
|
||||
await newSectionDialog
|
||||
.getByRole('button', { name: 'Create Section' })
|
||||
.click();
|
||||
await expect(newSectionDialog).not.toBeVisible();
|
||||
|
||||
await expect(
|
||||
page.getByText(sectionName, { exact: true }).first(),
|
||||
).toBeVisible();
|
||||
|
||||
await clickSectionSettings(sectionRow(page, sectionName));
|
||||
await page
|
||||
.getByRole('tooltip')
|
||||
.getByRole('button', { name: 'Remove Section' })
|
||||
.click();
|
||||
|
||||
const deleteRowDialog = page.getByRole('dialog', { name: 'Delete Row' });
|
||||
await expect(deleteRowDialog).toBeVisible();
|
||||
await deleteRowDialog.getByRole('button', { name: 'OK' }).click();
|
||||
await expect(deleteRowDialog).not.toBeVisible();
|
||||
|
||||
await expect(page.getByText(sectionName, { exact: true })).toHaveCount(0);
|
||||
|
||||
// Original sections are untouched.
|
||||
await expect(
|
||||
page.getByText('Overview', { exact: true }).first(),
|
||||
).toBeVisible();
|
||||
await expect(
|
||||
page.getByText('DB Metrics', { exact: true }).first(),
|
||||
).toBeVisible();
|
||||
await expect(
|
||||
page.getByText('External calls', { exact: true }).first(),
|
||||
).toBeVisible();
|
||||
});
|
||||
|
||||
// ─── Deep coverage ───────────────────────────────────────────────────────
|
||||
|
||||
test('TC-09 collapsing two sections in sequence shows both as collapsed', async ({
|
||||
authedPage: page,
|
||||
}) => {
|
||||
await gotoApmDashboard(page);
|
||||
|
||||
await setSectionCollapsed(page, 'DB Metrics', true);
|
||||
await expect(
|
||||
page.getByText(/^DB Metrics \(\d+ widgets?\)$/).first(),
|
||||
).toBeVisible();
|
||||
|
||||
await setSectionCollapsed(page, 'External calls', true);
|
||||
await expect(
|
||||
page.getByText(/^External calls \(\d+ widgets?\)$/).first(),
|
||||
).toBeVisible();
|
||||
|
||||
// Restore both so the test leaves no state behind.
|
||||
await setSectionCollapsed(page, 'DB Metrics', false);
|
||||
await setSectionCollapsed(page, 'External calls', false);
|
||||
await expect(page.getByText(/^DB Metrics \(\d+ widgets?\)$/)).toHaveCount(0);
|
||||
await expect(page.getByText(/^External calls \(\d+ widgets?\)$/)).toHaveCount(
|
||||
0,
|
||||
);
|
||||
});
|
||||
|
||||
test('TC-10 panels inside a collapsed section are not in the DOM', async ({
|
||||
authedPage: page,
|
||||
}) => {
|
||||
await gotoApmDashboard(page);
|
||||
|
||||
// "DB Calls RPS" is a unique panel inside the "DB Metrics" section.
|
||||
const dbPanel = page.getByText('DB Calls RPS', { exact: true });
|
||||
await dbPanel.first().scrollIntoViewIfNeeded();
|
||||
await expect(dbPanel.first()).toBeVisible();
|
||||
|
||||
await toggleSection(sectionRow(page, 'DB Metrics'));
|
||||
await expect(
|
||||
page.getByText(/^DB Metrics \(\d+ widgets?\)$/).first(),
|
||||
).toBeVisible();
|
||||
|
||||
// Panels inside the collapsed section unmount, not just hidden.
|
||||
await expect(dbPanel).toHaveCount(0);
|
||||
|
||||
// Restore.
|
||||
await toggleSection(sectionRow(page, /^DB Metrics \(\d+ widgets?\)$/));
|
||||
await expect(dbPanel.first()).toBeVisible();
|
||||
});
|
||||
});
|
||||
@@ -1,635 +0,0 @@
|
||||
import type { Locator, Page } from '@playwright/test';
|
||||
|
||||
import { expect, test } from '../../../fixtures/auth';
|
||||
import { newAdminContext } from '../../../helpers/auth';
|
||||
import {
|
||||
authToken,
|
||||
createApmMetricsDashboardViaApi,
|
||||
createChartDataDashboardViaApi,
|
||||
deleteDashboardViaApi,
|
||||
} from '../../../helpers/dashboards';
|
||||
|
||||
// Tests in this file mutate the same dashboard (clone / delete panels). Run
|
||||
// them serially within the worker so state from one test does not leak into
|
||||
// another's assertions.
|
||||
test.describe.configure({ mode: 'serial' });
|
||||
|
||||
const seedIds = new Set<string>();
|
||||
let apmDashboardId = '';
|
||||
|
||||
const TIME_SERIES_PANEL = 'Latency';
|
||||
const TABLE_PANEL = 'Top operations';
|
||||
|
||||
test.beforeAll(async ({ browser }) => {
|
||||
const ctx = await newAdminContext(browser);
|
||||
const page = await ctx.newPage();
|
||||
try {
|
||||
apmDashboardId = await createApmMetricsDashboardViaApi(page);
|
||||
seedIds.add(apmDashboardId);
|
||||
} finally {
|
||||
await ctx.close();
|
||||
}
|
||||
});
|
||||
|
||||
test.afterAll(async ({ browser }) => {
|
||||
if (seedIds.size === 0) {
|
||||
return;
|
||||
}
|
||||
const ctx = await newAdminContext(browser);
|
||||
const page = await ctx.newPage();
|
||||
try {
|
||||
const token = await authToken(page);
|
||||
for (const id of seedIds) {
|
||||
await deleteDashboardViaApi(ctx.request, id, token);
|
||||
seedIds.delete(id);
|
||||
}
|
||||
} finally {
|
||||
await ctx.close();
|
||||
}
|
||||
});
|
||||
|
||||
async function gotoDetail(page: Page, id: string): Promise<void> {
|
||||
await page.goto(`/dashboard/${id}`);
|
||||
// `GridCardLayout`'s auto-save effect fires a PUT on initial load when the
|
||||
// local `dashboardLayout` state diverges from the server `layouts`. Under
|
||||
// CI load this PUT can still be in-flight when a test registers its own
|
||||
// `waitForResponse(PUT)` or triggers a mutation (clone / delete), causing
|
||||
// the wrong PUT to be captured or concurrent writes to corrupt layout state.
|
||||
// Drain the in-flight PUT now so every test in this file starts clean.
|
||||
// The try/catch handles dashboards whose layout is already in sync (no PUT).
|
||||
try {
|
||||
await page.waitForResponse(
|
||||
(r) => r.request().method() === 'PUT' && /\/dashboards\//.test(r.url()),
|
||||
{ timeout: 5_000 },
|
||||
);
|
||||
} catch {
|
||||
// No initial-load PUT within 5 s — layout was already synchronised.
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Click NewWidget's Save button after waiting for it to become enabled.
|
||||
*
|
||||
* Why this helper exists: `container/NewWidget/index.tsx` renders TWO buttons
|
||||
* with `data-testid="new-widget-save"` — a disabled variant when
|
||||
* `isSaveDisabled` is true and an enabled variant when it is false. Under CI
|
||||
* load the editor mounts with the disabled variant first; without
|
||||
* `toBeEnabled` the click can hit the disabled button and the Save dialog
|
||||
* never opens, failing the next assertion with "dialog not found".
|
||||
*/
|
||||
async function clickNewWidgetSave(page: Page): Promise<void> {
|
||||
const saveBtn = page.getByTestId('new-widget-save');
|
||||
await expect(saveBtn).toBeVisible();
|
||||
await expect(saveBtn).toBeEnabled({ timeout: 20_000 });
|
||||
// `dispatchEvent('click')` — under CI viewport the editor's right-header
|
||||
// can be partially covered by the sidenav's secondary nav panel, and
|
||||
// `.click()` retries are then swallowed by the overlay. The synthetic
|
||||
// click bypasses hit testing and triggers React's `onClick` directly.
|
||||
await saveBtn.dispatchEvent('click');
|
||||
}
|
||||
|
||||
/**
|
||||
* Locate the panel container (`.widget-graph-component-container`) for the
|
||||
* panel with the given title. The title is exposed via `data-testid={title}`
|
||||
* on the inner `Typography.Text` — traverse upward to the container so we
|
||||
* can scope the ⋮ icon, search icon, etc. to this panel only.
|
||||
*
|
||||
* Multiple panels with the same title (e.g. cloned `Latency` panels) are
|
||||
* disambiguated by `index`, defaulting to the first match in DOM order.
|
||||
*/
|
||||
function panelContainer(page: Page, title: string, index = 0): Locator {
|
||||
return page
|
||||
.getByTestId(title)
|
||||
.nth(index)
|
||||
.locator(
|
||||
'xpath=ancestor::div[contains(@class, "widget-graph-component-container")][1]',
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Reveal the panel header's ⋮ icon and open the action dropdown. Returns the
|
||||
* opened menu locator.
|
||||
*
|
||||
* The ⋮ icon is a `@signozhq/ui` `DropdownMenuSimple` (Radix
|
||||
* `@radix-ui/react-dropdown-menu` under the hood — see `WidgetHeader/index.tsx`),
|
||||
* so the menu opens on click, not hover. We still hover the container first
|
||||
* because the icon is `visibility: hidden` until the row is hovered (see the
|
||||
* `.widget-graph-component-container:hover` rule in `GridCardLayout.styles.scss`);
|
||||
* then we click the revealed icon to toggle the Radix menu open.
|
||||
*/
|
||||
async function openPanelMoreMenu(
|
||||
page: Page,
|
||||
title: string,
|
||||
index = 0,
|
||||
): Promise<Locator> {
|
||||
const container = panelContainer(page, title, index);
|
||||
await container.scrollIntoViewIfNeeded();
|
||||
await container.hover();
|
||||
const moreOptions = container.getByTestId('widget-header-options');
|
||||
await moreOptions.click();
|
||||
const menu = page.getByRole('menu');
|
||||
await menu.waitFor({ state: 'visible' });
|
||||
return menu;
|
||||
}
|
||||
|
||||
test.describe('Dashboard Detail Page — Panel Actions', () => {
|
||||
// ─── ⋮ menu contents ─────────────────────────────────────────────────────
|
||||
|
||||
test('TC-01 panel ⋮ menu shows the 5 actions for a Time Series panel', async ({
|
||||
authedPage: page,
|
||||
}) => {
|
||||
await gotoDetail(page, apmDashboardId);
|
||||
await expect(
|
||||
page.getByText(TIME_SERIES_PANEL, { exact: true }).first(),
|
||||
).toBeVisible();
|
||||
|
||||
const menu = await openPanelMoreMenu(page, TIME_SERIES_PANEL);
|
||||
|
||||
// Time Series headerMenuList = ViewMenuAction + EditMenuAction
|
||||
// = [View, Clone, Delete, Edit, CreateAlerts]. Download is hidden
|
||||
// because panelTypes !== TABLE.
|
||||
await expect(menu.getByRole('menuitem')).toHaveCount(5);
|
||||
await expect(
|
||||
menu.getByRole('menuitem', { name: 'View', exact: true }),
|
||||
).toBeVisible();
|
||||
await expect(
|
||||
menu.getByRole('menuitem', { name: 'Edit', exact: true }),
|
||||
).toBeVisible();
|
||||
await expect(
|
||||
menu.getByRole('menuitem', { name: 'Clone', exact: true }),
|
||||
).toBeVisible();
|
||||
await expect(
|
||||
menu.getByRole('menuitem', { name: 'Delete', exact: true }),
|
||||
).toBeVisible();
|
||||
await expect(
|
||||
menu.getByRole('menuitem', { name: /Create Alerts/ }),
|
||||
).toBeVisible();
|
||||
|
||||
await page.keyboard.press('Escape');
|
||||
});
|
||||
|
||||
test('TC-02 Table panel ⋮ menu replaces Create Alerts with Download as CSV', async ({
|
||||
authedPage: page,
|
||||
}) => {
|
||||
await gotoDetail(page, apmDashboardId);
|
||||
await expect(
|
||||
page.getByText(TABLE_PANEL, { exact: true }).first(),
|
||||
).toBeVisible();
|
||||
|
||||
const menu = await openPanelMoreMenu(page, TABLE_PANEL);
|
||||
|
||||
// Table panels filter CreateAlerts out of the menu (see GridCard
|
||||
// `menuList`) and the Download item turns visible because
|
||||
// panelTypes === TABLE.
|
||||
await expect(
|
||||
menu.getByRole('menuitem', {
|
||||
name: 'Download as CSV',
|
||||
exact: true,
|
||||
}),
|
||||
).toBeVisible();
|
||||
await expect(
|
||||
menu.getByRole('menuitem', { name: /Create Alerts/ }),
|
||||
).toHaveCount(0);
|
||||
|
||||
await page.keyboard.press('Escape');
|
||||
});
|
||||
|
||||
// ─── View / Fullscreen ───────────────────────────────────────────────────
|
||||
|
||||
test('TC-03 View action opens fullscreen with `expandedWidgetId` URL param', async ({
|
||||
authedPage: page,
|
||||
}) => {
|
||||
await gotoDetail(page, apmDashboardId);
|
||||
await expect(
|
||||
page.getByText(TIME_SERIES_PANEL, { exact: true }).first(),
|
||||
).toBeVisible();
|
||||
|
||||
const menu = await openPanelMoreMenu(page, TIME_SERIES_PANEL);
|
||||
const viewItem = menu.getByRole('menuitem', { name: 'View', exact: true });
|
||||
// The View menuitem is `disabled: queryResponse.isFetching` — wait
|
||||
// for it to become enabled before clicking, otherwise the click is a
|
||||
// no-op and the dialog never opens.
|
||||
await expect(viewItem).toBeEnabled();
|
||||
await viewItem.click();
|
||||
|
||||
const dialog = page.getByRole('dialog', { name: TIME_SERIES_PANEL });
|
||||
await expect(dialog).toBeVisible();
|
||||
await expect(page).toHaveURL(/expandedWidgetId=/);
|
||||
|
||||
await dialog.getByRole('button', { name: 'Close' }).click();
|
||||
await expect(dialog).not.toBeVisible();
|
||||
await expect(page).not.toHaveURL(/expandedWidgetId=/);
|
||||
});
|
||||
|
||||
test('TC-04 fullscreen panel renders chart canvas or "No Data"', async ({
|
||||
authedPage: page,
|
||||
}) => {
|
||||
await gotoDetail(page, apmDashboardId);
|
||||
await expect(
|
||||
page.getByText(TIME_SERIES_PANEL, { exact: true }).first(),
|
||||
).toBeVisible();
|
||||
|
||||
const menu = await openPanelMoreMenu(page, TIME_SERIES_PANEL);
|
||||
const viewItem = menu.getByRole('menuitem', { name: 'View', exact: true });
|
||||
await expect(viewItem).toBeEnabled();
|
||||
await viewItem.click();
|
||||
|
||||
const dialog = page.getByRole('dialog', { name: TIME_SERIES_PANEL });
|
||||
await expect(dialog).toBeVisible();
|
||||
|
||||
// known behaviour: the bootstrap stack ingests no telemetry, so a
|
||||
// fully-rendered chart and a "No Data" empty state are both valid
|
||||
// terminal states. Both can also coexist (the chart canvas mounts
|
||||
// before the empty-state overlay paints), so assert that at least
|
||||
// one of the two is reachable rather than using `.or().toBeVisible()`
|
||||
// — that combination triggers strict-mode violations when both
|
||||
// matches resolve.
|
||||
const canvas = dialog.locator('canvas');
|
||||
const noData = dialog.getByText(/no data/i);
|
||||
await expect
|
||||
.poll(async () => (await canvas.count()) + (await noData.count()), {
|
||||
timeout: 30_000,
|
||||
})
|
||||
.toBeGreaterThan(0);
|
||||
|
||||
await dialog.getByRole('button', { name: 'Close' }).click();
|
||||
await expect(dialog).not.toBeVisible();
|
||||
});
|
||||
|
||||
// ─── Table search ────────────────────────────────────────────────────────
|
||||
|
||||
test('TC-05 Table panel search icon reveals search input', async ({
|
||||
authedPage: page,
|
||||
}) => {
|
||||
await gotoDetail(page, apmDashboardId);
|
||||
await expect(
|
||||
page.getByText(TABLE_PANEL, { exact: true }).first(),
|
||||
).toBeVisible();
|
||||
|
||||
const container = panelContainer(page, TABLE_PANEL);
|
||||
await container.scrollIntoViewIfNeeded();
|
||||
await container.hover();
|
||||
|
||||
// The search icon is hover-revealed; click it to swap the title row
|
||||
// out for the search input.
|
||||
const searchIcon = container.getByTestId('widget-header-search');
|
||||
await searchIcon.click();
|
||||
|
||||
// When `showGlobalSearch` is true, the WidgetHeader unmounts the
|
||||
// Typography.Text that carries the title's `data-testid`, so the
|
||||
// `panelContainer` ancestor chain no longer resolves. Look up the
|
||||
// search input by its testid directly — only one search input is
|
||||
// ever open at a time on a dashboard.
|
||||
const searchInput = page.getByTestId('widget-header-search-input');
|
||||
await expect(searchInput).toBeVisible();
|
||||
await searchInput.fill('test');
|
||||
await expect(searchInput).toHaveValue('test');
|
||||
});
|
||||
|
||||
// ─── Download as CSV ─────────────────────────────────────────────────────
|
||||
|
||||
test('TC-06 Download as CSV triggers a file download', async ({
|
||||
authedPage: page,
|
||||
}) => {
|
||||
await gotoDetail(page, apmDashboardId);
|
||||
await expect(
|
||||
page.getByText(TABLE_PANEL, { exact: true }).first(),
|
||||
).toBeVisible();
|
||||
|
||||
const menu = await openPanelMoreMenu(page, TABLE_PANEL);
|
||||
|
||||
// known behaviour: with no telemetry, the CSV may contain only the
|
||||
// header row — asserting on `suggestedFilename()` is the resilient
|
||||
// cross-environment signal that the download actually fired.
|
||||
const [download] = await Promise.all([
|
||||
page.waitForEvent('download'),
|
||||
menu
|
||||
.getByRole('menuitem', {
|
||||
name: 'Download as CSV',
|
||||
exact: true,
|
||||
})
|
||||
.click(),
|
||||
]);
|
||||
|
||||
expect(download.suggestedFilename().length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
// ─── Clone / Delete ──────────────────────────────────────────────────────
|
||||
//
|
||||
// Clone unconditionally navigates to the panel editor (`/new`) — see
|
||||
// `onCloneHandler` in WidgetGraphComponent. Saving from the editor
|
||||
// returns to the dashboard with the duplicated panel persisted.
|
||||
|
||||
// TODO(e2e): re-enable once CI consistently passes. The Save dialog
|
||||
// intermittently fails to appear on the GitHub Linux runner after
|
||||
// clicking `new-widget-save` — `clickNewWidgetSave` gates on
|
||||
// `toBeEnabled` + `dispatchEvent('click')` and passes locally (incl.
|
||||
// `STRESS=1 CI=1`) but greens out under CI's slower scheduler in a way
|
||||
// I haven't been able to reproduce. See CI-HARDENING.md.
|
||||
test.skip('TC-07 Clone a panel creates a duplicate', async ({
|
||||
authedPage: page,
|
||||
}) => {
|
||||
await gotoDetail(page, apmDashboardId);
|
||||
const titleLocator = page.getByText(TIME_SERIES_PANEL, { exact: true });
|
||||
await expect(titleLocator.first()).toBeVisible();
|
||||
const beforeCount = await titleLocator.count();
|
||||
|
||||
const menu = await openPanelMoreMenu(page, TIME_SERIES_PANEL);
|
||||
const cloneItem = menu.getByRole('menuitem', { name: 'Clone', exact: true });
|
||||
await expect(cloneItem).toBeEnabled();
|
||||
await cloneItem.click();
|
||||
|
||||
// The clone handler PUTs the new layout, then redirects to /new.
|
||||
await page.waitForURL(/\/new/);
|
||||
await clickNewWidgetSave(page);
|
||||
|
||||
// The Save dialog title varies — "Save Widget" if the query is
|
||||
// untouched (the case here, since clone preserves the original
|
||||
// query) or "Unsaved Changes" otherwise. Match either by clicking
|
||||
// OK in whichever dialog appears.
|
||||
const saveDialog = page.getByRole('dialog');
|
||||
await expect(saveDialog).toBeVisible();
|
||||
await saveDialog.getByRole('button', { name: 'OK' }).click();
|
||||
|
||||
await page.waitForURL((url) => !url.pathname.includes('/new'));
|
||||
await expect(titleLocator).toHaveCount(beforeCount + 1);
|
||||
|
||||
// Cleanup the cloned panel — its index is `beforeCount` (the last match).
|
||||
const cleanupMenu = await openPanelMoreMenu(
|
||||
page,
|
||||
TIME_SERIES_PANEL,
|
||||
beforeCount,
|
||||
);
|
||||
await cleanupMenu
|
||||
.getByRole('menuitem', { name: 'Delete', exact: true })
|
||||
.click();
|
||||
const confirmDialog = page.getByRole('dialog', { name: 'Delete' });
|
||||
await expect(confirmDialog).toBeVisible();
|
||||
await confirmDialog.getByRole('button', { name: 'OK' }).click();
|
||||
await expect(titleLocator).toHaveCount(beforeCount);
|
||||
});
|
||||
|
||||
// TODO(e2e): re-enable once CI consistently passes. Tied to above test.
|
||||
// Will work automatically when that test is re-enabled
|
||||
test.skip('TC-08 Delete confirm dialog removes a cloned panel', async ({
|
||||
authedPage: page,
|
||||
}) => {
|
||||
await gotoDetail(page, apmDashboardId);
|
||||
const titleLocator = page.getByText(TIME_SERIES_PANEL, { exact: true });
|
||||
await expect(titleLocator.first()).toBeVisible();
|
||||
const beforeCount = await titleLocator.count();
|
||||
|
||||
// Clone a disposable panel — never mutate the seed's original
|
||||
// `Latency` panel because sibling specs depend on it.
|
||||
const cloneMenu = await openPanelMoreMenu(page, TIME_SERIES_PANEL);
|
||||
await cloneMenu.getByRole('menuitem', { name: 'Clone', exact: true }).click();
|
||||
await page.waitForURL(/\/new/);
|
||||
await clickNewWidgetSave(page);
|
||||
await page.getByRole('dialog').getByRole('button', { name: 'OK' }).click();
|
||||
await page.waitForURL((url) => !url.pathname.includes('/new'));
|
||||
await expect(titleLocator).toHaveCount(beforeCount + 1);
|
||||
|
||||
// Delete the clone — last `Latency` in DOM order.
|
||||
const deleteMenu = await openPanelMoreMenu(
|
||||
page,
|
||||
TIME_SERIES_PANEL,
|
||||
beforeCount,
|
||||
);
|
||||
await deleteMenu
|
||||
.getByRole('menuitem', { name: 'Delete', exact: true })
|
||||
.click();
|
||||
|
||||
const dialog = page.getByRole('dialog', { name: 'Delete' });
|
||||
await expect(dialog).toBeVisible();
|
||||
await expect(dialog).toContainText(/are you sure/i);
|
||||
|
||||
await dialog.getByRole('button', { name: 'OK' }).click();
|
||||
await expect(dialog).not.toBeVisible();
|
||||
await expect(titleLocator).toHaveCount(beforeCount);
|
||||
});
|
||||
|
||||
// TODO(e2e): re-enable once CI consistently passes. Tied to above test.
|
||||
// Will work automatically when that test is re-enabled
|
||||
test.skip('TC-09 Cancel delete keeps the panel', async ({
|
||||
authedPage: page,
|
||||
}) => {
|
||||
await gotoDetail(page, apmDashboardId);
|
||||
const titleLocator = page.getByText(TIME_SERIES_PANEL, { exact: true });
|
||||
await expect(titleLocator.first()).toBeVisible();
|
||||
const beforeCount = await titleLocator.count();
|
||||
|
||||
// Clone a disposable panel to operate on.
|
||||
const cloneMenu = await openPanelMoreMenu(page, TIME_SERIES_PANEL);
|
||||
await cloneMenu.getByRole('menuitem', { name: 'Clone', exact: true }).click();
|
||||
await page.waitForURL(/\/new/);
|
||||
await clickNewWidgetSave(page);
|
||||
await page.getByRole('dialog').getByRole('button', { name: 'OK' }).click();
|
||||
await page.waitForURL((url) => !url.pathname.includes('/new'));
|
||||
await expect(titleLocator).toHaveCount(beforeCount + 1);
|
||||
|
||||
const deleteMenu = await openPanelMoreMenu(
|
||||
page,
|
||||
TIME_SERIES_PANEL,
|
||||
beforeCount,
|
||||
);
|
||||
await deleteMenu
|
||||
.getByRole('menuitem', { name: 'Delete', exact: true })
|
||||
.click();
|
||||
|
||||
const dialog = page.getByRole('dialog', { name: 'Delete' });
|
||||
await expect(dialog).toBeVisible();
|
||||
await dialog.getByRole('button', { name: 'Cancel' }).click();
|
||||
await expect(dialog).not.toBeVisible();
|
||||
// Cancel keeps the clone in place — count unchanged from the
|
||||
// post-clone state.
|
||||
await expect(titleLocator).toHaveCount(beforeCount + 1);
|
||||
|
||||
// Per-test cleanup: actually delete the clone we just kept so
|
||||
// subsequent tests start from the seeded count.
|
||||
const cleanupMenu = await openPanelMoreMenu(
|
||||
page,
|
||||
TIME_SERIES_PANEL,
|
||||
beforeCount,
|
||||
);
|
||||
await cleanupMenu
|
||||
.getByRole('menuitem', { name: 'Delete', exact: true })
|
||||
.click();
|
||||
const confirmDialog = page.getByRole('dialog', { name: 'Delete' });
|
||||
await confirmDialog.getByRole('button', { name: 'OK' }).click();
|
||||
await expect(titleLocator).toHaveCount(beforeCount);
|
||||
});
|
||||
|
||||
// ─── Create Alerts ───────────────────────────────────────────────────────
|
||||
|
||||
test('TC-10 Create Alerts menuitem on a Time Series panel navigates to the alerts editor', async ({
|
||||
authedPage: page,
|
||||
}) => {
|
||||
await gotoDetail(page, apmDashboardId);
|
||||
await expect(
|
||||
page.getByText(TIME_SERIES_PANEL, { exact: true }).first(),
|
||||
).toBeVisible();
|
||||
|
||||
const menu = await openPanelMoreMenu(page, TIME_SERIES_PANEL);
|
||||
const createAlerts = menu.getByRole('menuitem', {
|
||||
name: /Create Alerts/,
|
||||
});
|
||||
await expect(createAlerts).toBeEnabled();
|
||||
|
||||
// known behaviour: `useCreateAlerts` opens the alerts editor in a
|
||||
// new tab via `window.open(...)` — the current page's URL does not
|
||||
// change. Wait for the new browser tab on the context, not the
|
||||
// existing page.
|
||||
const [newPage] = await Promise.all([
|
||||
page.context().waitForEvent('page'),
|
||||
createAlerts.click(),
|
||||
]);
|
||||
await newPage.waitForLoadState();
|
||||
await expect(newPage).toHaveURL(/\/alerts\/new/);
|
||||
await newPage.close();
|
||||
});
|
||||
|
||||
// ─── Deep coverage ───────────────────────────────────────────────────────
|
||||
|
||||
test('TC-11 fullscreen URL deep-link opens the panel modal directly', async ({
|
||||
authedPage: page,
|
||||
}) => {
|
||||
// First navigate normally and capture the panel's widgetId from the
|
||||
// View action's URL transition — we cannot hard-code a uuid.
|
||||
await gotoDetail(page, apmDashboardId);
|
||||
const menu = await openPanelMoreMenu(page, TIME_SERIES_PANEL);
|
||||
const viewItem = menu.getByRole('menuitem', { name: 'View', exact: true });
|
||||
await expect(viewItem).toBeEnabled();
|
||||
await viewItem.click();
|
||||
await expect(page).toHaveURL(/expandedWidgetId=/);
|
||||
const expandedUrl = page.url();
|
||||
await page
|
||||
.getByRole('dialog', { name: TIME_SERIES_PANEL })
|
||||
.getByRole('button', { name: 'Close' })
|
||||
.click();
|
||||
await expect(page).not.toHaveURL(/expandedWidgetId=/);
|
||||
|
||||
// Now hard-navigate to the captured deep-link in a fresh page state.
|
||||
await page.goto(expandedUrl);
|
||||
await expect(
|
||||
page.getByRole('dialog', { name: TIME_SERIES_PANEL }),
|
||||
).toBeVisible();
|
||||
await expect(page).toHaveURL(/expandedWidgetId=/);
|
||||
});
|
||||
|
||||
test('TC-12 Table panel search filters rows in real time', async ({
|
||||
authedPage: page,
|
||||
}) => {
|
||||
await gotoDetail(page, apmDashboardId);
|
||||
const tableTitle = page.getByText(TABLE_PANEL, { exact: true }).first();
|
||||
await expect(tableTitle).toBeVisible();
|
||||
|
||||
const container = panelContainer(page, TABLE_PANEL);
|
||||
await container.scrollIntoViewIfNeeded();
|
||||
await container.hover();
|
||||
await container.getByTestId('widget-header-search').click();
|
||||
|
||||
const searchInput = page.getByTestId('widget-header-search-input');
|
||||
await expect(searchInput).toBeVisible();
|
||||
|
||||
// known behaviour: the bootstrap stack ingests no telemetry, so the
|
||||
// table body may be empty. The contract this TC guards is "typing in
|
||||
// the search updates the input value live and does not throw" — a
|
||||
// rendered row count check only fires when telemetry happens to seed
|
||||
// rows. We log no console errors during the search keystrokes either.
|
||||
const errors: Error[] = [];
|
||||
page.on('pageerror', (err) => errors.push(err));
|
||||
|
||||
await searchInput.fill('foo');
|
||||
await expect(searchInput).toHaveValue('foo');
|
||||
await searchInput.fill('');
|
||||
await expect(searchInput).toHaveValue('');
|
||||
await searchInput.fill('bar-baz');
|
||||
await expect(searchInput).toHaveValue('bar-baz');
|
||||
|
||||
expect(errors).toHaveLength(0);
|
||||
});
|
||||
|
||||
test('TC-13 panel renders chart data from the bootstrap golden seed', async ({
|
||||
authedPage: page,
|
||||
}) => {
|
||||
const chartId = await createChartDataDashboardViaApi(page);
|
||||
seedIds.add(chartId);
|
||||
|
||||
await page.goto(`/dashboard/${chartId}`);
|
||||
await expect(
|
||||
page.getByRole('button', {
|
||||
name: /dashboard-icon detail-chart-data-suite/,
|
||||
}),
|
||||
).toBeVisible();
|
||||
|
||||
const panel = page
|
||||
.getByText('E2E Metric RPS', { exact: true })
|
||||
.first()
|
||||
.locator(
|
||||
'xpath=ancestor::div[contains(@class,"widget-graph-component-container")][1]',
|
||||
);
|
||||
await expect(panel).toBeVisible();
|
||||
await expect(panel.locator('canvas').first()).toBeVisible({
|
||||
timeout: 30_000,
|
||||
});
|
||||
|
||||
const dimensions = await panel
|
||||
.locator('canvas')
|
||||
.first()
|
||||
.evaluate((el) => {
|
||||
const c = el as HTMLCanvasElement;
|
||||
return { w: c.width, h: c.height };
|
||||
});
|
||||
expect(dimensions.w).toBeGreaterThan(0);
|
||||
expect(dimensions.h).toBeGreaterThan(0);
|
||||
|
||||
// Empty-state must NOT render — proves the golden seed landed and
|
||||
// the panel query found rows.
|
||||
await expect(panel.getByText(/no data/i)).toHaveCount(0);
|
||||
});
|
||||
|
||||
// TODO(e2e): re-enable once CI consistently passes. Same panel
|
||||
// clone-then-delete flake family as TC-07/TC-08/TC-09 above — the
|
||||
// Save dialog and / or the delete confirmation intermittently fail
|
||||
// on CI's slower scheduler.
|
||||
test.skip('TC-14 Delete only removes the targeted panel — siblings remain', async ({
|
||||
authedPage: page,
|
||||
}) => {
|
||||
await gotoDetail(page, apmDashboardId);
|
||||
|
||||
// "DB Calls RPS" is a stable sibling we check survives the round-trip.
|
||||
const sibling = page.getByText('DB Calls RPS', { exact: true }).first();
|
||||
await sibling.scrollIntoViewIfNeeded();
|
||||
await expect(sibling).toBeVisible();
|
||||
|
||||
const titleLocator = page.getByText(TIME_SERIES_PANEL, { exact: true });
|
||||
const beforeCount = await titleLocator.count();
|
||||
|
||||
// Clone first so the test is read-only at the seed level.
|
||||
const cloneMenu = await openPanelMoreMenu(page, TIME_SERIES_PANEL);
|
||||
await cloneMenu.getByRole('menuitem', { name: 'Clone', exact: true }).click();
|
||||
await page.waitForURL(/\/new/);
|
||||
await clickNewWidgetSave(page);
|
||||
await page.getByRole('dialog').getByRole('button', { name: 'OK' }).click();
|
||||
await page.waitForURL((url) => !url.pathname.includes('/new'));
|
||||
await expect(titleLocator).toHaveCount(beforeCount + 1);
|
||||
|
||||
// Delete the clone (last in DOM order).
|
||||
const deleteMenu = await openPanelMoreMenu(
|
||||
page,
|
||||
TIME_SERIES_PANEL,
|
||||
beforeCount,
|
||||
);
|
||||
await deleteMenu
|
||||
.getByRole('menuitem', { name: 'Delete', exact: true })
|
||||
.click();
|
||||
await page
|
||||
.getByRole('dialog', { name: 'Delete' })
|
||||
.getByRole('button', { name: 'OK' })
|
||||
.click();
|
||||
|
||||
// Originals + siblings still present.
|
||||
await expect(titleLocator).toHaveCount(beforeCount);
|
||||
await expect(sibling).toBeVisible();
|
||||
});
|
||||
});
|
||||
@@ -1,146 +0,0 @@
|
||||
import { expect, test } from '../../../fixtures/auth';
|
||||
import { newAdminContext } from '../../../helpers/auth';
|
||||
import {
|
||||
authToken,
|
||||
createDashboardViaApi,
|
||||
deleteDashboardViaApi,
|
||||
} from '../../../helpers/dashboards';
|
||||
|
||||
// Scope: dashboard-side seams only —
|
||||
// 1. The toolbar "New Panel" button opens a dialog listing every panel type
|
||||
// the app supports (the dashboard's responsibility).
|
||||
// 2. A panel created from the dialog actually lands on the canvas and
|
||||
// survives a hard reload (the dashboard's persistence contract).
|
||||
//
|
||||
// Editor-internal behaviour (Query Builder vs ClickHouse tab, Panel Settings,
|
||||
// y-axis units, panel-type changes, etc.) belongs in a separate panel-editor
|
||||
// spec — do NOT add those here.
|
||||
|
||||
test.describe.configure({ mode: 'serial' });
|
||||
|
||||
const seedIds = new Set<string>();
|
||||
|
||||
test.afterAll(async ({ browser }) => {
|
||||
if (seedIds.size === 0) return;
|
||||
const ctx = await newAdminContext(browser);
|
||||
const page = await ctx.newPage();
|
||||
try {
|
||||
const token = await authToken(page);
|
||||
for (const id of seedIds) {
|
||||
await deleteDashboardViaApi(ctx.request, id, token);
|
||||
seedIds.delete(id);
|
||||
}
|
||||
} finally {
|
||||
await ctx.close();
|
||||
}
|
||||
});
|
||||
|
||||
test.describe('Dashboard Detail — Add Panel (entry-point + persistence)', () => {
|
||||
test('TC-01 New Panel toolbar button opens a dialog listing all 7 panel types', async ({
|
||||
authedPage: page,
|
||||
}) => {
|
||||
const ts = Date.now();
|
||||
const id = await createDashboardViaApi(page, `add-panel-dialog-${ts}`);
|
||||
seedIds.add(id);
|
||||
|
||||
await page.goto(`/dashboard/${id}`);
|
||||
// Empty dashboards render an onboarding canvas with a duplicate
|
||||
// `add-panel-header` CTA. Scope to the toolbar (`.right-section`).
|
||||
await page
|
||||
.locator('.dashboard-details .right-section')
|
||||
.getByTestId('add-panel-header')
|
||||
.click();
|
||||
|
||||
const dialog = page.getByRole('dialog', { name: 'New Panel' });
|
||||
await expect(dialog).toBeVisible();
|
||||
|
||||
for (const tile of [
|
||||
'panel-type-graph',
|
||||
'panel-type-value',
|
||||
'panel-type-table',
|
||||
'panel-type-list',
|
||||
'panel-type-bar',
|
||||
'panel-type-pie',
|
||||
'panel-type-histogram',
|
||||
]) {
|
||||
await expect(dialog.getByTestId(tile)).toBeVisible();
|
||||
}
|
||||
|
||||
// Dialog dismisses via the Close (×) button — confirms the user can
|
||||
// back out without entering the editor (no /new navigation happens
|
||||
// until a tile is picked).
|
||||
await dialog.getByRole('button', { name: 'Close' }).click();
|
||||
await expect(dialog).toBeHidden();
|
||||
await expect(page).not.toHaveURL(/\/new/);
|
||||
});
|
||||
|
||||
// TODO(e2e): re-enable once CI consistently passes. Same flake family
|
||||
// as `21-panel-actions.spec.ts` TC-07 — Save dialog intermittently
|
||||
// fails to appear on CI after `new-widget-save` despite the
|
||||
// `toBeEnabled` gate and `dispatchEvent('click')`. Passes locally
|
||||
// (incl. `STRESS=1 CI=1`). See CI-HARDENING.md.
|
||||
test.skip('TC-02 saving a new panel persists it on the canvas across reload', async ({
|
||||
authedPage: page,
|
||||
}) => {
|
||||
const ts = Date.now();
|
||||
const id = await createDashboardViaApi(page, `add-panel-persist-${ts}`);
|
||||
seedIds.add(id);
|
||||
const panelName = `e2e-panel-${ts}`;
|
||||
|
||||
await page.goto(`/dashboard/${id}`);
|
||||
await page
|
||||
.locator('.dashboard-details .right-section')
|
||||
.getByTestId('add-panel-header')
|
||||
.click();
|
||||
await page
|
||||
.getByRole('dialog', { name: 'New Panel' })
|
||||
.getByTestId('panel-type-graph')
|
||||
.click();
|
||||
|
||||
// We're now on the editor; minimal interaction — set the name and save.
|
||||
// Anything else (queries, panel-type changes, units) is editor-internal
|
||||
// and belongs in a panel-editor spec.
|
||||
await page.getByTestId('panel-name-input').fill(panelName);
|
||||
|
||||
// NewWidget renders TWO buttons with `data-testid="new-widget-save"` —
|
||||
// a disabled variant when `isSaveDisabled` is true and an enabled
|
||||
// variant when it is false (see container/NewWidget/index.tsx). Under
|
||||
// CI load the editor mounts with the disabled variant first; clicking
|
||||
// before `toBeEnabled` resolves means the click hits the disabled
|
||||
// button and the Save dialog never opens.
|
||||
const saveBtn = page.getByTestId('new-widget-save');
|
||||
await expect(saveBtn).toBeVisible();
|
||||
await expect(saveBtn).toBeEnabled({ timeout: 20_000 });
|
||||
|
||||
// `dispatchEvent('click')` — under CI viewport the editor's right-header
|
||||
// can be partially covered by the sidenav's secondary nav panel. Bypass
|
||||
// hit testing via a synthetic click. Also wait for the dialog before
|
||||
// registering the PUT listener so we capture the save mutation rather
|
||||
// than any unrelated background request.
|
||||
await saveBtn.dispatchEvent('click');
|
||||
const saveDialog = page.getByRole('dialog', { name: 'Save Widget' });
|
||||
await expect(saveDialog).toBeVisible();
|
||||
|
||||
const savePut = page.waitForResponse(
|
||||
(r) => r.request().method() === 'PUT' && /\/dashboards\//.test(r.url()),
|
||||
);
|
||||
await saveDialog.getByRole('button', { name: 'OK' }).click();
|
||||
const putResp = await savePut;
|
||||
expect(putResp.ok()).toBeTruthy();
|
||||
|
||||
// The editor navigates back to the dashboard inside the PUT onSuccess
|
||||
// handler — wait for the URL to update before asserting on the canvas.
|
||||
await page.waitForURL((url) => !url.pathname.includes('/new'));
|
||||
// Back on the dashboard — the new panel must render with the typed name.
|
||||
await expect(page).not.toHaveURL(/\/new/);
|
||||
await expect(
|
||||
page.getByText(panelName, { exact: true }).first(),
|
||||
).toBeVisible();
|
||||
|
||||
// Persistence — hard reload, panel still there.
|
||||
await page.reload();
|
||||
await expect(
|
||||
page.getByText(panelName, { exact: true }).first(),
|
||||
).toBeVisible();
|
||||
});
|
||||
});
|
||||
@@ -1,79 +0,0 @@
|
||||
import { expect, test } from '../../../fixtures/auth';
|
||||
import { newAdminContext } from '../../../helpers/auth';
|
||||
import {
|
||||
authToken,
|
||||
createApmMetricsDashboardViaApi,
|
||||
deleteDashboardViaApi,
|
||||
} from '../../../helpers/dashboards';
|
||||
|
||||
// This file's scope is intentionally narrow: prove that the detail page's
|
||||
// "Edit panel" entry-point lands the user in the panel editor at
|
||||
// `/dashboard/:id/new?widgetId=…`. Editor-internal behaviour (Query Builder
|
||||
// pre-population, ClickHouse tab, Panel Settings rename, query-edit + revert,
|
||||
// y-axis units, panel-type changes, etc.) is the responsibility of a separate
|
||||
// panel-editor spec — keep this file as the dashboard-side seam only.
|
||||
|
||||
const seedIds = new Set<string>();
|
||||
let apmDashboardId = '';
|
||||
|
||||
test.beforeAll(async ({ browser }) => {
|
||||
const ctx = await newAdminContext(browser);
|
||||
const page = await ctx.newPage();
|
||||
try {
|
||||
apmDashboardId = await createApmMetricsDashboardViaApi(page);
|
||||
seedIds.add(apmDashboardId);
|
||||
} finally {
|
||||
await ctx.close();
|
||||
}
|
||||
});
|
||||
|
||||
test.afterAll(async ({ browser }) => {
|
||||
if (seedIds.size === 0) return;
|
||||
const ctx = await newAdminContext(browser);
|
||||
const page = await ctx.newPage();
|
||||
try {
|
||||
const token = await authToken(page);
|
||||
for (const id of seedIds) {
|
||||
await deleteDashboardViaApi(ctx.request, id, token);
|
||||
seedIds.delete(id);
|
||||
}
|
||||
} finally {
|
||||
await ctx.close();
|
||||
}
|
||||
});
|
||||
|
||||
test.describe('Dashboard Detail — Edit Panel (entry-point only)', () => {
|
||||
test('TC-01 Edit menu item on a panel navigates to the panel editor', async ({
|
||||
authedPage: page,
|
||||
}) => {
|
||||
await page.goto(`/dashboard/${apmDashboardId}`);
|
||||
await expect(
|
||||
page.getByRole('button', { name: /dashboard-icon APM Metrics/ }),
|
||||
).toBeVisible();
|
||||
|
||||
// "DB Calls RPS" is the only single-instance panel name in the APM
|
||||
// Metrics fixture (other titles like "Latency" repeat across sections),
|
||||
// so it round-trips uniquely without `.first()` gymnastics.
|
||||
const panelTitle = page.getByText('DB Calls RPS', { exact: true }).first();
|
||||
await panelTitle.scrollIntoViewIfNeeded();
|
||||
|
||||
// Walk up to the widget-graph container. Its `:hover` flips the ⋮ icon
|
||||
// from `visibility: hidden` to visible (see GridCardLayout.styles.scss
|
||||
// rule on `.widget-graph-component-container:hover .options-action`).
|
||||
const container = panelTitle.locator(
|
||||
'xpath=ancestor::*[contains(@class,"widget-graph-component-container")][1]',
|
||||
);
|
||||
await container.hover();
|
||||
|
||||
const options = container.getByTestId('widget-header-options');
|
||||
// The ⋮ is a `@signozhq/ui` `DropdownMenuSimple` (Radix-based); it opens
|
||||
// on click, not hover. The container hover above only reveals the icon
|
||||
// (it's `visibility: hidden` until then) — the click toggles the menu.
|
||||
await options.click();
|
||||
|
||||
await page.getByRole('menuitem', { name: 'Edit' }).click();
|
||||
|
||||
await expect(page).toHaveURL(/\/dashboard\/[0-9a-f-]+\/new\?.*widgetId=/);
|
||||
await expect(page.getByTestId('new-widget-save')).toBeVisible();
|
||||
});
|
||||
});
|
||||
@@ -1,235 +0,0 @@
|
||||
import type { Page } from '@playwright/test';
|
||||
|
||||
import { expect, test } from '../../../fixtures/auth';
|
||||
import { newAdminContext } from '../../../helpers/auth';
|
||||
import {
|
||||
authToken,
|
||||
createApmMetricsDashboardViaApi,
|
||||
deleteDashboardViaApi,
|
||||
} from '../../../helpers/dashboards';
|
||||
|
||||
const seedIds = new Set<string>();
|
||||
let apmId = '';
|
||||
|
||||
test.beforeAll(async ({ browser }) => {
|
||||
const ctx = await newAdminContext(browser);
|
||||
const page = await ctx.newPage();
|
||||
try {
|
||||
apmId = await createApmMetricsDashboardViaApi(page);
|
||||
seedIds.add(apmId);
|
||||
} finally {
|
||||
await ctx.close();
|
||||
}
|
||||
});
|
||||
|
||||
test.afterAll(async ({ browser }) => {
|
||||
if (seedIds.size === 0) {
|
||||
return;
|
||||
}
|
||||
const ctx = await newAdminContext(browser);
|
||||
const page = await ctx.newPage();
|
||||
try {
|
||||
const token = await authToken(page);
|
||||
for (const id of seedIds) {
|
||||
await deleteDashboardViaApi(ctx.request, id, token);
|
||||
seedIds.delete(id);
|
||||
}
|
||||
} finally {
|
||||
await ctx.close();
|
||||
}
|
||||
});
|
||||
|
||||
async function openTimePicker(page: Page): Promise<void> {
|
||||
await page
|
||||
.getByRole('textbox', { name: /Last \d+/ })
|
||||
.first()
|
||||
.click();
|
||||
}
|
||||
|
||||
test.describe('Dashboard Detail — Time Range', () => {
|
||||
test('TC-01 selecting a preset updates the textbox label and URL', async ({
|
||||
authedPage: page,
|
||||
}) => {
|
||||
await page.goto(`/dashboard/${apmId}`);
|
||||
await expect(
|
||||
page.getByRole('button', { name: /dashboard-icon APM Metrics/ }),
|
||||
).toBeVisible();
|
||||
|
||||
await openTimePicker(page);
|
||||
|
||||
const refetch = page.waitForResponse((r) => r.url().includes('/query_range'));
|
||||
await page.getByRole('button', { name: 'Last 1 hour 1h' }).click();
|
||||
const response = await refetch;
|
||||
|
||||
await expect(
|
||||
page.getByRole('textbox', { name: 'Last 1 hour' }),
|
||||
).toBeVisible();
|
||||
await expect(page).toHaveURL(/relativeTime=1h/);
|
||||
// Without seeded telemetry the backend may return 4xx for query_range
|
||||
// (panels render "No Data" — a known harness limitation, not a test
|
||||
// bug). Cancelled in-flight responses also surface here as non-ok.
|
||||
// Only 5xx is a real failure; the URL + textbox label assertions
|
||||
// above already prove the preset click took effect.
|
||||
expect(response.status()).toBeLessThan(500);
|
||||
});
|
||||
|
||||
test('TC-02 switching presets twice updates the label both times', async ({
|
||||
authedPage: page,
|
||||
}) => {
|
||||
await page.goto(`/dashboard/${apmId}`);
|
||||
await expect(
|
||||
page.getByRole('button', { name: /dashboard-icon APM Metrics/ }),
|
||||
).toBeVisible();
|
||||
|
||||
await openTimePicker(page);
|
||||
await page.getByRole('button', { name: 'Last 6 hours 6h' }).click();
|
||||
await expect(
|
||||
page.getByRole('textbox', { name: 'Last 6 hours' }),
|
||||
).toBeVisible();
|
||||
await expect(page).toHaveURL(/relativeTime=6h/);
|
||||
|
||||
await openTimePicker(page);
|
||||
await page.getByRole('button', { name: 'Last 1 day 1d' }).click();
|
||||
await expect(page.getByRole('textbox', { name: 'Last 1 day' })).toBeVisible();
|
||||
await expect(page).toHaveURL(/relativeTime=1d/);
|
||||
await expect(page).not.toHaveURL(/relativeTime=6h/);
|
||||
});
|
||||
|
||||
test('TC-03 custom date range picker reflects selected dates and switches URL to absolute timestamps', async ({
|
||||
authedPage: page,
|
||||
}) => {
|
||||
await page.goto(`/dashboard/${apmId}`);
|
||||
await expect(
|
||||
page.getByRole('button', { name: /dashboard-icon APM Metrics/ }),
|
||||
).toBeVisible();
|
||||
|
||||
await openTimePicker(page);
|
||||
await page.getByRole('button', { name: 'Custom Date Range' }).click();
|
||||
|
||||
const prevMonth = page.getByRole('button', {
|
||||
name: 'Go to the Previous Month',
|
||||
});
|
||||
for (let i = 0; i < 2; i += 1) {
|
||||
await prevMonth.click();
|
||||
}
|
||||
|
||||
// Calendar day buttons have accessible names like "Saturday, March
|
||||
// 14th, 2026" (the rendered label is "14" but a11y appends the suffix
|
||||
// + month + year). Pick a known day by its long-form name regex
|
||||
// against the gridcell — `\b14th\b` is unambiguous and avoids
|
||||
// matching siblings like "14" inside "2014".
|
||||
await page
|
||||
.getByRole('gridcell', { name: /\b14th\b/ })
|
||||
.first()
|
||||
.click();
|
||||
|
||||
const refetch = page.waitForResponse((r) => r.url().includes('/query_range'));
|
||||
await page.getByRole('button', { name: 'Apply' }).click();
|
||||
const response = await refetch;
|
||||
|
||||
await expect(
|
||||
page.getByRole('textbox', { name: /\d{2}\/\d{2}\/\d{4}/ }).first(),
|
||||
).toBeVisible();
|
||||
await expect(page).toHaveURL(/startTime=\d+/);
|
||||
await expect(page).toHaveURL(/endTime=\d+/);
|
||||
// As TC-01: backend 4xx (no telemetry) is acceptable; only 5xx is
|
||||
// failure. Apply triggered the refetch, which is what we verify.
|
||||
expect(response.status()).toBeLessThan(500);
|
||||
});
|
||||
|
||||
test('TC-04 timezone change updates the toolbar timezone label', async ({
|
||||
authedPage: page,
|
||||
}) => {
|
||||
await page.goto(`/dashboard/${apmId}`);
|
||||
await expect(
|
||||
page.getByRole('button', { name: /dashboard-icon APM Metrics/ }),
|
||||
).toBeVisible();
|
||||
|
||||
await openTimePicker(page);
|
||||
await page.getByRole('button', { name: 'Change Timezone' }).click();
|
||||
await expect(
|
||||
page.getByRole('textbox', { name: 'Search timezones...' }),
|
||||
).toBeVisible();
|
||||
|
||||
await page
|
||||
.getByRole('button', { name: /Coordinated Universal Time —/ })
|
||||
.click();
|
||||
await page.keyboard.press('Escape');
|
||||
|
||||
await expect(page.getByText('UTC', { exact: true }).first()).toBeVisible();
|
||||
});
|
||||
|
||||
test('TC-05 refresh-interval popup contents', async ({ authedPage: page }) => {
|
||||
await page.goto(`/dashboard/${apmId}`);
|
||||
await expect(
|
||||
page.getByRole('button', { name: /dashboard-icon APM Metrics/ }),
|
||||
).toBeVisible();
|
||||
|
||||
await page.getByRole('button', { name: 'Set auto refresh' }).click();
|
||||
|
||||
const autoRefresh = page.getByRole('checkbox', { name: 'Auto Refresh' });
|
||||
await expect(autoRefresh).toBeVisible();
|
||||
await expect(autoRefresh).not.toBeChecked();
|
||||
|
||||
// Labels match the live build (no `15 minutes` / `12 hours` — the
|
||||
// test plan's enumeration was approximate).
|
||||
for (const label of [
|
||||
'5 seconds',
|
||||
'10 seconds',
|
||||
'30 seconds',
|
||||
'1 minute',
|
||||
'5 minutes',
|
||||
'10 minutes',
|
||||
'30 minutes',
|
||||
'1 hour',
|
||||
'2 hours',
|
||||
'1 day',
|
||||
]) {
|
||||
await expect(
|
||||
page.getByRole('button', { name: label, exact: true }),
|
||||
).toBeVisible();
|
||||
}
|
||||
});
|
||||
|
||||
test('TC-06 toggling auto-refresh on then changing the interval', async ({
|
||||
authedPage: page,
|
||||
}) => {
|
||||
await page.goto(`/dashboard/${apmId}`);
|
||||
await expect(
|
||||
page.getByRole('button', { name: /dashboard-icon APM Metrics/ }),
|
||||
).toBeVisible();
|
||||
|
||||
await page.getByRole('button', { name: 'Set auto refresh' }).click();
|
||||
|
||||
const autoRefresh = page.getByRole('checkbox', { name: 'Auto Refresh' });
|
||||
await autoRefresh.click();
|
||||
await expect(autoRefresh).toBeChecked();
|
||||
|
||||
await page.getByRole('button', { name: '1 minute', exact: true }).click();
|
||||
await page.getByRole('button', { name: '5 minutes', exact: true }).click();
|
||||
await expect(autoRefresh).toBeChecked();
|
||||
|
||||
await autoRefresh.click();
|
||||
await expect(autoRefresh).not.toBeChecked();
|
||||
});
|
||||
|
||||
test('TC-07 manual sync triggers a query_range refetch', async ({
|
||||
authedPage: page,
|
||||
}) => {
|
||||
await page.goto(`/dashboard/${apmId}`);
|
||||
await expect(
|
||||
page.getByRole('button', { name: /dashboard-icon APM Metrics/ }),
|
||||
).toBeVisible();
|
||||
await expect(
|
||||
page.getByText('Latency', { exact: true }).first(),
|
||||
).toBeVisible();
|
||||
|
||||
const refetch = page.waitForResponse((r) => r.url().includes('/query_range'));
|
||||
await page.locator('.refresh-btn button').click();
|
||||
const response = await refetch;
|
||||
// 4xx is expected without seeded telemetry; only 5xx is a failure.
|
||||
// The sync click successfully triggering a query_range fetch is the
|
||||
// behaviour under test.
|
||||
expect(response.status()).toBeLessThan(500);
|
||||
});
|
||||
});
|
||||
@@ -1,483 +0,0 @@
|
||||
import type { Page } from '@playwright/test';
|
||||
|
||||
import { expect, test } from '../../../fixtures/auth';
|
||||
import { newAdminContext } from '../../../helpers/auth';
|
||||
import {
|
||||
authToken,
|
||||
awaitVariablesResolved,
|
||||
createVariablesDashboardViaApi,
|
||||
deleteDashboardViaApi,
|
||||
} from '../../../helpers/dashboards';
|
||||
import variablesTemplate from '../../../testdata/variables-dashboard.json';
|
||||
|
||||
// Variables that depend on backend resolution against seeded telemetry the
|
||||
// bootstrap stack does not produce. Skip them so `awaitVariablesResolved`
|
||||
// does not block on values that can never appear.
|
||||
const TELEMETRY_DEPENDENT_VARS = ['q_env', 'q_service', 'd_namespace'];
|
||||
|
||||
test.describe.configure({ mode: 'serial' });
|
||||
|
||||
const seedIds = new Set<string>();
|
||||
let varDashboardId = '';
|
||||
|
||||
test.beforeAll(async ({ browser }) => {
|
||||
const ctx = await newAdminContext(browser);
|
||||
const page = await ctx.newPage();
|
||||
try {
|
||||
varDashboardId = await createVariablesDashboardViaApi(
|
||||
page,
|
||||
'detail-variables-suite',
|
||||
);
|
||||
seedIds.add(varDashboardId);
|
||||
// Per the framework contract: every variable with a default has its
|
||||
// `selectedValue` set in the seed JSON; backend-resolved variables
|
||||
// (Query / Dynamic) cannot resolve without seeded telemetry, so we
|
||||
// list them in `skipNames`. Tests must not race ahead of seed
|
||||
// materialisation — this gate ensures the persisted dashboard is in
|
||||
// a known state before any test runs.
|
||||
await awaitVariablesResolved(page, varDashboardId, {
|
||||
skipNames: TELEMETRY_DEPENDENT_VARS,
|
||||
});
|
||||
} finally {
|
||||
await ctx.close();
|
||||
}
|
||||
});
|
||||
|
||||
test.afterAll(async ({ browser }) => {
|
||||
if (seedIds.size === 0) {
|
||||
return;
|
||||
}
|
||||
const ctx = await newAdminContext(browser);
|
||||
const page = await ctx.newPage();
|
||||
try {
|
||||
const token = await authToken(page);
|
||||
for (const id of seedIds) {
|
||||
await deleteDashboardViaApi(ctx.request, id, token);
|
||||
seedIds.delete(id);
|
||||
}
|
||||
} finally {
|
||||
await ctx.close();
|
||||
}
|
||||
});
|
||||
|
||||
function variablesQueryParam(state: Record<string, unknown>): string {
|
||||
return encodeURIComponent(encodeURIComponent(JSON.stringify(state)));
|
||||
}
|
||||
|
||||
async function gotoVariablesDashboard(
|
||||
page: Page,
|
||||
urlState?: Record<string, unknown>,
|
||||
): Promise<void> {
|
||||
const url = urlState
|
||||
? `/dashboard/${varDashboardId}?variables=${variablesQueryParam(urlState)}`
|
||||
: `/dashboard/${varDashboardId}`;
|
||||
await page.goto(url);
|
||||
await expect(
|
||||
page.getByRole('button', {
|
||||
name: /dashboard-icon detail-variables-suite/,
|
||||
}),
|
||||
).toBeVisible();
|
||||
}
|
||||
|
||||
test.describe('Dashboard Detail — Variables', () => {
|
||||
test('TC-01 variables bar renders all four types', async ({
|
||||
authedPage: page,
|
||||
}) => {
|
||||
await gotoVariablesDashboard(page);
|
||||
|
||||
for (const name of [
|
||||
'$tb_env',
|
||||
'$tb_service',
|
||||
'$cu_single',
|
||||
'$cu_env_all',
|
||||
'$cu_services',
|
||||
'$q_env',
|
||||
'$q_service',
|
||||
'$d_namespace',
|
||||
]) {
|
||||
await expect(page.getByText(name, { exact: true })).toBeVisible();
|
||||
}
|
||||
|
||||
// Textbox variables expose their current value via `value` and `title`
|
||||
// attributes (the antd Input has no accessible name matching the value),
|
||||
// so we match on input[value="..."] rather than getByRole+name.
|
||||
await expect(page.locator('input[value="otel-demo"]')).toBeVisible();
|
||||
await expect(page.locator('input[value="frontend"]')).toBeVisible();
|
||||
|
||||
await expect(page.getByTestId('variable-select')).toHaveCount(6);
|
||||
});
|
||||
|
||||
test('TC-02 selecting a value in a single-value Custom variable updates URL and aria-selected', async ({
|
||||
authedPage: page,
|
||||
}) => {
|
||||
await gotoVariablesDashboard(page);
|
||||
|
||||
// $cu_single (nth(0)) — single-select Custom with three static
|
||||
// options. Driving Custom rather than Query keeps the test
|
||||
// deterministic regardless of seeded telemetry.
|
||||
const dropdown = page.getByTestId('variable-select').nth(0);
|
||||
await dropdown.click();
|
||||
await page.getByRole('option', { name: 'mq-kafka' }).click();
|
||||
|
||||
await expect(
|
||||
dropdown.locator('.ant-select-selection-item', { hasText: 'mq-kafka' }),
|
||||
).toBeVisible();
|
||||
await expect(page).toHaveURL(/variables=.*mq-kafka/);
|
||||
|
||||
await dropdown.click();
|
||||
await expect(page.getByRole('option', { name: 'mq-kafka' })).toHaveAttribute(
|
||||
'aria-selected',
|
||||
'true',
|
||||
);
|
||||
await page.keyboard.press('Escape');
|
||||
});
|
||||
|
||||
test('TC-03 multi-select renders chips and URL encodes array', async ({
|
||||
authedPage: page,
|
||||
}) => {
|
||||
// URL state seeds adservice + cartservice as initial selection; this also
|
||||
// guarantees the URL contains the encoded array so we can assert on it
|
||||
// without relying on the seeded server-side selection rendering identically
|
||||
// across reloads.
|
||||
await gotoVariablesDashboard(page, {
|
||||
cu_services: ['adservice', 'cartservice'],
|
||||
});
|
||||
|
||||
await expect(
|
||||
page.getByRole('button', { name: 'Remove tag adservice' }),
|
||||
).toBeVisible();
|
||||
await expect(
|
||||
page.getByRole('button', { name: 'Remove tag cartservice' }),
|
||||
).toBeVisible();
|
||||
|
||||
await expect(page).toHaveURL(/adservice/);
|
||||
await expect(page).toHaveURL(/cartservice/);
|
||||
});
|
||||
|
||||
test('TC-04 removing a chip updates URL', async ({ authedPage: page }) => {
|
||||
await gotoVariablesDashboard(page, {
|
||||
cu_services: ['adservice', 'cartservice'],
|
||||
});
|
||||
|
||||
await expect(
|
||||
page.getByRole('button', { name: 'Remove tag adservice' }),
|
||||
).toBeVisible();
|
||||
await page.getByRole('button', { name: 'Remove tag adservice' }).click();
|
||||
|
||||
// Removing a chip on a multi-select expands the dropdown; URL state
|
||||
// only commits when the dropdown closes (onDropdownVisibleChange =>
|
||||
// false). The CustomMultiSelect swallows Escape, so click outside the
|
||||
// dropdown to dismiss it.
|
||||
await page.locator('img[alt="dashboard-img"]').click();
|
||||
await expect(page.getByRole('listbox')).toBeHidden();
|
||||
|
||||
await expect(
|
||||
page.getByRole('button', { name: 'Remove tag adservice' }),
|
||||
).toBeHidden();
|
||||
await expect(
|
||||
page.getByRole('button', { name: 'Remove tag cartservice' }),
|
||||
).toBeVisible();
|
||||
await expect(page).toHaveURL(/variables=/);
|
||||
await expect(page).not.toHaveURL(/adservice/);
|
||||
});
|
||||
|
||||
test('TC-05 ALL option on a Custom variable', async ({ authedPage: page }) => {
|
||||
await gotoVariablesDashboard(page, { cu_env_all: 'otel-demo' });
|
||||
|
||||
// $cu_env_all (nth(1)) — multi-select Custom with showALLOption: true,
|
||||
// so the dropdown exposes an "ALL" toggle alongside the static options.
|
||||
const dropdown = page.getByTestId('variable-select').nth(1);
|
||||
await expect(
|
||||
dropdown.locator('.ant-select-selection-item', {
|
||||
hasText: 'otel-demo',
|
||||
}),
|
||||
).toBeVisible();
|
||||
|
||||
await dropdown.click();
|
||||
await page.getByRole('option', { name: 'ALL' }).click();
|
||||
|
||||
// When ALL is selected, the multi-select renders an "ALL" badge in a
|
||||
// custom container (not the standard .ant-select-selection-item), so
|
||||
// match on the option's checked state inside the dropdown listbox
|
||||
// rather than on the closed-state chip.
|
||||
await expect(page.getByRole('option', { name: 'ALL' })).toHaveAttribute(
|
||||
'aria-selected',
|
||||
'true',
|
||||
);
|
||||
await expect(page).toHaveURL(/variables=/);
|
||||
});
|
||||
|
||||
test('TC-06 textbox variable update propagates to URL', async ({
|
||||
authedPage: page,
|
||||
}) => {
|
||||
await gotoVariablesDashboard(page);
|
||||
|
||||
// Locate by the testid wrapping a stable id, since `input[value="..."]`
|
||||
// becomes stale the moment we fill('') the field.
|
||||
await expect(page.locator('input[value="otel-demo"]')).toBeVisible();
|
||||
const tb = page.getByPlaceholder('Enter value').first();
|
||||
await tb.click();
|
||||
await tb.fill('');
|
||||
await tb.fill('production');
|
||||
await tb.press('Enter');
|
||||
|
||||
await expect(page.locator('input[value="production"]')).toBeVisible();
|
||||
await expect(page).toHaveURL(/variables=.*production/);
|
||||
});
|
||||
|
||||
test('TC-07 cascading: child variable listbox opens after parent change', async ({
|
||||
authedPage: page,
|
||||
}) => {
|
||||
await gotoVariablesDashboard(page, { q_env: 'otel-demo' });
|
||||
|
||||
// q_service (nth(4)) is cascaded from q_env (nth(3)).
|
||||
const child = page.getByTestId('variable-select').nth(4);
|
||||
await child.click();
|
||||
|
||||
// known behaviour: the child's option list requires seeded telemetry —
|
||||
// the bootstrap stack has none, so we only assert that the listbox
|
||||
// renders without crashing rather than checking specific options.
|
||||
await expect(page.getByRole('listbox').first()).toBeVisible();
|
||||
await page.keyboard.press('Escape');
|
||||
|
||||
await expect(page).toHaveURL(/otel-demo/);
|
||||
});
|
||||
|
||||
test('TC-08 URL deep-link restores variable state on hard reload', async ({
|
||||
authedPage: page,
|
||||
}) => {
|
||||
await gotoVariablesDashboard(page, { cu_env_all: 'mq-kafka' });
|
||||
|
||||
const dropdown = page.getByTestId('variable-select').nth(1);
|
||||
await expect(
|
||||
dropdown.locator('.ant-select-selection-item', { hasText: 'mq-kafka' }),
|
||||
).toBeVisible();
|
||||
|
||||
await page.reload({ waitUntil: 'domcontentloaded' });
|
||||
await expect(
|
||||
page.getByRole('button', {
|
||||
name: /dashboard-icon detail-variables-suite/,
|
||||
}),
|
||||
).toBeVisible();
|
||||
await expect(
|
||||
dropdown.locator('.ant-select-selection-item', { hasText: 'mq-kafka' }),
|
||||
).toBeVisible();
|
||||
|
||||
await expect(page).toHaveURL(/variables=%257B/);
|
||||
});
|
||||
|
||||
// ─── Deep coverage ───────────────────────────────────────────────────────
|
||||
|
||||
test('TC-09 ALL → specific value → ALL round-trip preserves URL state', async ({
|
||||
authedPage: page,
|
||||
}) => {
|
||||
await gotoVariablesDashboard(page);
|
||||
const dropdown = page.getByTestId('variable-select').nth(1); // cu_env_all
|
||||
|
||||
// Seed defaults to ALL — open, pick a specific value, assert URL.
|
||||
await dropdown.click();
|
||||
await page.getByRole('option', { name: 'mq-kafka' }).click();
|
||||
await page.keyboard.press('Escape');
|
||||
await expect(page).toHaveURL(/mq-kafka/);
|
||||
|
||||
// Re-open, switch back to ALL — URL must update again.
|
||||
await dropdown.click();
|
||||
const allOption = page.getByRole('option', { name: 'ALL' });
|
||||
await allOption.click();
|
||||
await expect(allOption).toHaveAttribute('aria-selected', 'true');
|
||||
await page.keyboard.press('Escape');
|
||||
// `mq-kafka` should no longer appear in the URL after reverting to ALL.
|
||||
await expect(page).not.toHaveURL(/mq-kafka/);
|
||||
});
|
||||
|
||||
test('TC-10 two variables changed in sequence both encode in URL', async ({
|
||||
authedPage: page,
|
||||
}) => {
|
||||
await gotoVariablesDashboard(page);
|
||||
|
||||
// cu_single — pick `production`.
|
||||
const single = page.getByTestId('variable-select').nth(0);
|
||||
await single.click();
|
||||
await page.getByRole('option', { name: 'production' }).click();
|
||||
await page.keyboard.press('Escape');
|
||||
await expect(page).toHaveURL(/production/);
|
||||
|
||||
// q_service — open the multi-select, dismiss without picking. The URL
|
||||
// should still contain the previous selection.
|
||||
const cuServices = page.getByTestId('variable-select').nth(2);
|
||||
await cuServices.click();
|
||||
await page.keyboard.press('Escape');
|
||||
await expect(page).toHaveURL(/production/);
|
||||
await expect(page).toHaveURL(/cu_single/);
|
||||
});
|
||||
|
||||
test('TC-11 navigating away and back preserves the URL-encoded state', async ({
|
||||
authedPage: page,
|
||||
}) => {
|
||||
await gotoVariablesDashboard(page, { cu_single: 'mq-kafka' });
|
||||
const dropdown = page.getByTestId('variable-select').nth(0);
|
||||
await expect(
|
||||
dropdown.locator('.ant-select-selection-item', { hasText: 'mq-kafka' }),
|
||||
).toBeVisible();
|
||||
const stateUrl = page.url();
|
||||
|
||||
// Leave to the list, come back via browser back — URL is restored.
|
||||
// `dispatchEvent('click')` — the expanded sidenav intercepts pointer
|
||||
// events at the breadcrumb's center, defeating even `force: true`.
|
||||
// Dispatching the click directly on the DOM node bypasses hit testing.
|
||||
await page
|
||||
.getByRole('button', { name: 'Dashboard /' })
|
||||
.dispatchEvent('click');
|
||||
await expect(page).toHaveURL(/\/dashboard$/);
|
||||
await page.goBack();
|
||||
await expect(page).toHaveURL(stateUrl);
|
||||
await expect(
|
||||
dropdown.locator('.ant-select-selection-item', { hasText: 'mq-kafka' }),
|
||||
).toBeVisible();
|
||||
});
|
||||
|
||||
// ─── TBD coverage — placeholders to fill in when each feature lands ──────
|
||||
//
|
||||
// Each `test.skip` below marks a behaviour the spec does NOT yet exercise.
|
||||
// They are intentional gaps, not bugs — when the feature ships or the seed
|
||||
// gains telemetry, replace `test.skip` with `test`, drop the comment, and
|
||||
// implement.
|
||||
|
||||
// eslint-disable-next-line playwright/expect-expect
|
||||
test.skip('TC-12 Custom variable without a default prompts user to select a value', async () => {
|
||||
// Requires extending variables-dashboard.json with a Custom variable
|
||||
// that has no `selectedValue` and no `allSelected`. The UI should
|
||||
// render the dropdown empty/"Select value" until a user picks.
|
||||
});
|
||||
|
||||
// eslint-disable-next-line playwright/expect-expect
|
||||
test.skip('TC-13 Query variable with pre-seeded selectedValue renders without backend resolution', async () => {
|
||||
// Requires extending variables-dashboard.json with a Query variable
|
||||
// that ships with `selectedValue` already populated — the UI should
|
||||
// trust the seed and not block on a query.
|
||||
});
|
||||
|
||||
test('TC-14 multi-select Query variable without telemetry shows an empty option list', async ({
|
||||
authedPage: page,
|
||||
}) => {
|
||||
await gotoVariablesDashboard(page);
|
||||
|
||||
// q_service is the only multi-select Query in the seed (nth(4) in
|
||||
// the dropdown order). Without telemetry the option list is empty —
|
||||
// assert the empty-state explicitly.
|
||||
const child = page.getByTestId('variable-select').nth(4);
|
||||
await child.click();
|
||||
const listbox = page.getByRole('listbox').first();
|
||||
await expect(listbox).toBeVisible();
|
||||
await expect(listbox.getByRole('option')).toHaveCount(0);
|
||||
await page.keyboard.press('Escape');
|
||||
});
|
||||
|
||||
test('TC-15 Dynamic variable resolves a seeded namespace value', async ({
|
||||
authedPage: page,
|
||||
}) => {
|
||||
// d_namespace's `dynamicVariablesAttribute` is `k8s.namespace.name`
|
||||
// over the `metrics` source. The bootstrap OTel collector ingests
|
||||
// the golden dataset which tags every resource with
|
||||
// `k8s.namespace.name=signoz-<service>` for 8 distinct services.
|
||||
// SigNoz's `signoz_metrics.distributed_metadata` table is populated
|
||||
// naturally by the collector's signozclickhousemetrics exporter, and
|
||||
// `/api/v1/fields/values?signal=metrics&name=k8s.namespace.name`
|
||||
// surfaces the values so the Dynamic variable auto-resolves.
|
||||
await gotoVariablesDashboard(page);
|
||||
|
||||
// d_namespace is the 6th dropdown variable in DOM order. The
|
||||
// closed-state of the combobox renders the auto-resolved value
|
||||
// inline next to the variable name. Match any of the 8 seeded
|
||||
// namespaces — ordering depends on the backend sort, so we accept
|
||||
// whichever it returns first.
|
||||
const dynamic = page.getByTestId('variable-select').nth(5);
|
||||
await expect(dynamic).toContainText(/signoz-\w+/, { timeout: 15_000 });
|
||||
});
|
||||
|
||||
// eslint-disable-next-line playwright/expect-expect
|
||||
test.skip('TC-16 changing a variable referenced in a panel query refetches the panel data', async () => {
|
||||
// $service.name and $deployment.environment are referenced by APM
|
||||
// panel queries. Asserting that a variable change triggers a
|
||||
// query_range refetch with the new substitution requires either
|
||||
// seeded telemetry or a network-request listener that confirms the
|
||||
// outbound query body contains the new value. Defer until the
|
||||
// chart-data assertion path is in place.
|
||||
});
|
||||
|
||||
test('TC-17 variable bar order matches the `order` field in dashboard JSON', async ({
|
||||
authedPage: page,
|
||||
}) => {
|
||||
await gotoVariablesDashboard(page);
|
||||
|
||||
// Expected order matches the `order` field in variables-dashboard.json.
|
||||
const expected = [
|
||||
'$tb_env',
|
||||
'$tb_service',
|
||||
'$cu_single',
|
||||
'$cu_env_all',
|
||||
'$cu_services',
|
||||
'$q_env',
|
||||
'$q_service',
|
||||
'$d_namespace',
|
||||
];
|
||||
const allText = await page.locator('text=/^\\$\\w+$/').allInnerTexts();
|
||||
const actual = allText.filter((t) => /^\$\w+$/.test(t));
|
||||
expect(actual.slice(0, expected.length)).toEqual(expected);
|
||||
});
|
||||
|
||||
// eslint-disable-next-line playwright/expect-expect
|
||||
test.skip('TC-18 reordering variables via drag persists to the dashboard JSON', async () => {
|
||||
// The Configure → Variables tab supports drag handles. After a
|
||||
// reorder, the persisted `order` fields should update and the
|
||||
// variables bar should re-render in the new order.
|
||||
});
|
||||
|
||||
test('TC-19 variable removed via Configure disappears from the variables bar', async ({
|
||||
authedPage: page,
|
||||
}) => {
|
||||
await gotoVariablesDashboard(page);
|
||||
|
||||
// `tb_service` (textbox, no dependents) — easiest to remove cleanly.
|
||||
await expect(page.getByText('$tb_service', { exact: true })).toBeVisible();
|
||||
|
||||
await page
|
||||
.locator('.dashboard-details .right-section')
|
||||
.getByTestId('show-drawer')
|
||||
.click();
|
||||
const dialog = page.getByRole('dialog');
|
||||
await dialog.getByRole('tab', { name: 'Variables' }).click();
|
||||
const tabpanel = dialog.getByRole('tabpanel', { name: 'Variables' });
|
||||
|
||||
const nameCell = tabpanel.getByText('tb_service', { exact: true }).first();
|
||||
await nameCell.hover();
|
||||
await nameCell
|
||||
.locator(
|
||||
'xpath=ancestor::*[contains(@class,"variable-item") or self::tr][1]',
|
||||
)
|
||||
.locator('.delete-variable-button')
|
||||
.first()
|
||||
.dispatchEvent('click');
|
||||
const confirm = page
|
||||
.getByRole('dialog')
|
||||
.filter({ hasText: /delete variable/i })
|
||||
.last();
|
||||
await confirm.getByRole('button', { name: 'OK' }).click();
|
||||
|
||||
await expect(tabpanel.getByText('tb_service', { exact: true })).toHaveCount(
|
||||
0,
|
||||
);
|
||||
await dialog.getByRole('button', { name: /close/i }).first().click();
|
||||
|
||||
await expect(page.getByText('$tb_service', { exact: true })).toHaveCount(0);
|
||||
|
||||
// Restore the persisted variable so subsequent serial-mode tests still pass.
|
||||
const token = await authToken(page);
|
||||
await page.request.put(`/api/v1/dashboards/${varDashboardId}`, {
|
||||
data: { ...variablesTemplate, title: 'detail-variables-suite' },
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
});
|
||||
await page.reload();
|
||||
await expect(page.getByText('$tb_service', { exact: true })).toBeVisible();
|
||||
});
|
||||
});
|
||||
@@ -1,332 +0,0 @@
|
||||
import type { Page } from '@playwright/test';
|
||||
|
||||
import { expect, test } from '../../../fixtures/auth';
|
||||
import { newAdminContext } from '../../../helpers/auth';
|
||||
import {
|
||||
authToken,
|
||||
createDashboardViaApi,
|
||||
deleteDashboardViaApi,
|
||||
} from '../../../helpers/dashboards';
|
||||
|
||||
test.describe.configure({ mode: 'serial' });
|
||||
|
||||
const seedIds = new Set<string>();
|
||||
|
||||
async function seed(page: Page, title: string): Promise<string> {
|
||||
const id = await createDashboardViaApi(page, title);
|
||||
seedIds.add(id);
|
||||
return id;
|
||||
}
|
||||
|
||||
test.afterAll(async ({ browser }) => {
|
||||
if (seedIds.size === 0) return;
|
||||
const ctx = await newAdminContext(browser);
|
||||
const page = await ctx.newPage();
|
||||
try {
|
||||
const token = await authToken(page);
|
||||
for (const id of seedIds) {
|
||||
await deleteDashboardViaApi(ctx.request, id, token);
|
||||
seedIds.delete(id);
|
||||
}
|
||||
} finally {
|
||||
await ctx.close();
|
||||
}
|
||||
});
|
||||
|
||||
async function openEditMode(page: Page): Promise<void> {
|
||||
await page.getByTestId('options').click();
|
||||
}
|
||||
|
||||
async function closeEditModeIfOpen(page: Page): Promise<void> {
|
||||
const lockBtn = page.getByRole('button', { name: 'Lock Dashboard' });
|
||||
if (await lockBtn.isVisible().catch(() => false)) {
|
||||
await lockBtn.click({ force: true });
|
||||
}
|
||||
}
|
||||
|
||||
test.describe('Dashboard Detail — Edit Mode', () => {
|
||||
test('TC-01 edit-mode popup contains all six action buttons', async ({
|
||||
authedPage: page,
|
||||
}) => {
|
||||
const id = await seed(page, 'edit-mode-popup');
|
||||
await page.goto(`/dashboard/${id}`);
|
||||
|
||||
await openEditMode(page);
|
||||
|
||||
await expect(
|
||||
page.getByRole('button', { name: 'Lock Dashboard' }),
|
||||
).toBeVisible();
|
||||
await expect(
|
||||
page.getByRole('button', { name: 'Rename', exact: true }),
|
||||
).toBeVisible();
|
||||
await expect(page.getByRole('button', { name: 'Full screen' })).toBeVisible();
|
||||
await expect(page.getByRole('button', { name: 'New section' })).toBeVisible();
|
||||
await expect(page.getByRole('button', { name: 'Export JSON' })).toBeVisible();
|
||||
await expect(
|
||||
page.getByRole('button', { name: 'Copy as JSON' }),
|
||||
).toBeVisible();
|
||||
|
||||
await page.getByRole('button', { name: 'Lock Dashboard' }).click();
|
||||
await expect(
|
||||
page.getByRole('button', { name: 'Lock Dashboard' }),
|
||||
).toBeHidden();
|
||||
});
|
||||
|
||||
test('TC-02 Lock Dashboard exits edit mode', async ({ authedPage: page }) => {
|
||||
const id = await seed(page, 'edit-mode-lock');
|
||||
await page.goto(`/dashboard/${id}`);
|
||||
|
||||
await openEditMode(page);
|
||||
await page.getByRole('button', { name: 'Lock Dashboard' }).click();
|
||||
|
||||
await expect(
|
||||
page.getByRole('button', { name: 'Lock Dashboard' }),
|
||||
).toBeHidden();
|
||||
await expect(
|
||||
page.getByRole('button', { name: 'Rename', exact: true }),
|
||||
).toBeHidden();
|
||||
await expect(page.getByRole('button', { name: 'New section' })).toBeHidden();
|
||||
await expect(page.getByRole('button', { name: 'Export JSON' })).toBeHidden();
|
||||
});
|
||||
|
||||
test('TC-03 rename dashboard — breadcrumb updates, then restore', async ({
|
||||
authedPage: page,
|
||||
}) => {
|
||||
const ts = Date.now();
|
||||
const original = `original-${ts}`;
|
||||
const renamed = `Renamed-${ts}`;
|
||||
const id = await seed(page, original);
|
||||
await page.goto(`/dashboard/${id}`);
|
||||
|
||||
await openEditMode(page);
|
||||
await page.getByRole('button', { name: 'Rename', exact: true }).click();
|
||||
|
||||
const renameDialog = page.getByRole('dialog', {
|
||||
name: 'Rename Dashboard',
|
||||
});
|
||||
await expect(renameDialog).toBeVisible();
|
||||
|
||||
const nameInput = renameDialog.getByTestId('dashboard-name');
|
||||
await nameInput.fill('');
|
||||
await nameInput.fill(renamed);
|
||||
await renameDialog.getByRole('button', { name: 'Rename Dashboard' }).click();
|
||||
|
||||
await expect(
|
||||
page.getByRole('button', {
|
||||
name: new RegExp(`dashboard-icon ${renamed}`),
|
||||
}),
|
||||
).toBeVisible();
|
||||
|
||||
// Restore.
|
||||
await openEditMode(page);
|
||||
await page.getByRole('button', { name: 'Rename', exact: true }).click();
|
||||
const restoreDialog = page.getByRole('dialog', {
|
||||
name: 'Rename Dashboard',
|
||||
});
|
||||
const restoreInput = restoreDialog.getByTestId('dashboard-name');
|
||||
await restoreInput.fill('');
|
||||
await restoreInput.fill(original);
|
||||
await restoreDialog.getByRole('button', { name: 'Rename Dashboard' }).click();
|
||||
|
||||
await expect(
|
||||
page.getByRole('button', {
|
||||
name: new RegExp(`dashboard-icon ${original}`),
|
||||
}),
|
||||
).toBeVisible();
|
||||
});
|
||||
|
||||
test('TC-04 cancel rename leaves name unchanged', async ({
|
||||
authedPage: page,
|
||||
}) => {
|
||||
const ts = Date.now();
|
||||
const original = `cancel-rename-${ts}`;
|
||||
const id = await seed(page, original);
|
||||
await page.goto(`/dashboard/${id}`);
|
||||
|
||||
await openEditMode(page);
|
||||
await page.getByRole('button', { name: 'Rename', exact: true }).click();
|
||||
|
||||
const renameDialog = page.getByRole('dialog', {
|
||||
name: 'Rename Dashboard',
|
||||
});
|
||||
const nameInput = renameDialog.getByTestId('dashboard-name');
|
||||
await nameInput.fill('');
|
||||
await nameInput.fill('Should Not Be Saved');
|
||||
|
||||
await renameDialog.getByRole('button', { name: 'Cancel' }).click();
|
||||
|
||||
await expect(
|
||||
page.getByRole('button', {
|
||||
name: new RegExp(`dashboard-icon ${original}`),
|
||||
}),
|
||||
).toBeVisible();
|
||||
await expect(page.getByText('Should Not Be Saved')).toBeHidden();
|
||||
});
|
||||
|
||||
test('TC-05 add a new section via edit mode, then remove it', async ({
|
||||
authedPage: page,
|
||||
}) => {
|
||||
const ts = Date.now();
|
||||
const id = await seed(page, `edit-mode-section-${ts}`);
|
||||
await page.goto(`/dashboard/${id}`);
|
||||
|
||||
await openEditMode(page);
|
||||
await page.getByRole('button', { name: 'New section' }).click();
|
||||
|
||||
const sectionDialog = page.getByRole('dialog', { name: 'New Section' });
|
||||
const sectionName = `e2e-section-${ts}`;
|
||||
await sectionDialog.getByTestId('section-name').fill(sectionName);
|
||||
await sectionDialog.getByRole('button', { name: 'Create Section' }).click();
|
||||
|
||||
const sectionTitle = page
|
||||
.locator('.section-title')
|
||||
.filter({ hasText: sectionName });
|
||||
await expect(sectionTitle).toBeVisible();
|
||||
|
||||
// Cleanup — remove the section. The ellipsis trigger sits on the
|
||||
// `.row-panel` container alongside the section title; the popover it
|
||||
// opens has rootClassName="row-settings" and renders at body level.
|
||||
const sectionRow = sectionTitle.locator(
|
||||
'xpath=ancestor::*[contains(@class, "row-panel")]',
|
||||
);
|
||||
await sectionRow.hover();
|
||||
await sectionRow.locator('.settings-icon').click();
|
||||
const rowSettingsPopover = page.locator('.row-settings');
|
||||
await expect(rowSettingsPopover).toBeVisible();
|
||||
await rowSettingsPopover
|
||||
.getByRole('button', { name: 'Remove Section' })
|
||||
.click();
|
||||
|
||||
const deleteDialog = page.getByRole('dialog', { name: 'Delete Row' });
|
||||
await expect(deleteDialog).toBeVisible();
|
||||
await deleteDialog.getByRole('button', { name: 'OK' }).click();
|
||||
|
||||
await expect(sectionTitle).toBeHidden();
|
||||
});
|
||||
|
||||
test('TC-06 Export JSON triggers a .json download', async ({
|
||||
authedPage: page,
|
||||
}) => {
|
||||
const id = await seed(page, 'edit-mode-export');
|
||||
await page.goto(`/dashboard/${id}`);
|
||||
|
||||
await openEditMode(page);
|
||||
|
||||
const downloadPromise = page.waitForEvent('download');
|
||||
await page.getByRole('button', { name: 'Export JSON' }).click();
|
||||
const download = await downloadPromise;
|
||||
|
||||
expect(download.suggestedFilename()).toMatch(/\.json$/);
|
||||
|
||||
await closeEditModeIfOpen(page);
|
||||
});
|
||||
|
||||
test('TC-07 Copy as JSON puts dashboard JSON on the clipboard', async ({
|
||||
authedPage: page,
|
||||
}) => {
|
||||
await page.context().grantPermissions(['clipboard-read', 'clipboard-write']);
|
||||
|
||||
const ts = Date.now();
|
||||
const title = `edit-mode-copy-${ts}`;
|
||||
const id = await seed(page, title);
|
||||
await page.goto(`/dashboard/${id}`);
|
||||
|
||||
await openEditMode(page);
|
||||
await page.getByRole('button', { name: 'Copy as JSON' }).click();
|
||||
|
||||
const clipboardText = await page.evaluate(() =>
|
||||
navigator.clipboard.readText(),
|
||||
);
|
||||
const parsed = JSON.parse(clipboardText) as { title?: string };
|
||||
expect(parsed.title ?? '').toContain(title);
|
||||
|
||||
await closeEditModeIfOpen(page);
|
||||
});
|
||||
|
||||
// known behaviour: headless Chromium does not honour the Fullscreen API,
|
||||
// so we cannot assert `document.fullscreenElement`. Verifying that the
|
||||
// click is benign (breadcrumb still rendered) is the strongest cross-env
|
||||
// check available.
|
||||
test('TC-08 Full screen — clicking does not crash the dashboard', async ({
|
||||
authedPage: page,
|
||||
}) => {
|
||||
const id = await seed(page, 'edit-mode-fullscreen');
|
||||
await page.goto(`/dashboard/${id}`);
|
||||
|
||||
await openEditMode(page);
|
||||
await page.getByRole('button', { name: 'Full screen' }).click();
|
||||
|
||||
await expect(
|
||||
page.getByRole('button', { name: /Dashboard \// }),
|
||||
).toBeVisible();
|
||||
|
||||
await page.keyboard.press('Escape');
|
||||
await closeEditModeIfOpen(page);
|
||||
});
|
||||
|
||||
// ─── Deep coverage ───────────────────────────────────────────────────────
|
||||
|
||||
test('TC-09 lock → unlock round-trip restores edit-mode controls', async ({
|
||||
authedPage: page,
|
||||
}) => {
|
||||
const id = await seed(page, 'edit-mode-lock-roundtrip');
|
||||
await page.goto(`/dashboard/${id}`);
|
||||
|
||||
// Lock the dashboard.
|
||||
await openEditMode(page);
|
||||
await page.getByRole('button', { name: 'Lock Dashboard' }).click();
|
||||
await expect(
|
||||
page.getByRole('button', { name: 'Lock Dashboard' }),
|
||||
).toBeHidden();
|
||||
|
||||
// Re-opening the popup after a lock shows the Unlock label instead of
|
||||
// Lock. The button label flips based on `isDashboardLocked`.
|
||||
await openEditMode(page);
|
||||
const unlockBtn = page.getByRole('button', { name: 'Unlock Dashboard' });
|
||||
await expect(unlockBtn).toBeVisible();
|
||||
await unlockBtn.click();
|
||||
|
||||
// After unlock, the popup should re-expose the original action buttons.
|
||||
await openEditMode(page);
|
||||
await expect(
|
||||
page.getByRole('button', { name: 'Rename', exact: true }),
|
||||
).toBeVisible();
|
||||
await expect(page.getByRole('button', { name: 'New section' })).toBeVisible();
|
||||
await closeEditModeIfOpen(page);
|
||||
});
|
||||
|
||||
test('TC-10 rename persists across hard reload', async ({
|
||||
authedPage: page,
|
||||
}) => {
|
||||
const ts = Date.now();
|
||||
const original = `rename-persist-${ts}`;
|
||||
const renamed = `Renamed-Persist-${ts}`;
|
||||
const id = await seed(page, original);
|
||||
await page.goto(`/dashboard/${id}`);
|
||||
|
||||
await openEditMode(page);
|
||||
await page.getByRole('button', { name: 'Rename', exact: true }).click();
|
||||
const renameDialog = page.getByRole('dialog', {
|
||||
name: 'Rename Dashboard',
|
||||
});
|
||||
const nameInput = renameDialog.getByTestId('dashboard-name');
|
||||
await nameInput.fill('');
|
||||
await nameInput.fill(renamed);
|
||||
await renameDialog.getByRole('button', { name: 'Rename Dashboard' }).click();
|
||||
|
||||
await expect(
|
||||
page.getByRole('button', {
|
||||
name: new RegExp(`dashboard-icon ${renamed}`),
|
||||
}),
|
||||
).toBeVisible();
|
||||
|
||||
// Hard reload — name must still be the renamed one.
|
||||
await page.reload();
|
||||
await expect(
|
||||
page.getByRole('button', {
|
||||
name: new RegExp(`dashboard-icon ${renamed}`),
|
||||
}),
|
||||
).toBeVisible();
|
||||
await expect(page).toHaveTitle(new RegExp(renamed));
|
||||
});
|
||||
});
|
||||
@@ -1,687 +0,0 @@
|
||||
import type { Page } from '@playwright/test';
|
||||
|
||||
import { expect, test } from '../../../fixtures/auth';
|
||||
import { newAdminContext } from '../../../helpers/auth';
|
||||
import {
|
||||
authToken,
|
||||
awaitVariablesResolved,
|
||||
createDashboardViaApi,
|
||||
deleteDashboardViaApi,
|
||||
} from '../../../helpers/dashboards';
|
||||
|
||||
const TELEMETRY_DEPENDENT_VARS = ['q_env', 'q_service', 'd_namespace'];
|
||||
|
||||
// `createVariablesDashboardViaApi` is added by the group-3 spec. Import lazily
|
||||
// so this file still compiles while it is missing — tests that need it skip
|
||||
// at runtime.
|
||||
// eslint-disable-next-line @typescript-eslint/no-var-requires
|
||||
const dashboardsHelpers = require('../../../helpers/dashboards') as {
|
||||
createVariablesDashboardViaApi?: (
|
||||
page: Page,
|
||||
title: string,
|
||||
) => Promise<string>;
|
||||
};
|
||||
const hasVariablesHelper =
|
||||
typeof dashboardsHelpers.createVariablesDashboardViaApi === 'function';
|
||||
|
||||
test.describe.configure({ mode: 'serial' });
|
||||
|
||||
const seedIds = new Set<string>();
|
||||
|
||||
async function seed(page: Page, title: string): Promise<string> {
|
||||
const id = await createDashboardViaApi(page, title);
|
||||
seedIds.add(id);
|
||||
return id;
|
||||
}
|
||||
|
||||
async function seedVariablesDashboard(
|
||||
page: Page,
|
||||
title: string,
|
||||
): Promise<string> {
|
||||
if (!dashboardsHelpers.createVariablesDashboardViaApi) {
|
||||
throw new Error('createVariablesDashboardViaApi helper is not available');
|
||||
}
|
||||
const id = await dashboardsHelpers.createVariablesDashboardViaApi(page, title);
|
||||
seedIds.add(id);
|
||||
// Wait for the seeded dashboard's variables to fully resolve before any
|
||||
// caller test acts on them. Variables with defaults already have
|
||||
// `selectedValue` set; Query/Dynamic variables can't resolve without
|
||||
// telemetry and are skipped.
|
||||
await awaitVariablesResolved(page, id, {
|
||||
skipNames: TELEMETRY_DEPENDENT_VARS,
|
||||
});
|
||||
return id;
|
||||
}
|
||||
|
||||
test.afterAll(async ({ browser }) => {
|
||||
if (seedIds.size === 0) return;
|
||||
const ctx = await newAdminContext(browser);
|
||||
const page = await ctx.newPage();
|
||||
try {
|
||||
const token = await authToken(page);
|
||||
for (const id of seedIds) {
|
||||
await deleteDashboardViaApi(ctx.request, id, token);
|
||||
seedIds.delete(id);
|
||||
}
|
||||
} finally {
|
||||
await ctx.close();
|
||||
}
|
||||
});
|
||||
|
||||
async function openConfigureDrawer(page: Page) {
|
||||
// An empty dashboard renders an onboarding canvas with a duplicate
|
||||
// `data-testid="show-drawer"` Configure CTA alongside the toolbar one.
|
||||
// Scope to the toolbar (`.dashboard-details .right-section`) to avoid the
|
||||
// strict-mode collision.
|
||||
await page
|
||||
.locator('.dashboard-details .right-section')
|
||||
.getByTestId('show-drawer')
|
||||
.click();
|
||||
const dialog = page.getByRole('dialog');
|
||||
await expect(dialog).toBeVisible();
|
||||
return dialog;
|
||||
}
|
||||
|
||||
async function deleteVariableByName(page: Page, varName: string) {
|
||||
const dialog = await openConfigureDrawer(page);
|
||||
await dialog.getByRole('tab', { name: 'Variables' }).click();
|
||||
const tabpanel = dialog.getByRole('tabpanel', { name: 'Variables' });
|
||||
const nameCell = tabpanel.getByText(varName, { exact: true }).first();
|
||||
await nameCell.hover();
|
||||
// Walk up to the surrounding row container to scope the delete-button
|
||||
// search; `.variable-item` (or the variable row container) wraps the
|
||||
// hover-revealed delete button.
|
||||
await nameCell
|
||||
.locator('xpath=ancestor::*[contains(@class,"variable-item") or self::tr][1]')
|
||||
.locator('.delete-variable-button')
|
||||
.first()
|
||||
.dispatchEvent('click');
|
||||
const confirm = page
|
||||
.getByRole('dialog')
|
||||
.filter({ hasText: /delete variable/i })
|
||||
.last();
|
||||
await confirm.getByRole('button', { name: 'OK' }).click();
|
||||
await expect(tabpanel.getByText(varName, { exact: true })).toHaveCount(0);
|
||||
await dialog.getByRole('button', { name: /close/i }).first().click();
|
||||
}
|
||||
|
||||
test.describe('Dashboard Detail — Configure drawer', () => {
|
||||
test('TC-01 Configure drawer opens with three tabs and Overview is active', async ({
|
||||
authedPage: page,
|
||||
}) => {
|
||||
const id = await seed(page, 'cfg-drawer-chrome');
|
||||
await page.goto(`/dashboard/${id}`);
|
||||
|
||||
const dialog = await openConfigureDrawer(page);
|
||||
|
||||
await expect(dialog.getByText('Dashboard Configuration')).toBeVisible();
|
||||
await expect(dialog.getByRole('tab', { name: 'Overview' })).toBeVisible();
|
||||
await expect(dialog.getByRole('tab', { name: 'Variables' })).toBeVisible();
|
||||
await expect(dialog.getByRole('tab', { name: 'Publish' })).toBeVisible();
|
||||
|
||||
await expect(dialog.getByRole('tab', { name: 'Overview' })).toHaveAttribute(
|
||||
'aria-selected',
|
||||
'true',
|
||||
);
|
||||
await expect(
|
||||
dialog.getByRole('tabpanel', { name: 'Overview' }),
|
||||
).toBeVisible();
|
||||
|
||||
await dialog.getByRole('button', { name: /close/i }).first().click();
|
||||
await expect(dialog).not.toBeVisible();
|
||||
});
|
||||
|
||||
test('TC-02 update name, description, and tag — persists across reload', async ({
|
||||
authedPage: page,
|
||||
}) => {
|
||||
const ts = Date.now();
|
||||
const original = `cfg-overview-save-${ts}`;
|
||||
const updated = `Configured-${ts}`;
|
||||
const id = await seed(page, original);
|
||||
await page.goto(`/dashboard/${id}`);
|
||||
|
||||
const dialog = await openConfigureDrawer(page);
|
||||
|
||||
const nameInput = dialog.getByTestId('dashboard-name');
|
||||
await nameInput.click();
|
||||
await nameInput.fill('');
|
||||
await nameInput.fill(updated);
|
||||
|
||||
await dialog.getByTestId('dashboard-desc').fill('Automated test description');
|
||||
|
||||
const tagInput = dialog.getByPlaceholder('Start typing your tag name');
|
||||
await tagInput.fill(`e2e-tag-${ts}`);
|
||||
await tagInput.press('Enter');
|
||||
|
||||
const saveBtn = dialog.getByRole('button', { name: 'Save' });
|
||||
await saveBtn.scrollIntoViewIfNeeded();
|
||||
const [putResp] = await Promise.all([
|
||||
page.waitForResponse(
|
||||
(r) => r.request().method() === 'PUT' && /\/dashboards\//.test(r.url()),
|
||||
),
|
||||
saveBtn.click({ force: true }),
|
||||
]);
|
||||
expect(putResp.ok()).toBeTruthy();
|
||||
|
||||
await dialog.getByRole('button', { name: /close/i }).first().click();
|
||||
await page.reload();
|
||||
await expect(
|
||||
page.getByRole('button', {
|
||||
name: new RegExp(`dashboard-icon ${updated}`),
|
||||
}),
|
||||
).toBeVisible();
|
||||
});
|
||||
|
||||
test('TC-03 Discard reverts unsaved Overview changes', async ({
|
||||
authedPage: page,
|
||||
}) => {
|
||||
const original = 'cfg-overview-discard';
|
||||
const id = await seed(page, original);
|
||||
await page.goto(`/dashboard/${id}`);
|
||||
|
||||
const dialog = await openConfigureDrawer(page);
|
||||
const nameInput = dialog.getByTestId('dashboard-name');
|
||||
await expect(nameInput).toHaveValue(original);
|
||||
|
||||
await nameInput.fill('Temp Modified Name');
|
||||
const discard = dialog.getByRole('button', { name: 'Discard' });
|
||||
await expect(discard).toBeVisible();
|
||||
await discard.click();
|
||||
|
||||
await expect(nameInput).toHaveValue(original);
|
||||
await expect(dialog.getByRole('button', { name: 'Save' })).not.toBeVisible();
|
||||
|
||||
await dialog.getByRole('button', { name: /close/i }).first().click();
|
||||
});
|
||||
|
||||
test('TC-04 Variables tab lists existing variables', async ({
|
||||
authedPage: page,
|
||||
}) => {
|
||||
test.skip(
|
||||
!hasVariablesHelper,
|
||||
'createVariablesDashboardViaApi helper not yet available (lands with group 3)',
|
||||
);
|
||||
|
||||
const id = await seedVariablesDashboard(page, 'cfg-variables-list');
|
||||
await page.goto(`/dashboard/${id}`);
|
||||
|
||||
const dialog = await openConfigureDrawer(page);
|
||||
await dialog.getByRole('tab', { name: 'Variables' }).click();
|
||||
const tabpanel = dialog.getByRole('tabpanel', { name: 'Variables' });
|
||||
await expect(tabpanel).toBeVisible();
|
||||
|
||||
for (const varName of [
|
||||
'tb_env',
|
||||
'tb_service',
|
||||
'cu_env_all',
|
||||
'cu_services',
|
||||
'q_env',
|
||||
'q_service',
|
||||
'd_namespace',
|
||||
]) {
|
||||
// Variable rows render as plain text inside the Variables tab
|
||||
// (not a true Antd `Table` with role="row"). Locate via text.
|
||||
await expect(
|
||||
tabpanel.getByText(varName, { exact: true }).first(),
|
||||
).toBeVisible();
|
||||
}
|
||||
|
||||
await dialog.getByRole('button', { name: /close/i }).first().click();
|
||||
});
|
||||
|
||||
test('TC-05 add a Textbox variable — appears in the variables bar and is interactive', async ({
|
||||
authedPage: page,
|
||||
}) => {
|
||||
test.skip(
|
||||
!hasVariablesHelper,
|
||||
'createVariablesDashboardViaApi helper not yet available (lands with group 3)',
|
||||
);
|
||||
|
||||
const id = await seedVariablesDashboard(page, 'cfg-variables-add-textbox');
|
||||
await page.goto(`/dashboard/${id}`);
|
||||
|
||||
const ts = Date.now();
|
||||
const varName = `tb_var_${ts}`;
|
||||
|
||||
const dialog = await openConfigureDrawer(page);
|
||||
await dialog.getByRole('tab', { name: 'Variables' }).click();
|
||||
await dialog.getByTestId('add-new-variable').click();
|
||||
|
||||
await dialog.getByPlaceholder('Unique name of the variable').fill(varName);
|
||||
await dialog.getByRole('button', { name: 'Textbox' }).click();
|
||||
|
||||
const saveBtn = dialog.getByRole('button', { name: 'Save Variable' });
|
||||
await expect(saveBtn).toBeEnabled();
|
||||
await saveBtn.click({ force: true });
|
||||
|
||||
const tabpanel = dialog.getByRole('tabpanel', { name: 'Variables' });
|
||||
await expect(
|
||||
tabpanel.getByText(varName, { exact: true }).first(),
|
||||
).toBeVisible();
|
||||
await dialog.getByRole('button', { name: /close/i }).first().click();
|
||||
await expect(dialog).not.toBeVisible();
|
||||
|
||||
await expect(page.getByText(`$${varName}`)).toBeVisible();
|
||||
|
||||
const newTextbox = page.locator('input[placeholder="Enter value"]').last();
|
||||
await newTextbox.fill('test-value');
|
||||
await newTextbox.press('Enter');
|
||||
await expect(page).toHaveURL(/test-value/);
|
||||
|
||||
await deleteVariableByName(page, varName);
|
||||
});
|
||||
|
||||
test('TC-06 add a Custom variable — appears in the list', async ({
|
||||
authedPage: page,
|
||||
}) => {
|
||||
test.skip(
|
||||
!hasVariablesHelper,
|
||||
'createVariablesDashboardViaApi helper not yet available (lands with group 3)',
|
||||
);
|
||||
|
||||
const id = await seedVariablesDashboard(page, 'cfg-variables-add-custom');
|
||||
await page.goto(`/dashboard/${id}`);
|
||||
|
||||
const ts = Date.now();
|
||||
const varName = `custom_var_${ts}`;
|
||||
|
||||
const dialog = await openConfigureDrawer(page);
|
||||
await dialog.getByRole('tab', { name: 'Variables' }).click();
|
||||
await dialog.getByTestId('add-new-variable').click();
|
||||
|
||||
await dialog.getByPlaceholder('Unique name of the variable').fill(varName);
|
||||
await dialog.getByRole('button', { name: 'Custom' }).click();
|
||||
|
||||
await dialog
|
||||
.getByRole('button', { name: 'Save Variable' })
|
||||
.click({ force: true });
|
||||
|
||||
const tabpanel = dialog.getByRole('tabpanel', { name: 'Variables' });
|
||||
await expect(
|
||||
tabpanel.getByText(varName, { exact: true }).first(),
|
||||
).toBeVisible();
|
||||
await dialog.getByRole('button', { name: /close/i }).first().click();
|
||||
|
||||
await deleteVariableByName(page, varName);
|
||||
});
|
||||
|
||||
// known limitation: TC-07 (add a Dynamic (Beta) variable) is intentionally
|
||||
// not implemented. Dynamic variables source from the SigNoz attribute
|
||||
// index — the bootstrap stack ingests no telemetry, so the field selector
|
||||
// renders an empty option list and Save Variable can never be enabled.
|
||||
// Re-add once the bootstrap seeds telemetry attributes.
|
||||
|
||||
test('TC-08 selecting Query type renders the query editor', async ({
|
||||
authedPage: page,
|
||||
}) => {
|
||||
test.skip(
|
||||
!hasVariablesHelper,
|
||||
'createVariablesDashboardViaApi helper not yet available (lands with group 3)',
|
||||
);
|
||||
|
||||
const id = await seedVariablesDashboard(page, 'cfg-variables-add-query');
|
||||
await page.goto(`/dashboard/${id}`);
|
||||
|
||||
const ts = Date.now();
|
||||
const varName = `query_var_${ts}`;
|
||||
|
||||
const dialog = await openConfigureDrawer(page);
|
||||
await dialog.getByRole('tab', { name: 'Variables' }).click();
|
||||
await dialog.getByTestId('add-new-variable').click();
|
||||
|
||||
await dialog.getByPlaceholder('Unique name of the variable').fill(varName);
|
||||
await dialog.getByRole('button', { name: /Query/ }).click();
|
||||
|
||||
// Monaco is lazy-loaded — its bundle chunk can take several seconds to
|
||||
// arrive under parallel-worker CI load, far longer than the default 5 s
|
||||
// locator timeout. 20 s is comfortable headroom without masking real
|
||||
// regressions.
|
||||
await expect(dialog.locator('.monaco-editor').first()).toBeVisible({
|
||||
timeout: 20_000,
|
||||
});
|
||||
|
||||
await dialog.getByRole('button', { name: 'Discard' }).click();
|
||||
await dialog.getByRole('button', { name: /close/i }).first().click();
|
||||
});
|
||||
|
||||
test('TC-09 Save Variable disabled when name is empty', async ({
|
||||
authedPage: page,
|
||||
}) => {
|
||||
const id = await seed(page, 'cfg-variables-empty-name');
|
||||
await page.goto(`/dashboard/${id}`);
|
||||
|
||||
const dialog = await openConfigureDrawer(page);
|
||||
await dialog.getByRole('tab', { name: 'Variables' }).click();
|
||||
await dialog.getByTestId('add-new-variable').click();
|
||||
|
||||
const nameField = dialog.getByPlaceholder('Unique name of the variable');
|
||||
await expect(nameField).toHaveValue('');
|
||||
await expect(
|
||||
dialog.getByRole('button', { name: 'Save Variable' }),
|
||||
).toBeDisabled();
|
||||
|
||||
await dialog.getByRole('button', { name: 'Discard' }).click();
|
||||
await dialog.getByRole('button', { name: /close/i }).first().click();
|
||||
});
|
||||
|
||||
test('TC-10 Publish tab shows private message and Publish button', async ({
|
||||
authedPage: page,
|
||||
}) => {
|
||||
const id = await seed(page, 'cfg-publish');
|
||||
await page.goto(`/dashboard/${id}`);
|
||||
|
||||
const dialog = await openConfigureDrawer(page);
|
||||
await dialog.getByRole('tab', { name: 'Publish' }).click();
|
||||
await expect(dialog.getByRole('tabpanel', { name: 'Publish' })).toBeVisible();
|
||||
|
||||
await expect(
|
||||
dialog.getByText(
|
||||
'This dashboard is private. Publish it to make it accessible to anyone with the link.',
|
||||
),
|
||||
).toBeVisible();
|
||||
await expect(
|
||||
dialog.getByRole('checkbox', { name: 'Enable time range' }),
|
||||
).toBeVisible();
|
||||
await expect(
|
||||
dialog.getByText("Dashboard variables won't work in public dashboards"),
|
||||
).toBeVisible();
|
||||
await expect(
|
||||
dialog.getByRole('button', { name: 'Publish dashboard' }),
|
||||
).toBeVisible();
|
||||
|
||||
await dialog.getByRole('button', { name: /close/i }).first().click();
|
||||
});
|
||||
|
||||
// ─── TBD coverage — placeholders to fill in when each feature lands ──────
|
||||
//
|
||||
// `test.skip` placeholders for behaviours not yet covered. Replace with
|
||||
// `test` and implement when the corresponding feature ships or the seed
|
||||
// gains the necessary state.
|
||||
|
||||
test('TC-11 edit existing variable — rename', async ({ authedPage: page }) => {
|
||||
test.skip(
|
||||
!hasVariablesHelper,
|
||||
'createVariablesDashboardViaApi helper not available',
|
||||
);
|
||||
|
||||
const id = await seedVariablesDashboard(page, 'cfg-rename-variable');
|
||||
await page.goto(`/dashboard/${id}`);
|
||||
await expect(page.getByText('$tb_env', { exact: true })).toBeVisible();
|
||||
|
||||
const dialog = await openConfigureDrawer(page);
|
||||
await dialog.getByRole('tab', { name: 'Variables' }).click();
|
||||
const tabpanel = dialog.getByRole('tabpanel', { name: 'Variables' });
|
||||
|
||||
// Hover the row to reveal the edit button (Pylon overlay can intercept,
|
||||
// so dispatchEvent fires the click directly on the React onClick).
|
||||
const nameCell = tabpanel.getByText('tb_env', { exact: true }).first();
|
||||
await nameCell.hover();
|
||||
await nameCell
|
||||
.locator(
|
||||
'xpath=ancestor::*[contains(@class,"variable-item") or self::tr][1]',
|
||||
)
|
||||
.locator('.edit-variable-button')
|
||||
.first()
|
||||
.dispatchEvent('click');
|
||||
|
||||
// Editor form mounts; rename and save.
|
||||
const renamed = `tb_env_renamed_${Date.now()}`;
|
||||
const nameInput = dialog.getByPlaceholder('Unique name of the variable');
|
||||
await expect(nameInput).toHaveValue('tb_env');
|
||||
await nameInput.fill(renamed);
|
||||
await dialog
|
||||
.getByRole('button', { name: 'Save Variable' })
|
||||
.click({ force: true });
|
||||
|
||||
// Variables bar reflects the rename; the original label is gone.
|
||||
await dialog.getByRole('button', { name: /close/i }).first().click();
|
||||
await expect(page.getByText(`$${renamed}`, { exact: true })).toBeVisible();
|
||||
await expect(page.getByText('$tb_env', { exact: true })).toHaveCount(0);
|
||||
});
|
||||
|
||||
test('TC-12 edit existing variable — change type (CUSTOM → QUERY)', async ({
|
||||
authedPage: page,
|
||||
}) => {
|
||||
test.skip(
|
||||
!hasVariablesHelper,
|
||||
'createVariablesDashboardViaApi helper not available',
|
||||
);
|
||||
|
||||
const id = await seedVariablesDashboard(page, 'cfg-change-type');
|
||||
await page.goto(`/dashboard/${id}`);
|
||||
|
||||
const dialog = await openConfigureDrawer(page);
|
||||
await dialog.getByRole('tab', { name: 'Variables' }).click();
|
||||
const tabpanel = dialog.getByRole('tabpanel', { name: 'Variables' });
|
||||
const nameCell = tabpanel.getByText('cu_single', { exact: true }).first();
|
||||
await nameCell.hover();
|
||||
await nameCell
|
||||
.locator(
|
||||
'xpath=ancestor::*[contains(@class,"variable-item") or self::tr][1]',
|
||||
)
|
||||
.locator('.edit-variable-button')
|
||||
.first()
|
||||
.dispatchEvent('click');
|
||||
|
||||
// Change type from Custom to Query and verify the form swaps to the
|
||||
// Query editor (Monaco SQL editor mounts where the comma-separated
|
||||
// values input used to live).
|
||||
await dialog.getByRole('button', { name: /Query/ }).click();
|
||||
// Monaco is lazy-loaded — its bundle chunk can take several seconds to
|
||||
// arrive under parallel-worker CI load, far longer than the default 5 s
|
||||
// locator timeout. 20 s is comfortable headroom without masking real
|
||||
// regressions.
|
||||
await expect(dialog.locator('.monaco-editor').first()).toBeVisible({
|
||||
timeout: 20_000,
|
||||
});
|
||||
|
||||
// The previous Custom-specific fields must no longer be visible.
|
||||
await expect(dialog.getByPlaceholder(/Comma separated values/i)).toHaveCount(
|
||||
0,
|
||||
);
|
||||
|
||||
// Discard rather than save — saving without filling the new query
|
||||
// would leave a half-configured Query variable. The contract this TC
|
||||
// guards is "type switching swaps the form correctly", which the
|
||||
// assertions above already prove.
|
||||
await dialog.getByRole('button', { name: 'Discard' }).click();
|
||||
await dialog.getByRole('button', { name: /close/i }).first().click();
|
||||
});
|
||||
|
||||
test('TC-13 edit existing variable — change default textbox value persists across reload', async ({
|
||||
authedPage: page,
|
||||
}) => {
|
||||
test.skip(
|
||||
!hasVariablesHelper,
|
||||
'createVariablesDashboardViaApi helper not available',
|
||||
);
|
||||
|
||||
const id = await seedVariablesDashboard(page, 'cfg-change-default');
|
||||
await page.goto(`/dashboard/${id}`);
|
||||
await expect(page.locator('input[value="otel-demo"]')).toBeVisible();
|
||||
|
||||
const dialog = await openConfigureDrawer(page);
|
||||
await dialog.getByRole('tab', { name: 'Variables' }).click();
|
||||
const tabpanel = dialog.getByRole('tabpanel', { name: 'Variables' });
|
||||
const nameCell = tabpanel.getByText('tb_env', { exact: true }).first();
|
||||
await nameCell.hover();
|
||||
await nameCell
|
||||
.locator(
|
||||
'xpath=ancestor::*[contains(@class,"variable-item") or self::tr][1]',
|
||||
)
|
||||
.locator('.edit-variable-button')
|
||||
.first()
|
||||
.dispatchEvent('click');
|
||||
|
||||
// Update the default textbox value. The Default Value input is the
|
||||
// second/third field (Name first); locate it via its placeholder.
|
||||
const defaultInput = dialog
|
||||
.getByPlaceholder(/Enter default value|Default value/i)
|
||||
.first();
|
||||
await defaultInput.fill('new-default');
|
||||
|
||||
// PUT confirms the variable persisted server-side before we close +
|
||||
// reload. Without this wait the reload races the save and the old
|
||||
// "otel-demo" default renders, producing the observed flake.
|
||||
const putResponse = page.waitForResponse(
|
||||
(r) => r.request().method() === 'PUT' && /\/dashboards\//.test(r.url()),
|
||||
);
|
||||
await dialog
|
||||
.getByRole('button', { name: 'Save Variable' })
|
||||
.click({ force: true });
|
||||
await putResponse;
|
||||
|
||||
// Reload — the new default renders without URL state because it's
|
||||
// now the persisted seed value.
|
||||
await dialog.getByRole('button', { name: /close/i }).first().click();
|
||||
await page.reload();
|
||||
await expect(page.locator('input[value="new-default"]')).toBeVisible();
|
||||
});
|
||||
|
||||
test('TC-14 delete variable — removed from variables bar', async ({
|
||||
authedPage: page,
|
||||
}) => {
|
||||
test.skip(
|
||||
!hasVariablesHelper,
|
||||
'createVariablesDashboardViaApi helper not available',
|
||||
);
|
||||
|
||||
const id = await seedVariablesDashboard(page, 'cfg-delete-variable');
|
||||
await page.goto(`/dashboard/${id}`);
|
||||
await expect(page.getByText('$tb_env', { exact: true })).toBeVisible();
|
||||
|
||||
// Reuse the existing helper and assert the variables bar reflects
|
||||
// the deletion — `deleteVariableByName` covers the Configure-side
|
||||
// removal; the bar update is the new contract this TC adds.
|
||||
await deleteVariableByName(page, 'tb_env');
|
||||
await expect(page.getByText('$tb_env', { exact: true })).toHaveCount(0);
|
||||
// Sibling textbox is unaffected.
|
||||
await expect(page.getByText('$tb_service', { exact: true })).toBeVisible();
|
||||
});
|
||||
|
||||
test('TC-15 variable name validation — duplicate name keeps Save disabled', async ({
|
||||
authedPage: page,
|
||||
}) => {
|
||||
test.skip(
|
||||
!hasVariablesHelper,
|
||||
'createVariablesDashboardViaApi helper not available',
|
||||
);
|
||||
|
||||
const id = await seedVariablesDashboard(page, 'cfg-validate-duplicate');
|
||||
await page.goto(`/dashboard/${id}`);
|
||||
|
||||
const dialog = await openConfigureDrawer(page);
|
||||
await dialog.getByRole('tab', { name: 'Variables' }).click();
|
||||
await dialog.getByTestId('add-new-variable').click();
|
||||
|
||||
await dialog.getByPlaceholder('Unique name of the variable').fill('tb_env');
|
||||
await dialog.getByRole('button', { name: 'Textbox' }).click();
|
||||
|
||||
// Save Variable should refuse to enable while the name collides with
|
||||
// an existing variable. Assert the button stays disabled, OR a
|
||||
// validation message surfaces — UI may pick either signal.
|
||||
const saveBtn = dialog.getByRole('button', { name: 'Save Variable' });
|
||||
const errorMsg = dialog.getByText(/already exists|duplicate|in use/i);
|
||||
|
||||
// Either Save is disabled, or an explicit error is shown — both are
|
||||
// valid contracts. `Promise.race` between the two assertions tolerates
|
||||
// whichever the UI provides.
|
||||
await expect
|
||||
.poll(async () => {
|
||||
const disabled = await saveBtn.isDisabled().catch(() => false);
|
||||
const err = await errorMsg.isVisible().catch(() => false);
|
||||
return disabled || err;
|
||||
})
|
||||
.toBeTruthy();
|
||||
|
||||
await dialog.getByRole('button', { name: 'Discard' }).click();
|
||||
await dialog.getByRole('button', { name: /close/i }).first().click();
|
||||
});
|
||||
|
||||
// eslint-disable-next-line playwright/expect-expect
|
||||
test.skip('TC-16 variable name validation — invalid characters / whitespace', async () => {
|
||||
// Names containing spaces, $-prefix, dots, etc. should be rejected
|
||||
// by the validator. Confirm Save Variable stays disabled with an
|
||||
// inline error message.
|
||||
});
|
||||
|
||||
// eslint-disable-next-line playwright/expect-expect
|
||||
test.skip('TC-17 reorder variables via drag persists `order` in JSON', async () => {
|
||||
// The Variables tab supports drag handles. After a reorder, the
|
||||
// persisted `data.variables[*].order` reflects the new sequence and
|
||||
// the variables bar re-renders accordingly.
|
||||
});
|
||||
|
||||
// eslint-disable-next-line playwright/expect-expect
|
||||
test.skip('TC-18 add a Dynamic (Beta) variable via Configure → pick seeded attribute', async () => {
|
||||
// Dynamic-variable resolution itself is covered by
|
||||
// `67-variables` TC-15 (seed metric → Dynamic dropdown lists the
|
||||
// namespace → URL state updates). What this TC adds is the Configure
|
||||
// drawer's *Add Variable → Dynamic* form, whose attribute-picker
|
||||
// uses a combobox whose stable locator hasn't been pinned in this
|
||||
// suite yet — leave skipped pending a snapshot pass.
|
||||
});
|
||||
|
||||
// eslint-disable-next-line playwright/expect-expect
|
||||
test.skip('TC-19 Variable description renders in tooltip / inline metadata', async () => {
|
||||
// `description` field on each variable should be surfaced in the
|
||||
// variables bar tooltip and in the Variables tab's row.
|
||||
});
|
||||
|
||||
// eslint-disable-next-line playwright/expect-expect
|
||||
test.skip('TC-20 Save Variable disabled while query is in flight', async () => {
|
||||
// For a Query variable mid-resolution, Save Variable should be
|
||||
// disabled until the query returns options. Otherwise we'd save
|
||||
// a variable with an empty option list.
|
||||
});
|
||||
|
||||
test('TC-21 cancel-mid-edit variable changes are not persisted', async ({
|
||||
authedPage: page,
|
||||
}) => {
|
||||
test.skip(
|
||||
!hasVariablesHelper,
|
||||
'createVariablesDashboardViaApi helper not available',
|
||||
);
|
||||
|
||||
const id = await seedVariablesDashboard(page, 'cfg-cancel-edit-variable');
|
||||
await page.goto(`/dashboard/${id}`);
|
||||
await expect(page.getByText('$tb_env', { exact: true })).toBeVisible();
|
||||
|
||||
// Open the editor for tb_env and dirty the Name field.
|
||||
const dialog = await openConfigureDrawer(page);
|
||||
await dialog.getByRole('tab', { name: 'Variables' }).click();
|
||||
const tabpanel = dialog.getByRole('tabpanel', { name: 'Variables' });
|
||||
const nameCell = tabpanel.getByText('tb_env', { exact: true }).first();
|
||||
await nameCell.hover();
|
||||
await nameCell
|
||||
.locator(
|
||||
'xpath=ancestor::*[contains(@class,"variable-item") or self::tr][1]',
|
||||
)
|
||||
.locator('.edit-variable-button')
|
||||
.first()
|
||||
.dispatchEvent('click');
|
||||
|
||||
const nameInput = dialog.getByPlaceholder('Unique name of the variable');
|
||||
await expect(nameInput).toHaveValue('tb_env');
|
||||
await nameInput.fill('SHOULD_NOT_PERSIST');
|
||||
|
||||
// Discard, then re-open the same row. The Name must still be the
|
||||
// original — abandoned edits never reach the persisted JSON.
|
||||
await dialog.getByRole('button', { name: 'Discard' }).click();
|
||||
await dialog.getByRole('button', { name: /close/i }).first().click();
|
||||
await expect(page.getByText('$tb_env', { exact: true })).toBeVisible();
|
||||
await expect(
|
||||
page.getByText('$SHOULD_NOT_PERSIST', { exact: true }),
|
||||
).toHaveCount(0);
|
||||
|
||||
// Reopen Configure → tb_env still has the original name.
|
||||
const dialog2 = await openConfigureDrawer(page);
|
||||
await dialog2.getByRole('tab', { name: 'Variables' }).click();
|
||||
await expect(
|
||||
dialog2
|
||||
.getByRole('tabpanel', { name: 'Variables' })
|
||||
.getByText('tb_env', { exact: true })
|
||||
.first(),
|
||||
).toBeVisible();
|
||||
});
|
||||
});
|
||||
@@ -1,243 +0,0 @@
|
||||
import type { Page } from '@playwright/test';
|
||||
|
||||
import { expect, test } from '../../../fixtures/auth';
|
||||
import { newAdminContext } from '../../../helpers/auth';
|
||||
import {
|
||||
APM_METRICS_TITLE,
|
||||
authToken,
|
||||
createApmMetricsDashboardViaApi,
|
||||
createDashboardViaApi,
|
||||
createVariablesDashboardViaApi,
|
||||
deleteDashboardViaApi,
|
||||
} from '../../../helpers/dashboards';
|
||||
|
||||
const seedIds = new Set<string>();
|
||||
const VARIABLES_TITLE = 'detail-edge-cases-variables';
|
||||
let apmDashboardId = '';
|
||||
let variablesDashboardId = '';
|
||||
|
||||
test.beforeAll(async ({ browser }) => {
|
||||
const ctx = await newAdminContext(browser);
|
||||
const page = await ctx.newPage();
|
||||
try {
|
||||
apmDashboardId = await createApmMetricsDashboardViaApi(page);
|
||||
seedIds.add(apmDashboardId);
|
||||
variablesDashboardId = await createVariablesDashboardViaApi(
|
||||
page,
|
||||
VARIABLES_TITLE,
|
||||
);
|
||||
seedIds.add(variablesDashboardId);
|
||||
} finally {
|
||||
await ctx.close();
|
||||
}
|
||||
});
|
||||
|
||||
test.afterAll(async ({ browser }) => {
|
||||
if (seedIds.size === 0) {
|
||||
return;
|
||||
}
|
||||
const ctx = await newAdminContext(browser);
|
||||
const page = await ctx.newPage();
|
||||
try {
|
||||
const token = await authToken(page);
|
||||
for (const id of seedIds) {
|
||||
await deleteDashboardViaApi(ctx.request, id, token);
|
||||
seedIds.delete(id);
|
||||
}
|
||||
} finally {
|
||||
await ctx.close();
|
||||
}
|
||||
});
|
||||
|
||||
function encodeVariables(payload: Record<string, unknown>): string {
|
||||
return encodeURIComponent(encodeURIComponent(JSON.stringify(payload)));
|
||||
}
|
||||
|
||||
async function gotoDetail(page: Page, id: string, query = ''): Promise<void> {
|
||||
await page.goto(`/dashboard/${id}${query}`);
|
||||
}
|
||||
|
||||
test.describe('Dashboard Detail Page — Edge Cases', () => {
|
||||
test('TC-01 panels show "No Data" for a far-past time range without pageerror', async ({
|
||||
authedPage: page,
|
||||
}) => {
|
||||
const errors: Error[] = [];
|
||||
page.on('pageerror', (err) => errors.push(err));
|
||||
|
||||
await gotoDetail(
|
||||
page,
|
||||
apmDashboardId,
|
||||
'?startTime=1672531200000&endTime=1672531260000',
|
||||
);
|
||||
|
||||
// The dashboard chrome must render with the far-past range applied:
|
||||
// breadcrumb resolves the dashboard title, panel headers render, and the
|
||||
// time-range textbox reflects the URL.
|
||||
await expect(
|
||||
page.getByRole('button', {
|
||||
name: new RegExp(`dashboard-icon ${APM_METRICS_TITLE}`),
|
||||
}),
|
||||
).toBeVisible();
|
||||
await expect(
|
||||
page.getByText('Latency', { exact: true }).first(),
|
||||
).toBeVisible();
|
||||
|
||||
// known behaviour: with no variable values resolvable in the far-past
|
||||
// window, APM panels stay in a waiting-on-variable state and never
|
||||
// render the uplot "No Data" overlay. The contract this TC really
|
||||
// guards is that the page does not throw — assert no client-side
|
||||
// pageerror was raised.
|
||||
expect(errors).toHaveLength(0);
|
||||
});
|
||||
|
||||
test('TC-02 nonexistent dashboard ID handled gracefully', async ({
|
||||
authedPage: page,
|
||||
}) => {
|
||||
await page.goto('/dashboard/nonexistent-id-99999');
|
||||
|
||||
// The chrome (sidebar logo) must always render, regardless of whether
|
||||
// the app redirects to /dashboard or shows an in-place error shell.
|
||||
// The bogus-id breadcrumb must never resolve.
|
||||
await expect(page.getByRole('img', { name: 'SigNoz' })).toBeVisible();
|
||||
await expect(
|
||||
page.getByRole('button', {
|
||||
name: /dashboard-icon nonexistent-id-99999/,
|
||||
}),
|
||||
).toBeHidden();
|
||||
});
|
||||
|
||||
test('TC-03 sidebar nav still works after hitting a nonexistent dashboard URL', async ({
|
||||
authedPage: page,
|
||||
}) => {
|
||||
await page.goto('/dashboard/nonexistent-id-99999');
|
||||
await expect(page.getByRole('img', { name: 'SigNoz' })).toBeVisible();
|
||||
|
||||
await page
|
||||
.locator('.nav-item')
|
||||
.filter({ hasText: /^Dashboards$/ })
|
||||
.click();
|
||||
|
||||
await expect(page).toHaveURL(/\/dashboard($|\?)/);
|
||||
await expect(
|
||||
page.getByRole('heading', { name: 'Dashboards', level: 1 }),
|
||||
).toBeVisible();
|
||||
});
|
||||
|
||||
test('TC-04 variable URL deep-link survives hard reload', async ({
|
||||
authedPage: page,
|
||||
}) => {
|
||||
const deepLink = `?variables=${encodeVariables({ q_env: 'otel-demo' })}`;
|
||||
await gotoDetail(page, variablesDashboardId, deepLink);
|
||||
|
||||
await expect(
|
||||
page.getByRole('button', {
|
||||
name: new RegExp(`dashboard-icon ${VARIABLES_TITLE}`),
|
||||
}),
|
||||
).toBeVisible();
|
||||
await expect(page.getByText('$q_env', { exact: true })).toBeVisible();
|
||||
|
||||
await expect(page).toHaveURL(/variables=%257B/);
|
||||
await expect(page).toHaveURL(/otel-demo/);
|
||||
|
||||
// Dropdown index — selects (in DOM order): 0=cu_single, 1=cu_env_all,
|
||||
// 2=cu_services, 3=q_env, 4=q_service, 5=d_namespace.
|
||||
const qEnv = page.getByTestId('variable-select').nth(3);
|
||||
await expect(
|
||||
qEnv.locator('.ant-select-selection-item', { hasText: 'otel-demo' }),
|
||||
).toBeVisible();
|
||||
|
||||
await page.reload({ waitUntil: 'domcontentloaded' });
|
||||
|
||||
await expect(page).toHaveURL(/variables=%257B/);
|
||||
await expect(page).toHaveURL(/otel-demo/);
|
||||
await expect(
|
||||
qEnv.locator('.ant-select-selection-item', { hasText: 'otel-demo' }),
|
||||
).toBeVisible();
|
||||
});
|
||||
|
||||
test('TC-05 a single broken time range does not crash the dashboard canvas', async ({
|
||||
authedPage: page,
|
||||
}) => {
|
||||
const errors: Error[] = [];
|
||||
page.on('pageerror', (err) => errors.push(err));
|
||||
|
||||
// known behaviour: the app may either reject a swapped range
|
||||
// client-side or render error states per-panel — either way, the
|
||||
// dashboard chrome and at least one panel header must still render.
|
||||
await gotoDetail(page, apmDashboardId, '?startTime=999999&endTime=999998');
|
||||
|
||||
await expect(
|
||||
page.getByRole('button', {
|
||||
name: new RegExp(`dashboard-icon ${APM_METRICS_TITLE}`),
|
||||
}),
|
||||
).toBeVisible();
|
||||
await expect(
|
||||
page.getByText('Latency', { exact: true }).first(),
|
||||
).toBeVisible();
|
||||
|
||||
expect(errors).toHaveLength(0);
|
||||
});
|
||||
|
||||
test('TC-06 sidebar Dashboards link from detail page navigates to /dashboard', async ({
|
||||
authedPage: page,
|
||||
}) => {
|
||||
await gotoDetail(page, apmDashboardId);
|
||||
await expect(
|
||||
page.getByRole('button', {
|
||||
name: new RegExp(`dashboard-icon ${APM_METRICS_TITLE}`),
|
||||
}),
|
||||
).toBeVisible();
|
||||
|
||||
await page
|
||||
.locator('.nav-item')
|
||||
.filter({ hasText: /^Dashboards$/ })
|
||||
.click();
|
||||
|
||||
await expect(page).toHaveURL(/\/dashboard($|\?)/);
|
||||
await expect(
|
||||
page.getByRole('heading', { name: 'Dashboards', level: 1 }),
|
||||
).toBeVisible();
|
||||
});
|
||||
|
||||
// ─── Deep coverage ───────────────────────────────────────────────────────
|
||||
|
||||
test('TC-07 a 200-character dashboard name renders without breaking layout', async ({
|
||||
authedPage: page,
|
||||
}) => {
|
||||
const longName = `LongName-${'x'.repeat(190)}`;
|
||||
const id = await createDashboardViaApi(page, longName);
|
||||
seedIds.add(id);
|
||||
|
||||
await gotoDetail(page, id);
|
||||
|
||||
await expect(
|
||||
page.getByRole('button', {
|
||||
name: new RegExp(`dashboard-icon ${longName.slice(0, 30)}`),
|
||||
}),
|
||||
).toBeVisible();
|
||||
// The toolbar must still render — long titles cannot push the toolbar
|
||||
// off-screen or unmount it. Scope to `.right-section` because empty
|
||||
// dashboards render an onboarding canvas with duplicate testids.
|
||||
const toolbar = page.locator('.dashboard-details .right-section');
|
||||
await expect(toolbar.getByTestId('show-drawer')).toBeVisible();
|
||||
await expect(toolbar.getByTestId('add-panel-header')).toBeVisible();
|
||||
});
|
||||
|
||||
test('TC-08 special characters in the dashboard name round-trip via URL and breadcrumb', async ({
|
||||
authedPage: page,
|
||||
}) => {
|
||||
const trickyName = `Spec & Chars / "${Date.now()}" — émoji 🎯`;
|
||||
const id = await createDashboardViaApi(page, trickyName);
|
||||
seedIds.add(id);
|
||||
|
||||
await gotoDetail(page, id);
|
||||
|
||||
// The full title must round-trip through the breadcrumb without HTML
|
||||
// entity mangling (`&`, `"` are bugs we'd want to catch).
|
||||
await expect(
|
||||
page.getByText(trickyName, { exact: true }).first(),
|
||||
).toBeVisible();
|
||||
// document.title is set from the dashboard name — confirm it is intact.
|
||||
await expect(page).toHaveTitle(new RegExp('Spec & Chars'));
|
||||
});
|
||||
});
|
||||
64
tests/e2e/tests/dashboards/editor/capabilities.spec.ts
Normal file
64
tests/e2e/tests/dashboards/editor/capabilities.spec.ts
Normal file
@@ -0,0 +1,64 @@
|
||||
import {
|
||||
expect,
|
||||
GOLDEN,
|
||||
metricsQuery,
|
||||
PanelKind,
|
||||
promqlQuery,
|
||||
SINGLE_PANEL_ID,
|
||||
singlePanelDashboard,
|
||||
test,
|
||||
textPanelWithQueryDashboard,
|
||||
} from '../../../drivers/panel-editor';
|
||||
|
||||
// Scope: how the editor explains a disabled kind, and the backend enforcing the
|
||||
// same panelKind × queryType matrix.
|
||||
|
||||
test.describe('Dashboards — editor capabilities', () => {
|
||||
test('TC-01 a disabled option explains itself in a tooltip', async ({
|
||||
dashboards,
|
||||
editor,
|
||||
panels,
|
||||
}) => {
|
||||
await dashboards.seedAndEdit(
|
||||
singlePanelDashboard({ query: metricsQuery() }),
|
||||
SINGLE_PANEL_ID,
|
||||
);
|
||||
|
||||
await editor.openTypeSwitcher();
|
||||
await expect(editor.typeOption(PanelKind.List)).toHaveAttribute(
|
||||
'aria-disabled',
|
||||
'true',
|
||||
);
|
||||
await editor.typeOption(PanelKind.List).hover();
|
||||
|
||||
// The wording is the contract users read.
|
||||
await expect(
|
||||
panels.tooltip.text("List doesn't support metrics data"),
|
||||
).toBeVisible();
|
||||
});
|
||||
|
||||
test('TC-02 the backend rejects a combination the editor disables', async ({
|
||||
dashboards,
|
||||
}) => {
|
||||
// Enforced server-side too, so an invalid pairing can never be persisted.
|
||||
// Pins the layers together: if capabilities.ts and allowedQueryKinds
|
||||
// drift, either the switcher contract or this fails.
|
||||
await expect(
|
||||
dashboards.seed(
|
||||
singlePanelDashboard({
|
||||
kind: PanelKind.PieChart,
|
||||
query: promqlQuery(`sum(rate(${GOLDEN.metrics.calls}[5m]))`),
|
||||
}),
|
||||
),
|
||||
// Quotes in the message are JSON-escaped, so match around them.
|
||||
).rejects.toThrow(/PromQLQuery.*not supported by panel kind.*PieChartPanel/);
|
||||
});
|
||||
|
||||
test('TC-03 the backend rejects a Text panel that carries a query', async ({
|
||||
dashboards,
|
||||
}) => {
|
||||
await expect(dashboards.seed(textPanelWithQueryDashboard())).rejects.toThrow(
|
||||
/renders without a query and must have queries: \[\]/,
|
||||
);
|
||||
});
|
||||
});
|
||||
180
tests/e2e/tests/dashboards/editor/context-links.spec.ts
Normal file
180
tests/e2e/tests/dashboards/editor/context-links.spec.ts
Normal file
@@ -0,0 +1,180 @@
|
||||
import {
|
||||
expect,
|
||||
Section,
|
||||
SINGLE_PANEL_ID,
|
||||
singlePanelDashboard,
|
||||
test,
|
||||
} from '../../../drivers/panel-config';
|
||||
|
||||
// Scope: the Context Links dialog and that a saved link reaches
|
||||
// `panel.spec.links` — where it surfaces as a `drilldown-context-link`
|
||||
// (covered in panels/57-drilldown).
|
||||
|
||||
const ONE_LINK = singlePanelDashboard({
|
||||
links: [{ name: 'Original', url: 'https://example.com/one' }],
|
||||
});
|
||||
|
||||
test.describe('Dashboards — editor context links', () => {
|
||||
test('TC-01 the section is offered and opens an empty dialog', async ({
|
||||
dashboards,
|
||||
editor,
|
||||
config,
|
||||
}) => {
|
||||
const links = config.contextLinks;
|
||||
await dashboards.seedAndEdit(singlePanelDashboard(), SINGLE_PANEL_ID);
|
||||
|
||||
await editor.section.expand(Section.contextLinks);
|
||||
await links.addButton().click();
|
||||
|
||||
await expect(links.dialog()).toBeVisible();
|
||||
await expect(links.label()).toHaveValue('');
|
||||
await expect(links.url()).toHaveValue('');
|
||||
});
|
||||
|
||||
test('TC-02 Save stays disabled until the link is valid', async ({
|
||||
dashboards,
|
||||
editor,
|
||||
config,
|
||||
}) => {
|
||||
const links = config.contextLinks;
|
||||
await dashboards.seedAndEdit(singlePanelDashboard(), SINGLE_PANEL_ID);
|
||||
|
||||
await editor.section.expand(Section.contextLinks);
|
||||
await links.addButton().click();
|
||||
|
||||
// A link with no URL would be a dead menu entry.
|
||||
await expect(links.save()).toBeDisabled();
|
||||
await links.label().fill('Runbook');
|
||||
await expect(links.save()).toBeDisabled();
|
||||
|
||||
await links.url().fill('https://example.com/runbook');
|
||||
await expect(links.save()).toBeEnabled();
|
||||
});
|
||||
|
||||
test('TC-03 a saved link persists into panel.spec.links', async ({
|
||||
dashboards,
|
||||
editor,
|
||||
config,
|
||||
}) => {
|
||||
const links = config.contextLinks;
|
||||
const id = await dashboards.seedAndEdit(
|
||||
singlePanelDashboard(),
|
||||
SINGLE_PANEL_ID,
|
||||
);
|
||||
|
||||
await editor.section.expand(Section.contextLinks);
|
||||
await links.addButton().click();
|
||||
await links.label().fill('Runbook');
|
||||
await links.url().fill('https://example.com/runbook');
|
||||
await links.blurFields();
|
||||
await links.save().click();
|
||||
|
||||
await expect(links.item(0)).toBeVisible();
|
||||
await editor.save();
|
||||
|
||||
const saved = (await config.saved(id)).links;
|
||||
expect(saved).toHaveLength(1);
|
||||
// "Label" persists as `name` (Perses link model).
|
||||
expect(saved[0]).toMatchObject({
|
||||
name: 'Runbook',
|
||||
url: 'https://example.com/runbook',
|
||||
});
|
||||
});
|
||||
|
||||
test('TC-04 Cancel discards the dialog without adding a link', async ({
|
||||
dashboards,
|
||||
editor,
|
||||
config,
|
||||
}) => {
|
||||
const links = config.contextLinks;
|
||||
await dashboards.seedAndEdit(singlePanelDashboard(), SINGLE_PANEL_ID);
|
||||
|
||||
await editor.section.expand(Section.contextLinks);
|
||||
await links.addButton().click();
|
||||
await links.label().fill('Throwaway');
|
||||
await links.url().fill('https://example.com');
|
||||
await links.blurFields();
|
||||
await links.cancel().click();
|
||||
|
||||
await expect(links.dialog()).toHaveCount(0);
|
||||
await expect(links.item(0)).toHaveCount(0);
|
||||
});
|
||||
|
||||
test('TC-05 an existing link can be edited', async ({
|
||||
dashboards,
|
||||
editor,
|
||||
config,
|
||||
}) => {
|
||||
const links = config.contextLinks;
|
||||
const id = await dashboards.seedAndEdit(ONE_LINK, SINGLE_PANEL_ID);
|
||||
|
||||
await editor.section.expand(Section.contextLinks);
|
||||
await links.edit(0).click();
|
||||
await links.label().fill('Renamed');
|
||||
await links.blurFields();
|
||||
await links.save().click();
|
||||
await editor.save();
|
||||
|
||||
expect((await config.saved(id)).links[0]).toMatchObject({
|
||||
name: 'Renamed',
|
||||
});
|
||||
});
|
||||
|
||||
test('TC-06 a link can be removed', async ({ dashboards, editor, config }) => {
|
||||
const id = await dashboards.seedAndEdit(ONE_LINK, SINGLE_PANEL_ID);
|
||||
|
||||
await editor.section.expand(Section.contextLinks);
|
||||
await config.contextLinks.remove(0).click();
|
||||
await editor.save();
|
||||
|
||||
expect((await config.saved(id)).links).toHaveLength(0);
|
||||
});
|
||||
|
||||
test('TC-07 URL parameters can be added to a link', async ({
|
||||
dashboards,
|
||||
editor,
|
||||
config,
|
||||
}) => {
|
||||
const links = config.contextLinks;
|
||||
const id = await dashboards.seedAndEdit(
|
||||
singlePanelDashboard(),
|
||||
SINGLE_PANEL_ID,
|
||||
);
|
||||
|
||||
await editor.section.expand(Section.contextLinks);
|
||||
await links.addButton().click();
|
||||
await links.label().fill('With params');
|
||||
await links.url().fill('https://example.com/search');
|
||||
|
||||
await links.blurFields();
|
||||
await links.addParam().click();
|
||||
await links.paramKey(0).fill('service');
|
||||
await links.paramValue(0).fill('adservice');
|
||||
await links.blurFields();
|
||||
await links.save().click();
|
||||
await editor.save();
|
||||
|
||||
const saved = (await config.saved(id)).links;
|
||||
expect(saved).toHaveLength(1);
|
||||
// Params fold into the persisted URL.
|
||||
expect(new URL(saved[0].url).searchParams.get('service')).toBe('adservice');
|
||||
});
|
||||
|
||||
test('TC-08 the header quick-add opens the dialog from a collapsed section', async ({
|
||||
dashboards,
|
||||
editor,
|
||||
config,
|
||||
}) => {
|
||||
await dashboards.seedAndEdit(singlePanelDashboard(), SINGLE_PANEL_ID);
|
||||
|
||||
// One click must expand AND add (pendingAction hop).
|
||||
await editor.section.collapse(Section.contextLinks);
|
||||
await config.contextLinks.headerAdd().click();
|
||||
|
||||
await expect(editor.section.toggle(Section.contextLinks)).toHaveAttribute(
|
||||
'aria-expanded',
|
||||
'true',
|
||||
);
|
||||
await expect(config.contextLinks.dialog()).toBeVisible();
|
||||
});
|
||||
});
|
||||
246
tests/e2e/tests/dashboards/editor/picker.spec.ts
Normal file
246
tests/e2e/tests/dashboards/editor/picker.spec.ts
Normal file
@@ -0,0 +1,246 @@
|
||||
import {
|
||||
compactDashboard,
|
||||
emptyDashboard,
|
||||
expect,
|
||||
PANEL_TYPE_TILES,
|
||||
PanelKind,
|
||||
PATH,
|
||||
singlePanelDashboard,
|
||||
SINGLE_PANEL_ID,
|
||||
test,
|
||||
} from '../../../drivers/panel-editor';
|
||||
|
||||
// Scope: creating a panel — the New panel drawer, the route it hands off to,
|
||||
// and the JSON-Patch a save emits.
|
||||
//
|
||||
// The subtlety: a tile click only selects; the confirm button carries the
|
||||
// target section, and the editor route carries it on as `layoutIndex`.
|
||||
|
||||
test.describe('Dashboards — panel picker', () => {
|
||||
test('TC-01 with one section a tile click selects and Add panel creates', async ({
|
||||
authedPage: page,
|
||||
dashboards,
|
||||
editor,
|
||||
newPanel,
|
||||
}) => {
|
||||
const id = await dashboards.seed(emptyDashboard());
|
||||
await dashboards.openEmpty(id);
|
||||
|
||||
await dashboards.emptyAddPanel().click();
|
||||
// No section to choose, so the confirm names none.
|
||||
await expect(newPanel.confirmButton()).toHaveText('Add panel');
|
||||
|
||||
await newPanel.pick(PanelKind.Table);
|
||||
await expect(page).toHaveURL(PATH.onDashboard(id));
|
||||
await expect(newPanel.root()).toBeVisible();
|
||||
|
||||
await newPanel.confirm();
|
||||
expect(dashboards.param('panelKind')).toBe(PanelKind.Table);
|
||||
await expect(editor.root()).toBeVisible();
|
||||
});
|
||||
|
||||
test('TC-02 with several sections the confirm names its target section', async ({
|
||||
dashboards,
|
||||
newPanel,
|
||||
}) => {
|
||||
await dashboards.seedAndOpen(compactDashboard());
|
||||
|
||||
await dashboards.headerAddPanel().click();
|
||||
await expect(newPanel.root()).toBeVisible();
|
||||
|
||||
// Every section is titled, so the toolbar trigger defaults to a new root.
|
||||
await expect(newPanel.confirmButton()).toHaveText('Add to Dashboard (root)');
|
||||
await expect(newPanel.tile(PanelKind.TimeSeries)).toHaveAttribute(
|
||||
'aria-pressed',
|
||||
'true',
|
||||
);
|
||||
|
||||
await newPanel.choose(PanelKind.Number);
|
||||
expect(dashboards.param('panelKind')).toBe(PanelKind.Number);
|
||||
expect(dashboards.param('layoutIndex')).toBe('root');
|
||||
});
|
||||
|
||||
test('TC-03 the chosen section becomes the new panel layoutIndex', async ({
|
||||
dashboards,
|
||||
newPanel,
|
||||
}) => {
|
||||
await dashboards.seedAndOpen(compactDashboard());
|
||||
|
||||
await dashboards.headerAddPanel().click();
|
||||
await newPanel.pick(PanelKind.Number);
|
||||
await newPanel.chooseSection('1');
|
||||
await expect(newPanel.confirmButton()).toHaveText('Add to Tabular');
|
||||
await newPanel.confirm();
|
||||
|
||||
expect(dashboards.param('layoutIndex')).toBe('1');
|
||||
});
|
||||
|
||||
test('TC-04 creating from an empty section targets that section', async ({
|
||||
dashboards,
|
||||
newPanel,
|
||||
}) => {
|
||||
const dashboard = compactDashboard();
|
||||
|
||||
dashboard.spec.layouts.push({
|
||||
kind: 'Grid',
|
||||
spec: { display: { title: 'Empty' }, items: [] },
|
||||
});
|
||||
await dashboards.seedAndOpen(dashboard);
|
||||
|
||||
await newPanel.sectionAddPanel().scrollIntoViewIfNeeded();
|
||||
await newPanel.sectionAddPanel().click();
|
||||
|
||||
await expect(newPanel.root()).toBeVisible();
|
||||
await expect(newPanel.confirmButton()).toHaveText('Add to Empty');
|
||||
await newPanel.choose(PanelKind.TimeSeries);
|
||||
expect(dashboards.param('layoutIndex')).toBe('2');
|
||||
});
|
||||
|
||||
test('TC-05 saving a new panel emits add-panel and add-layout-item ops', async ({
|
||||
dashboards,
|
||||
editor,
|
||||
newPanel,
|
||||
}) => {
|
||||
await dashboards.seedAndOpen(singlePanelDashboard());
|
||||
|
||||
const patches = editor.capturePatches();
|
||||
|
||||
await dashboards.headerAddPanel().click();
|
||||
// List: its seeded query passes validation (see TC-06).
|
||||
await newPanel.choose(PanelKind.List);
|
||||
await expect(editor.root()).toBeVisible();
|
||||
|
||||
await editor.title().fill('Created from the modal');
|
||||
await editor.save();
|
||||
|
||||
// Targeted adds for panel AND grid item — a replace would clobber edits.
|
||||
expect(patches.length).toBeGreaterThan(0);
|
||||
const ops = patches[patches.length - 1];
|
||||
const panelAdd = ops.find((op) => /^\/spec\/panels\/[^/]+$/.test(op.path));
|
||||
expect(panelAdd?.op).toBe('add');
|
||||
expect(
|
||||
ops.some((op) => /^\/spec\/layouts\/\d+\/spec\/items\/-$/.test(op.path)),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
test('TC-06 the created panel lands on the dashboard and persists', async ({
|
||||
authedPage: page,
|
||||
dashboards,
|
||||
editor,
|
||||
newPanel,
|
||||
panels,
|
||||
}) => {
|
||||
const id = await dashboards.seedAndOpen(singlePanelDashboard());
|
||||
|
||||
const before = await dashboards.get(id);
|
||||
const beforeCount = Object.keys(before.spec.panels).length;
|
||||
|
||||
await dashboards.headerAddPanel().click();
|
||||
// List is the one kind whose seeded query saves as-is (logs `count()`);
|
||||
// metrics kinds are rejected until a metric is chosen — see TC-07.
|
||||
await newPanel.choose(PanelKind.List);
|
||||
await expect(editor.root()).toBeVisible();
|
||||
await editor.title().fill('Fresh list panel');
|
||||
await editor.save();
|
||||
|
||||
await page.waitForURL(PATH.onDashboard(id));
|
||||
await expect(
|
||||
panels.titles().filter({ hasText: 'Fresh list panel' }),
|
||||
).toBeVisible();
|
||||
|
||||
const after = await dashboards.get(id);
|
||||
expect(Object.keys(after.spec.panels)).toHaveLength(beforeCount + 1);
|
||||
});
|
||||
|
||||
test('TC-07 saving a metrics panel with no metric chosen is rejected', async ({
|
||||
dashboards,
|
||||
editor,
|
||||
newPanel,
|
||||
}) => {
|
||||
await dashboards.seedAndOpen(singlePanelDashboard());
|
||||
|
||||
await dashboards.headerAddPanel().click();
|
||||
await newPanel.choose(PanelKind.Number);
|
||||
await expect(editor.root()).toBeVisible();
|
||||
|
||||
// Metrics kinds seed an empty aggregation, so the editor must stay open and
|
||||
// surface the reason rather than dropping the panel.
|
||||
const { ok, body } = await editor.trySave();
|
||||
expect(ok).toBe(false);
|
||||
expect(body).toContain('metric name is required');
|
||||
|
||||
await expect(editor.root()).toBeVisible();
|
||||
});
|
||||
|
||||
test('TC-08 the editor route redirects when panelKind is missing', async ({
|
||||
authedPage: page,
|
||||
dashboards,
|
||||
panels,
|
||||
}) => {
|
||||
const id = await dashboards.seed(singlePanelDashboard());
|
||||
|
||||
// No kind to seed, so the page bounces back.
|
||||
await dashboards.visit(PATH.editor(id, 'new'));
|
||||
await page.waitForURL(PATH.onDashboard(id));
|
||||
await expect(panels.panel(SINGLE_PANEL_ID).root()).toBeVisible();
|
||||
});
|
||||
|
||||
test('TC-09 a new section from the picker is created on save', async ({
|
||||
authedPage: page,
|
||||
dashboards,
|
||||
editor,
|
||||
newPanel,
|
||||
}) => {
|
||||
const id = await dashboards.seedAndOpen(singlePanelDashboard());
|
||||
|
||||
await dashboards.headerAddPanel().click();
|
||||
await newPanel.pick(PanelKind.List);
|
||||
await newPanel.newSection('Fresh section');
|
||||
await expect(newPanel.confirmButton()).toHaveText('Add to new section');
|
||||
await newPanel.confirm();
|
||||
expect(dashboards.param('newSection')).toBe('Fresh section');
|
||||
|
||||
await editor.save();
|
||||
await page.waitForURL(PATH.onDashboard(id));
|
||||
|
||||
const after = await dashboards.get(id);
|
||||
const section = after.spec.layouts.find(
|
||||
(layout) => layout.spec.display?.title === 'Fresh section',
|
||||
);
|
||||
expect(section?.spec.items).toHaveLength(1);
|
||||
});
|
||||
|
||||
test('TC-10 search narrows the tiles and offers a way back', async ({
|
||||
dashboards,
|
||||
newPanel,
|
||||
}) => {
|
||||
await dashboards.seedAndOpen(singlePanelDashboard());
|
||||
await dashboards.headerAddPanel().click();
|
||||
|
||||
// Matches name, description and group label.
|
||||
await newPanel.search().fill('pie');
|
||||
await expect(newPanel.tiles()).toHaveCount(1);
|
||||
await expect(newPanel.tile(PanelKind.PieChart)).toBeVisible();
|
||||
|
||||
await newPanel.search().fill('no-such-panel');
|
||||
await expect(newPanel.tiles()).toHaveCount(0);
|
||||
await expect(newPanel.emptyState()).toBeVisible();
|
||||
});
|
||||
|
||||
test('TC-11 a category chip filters to its group', async ({
|
||||
dashboards,
|
||||
newPanel,
|
||||
}) => {
|
||||
await dashboards.seedAndOpen(singlePanelDashboard());
|
||||
await dashboards.headerAddPanel().click();
|
||||
|
||||
await newPanel.category('Raw records').click();
|
||||
await expect(newPanel.groups()).toHaveText(['Raw records']);
|
||||
await expect(newPanel.tiles()).toHaveCount(
|
||||
PANEL_TYPE_TILES.filter((t) => t.category === 'Raw records').length,
|
||||
);
|
||||
|
||||
await newPanel.category('All').click();
|
||||
await expect(newPanel.tiles()).toHaveCount(PANEL_TYPE_TILES.length);
|
||||
});
|
||||
});
|
||||
142
tests/e2e/tests/dashboards/editor/query.spec.ts
Normal file
142
tests/e2e/tests/dashboards/editor/query.spec.ts
Normal file
@@ -0,0 +1,142 @@
|
||||
import {
|
||||
expect,
|
||||
GOLDEN,
|
||||
logsCountQuery,
|
||||
PanelKind,
|
||||
PATH,
|
||||
QueryTab,
|
||||
SINGLE_PANEL_ID,
|
||||
singlePanelDashboard,
|
||||
test,
|
||||
} from '../../../drivers/panel-editor';
|
||||
|
||||
// Scope: the query pane and its commit semantics.
|
||||
//
|
||||
// The rule worth pinning: editing the builder does NOT move the preview (only
|
||||
// Run, or a structural change, commits into the draft) — but Save serialises
|
||||
// the LIVE query anyway, so an unrun edit still persists. Contradictory-looking
|
||||
// and easy to "fix" into a regression.
|
||||
|
||||
test.describe('Dashboards — panel editor query pane', () => {
|
||||
test('TC-01 Run Query issues a fresh query_range', async ({
|
||||
dashboards,
|
||||
editor,
|
||||
}) => {
|
||||
await dashboards.seedAndEdit(singlePanelDashboard(), SINGLE_PANEL_ID);
|
||||
await expect(editor.preview.renderer(PanelKind.TimeSeries)).toBeVisible();
|
||||
|
||||
await editor.query.run();
|
||||
});
|
||||
|
||||
test('TC-02 the run keyboard shortcut works from inside the builder', async ({
|
||||
dashboards,
|
||||
editor,
|
||||
}) => {
|
||||
await dashboards.seedAndEdit(singlePanelDashboard(), SINGLE_PANEL_ID);
|
||||
await expect(editor.preview.renderer(PanelKind.TimeSeries)).toBeVisible();
|
||||
|
||||
// Bound with onKeyDownCapture, so it fires from inside inputs too.
|
||||
await editor.query.runShortcut();
|
||||
});
|
||||
|
||||
test('TC-03 switching query type re-renders the pane and marks the panel dirty', async ({
|
||||
dashboards,
|
||||
editor,
|
||||
}) => {
|
||||
await dashboards.seedAndEdit(singlePanelDashboard(), SINGLE_PANEL_ID);
|
||||
await expect(editor.unsavedBadge()).toHaveCount(0);
|
||||
|
||||
await editor.query.tab(QueryTab.promql).click();
|
||||
await expect(editor.query.tab(QueryTab.promql)).toHaveAttribute(
|
||||
'aria-selected',
|
||||
'true',
|
||||
);
|
||||
|
||||
// Structural change: auto-commits without Run.
|
||||
await expect(editor.unsavedBadge()).toBeVisible();
|
||||
});
|
||||
|
||||
test('TC-04 choosing a metric makes a new panel savable', async ({
|
||||
authedPage: page,
|
||||
dashboards,
|
||||
editor,
|
||||
newPanel,
|
||||
}) => {
|
||||
// create → configure → save. A new metrics panel seeds an empty
|
||||
// aggregation and is rejected until a metric is picked (picker TC-07).
|
||||
const id = await dashboards.seedAndOpen(singlePanelDashboard());
|
||||
|
||||
await dashboards.headerAddPanel().click();
|
||||
await newPanel.choose(PanelKind.TimeSeries);
|
||||
await expect(editor.root()).toBeVisible();
|
||||
|
||||
await editor.query.selectMetric(GOLDEN.metrics.calls);
|
||||
await editor.title().fill('Configured then saved');
|
||||
await editor.save();
|
||||
|
||||
await page.waitForURL(PATH.onDashboard(id));
|
||||
|
||||
const after = await dashboards.get(id);
|
||||
const saved = Object.values(after.spec.panels).find(
|
||||
(candidate) => candidate.spec.display.name === 'Configured then saved',
|
||||
);
|
||||
expect(saved).toBeDefined();
|
||||
expect(JSON.stringify(saved?.spec.queries)).toContain(GOLDEN.metrics.calls);
|
||||
});
|
||||
|
||||
test('TC-05 an unrun query edit is still persisted by Save', async ({
|
||||
dashboards,
|
||||
editor,
|
||||
}) => {
|
||||
const id = await dashboards.seedAndEdit(
|
||||
singlePanelDashboard(),
|
||||
SINGLE_PANEL_ID,
|
||||
);
|
||||
|
||||
// No Run: `buildSaveSpec` serialises the live query, so the edit must
|
||||
// survive — otherwise saving without running loses work.
|
||||
await editor.query.selectMetric(GOLDEN.metrics.latencyCount);
|
||||
await editor.save();
|
||||
|
||||
await expect
|
||||
.poll(async () => {
|
||||
const after = await dashboards.get(id);
|
||||
return JSON.stringify(
|
||||
after.spec.panels[SINGLE_PANEL_ID].spec.queries,
|
||||
).includes(GOLDEN.metrics.latencyCount);
|
||||
})
|
||||
.toBe(true);
|
||||
});
|
||||
|
||||
test('TC-06 the in-editor query survives a reload', async ({
|
||||
dashboards,
|
||||
editor,
|
||||
}) => {
|
||||
await dashboards.seedAndEdit(singlePanelDashboard(), SINGLE_PANEL_ID);
|
||||
|
||||
await editor.query.selectMetric(GOLDEN.metrics.latencySum);
|
||||
await editor.query.run();
|
||||
|
||||
// No forceReset, so the URL query wins and survives a refresh.
|
||||
await dashboards.reload();
|
||||
await expect(editor.root()).toBeVisible();
|
||||
await expect(editor.query.metricInput()).toHaveValue(
|
||||
GOLDEN.metrics.latencySum,
|
||||
);
|
||||
});
|
||||
|
||||
test('TC-07 a logs panel runs without needing a metric', async ({
|
||||
dashboards,
|
||||
editor,
|
||||
}) => {
|
||||
await dashboards.seedAndEdit(
|
||||
singlePanelDashboard({ query: logsCountQuery() }),
|
||||
SINGLE_PANEL_ID,
|
||||
);
|
||||
|
||||
// Logs aggregate by expression, so there's no metric to fill.
|
||||
await expect(editor.query.runButton()).toBeVisible();
|
||||
await editor.query.run();
|
||||
await editor.save();
|
||||
});
|
||||
});
|
||||
194
tests/e2e/tests/dashboards/editor/shell.spec.ts
Normal file
194
tests/e2e/tests/dashboards/editor/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 — 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();
|
||||
});
|
||||
});
|
||||
167
tests/e2e/tests/dashboards/editor/type-switch.spec.ts
Normal file
167
tests/e2e/tests/dashboards/editor/type-switch.spec.ts
Normal file
@@ -0,0 +1,167 @@
|
||||
import {
|
||||
expect,
|
||||
logsCountQuery,
|
||||
PanelKind,
|
||||
Section,
|
||||
SINGLE_PANEL_ID,
|
||||
singlePanelDashboard,
|
||||
test,
|
||||
} from '../../../drivers/panel-editor';
|
||||
|
||||
// Scope: switching kind mid-edit — what the session cache restores, and which
|
||||
// config survives a first-visit transfer. Config only carries when the TARGET
|
||||
// kind declares that control (buildPluginSpec).
|
||||
|
||||
test.describe('Dashboards — panel type switching', () => {
|
||||
test('TC-01 switching kind re-renders the preview', async ({
|
||||
dashboards,
|
||||
editor,
|
||||
}) => {
|
||||
await dashboards.seedAndEdit(singlePanelDashboard(), SINGLE_PANEL_ID);
|
||||
await expect(editor.preview.renderer(PanelKind.TimeSeries)).toBeVisible();
|
||||
|
||||
await editor.switchType(PanelKind.Table);
|
||||
await expect(editor.preview.renderer(PanelKind.Table)).toBeVisible();
|
||||
await expect(editor.preview.renderer(PanelKind.TimeSeries)).toHaveCount(0);
|
||||
});
|
||||
|
||||
test('TC-02 switching marks the editor dirty but does not persist', async ({
|
||||
dashboards,
|
||||
editor,
|
||||
}) => {
|
||||
const id = await dashboards.seedAndEdit(
|
||||
singlePanelDashboard(),
|
||||
SINGLE_PANEL_ID,
|
||||
);
|
||||
await editor.switchType(PanelKind.Number);
|
||||
|
||||
await expect(editor.unsavedBadge()).toBeVisible();
|
||||
const after = await dashboards.get(id);
|
||||
expect(after.spec.panels[SINGLE_PANEL_ID].spec.plugin.kind).toBe(
|
||||
PanelKind.TimeSeries,
|
||||
);
|
||||
});
|
||||
|
||||
test('TC-03 switching back restores the original kind and its settings from the session cache', async ({
|
||||
dashboards,
|
||||
editor,
|
||||
}) => {
|
||||
// Logs, because List rejects metrics; time_series-shaped, because Table
|
||||
// can't read `raw`.
|
||||
await dashboards.seedAndEdit(
|
||||
singlePanelDashboard({ kind: PanelKind.Table, query: logsCountQuery() }),
|
||||
SINGLE_PANEL_ID,
|
||||
);
|
||||
await expect(editor.preview.renderer(PanelKind.Table)).toBeVisible();
|
||||
await editor.section.expand(Section.formatting);
|
||||
await editor.setTile('decimals', '3');
|
||||
|
||||
// Not the default, so a freshly built Table would not have it.
|
||||
await editor.switchType(PanelKind.List);
|
||||
await expect(editor.preview.renderer(PanelKind.List)).toBeVisible();
|
||||
|
||||
await editor.switchType(PanelKind.Table);
|
||||
await expect(editor.preview.renderer(PanelKind.Table)).toBeVisible();
|
||||
await editor.section.expand(Section.formatting);
|
||||
await expect(editor.tileInput('decimals', '3')).toBeChecked();
|
||||
});
|
||||
|
||||
test('TC-04 the session cache does not survive a reload', async ({
|
||||
dashboards,
|
||||
editor,
|
||||
}) => {
|
||||
await dashboards.seedAndEdit(singlePanelDashboard(), SINGLE_PANEL_ID);
|
||||
await editor.switchType(PanelKind.Table);
|
||||
await expect(editor.preview.renderer(PanelKind.Table)).toBeVisible();
|
||||
|
||||
// The cache is a ref on the mounted editor, so a reload drops it. Reload
|
||||
// alone — a redundant goto afterwards crashed the page on WebKit.
|
||||
await dashboards.reload();
|
||||
await expect(editor.root()).toBeVisible();
|
||||
await expect(editor.preview.renderer(PanelKind.TimeSeries)).toBeVisible();
|
||||
});
|
||||
|
||||
test('TC-05 a saved switch persists the new kind', async ({
|
||||
dashboards,
|
||||
editor,
|
||||
}) => {
|
||||
const id = await dashboards.seedAndEdit(
|
||||
singlePanelDashboard(),
|
||||
SINGLE_PANEL_ID,
|
||||
);
|
||||
await editor.switchType(PanelKind.BarChart);
|
||||
await editor.save();
|
||||
|
||||
const after = await dashboards.get(id);
|
||||
expect(after.spec.panels[SINGLE_PANEL_ID].spec.plugin.kind).toBe(
|
||||
PanelKind.BarChart,
|
||||
);
|
||||
});
|
||||
|
||||
test('TC-06 a panel-wide unit fans out into per-column units on Table', async ({
|
||||
dashboards,
|
||||
editor,
|
||||
}) => {
|
||||
const id = await dashboards.seedAndEdit(
|
||||
singlePanelDashboard({ pluginSpec: { formatting: { unit: 'ms' } } }),
|
||||
SINGLE_PANEL_ID,
|
||||
);
|
||||
|
||||
await editor.switchType(PanelKind.Table);
|
||||
await editor.save();
|
||||
|
||||
// Table has no panel-wide unit, so it fans out to columns — one-way.
|
||||
const after = await dashboards.get(id);
|
||||
const formatting =
|
||||
after.spec.panels[SINGLE_PANEL_ID].spec.plugin.spec.formatting;
|
||||
expect(formatting?.unit).toBeUndefined();
|
||||
expect(Object.values(formatting?.columnUnits ?? {})).toContain('ms');
|
||||
});
|
||||
|
||||
test('TC-07 custom legend colours are dropped on a first-visit switch', async ({
|
||||
dashboards,
|
||||
editor,
|
||||
}) => {
|
||||
const id = await dashboards.seedAndEdit(
|
||||
singlePanelDashboard({
|
||||
pluginSpec: {
|
||||
legend: { position: 'bottom', customColors: { adservice: '#ff0000' } },
|
||||
},
|
||||
}),
|
||||
SINGLE_PANEL_ID,
|
||||
);
|
||||
|
||||
await editor.switchType(PanelKind.BarChart);
|
||||
await editor.save();
|
||||
|
||||
// Keyed by series label, which the new kind may not reproduce, so dropped.
|
||||
// Round-trips as null rather than being omitted.
|
||||
const after = await dashboards.get(id);
|
||||
const { customColors } =
|
||||
after.spec.panels[SINGLE_PANEL_ID].spec.plugin.spec.legend ?? {};
|
||||
expect(customColors ?? undefined).toBeUndefined();
|
||||
});
|
||||
|
||||
test('TC-08 axis bounds survive a switch between kinds that both declare them', async ({
|
||||
dashboards,
|
||||
editor,
|
||||
}) => {
|
||||
const id = await dashboards.seedAndEdit(
|
||||
singlePanelDashboard(),
|
||||
SINGLE_PANEL_ID,
|
||||
);
|
||||
|
||||
await editor.section.expand(Section.axes);
|
||||
await editor.control('soft-min').fill('5');
|
||||
await editor.control('soft-max').fill('50');
|
||||
|
||||
// Bar Chart also declares minMax, so the bounds must carry over.
|
||||
await editor.switchType(PanelKind.BarChart);
|
||||
await editor.save();
|
||||
|
||||
const after = await dashboards.get(id);
|
||||
const axes = after.spec.panels[SINGLE_PANEL_ID].spec.plugin.spec.axes;
|
||||
expect(axes?.softMin).toBe(5);
|
||||
expect(axes?.softMax).toBe(50);
|
||||
});
|
||||
});
|
||||
@@ -1,570 +0,0 @@
|
||||
import type { Page } from '@playwright/test';
|
||||
|
||||
import { expect, test } from '../../fixtures/auth';
|
||||
import { newAdminContext } from '../../helpers/auth';
|
||||
import {
|
||||
APM_METRICS_TITLE,
|
||||
authToken,
|
||||
createDashboardViaApi,
|
||||
DEFAULT_DASHBOARD_TITLE,
|
||||
deleteDashboardViaApi,
|
||||
findDashboardIdByTitle,
|
||||
gotoDashboardsList,
|
||||
importApmMetricsDashboardViaUI,
|
||||
openDashboardActionMenu,
|
||||
SEARCH_PLACEHOLDER,
|
||||
} from '../../helpers/dashboards';
|
||||
|
||||
// Tests in this file mutate the dashboard list (create / delete). Run them
|
||||
// serially within the worker so state from one test does not leak into
|
||||
// another's assertions. Files still run in parallel via the project-level
|
||||
// fullyParallel setting.
|
||||
test.describe.configure({ mode: 'serial' });
|
||||
|
||||
// ─── Suite-level seed registry ───────────────────────────────────────────
|
||||
//
|
||||
// Every dashboard a test creates is recorded here, and one `afterAll`
|
||||
// deletes the lot at suite teardown. Individual tests do not need their
|
||||
// own `try / finally` cleanup blocks.
|
||||
const seedIds = new Set<string>();
|
||||
const BASE_FIXTURE_TITLE = 'dashboards-list-base-fixture';
|
||||
|
||||
/** Seed a dashboard via API and register it for suite cleanup. */
|
||||
async function seed(page: Page, title: string): Promise<string> {
|
||||
const id = await createDashboardViaApi(page, title);
|
||||
seedIds.add(id);
|
||||
return id;
|
||||
}
|
||||
|
||||
test.beforeAll(async ({ browser }) => {
|
||||
// Persistent fixtures the read-only tests rely on:
|
||||
// - A minimal base dashboard — keeps the list non-empty so the search
|
||||
// input / sort button render. Seeded first via API so the workspace
|
||||
// is populated before the UI import flow runs.
|
||||
// - APM Metrics — a richer, real-world dashboard imported through the
|
||||
// real Import JSON UI flow (file upload + Monaco editor + submit).
|
||||
const ctx = await newAdminContext(browser);
|
||||
const page = await ctx.newPage();
|
||||
try {
|
||||
seedIds.add(await createDashboardViaApi(page, BASE_FIXTURE_TITLE));
|
||||
seedIds.add(await importApmMetricsDashboardViaUI(page));
|
||||
} finally {
|
||||
await ctx.close();
|
||||
}
|
||||
});
|
||||
|
||||
test.afterAll(async ({ browser }) => {
|
||||
if (seedIds.size === 0) {
|
||||
return;
|
||||
}
|
||||
const ctx = await newAdminContext(browser);
|
||||
const page = await ctx.newPage();
|
||||
try {
|
||||
const token = await authToken(page);
|
||||
for (const id of seedIds) {
|
||||
await deleteDashboardViaApi(ctx.request, id, token);
|
||||
seedIds.delete(id);
|
||||
}
|
||||
} finally {
|
||||
await ctx.close();
|
||||
}
|
||||
});
|
||||
|
||||
test.describe('Dashboards List Page', () => {
|
||||
// ─── Page load and layout ────────────────────────────────────────────────
|
||||
|
||||
test('TC-01 page chrome and core controls render', async ({
|
||||
authedPage: page,
|
||||
}) => {
|
||||
await gotoDashboardsList(page);
|
||||
|
||||
await expect(page).toHaveURL('/dashboard');
|
||||
await expect(page).toHaveTitle('SigNoz | All Dashboards');
|
||||
|
||||
await expect(
|
||||
page.getByRole('heading', { name: 'Dashboards', level: 1 }),
|
||||
).toBeVisible();
|
||||
await expect(
|
||||
page.getByText('Create and manage dashboards for your workspace.'),
|
||||
).toBeVisible();
|
||||
|
||||
await expect(page.getByPlaceholder(SEARCH_PLACEHOLDER)).toBeVisible();
|
||||
await expect(page.getByText('All Dashboards')).toBeVisible();
|
||||
await expect(page.getByTestId('sort-by')).toBeVisible();
|
||||
|
||||
await expect(page.getByAltText('dashboard-image').first()).toBeVisible();
|
||||
|
||||
await expect(page.getByRole('button', { name: 'Feedback' })).toBeVisible();
|
||||
await expect(page.getByRole('button', { name: 'Share' })).toBeVisible();
|
||||
});
|
||||
|
||||
// ─── Search functionality ────────────────────────────────────────────────
|
||||
|
||||
test('TC-02 search by title returns matching dashboard', async ({
|
||||
authedPage: page,
|
||||
}) => {
|
||||
const name = 'dashboards-list-search-title';
|
||||
await seed(page, name);
|
||||
|
||||
await gotoDashboardsList(page);
|
||||
const search = page.getByPlaceholder(SEARCH_PLACEHOLDER);
|
||||
|
||||
await search.fill(name);
|
||||
await expect(page).toHaveURL(new RegExp(`search=${name}`));
|
||||
await expect(search).toHaveValue(name);
|
||||
await expect(page.getByAltText('dashboard-image').first()).toBeVisible();
|
||||
await expect(page.getByText(name).first()).toBeVisible();
|
||||
});
|
||||
|
||||
test('TC-03 search by tag returns the APM Metrics dashboard', async ({
|
||||
authedPage: page,
|
||||
}) => {
|
||||
// APM Metrics carries multiple tags — searching by one of them ("apm")
|
||||
// surfaces the imported dashboard. This exercises the tag-match branch
|
||||
// in the filter, distinct from title-match.
|
||||
await gotoDashboardsList(page);
|
||||
const search = page.getByPlaceholder(SEARCH_PLACEHOLDER);
|
||||
|
||||
await search.fill('apm');
|
||||
await expect(page).toHaveURL(/search=apm/);
|
||||
await expect(page.getByText(APM_METRICS_TITLE).first()).toBeVisible();
|
||||
});
|
||||
|
||||
test('TC-04 direct navigation with ?search= pre-fills the input and filters results', async ({
|
||||
authedPage: page,
|
||||
}) => {
|
||||
const name = 'dashboards-list-search-deeplink';
|
||||
await seed(page, name);
|
||||
|
||||
await page.goto(`/dashboard?search=${name}`);
|
||||
await page
|
||||
.getByRole('heading', { name: 'Dashboards', level: 1 })
|
||||
.waitFor({ state: 'visible' });
|
||||
|
||||
await expect(page.getByPlaceholder(SEARCH_PLACEHOLDER)).toHaveValue(name);
|
||||
await expect(page.getByText(name).first()).toBeVisible();
|
||||
});
|
||||
|
||||
test('TC-05 clearing search restores the full list', async ({
|
||||
authedPage: page,
|
||||
}) => {
|
||||
await gotoDashboardsList(page);
|
||||
const search = page.getByPlaceholder(SEARCH_PLACEHOLDER);
|
||||
|
||||
await search.fill('apm');
|
||||
await expect(page).toHaveURL(/search=apm/);
|
||||
|
||||
await search.fill('');
|
||||
// The app keeps the empty `search=` param in the URL — assert that no
|
||||
// non-empty value remains and that rows are rendered again.
|
||||
await expect(page).not.toHaveURL(/search=[^&]/);
|
||||
await expect(search).toHaveValue('');
|
||||
await expect(page.getByAltText('dashboard-image').first()).toBeVisible();
|
||||
});
|
||||
|
||||
test('TC-06 search with no matching results shows empty state', async ({
|
||||
authedPage: page,
|
||||
}) => {
|
||||
await gotoDashboardsList(page);
|
||||
const search = page.getByPlaceholder(SEARCH_PLACEHOLDER);
|
||||
|
||||
await search.fill('xyznonexistent999');
|
||||
|
||||
await expect(page.getByAltText('dashboard-image')).toHaveCount(0);
|
||||
await expect(search).toBeVisible();
|
||||
await expect(search).toHaveValue('xyznonexistent999');
|
||||
});
|
||||
|
||||
test('TC-07 search is case-insensitive', async ({ authedPage: page }) => {
|
||||
await gotoDashboardsList(page);
|
||||
const search = page.getByPlaceholder(SEARCH_PLACEHOLDER);
|
||||
|
||||
await search.fill(APM_METRICS_TITLE.toLowerCase());
|
||||
await expect(page.getByAltText('dashboard-image').first()).toBeVisible();
|
||||
await expect(page.getByText(APM_METRICS_TITLE).first()).toBeVisible();
|
||||
});
|
||||
|
||||
// ─── Sorting ─────────────────────────────────────────────────────────────
|
||||
//
|
||||
// `sortHandle` in DashboardsList.tsx hard-codes `order: 'descend'` —
|
||||
// ascending mode is not yet implemented. Both sort options ride the same
|
||||
// descending-only path, so one parameterised test covers them.
|
||||
|
||||
test('TC-08 sort options write columnKey & order=descend to the URL', async ({
|
||||
authedPage: page,
|
||||
}) => {
|
||||
for (const [optionTestId, columnKey] of [
|
||||
['sort-by-last-updated', 'updatedAt'],
|
||||
['sort-by-last-created', 'createdAt'],
|
||||
] as const) {
|
||||
await gotoDashboardsList(page);
|
||||
await expect(page).not.toHaveURL(/columnKey/);
|
||||
|
||||
await page.getByTestId('sort-by').click();
|
||||
const option = page.getByTestId(optionTestId);
|
||||
await option.waitFor({ state: 'visible' });
|
||||
await option.click();
|
||||
|
||||
await expect(page).toHaveURL(new RegExp(`columnKey=${columnKey}`));
|
||||
await expect(page).toHaveURL(/order=descend/);
|
||||
await expect(page).not.toHaveURL(/order=ascend/);
|
||||
}
|
||||
});
|
||||
|
||||
// ─── Row actions (context menu) ──────────────────────────────────────────
|
||||
|
||||
test('TC-09 admin sees all five options in the action menu', async ({
|
||||
authedPage: page,
|
||||
}) => {
|
||||
const name = 'dashboards-list-actions-menu';
|
||||
await seed(page, name);
|
||||
|
||||
await gotoDashboardsList(page);
|
||||
const tooltip = await openDashboardActionMenu(page, name);
|
||||
await expect(tooltip).toBeVisible();
|
||||
|
||||
await expect(tooltip.getByRole('button', { name: 'View' })).toBeVisible();
|
||||
await expect(
|
||||
tooltip.getByRole('button', { name: 'Open in New Tab' }),
|
||||
).toBeVisible();
|
||||
await expect(
|
||||
tooltip.getByRole('button', { name: 'Copy Link' }),
|
||||
).toBeVisible();
|
||||
await expect(
|
||||
tooltip.getByRole('button', { name: 'Export JSON' }),
|
||||
).toBeVisible();
|
||||
// Delete is rendered as a generic, not a button.
|
||||
await expect(tooltip.getByText('Delete dashboard')).toBeVisible();
|
||||
});
|
||||
|
||||
test('TC-10 view action navigates to the dashboard detail page', async ({
|
||||
authedPage: page,
|
||||
}) => {
|
||||
const name = 'dashboards-list-action-view';
|
||||
await seed(page, name);
|
||||
|
||||
await gotoDashboardsList(page);
|
||||
const tooltip = await openDashboardActionMenu(page, name);
|
||||
await tooltip.getByRole('button', { name: 'View' }).click();
|
||||
|
||||
await expect(page).toHaveURL(/\/dashboard\/[0-9a-f-]+/);
|
||||
});
|
||||
|
||||
test('TC-11 open in new tab opens the dashboard in a new browser tab', async ({
|
||||
authedPage: page,
|
||||
}) => {
|
||||
const name = 'dashboards-list-action-newtab';
|
||||
await seed(page, name);
|
||||
|
||||
await gotoDashboardsList(page);
|
||||
const tooltip = await openDashboardActionMenu(page, name);
|
||||
|
||||
// Use page.context() — the auth fixture creates its own context per
|
||||
// test, which is not the same as the default `context` fixture.
|
||||
const [newPage] = await Promise.all([
|
||||
page.context().waitForEvent('page'),
|
||||
tooltip.getByRole('button', { name: 'Open in New Tab' }).click(),
|
||||
]);
|
||||
|
||||
await newPage.waitForLoadState();
|
||||
await expect(newPage).toHaveURL(/\/dashboard\/[0-9a-f-]+/);
|
||||
await newPage.close();
|
||||
});
|
||||
|
||||
test('TC-12 copy link copies the dashboard URL to the clipboard', async ({
|
||||
authedPage: page,
|
||||
}) => {
|
||||
const name = 'dashboards-list-action-copy';
|
||||
await seed(page, name);
|
||||
|
||||
await gotoDashboardsList(page);
|
||||
await page.context().grantPermissions(['clipboard-read', 'clipboard-write']);
|
||||
|
||||
const tooltip = await openDashboardActionMenu(page, name);
|
||||
await tooltip.getByRole('button', { name: 'Copy Link' }).click();
|
||||
|
||||
await expect(page.getByText(/copied|success/i)).toBeVisible();
|
||||
|
||||
const clipboardText = await page.evaluate(async () =>
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
(globalThis as any).navigator.clipboard.readText(),
|
||||
);
|
||||
expect(clipboardText).toMatch(/\/dashboard\/[0-9a-f-]+/);
|
||||
});
|
||||
|
||||
test('TC-13 export JSON downloads the dashboard as a JSON file', async ({
|
||||
authedPage: page,
|
||||
}) => {
|
||||
const name = 'dashboards-list-action-export';
|
||||
await seed(page, name);
|
||||
|
||||
await gotoDashboardsList(page);
|
||||
const tooltip = await openDashboardActionMenu(page, name);
|
||||
|
||||
const [download] = await Promise.all([
|
||||
page.waitForEvent('download'),
|
||||
tooltip.getByRole('button', { name: 'Export JSON' }).click(),
|
||||
]);
|
||||
|
||||
expect(download.suggestedFilename()).toMatch(/\.json$/);
|
||||
});
|
||||
|
||||
test('TC-14 action menu closes when clicking outside the popover', async ({
|
||||
authedPage: page,
|
||||
}) => {
|
||||
const name = 'dashboards-list-action-dismiss';
|
||||
await seed(page, name);
|
||||
|
||||
await gotoDashboardsList(page);
|
||||
await openDashboardActionMenu(page, name);
|
||||
await expect(page.getByRole('tooltip')).toBeVisible();
|
||||
|
||||
await page.getByRole('heading', { name: 'Dashboards', level: 1 }).click();
|
||||
await expect(page.getByRole('tooltip')).not.toBeVisible();
|
||||
|
||||
await expect(page).toHaveURL(/\/dashboard($|\?)/);
|
||||
});
|
||||
|
||||
// ─── Creating dashboards via "New dashboard" dropdown ─────────────────────
|
||||
//
|
||||
// The "Enter dashboard name…" inline input on the list page is a
|
||||
// `RequestDashboardBtn` (template-request feedback form), not a create
|
||||
// flow. The only UI create path is the "New dashboard" dropdown.
|
||||
|
||||
test('TC-15 New dashboard dropdown shows exactly three options', async ({
|
||||
authedPage: page,
|
||||
}) => {
|
||||
await gotoDashboardsList(page);
|
||||
await page.getByTestId('new-dashboard-cta').click();
|
||||
|
||||
const menu = page.getByRole('menu');
|
||||
await expect(menu).toBeVisible();
|
||||
await expect(menu.getByTestId('create-dashboard-menu-cta')).toBeVisible();
|
||||
await expect(menu.getByTestId('import-json-menu-cta')).toBeVisible();
|
||||
await expect(menu.getByTestId('view-templates-menu-cta')).toBeVisible();
|
||||
});
|
||||
|
||||
test('TC-16 Create dashboard dropdown option creates a dashboard with the default name', async ({
|
||||
authedPage: page,
|
||||
}) => {
|
||||
await gotoDashboardsList(page);
|
||||
await page.getByTestId('new-dashboard-cta').click();
|
||||
await page.getByTestId('create-dashboard-menu-cta').click();
|
||||
|
||||
await expect(page).toHaveURL(/\/dashboard\/[0-9a-f-]+/);
|
||||
await expect(page.getByText('Configure your new dashboard')).toBeVisible();
|
||||
// "Configure" appears twice on the new-dashboard onboarding state — once
|
||||
// in the toolbar and once in the empty-state section. The test only
|
||||
// needs to confirm the onboarding rendered, so .first() is sufficient.
|
||||
await expect(
|
||||
page.getByRole('button', { name: 'Configure' }).first(),
|
||||
).toBeVisible();
|
||||
await expect(
|
||||
page.getByRole('button', { name: /New Panel/ }).first(),
|
||||
).toBeVisible();
|
||||
|
||||
// Register the UI-created dashboard with the suite teardown. After a
|
||||
// successful "Create dashboard" the row must exist — assert that and
|
||||
// then unconditionally register, so the test contains no `if`.
|
||||
const sampleId = await findDashboardIdByTitle(page, DEFAULT_DASHBOARD_TITLE);
|
||||
expect(
|
||||
sampleId,
|
||||
`${DEFAULT_DASHBOARD_TITLE} not found after UI create`,
|
||||
).toBeDefined();
|
||||
seedIds.add(sampleId as string);
|
||||
});
|
||||
|
||||
test('TC-17 Import JSON dialog opens with code editor and upload button', async ({
|
||||
authedPage: page,
|
||||
}) => {
|
||||
await gotoDashboardsList(page);
|
||||
await page.getByTestId('new-dashboard-cta').click();
|
||||
await page.getByTestId('import-json-menu-cta').click();
|
||||
|
||||
const dialog = page.getByRole('dialog');
|
||||
await expect(dialog).toBeVisible();
|
||||
await expect(dialog.getByText('Import Dashboard JSON')).toBeVisible();
|
||||
// "Upload JSON file" appears twice — once as the Ant Upload's hidden
|
||||
// span wrapper, once as the visible button. .first() is enough to
|
||||
// confirm the upload affordance rendered.
|
||||
await expect(
|
||||
dialog.getByRole('button', { name: 'Upload JSON file' }).first(),
|
||||
).toBeVisible();
|
||||
await expect(
|
||||
dialog.getByRole('button', { name: 'Import and Next' }),
|
||||
).toBeVisible();
|
||||
});
|
||||
|
||||
test('TC-18 Import JSON dialog dismisses via Escape and via the close button', async ({
|
||||
authedPage: page,
|
||||
}) => {
|
||||
await gotoDashboardsList(page);
|
||||
|
||||
// Escape path — Monaco grabs focus on mount and swallows Escape; click
|
||||
// the modal title first to blur Monaco so Ant's Modal `keyboard`
|
||||
// handler picks up the keystroke.
|
||||
await page.getByTestId('new-dashboard-cta').click();
|
||||
await page.getByTestId('import-json-menu-cta').click();
|
||||
let dialog = page.getByRole('dialog');
|
||||
await expect(dialog).toBeVisible();
|
||||
await dialog.getByText('Import Dashboard JSON').click();
|
||||
await page.keyboard.press('Escape');
|
||||
await expect(dialog).not.toBeVisible();
|
||||
await expect(page).toHaveURL(/\/dashboard($|\?)/);
|
||||
|
||||
// Close-button path — re-open and dismiss via the X.
|
||||
await page.getByTestId('new-dashboard-cta').click();
|
||||
await page.getByTestId('import-json-menu-cta').click();
|
||||
dialog = page.getByRole('dialog');
|
||||
await expect(dialog).toBeVisible();
|
||||
await dialog.getByRole('button', { name: /close/i }).click();
|
||||
await expect(dialog).not.toBeVisible();
|
||||
await expect(page).toHaveURL(/\/dashboard($|\?)/);
|
||||
});
|
||||
|
||||
// ─── Deleting dashboards ─────────────────────────────────────────────────
|
||||
//
|
||||
// Known behaviour: clicking Cancel in the confirmation dialog navigates to
|
||||
// the dashboard detail page rather than staying on the list.
|
||||
|
||||
test('TC-19 delete confirmation dialog shows dashboard name with Cancel and Delete buttons', async ({
|
||||
authedPage: page,
|
||||
}) => {
|
||||
const name = 'dashboards-list-delete-confirm';
|
||||
await seed(page, name);
|
||||
|
||||
await gotoDashboardsList(page);
|
||||
const tooltip = await openDashboardActionMenu(page, name);
|
||||
// Ant's Popover can position the tooltip so the "Delete dashboard"
|
||||
// item ends up outside the viewport (especially in CI, where font
|
||||
// rendering shifts layout subtly). `click({ force: true })` skips
|
||||
// actionability checks but Playwright still requires the click
|
||||
// coordinates to land inside the viewport. `dispatchEvent('click')`
|
||||
// fires the synthetic event directly on the DOM node — React's
|
||||
// onClick handler runs normally — and bypasses coordinate checks
|
||||
// entirely. This is the robust fix for Ant Popover positioning.
|
||||
await tooltip.getByText('Delete dashboard').dispatchEvent('click');
|
||||
|
||||
const dialog = page.getByRole('dialog');
|
||||
await expect(dialog).toBeVisible();
|
||||
await expect(dialog.getByRole('heading')).toContainText(
|
||||
'Are you sure you want to delete the',
|
||||
);
|
||||
await expect(dialog.getByRole('heading')).toContainText(name);
|
||||
|
||||
await expect(dialog.getByRole('button', { name: 'Cancel' })).toBeVisible();
|
||||
await expect(dialog.getByRole('button', { name: 'Delete' })).toBeVisible();
|
||||
});
|
||||
|
||||
test('TC-20 cancelling delete navigates to the dashboard detail page (known behaviour)', async ({
|
||||
authedPage: page,
|
||||
}) => {
|
||||
const name = 'dashboards-list-delete-cancel';
|
||||
await seed(page, name);
|
||||
|
||||
await gotoDashboardsList(page);
|
||||
const tooltip = await openDashboardActionMenu(page, name);
|
||||
await tooltip.getByText('Delete dashboard').dispatchEvent('click');
|
||||
await expect(page.getByRole('dialog')).toBeVisible();
|
||||
|
||||
await page.getByRole('button', { name: 'Cancel' }).click();
|
||||
await expect(page).toHaveURL(/\/dashboard\/[0-9a-f-]+/);
|
||||
});
|
||||
|
||||
test('TC-21 confirming delete removes the dashboard from the list', async ({
|
||||
authedPage: page,
|
||||
}) => {
|
||||
const name = 'dashboards-list-delete-confirmed';
|
||||
const id = await seed(page, name);
|
||||
|
||||
await gotoDashboardsList(page);
|
||||
const tooltip = await openDashboardActionMenu(page, name);
|
||||
await tooltip.getByText('Delete dashboard').dispatchEvent('click');
|
||||
|
||||
const dialog = page.getByRole('dialog');
|
||||
await expect(dialog).toBeVisible();
|
||||
|
||||
// The Delete mutation is async — wait for the API response *and* the
|
||||
// dialog to dismiss before navigating away, otherwise React Query's
|
||||
// in-flight mutation gets cancelled by the navigation.
|
||||
const deleteResponse = page.waitForResponse(
|
||||
(r) => r.request().method() === 'DELETE' && /\/dashboards\//.test(r.url()),
|
||||
);
|
||||
await dialog.getByRole('button', { name: 'Delete' }).click();
|
||||
await deleteResponse;
|
||||
await expect(dialog).not.toBeVisible();
|
||||
|
||||
// After deletion, searching for the name should return no results.
|
||||
await gotoDashboardsList(page);
|
||||
await page.getByPlaceholder(SEARCH_PLACEHOLDER).fill(name);
|
||||
await expect(page.getByAltText('dashboard-image')).toHaveCount(0);
|
||||
|
||||
// The UI delete already removed the resource — drop it from the
|
||||
// suite-cleanup set so afterAll doesn't 404 on it.
|
||||
seedIds.delete(id);
|
||||
});
|
||||
|
||||
// ─── Row click navigation ────────────────────────────────────────────────
|
||||
|
||||
test('TC-22 clicking a dashboard row navigates to the detail page', async ({
|
||||
authedPage: page,
|
||||
}) => {
|
||||
const name = 'dashboards-list-row-click';
|
||||
await seed(page, name);
|
||||
|
||||
await gotoDashboardsList(page);
|
||||
await page.getByPlaceholder(SEARCH_PLACEHOLDER).fill(name);
|
||||
|
||||
await page.getByAltText('dashboard-image').first().click();
|
||||
|
||||
await expect(page).toHaveURL(/\/dashboard\/[0-9a-f-]+/);
|
||||
});
|
||||
|
||||
test('TC-23 sidebar Dashboards link navigates to the list page', async ({
|
||||
authedPage: page,
|
||||
}) => {
|
||||
await page.goto('/home');
|
||||
// Sidebar items are <div class="nav-item"> with the label as visible
|
||||
// text — they're not <a role="link">, so getByRole won't reach them.
|
||||
// Filter on the exact label to avoid matching nested items that
|
||||
// happen to contain the substring.
|
||||
await page
|
||||
.locator('.nav-item')
|
||||
.filter({ hasText: /^Dashboards$/ })
|
||||
.click();
|
||||
await expect(page).toHaveURL(/\/dashboard/);
|
||||
await expect(page).toHaveTitle('SigNoz | All Dashboards');
|
||||
});
|
||||
|
||||
// ─── URL state and deep linking ──────────────────────────────────────────
|
||||
|
||||
test('TC-24 browser Back after navigating to a dashboard restores search state', async ({
|
||||
authedPage: page,
|
||||
}) => {
|
||||
const name = 'dashboards-list-back-search';
|
||||
await seed(page, name);
|
||||
|
||||
await page.goto(`/dashboard?search=${name}`);
|
||||
await page
|
||||
.getByRole('heading', { name: 'Dashboards', level: 1 })
|
||||
.waitFor({ state: 'visible' });
|
||||
|
||||
await page.getByAltText('dashboard-image').first().click();
|
||||
await expect(page).toHaveURL(/\/dashboard\/[0-9a-f-]+/);
|
||||
|
||||
await page.goBack();
|
||||
await expect(page).toHaveURL(new RegExp(`search=${name}`));
|
||||
await expect(page.getByPlaceholder(SEARCH_PLACEHOLDER)).toHaveValue(name);
|
||||
});
|
||||
|
||||
test('TC-25 direct navigation with sort params honours them on load', async ({
|
||||
authedPage: page,
|
||||
}) => {
|
||||
await page.goto('/dashboard?columnKey=updatedAt&order=descend');
|
||||
await page
|
||||
.getByRole('heading', { name: 'Dashboards', level: 1 })
|
||||
.waitFor({ state: 'visible' });
|
||||
await expect(page).toHaveURL(/columnKey=updatedAt/);
|
||||
await expect(page).toHaveURL(/order=descend/);
|
||||
});
|
||||
});
|
||||
166
tests/e2e/tests/dashboards/page/actions.spec.ts
Normal file
166
tests/e2e/tests/dashboards/page/actions.spec.ts
Normal file
@@ -0,0 +1,166 @@
|
||||
import {
|
||||
COMPACT_PANELS,
|
||||
PanelAction,
|
||||
PanelKind,
|
||||
QueryRange,
|
||||
SINGLE_PANEL_ID,
|
||||
compactDashboard,
|
||||
expect,
|
||||
singlePanelDashboard,
|
||||
test,
|
||||
} from '../../../drivers/panels';
|
||||
|
||||
// Scope: what the panel ⋮ menu's actions do — new-tab alert, download, and the
|
||||
// mutating ones reaching the spec — plus the dashboard lock gating them.
|
||||
//
|
||||
// Items carry no testid, so everything matches role + visible label.
|
||||
|
||||
test.describe('Dashboards — panel actions', () => {
|
||||
test('TC-01 Create Alerts opens the alert builder in a new tab', async ({
|
||||
dashboards,
|
||||
panels,
|
||||
}) => {
|
||||
await dashboards.seedAndOpen(compactDashboard());
|
||||
|
||||
const popup = panels.nextTab();
|
||||
await panels
|
||||
.panel(COMPACT_PANELS.timeseries)
|
||||
.actions.run(PanelAction.createAlert);
|
||||
const alertTab = await popup;
|
||||
await expect(alertTab).toHaveURL(/\/alerts\/new/);
|
||||
await alertTab.close();
|
||||
});
|
||||
|
||||
// Chromium-only: headless Firefox/WebKit don't surface the canvas blob as a
|
||||
// Playwright download event. CSV and SVG are unaffected.
|
||||
test('TC-02 Download as PNG produces a file', async ({
|
||||
dashboards,
|
||||
panels,
|
||||
browserName,
|
||||
}) => {
|
||||
test.skip(
|
||||
browserName !== 'chromium',
|
||||
'headless Firefox/WebKit do not emit a download event for the canvas blob',
|
||||
);
|
||||
await dashboards.seedAndOpen(compactDashboard());
|
||||
const target = panels.panel(COMPACT_PANELS.timeseries);
|
||||
await target.scrollIntoView();
|
||||
|
||||
const download = panels.nextDownload();
|
||||
await target.actions.download('PNG');
|
||||
const file = await download;
|
||||
expect(file.suggestedFilename()).toMatch(/\.png$/);
|
||||
});
|
||||
|
||||
test('TC-03 Clone adds a second panel and persists it', async ({
|
||||
dashboards,
|
||||
panels,
|
||||
}) => {
|
||||
const id = await dashboards.seedAndOpen(compactDashboard());
|
||||
|
||||
const before = await dashboards.get(id);
|
||||
const beforeCount = Object.keys(before.spec.panels).length;
|
||||
|
||||
await panels.panel(COMPACT_PANELS.timeseries).actions.run(PanelAction.clone);
|
||||
|
||||
await expect
|
||||
.poll(async () => Object.keys((await dashboards.get(id)).spec.panels).length)
|
||||
.toBe(beforeCount + 1);
|
||||
});
|
||||
|
||||
test('TC-04 Delete panel removes the panel and its layout item', async ({
|
||||
dashboards,
|
||||
panels,
|
||||
}) => {
|
||||
const id = await dashboards.seedAndOpen(compactDashboard());
|
||||
|
||||
const list = panels.panel(COMPACT_PANELS.list);
|
||||
await list.actions.run(PanelAction.delete);
|
||||
await expect(dashboards.text('Delete panel?')).toBeVisible();
|
||||
await dashboards.confirmDelete().click();
|
||||
|
||||
await expect(list.root()).toHaveCount(0);
|
||||
|
||||
// Optimistic: the panel leaves the DOM before the PATCH lands.
|
||||
await expect
|
||||
.poll(
|
||||
async () => (await dashboards.get(id)).spec.panels[COMPACT_PANELS.list],
|
||||
)
|
||||
.toBeUndefined();
|
||||
|
||||
// The grid item must go too, or a dangling $ref renders an empty tile.
|
||||
const after = await dashboards.get(id);
|
||||
const refs = after.spec.layouts.flatMap((layout) =>
|
||||
layout.spec.items.map((item) => item.content.$ref),
|
||||
);
|
||||
expect(refs).not.toContain(`#/spec/panels/${COMPACT_PANELS.list}`);
|
||||
});
|
||||
|
||||
test('TC-05 Move to section relocates the panel between sections', async ({
|
||||
dashboards,
|
||||
panels,
|
||||
}) => {
|
||||
const id = await dashboards.seedAndOpen(compactDashboard());
|
||||
|
||||
await panels.panel(COMPACT_PANELS.timeseries).actions.moveTo('Tabular');
|
||||
|
||||
const ref = `#/spec/panels/${COMPACT_PANELS.timeseries}`;
|
||||
await expect
|
||||
.poll(async () => {
|
||||
const { spec } = await dashboards.get(id);
|
||||
return spec.layouts
|
||||
.filter((layout) =>
|
||||
layout.spec.items.some((item) => item.content.$ref === ref),
|
||||
)
|
||||
.map((layout) => layout.spec.display.title);
|
||||
})
|
||||
.toEqual(['Tabular']);
|
||||
});
|
||||
|
||||
test('TC-06 a locked dashboard disables the mutating actions', async ({
|
||||
dashboards,
|
||||
panels,
|
||||
}) => {
|
||||
const id = await dashboards.seed(compactDashboard());
|
||||
await dashboards.setLocked(id, true);
|
||||
await dashboards.open(id);
|
||||
|
||||
const { actions } = panels.panel(COMPACT_PANELS.timeseries);
|
||||
await actions.open();
|
||||
|
||||
// View and Download don't mutate, so they stay available.
|
||||
await expect(actions.item(PanelAction.view)).toBeEnabled();
|
||||
|
||||
for (const label of [
|
||||
PanelAction.edit,
|
||||
PanelAction.clone,
|
||||
PanelAction.move,
|
||||
PanelAction.delete,
|
||||
]) {
|
||||
await expect(actions.item(label)).toBeDisabled();
|
||||
}
|
||||
});
|
||||
|
||||
// An empty table exports nothing, so the rows are mocked.
|
||||
test('TC-07 Download as CSV on a Table panel produces a file', async ({
|
||||
dashboards,
|
||||
panels,
|
||||
queryRange,
|
||||
}) => {
|
||||
await queryRange.respond(
|
||||
QueryRange.scalar({
|
||||
groupColumns: ['service.name'],
|
||||
aggregationColumns: ['A'],
|
||||
rows: [['adservice', 10]],
|
||||
}),
|
||||
);
|
||||
await dashboards.seedAndOpen(singlePanelDashboard({ kind: PanelKind.Table }));
|
||||
const table = panels.panel(SINGLE_PANEL_ID);
|
||||
await table.waitRendered(PanelKind.Table);
|
||||
|
||||
const download = panels.nextDownload();
|
||||
await table.actions.download('CSV');
|
||||
const file = await download;
|
||||
expect(file.suggestedFilename()).toMatch(/\.csv$/);
|
||||
});
|
||||
});
|
||||
207
tests/e2e/tests/dashboards/page/drilldown.spec.ts
Normal file
207
tests/e2e/tests/dashboards/page/drilldown.spec.ts
Normal file
@@ -0,0 +1,207 @@
|
||||
import {
|
||||
type Dashboards,
|
||||
PanelKind,
|
||||
type Panels,
|
||||
QUERY_TYPE_PANELS,
|
||||
QueryRange,
|
||||
type QueryRangeMock,
|
||||
SINGLE_PANEL_ID,
|
||||
VARIABLE_NAMES,
|
||||
VARIABLE_PANEL_ID,
|
||||
expect,
|
||||
metricsQuery,
|
||||
queryTypesDashboard,
|
||||
ramp,
|
||||
singlePanelDashboard,
|
||||
test,
|
||||
variablesDashboard,
|
||||
} from '../../../drivers/panels';
|
||||
|
||||
// Scope: the drilldown ContextMenu — dismissal, navigation, variables, and
|
||||
// the query types deliberately excluded. Mocked so a click lands on a known series.
|
||||
|
||||
const ONE_SERIES = QueryRange.timeSeries([
|
||||
{ labels: { 'service.name': 'adservice' }, points: ramp(12, 2, 8) },
|
||||
]);
|
||||
|
||||
async function openChartDrilldown(
|
||||
panels: Panels,
|
||||
panelId: string,
|
||||
): Promise<void> {
|
||||
await panels.panel(panelId).chart.clickPoint();
|
||||
await expect(panels.drilldown.menu()).toBeVisible();
|
||||
}
|
||||
|
||||
async function seedChartPanel({
|
||||
dashboards,
|
||||
queryRange,
|
||||
panels,
|
||||
}: {
|
||||
dashboards: Dashboards;
|
||||
queryRange: QueryRangeMock;
|
||||
panels: Panels;
|
||||
}): Promise<string> {
|
||||
await queryRange.respond(ONE_SERIES);
|
||||
const id = await dashboards.seedAndOpen(singlePanelDashboard());
|
||||
await expect(
|
||||
panels.panel(SINGLE_PANEL_ID).renderer(PanelKind.TimeSeries),
|
||||
).toBeVisible();
|
||||
return id;
|
||||
}
|
||||
|
||||
test.describe('Dashboards — panel drilldown', () => {
|
||||
test('TC-01 clicking the backdrop closes the menu', async ({
|
||||
dashboards,
|
||||
queryRange,
|
||||
panels,
|
||||
}) => {
|
||||
await seedChartPanel({ dashboards, queryRange, panels });
|
||||
await openChartDrilldown(panels, SINGLE_PANEL_ID);
|
||||
|
||||
// Backdrop click. Escape only works when the backdrop holds focus.
|
||||
await panels.drilldown.dismiss();
|
||||
await expect(panels.drilldown.menu()).toHaveCount(0);
|
||||
});
|
||||
|
||||
test('TC-02 View in Logs navigates to the logs explorer', async ({
|
||||
authedPage: page,
|
||||
dashboards,
|
||||
queryRange,
|
||||
panels,
|
||||
}) => {
|
||||
await seedChartPanel({ dashboards, queryRange, panels });
|
||||
await openChartDrilldown(panels, SINGLE_PANEL_ID);
|
||||
|
||||
// Disabled while the drilldown query resolves.
|
||||
const viewLogs = panels.drilldown.item('drilldown-view-logs');
|
||||
await expect(viewLogs).toBeEnabled();
|
||||
|
||||
// safeNavigate uses `{ newTab: true }`, so the dashboard stays put.
|
||||
const popup = panels.nextTab();
|
||||
await viewLogs.click();
|
||||
const logsTab = await popup;
|
||||
|
||||
await logsTab.waitForURL(/\/logs\/logs-explorer/);
|
||||
// The clicked series carries across as a composite query.
|
||||
expect(
|
||||
new URL(logsTab.url()).searchParams.get('compositeQuery'),
|
||||
).toBeTruthy();
|
||||
await logsTab.close();
|
||||
|
||||
await expect(page).toHaveURL(/\/dashboard\//);
|
||||
});
|
||||
|
||||
test('TC-03 Breakout opens a submenu and the back arrow returns', async ({
|
||||
dashboards,
|
||||
queryRange,
|
||||
panels,
|
||||
}) => {
|
||||
await seedChartPanel({ dashboards, queryRange, panels });
|
||||
await openChartDrilldown(panels, SINGLE_PANEL_ID);
|
||||
|
||||
await panels.drilldown.item('drilldown-breakout').click();
|
||||
const back = panels.drilldown.back('breakout');
|
||||
await expect(back).toBeVisible();
|
||||
await expect(panels.drilldown.breakoutSearch()).toBeVisible();
|
||||
|
||||
// Back returns to the aggregate menu.
|
||||
await back.click();
|
||||
await expect(panels.drilldown.item('drilldown-view-logs')).toBeVisible();
|
||||
});
|
||||
|
||||
test('TC-04 the Dashboard Variables submenu offers set and create', async ({
|
||||
dashboards,
|
||||
queryRange,
|
||||
panels,
|
||||
}) => {
|
||||
await queryRange.respond(ONE_SERIES);
|
||||
await dashboards.seedAndOpen(variablesDashboard());
|
||||
await expect(
|
||||
panels.panel(VARIABLE_PANEL_ID).renderer(PanelKind.TimeSeries),
|
||||
).toBeVisible();
|
||||
|
||||
await openChartDrilldown(panels, VARIABLE_PANEL_ID);
|
||||
await panels.drilldown.item('drilldown-dashboard-variables').click();
|
||||
|
||||
// `service.name` is grouped-by and already has a variable, so Set is offered.
|
||||
await expect(panels.drilldown.item('drilldown-var-set')).toBeVisible();
|
||||
await expect(panels.drilldown.back('var')).toBeVisible();
|
||||
});
|
||||
|
||||
test('TC-05 setting a variable from the menu updates the variables bar', async ({
|
||||
dashboards,
|
||||
queryRange,
|
||||
panels,
|
||||
}) => {
|
||||
await queryRange.respond(ONE_SERIES);
|
||||
await dashboards.seedAndOpen(variablesDashboard());
|
||||
await expect(
|
||||
panels.panel(VARIABLE_PANEL_ID).renderer(PanelKind.TimeSeries),
|
||||
).toBeVisible();
|
||||
|
||||
// Set binds the clicked group-by field, so it targets the dynamic
|
||||
// variable on `service.name`; the custom one already defaults to adservice.
|
||||
const dynamic = dashboards.variable(VARIABLE_NAMES.dynamic);
|
||||
await expect(dynamic).not.toContainText('adservice');
|
||||
|
||||
await openChartDrilldown(panels, VARIABLE_PANEL_ID);
|
||||
await panels.drilldown.item('drilldown-dashboard-variables').click();
|
||||
await panels.drilldown.item('drilldown-var-set').click();
|
||||
|
||||
await expect(dynamic).toContainText('adservice');
|
||||
});
|
||||
|
||||
test('TC-06 creating a variable from the menu patches the dashboard spec', async ({
|
||||
dashboards,
|
||||
queryRange,
|
||||
panels,
|
||||
}) => {
|
||||
await queryRange.respond(
|
||||
QueryRange.timeSeries([
|
||||
{
|
||||
labels: { 'k8s.namespace.name': 'signoz-adservice' },
|
||||
points: ramp(12, 2, 8),
|
||||
},
|
||||
]),
|
||||
);
|
||||
// No matching variable for this field, so the menu offers Create.
|
||||
const id = await dashboards.seedAndOpen(
|
||||
variablesDashboard(
|
||||
undefined,
|
||||
metricsQuery({ groupBy: ['k8s.namespace.name'] }),
|
||||
),
|
||||
);
|
||||
await expect(
|
||||
panels.panel(VARIABLE_PANEL_ID).renderer(PanelKind.TimeSeries),
|
||||
).toBeVisible();
|
||||
|
||||
await openChartDrilldown(panels, VARIABLE_PANEL_ID);
|
||||
await panels.drilldown.item('drilldown-dashboard-variables').click();
|
||||
await panels.drilldown.item('drilldown-var-create').click();
|
||||
|
||||
// Create persists a DYNAMIC variable into spec.variables.
|
||||
await expect
|
||||
.poll(async () =>
|
||||
(await dashboards.get(id)).spec.variables.some(
|
||||
(variable) => variable.spec.name === 'k8s.namespace.name',
|
||||
),
|
||||
)
|
||||
.toBe(true);
|
||||
});
|
||||
|
||||
test('TC-07 a non-builder query opens no menu', async ({
|
||||
dashboards,
|
||||
queryRange,
|
||||
panels,
|
||||
}) => {
|
||||
await queryRange.respond(ONE_SERIES);
|
||||
await dashboards.seedAndOpen(queryTypesDashboard());
|
||||
|
||||
const target = panels.panel(QUERY_TYPE_PANELS.promql);
|
||||
await expect(target.renderer(PanelKind.TimeSeries)).toBeVisible();
|
||||
|
||||
// Gated to QUERY_BUILDER queries.
|
||||
await target.chart.click();
|
||||
await expect(panels.drilldown.menu()).toHaveCount(0);
|
||||
});
|
||||
});
|
||||
122
tests/e2e/tests/dashboards/page/edge-cases.spec.ts
Normal file
122
tests/e2e/tests/dashboards/page/edge-cases.spec.ts
Normal file
@@ -0,0 +1,122 @@
|
||||
import {
|
||||
SINGLE_PANEL_ID,
|
||||
VARIABLE_NAMES,
|
||||
compactDashboard,
|
||||
expect,
|
||||
singlePanelDashboard,
|
||||
test,
|
||||
variablesDashboard,
|
||||
} from '../../../drivers/dashboard-page';
|
||||
|
||||
const MISSING_ID = 'nonexistent-id-99999';
|
||||
|
||||
// 2023-01-01, one minute: long before any golden telemetry.
|
||||
const FAR_PAST = '?startTime=1672531200000&endTime=1672531260000';
|
||||
|
||||
test.describe('Dashboards — edge cases', () => {
|
||||
test('TC-01 panels show their empty state for a far-past range', async ({
|
||||
dashboards,
|
||||
dashboardPage,
|
||||
panels,
|
||||
}) => {
|
||||
const collected = dashboardPage.collectPageErrors();
|
||||
const id = await dashboards.seed(singlePanelDashboard());
|
||||
|
||||
await dashboards.visit(`/dashboard/${id}${FAR_PAST}`);
|
||||
|
||||
const target = panels.panel(SINGLE_PANEL_ID);
|
||||
await target.scrollIntoView();
|
||||
await expect(target.noData()).toBeVisible();
|
||||
expect(collected.errors).toEqual([]);
|
||||
});
|
||||
|
||||
test('TC-02 a nonexistent dashboard id shows a load error', async ({
|
||||
dashboards,
|
||||
dashboardPage,
|
||||
}) => {
|
||||
await dashboards.visit(`/dashboard/${MISSING_ID}`);
|
||||
|
||||
// The fetch retries before giving up.
|
||||
await expect(dashboardPage.loadError()).toBeVisible({ timeout: 20_000 });
|
||||
await expect(dashboardPage.breadcrumb()).toHaveCount(0);
|
||||
});
|
||||
|
||||
test('TC-03 the side nav still works after a nonexistent dashboard', async ({
|
||||
dashboards,
|
||||
dashboardPage,
|
||||
authedPage: page,
|
||||
}) => {
|
||||
await dashboards.visit(`/dashboard/${MISSING_ID}`);
|
||||
await expect(dashboardPage.loadError()).toBeVisible({ timeout: 20_000 });
|
||||
|
||||
await dashboardPage.navigateSideNav('Dashboards');
|
||||
|
||||
await expect(page).toHaveURL(/\/dashboard(\?|$)/);
|
||||
await expect(dashboardPage.listPage()).toBeVisible();
|
||||
});
|
||||
|
||||
// `?variables=` is read once into the persisted selection and then dropped
|
||||
// from the URL, so the reload proves the selection, not the param, survives.
|
||||
// Fixme: the param is dropped but the selection keeps its default.
|
||||
test.fixme('TC-04 a variable deep link survives a reload', async ({
|
||||
dashboards,
|
||||
dashboardPage,
|
||||
authedPage: page,
|
||||
}) => {
|
||||
const id = await dashboards.seed(variablesDashboard());
|
||||
const value = 'cartservice';
|
||||
const deepLink = new URLSearchParams({
|
||||
variables: JSON.stringify({ [VARIABLE_NAMES.custom]: value }),
|
||||
});
|
||||
|
||||
await dashboards.visit(`/dashboard/${id}?${deepLink.toString()}`);
|
||||
|
||||
const selector = dashboardPage.variableValue(VARIABLE_NAMES.custom);
|
||||
await expect(selector).toContainText(value);
|
||||
await expect(page).not.toHaveURL(/variables=/);
|
||||
|
||||
await dashboards.reload();
|
||||
|
||||
await expect(selector).toContainText(value);
|
||||
});
|
||||
|
||||
test('TC-05 an inverted time range does not crash the dashboard', async ({
|
||||
dashboards,
|
||||
dashboardPage,
|
||||
panels,
|
||||
}) => {
|
||||
const collected = dashboardPage.collectPageErrors();
|
||||
const fixture = singlePanelDashboard();
|
||||
const id = await dashboards.seed(fixture);
|
||||
|
||||
await dashboards.visit(`/dashboard/${id}?startTime=999999&endTime=999998`);
|
||||
|
||||
await expect(dashboardPage.title()).toHaveText(fixture.spec.display.name);
|
||||
await expect(panels.panel(SINGLE_PANEL_ID).title()).toBeVisible();
|
||||
expect(collected.errors).toEqual([]);
|
||||
});
|
||||
|
||||
test('TC-06 a name at the 128-character limit keeps the toolbar on screen', async ({
|
||||
dashboards,
|
||||
dashboardPage,
|
||||
}) => {
|
||||
const longName = `LongName-${'x'.repeat(119)}`;
|
||||
await dashboards.seedAndOpen(compactDashboard(longName));
|
||||
|
||||
await expect(dashboardPage.title()).toHaveText(longName);
|
||||
await expect(dashboardPage.toolbar.configure()).toBeInViewport();
|
||||
await expect(dashboardPage.toolbar.newPanel()).toBeInViewport();
|
||||
});
|
||||
|
||||
test('TC-07 special characters in the name round-trip unescaped', async ({
|
||||
dashboards,
|
||||
dashboardPage,
|
||||
}) => {
|
||||
const trickyName = `Spec & Chars / "${Date.now()}" — émoji 🎯`;
|
||||
await dashboards.seedAndOpen(compactDashboard(trickyName));
|
||||
|
||||
await expect(dashboardPage.title()).toHaveText(trickyName);
|
||||
await expect(dashboardPage.breadcrumb()).toContainText(trickyName);
|
||||
await expect.poll(() => dashboardPage.documentTitle()).toBe(trickyName);
|
||||
});
|
||||
});
|
||||
186
tests/e2e/tests/dashboards/page/edit-mode.spec.ts
Normal file
186
tests/e2e/tests/dashboards/page/edit-mode.spec.ts
Normal file
@@ -0,0 +1,186 @@
|
||||
import {
|
||||
DashboardAction,
|
||||
compactDashboard,
|
||||
expect,
|
||||
test,
|
||||
} from '../../../drivers/dashboard-page';
|
||||
|
||||
// Export and copy moved from the Actions menu into the JSON drawer.
|
||||
|
||||
test.describe('Dashboards — edit mode', () => {
|
||||
test('TC-01 the Actions menu lists every dashboard action', async ({
|
||||
dashboards,
|
||||
dashboardPage,
|
||||
}) => {
|
||||
await dashboards.seedAndOpen(compactDashboard());
|
||||
|
||||
await dashboardPage.actions.open();
|
||||
|
||||
for (const action of Object.values(DashboardAction)) {
|
||||
await expect(dashboardPage.actions.item(action)).toBeEnabled();
|
||||
}
|
||||
await expect(dashboardPage.actions.item(DashboardAction.lock)).toHaveText(
|
||||
'Lock dashboard',
|
||||
);
|
||||
});
|
||||
|
||||
test('TC-02 locking disables the edit affordances and persists', async ({
|
||||
dashboards,
|
||||
dashboardPage,
|
||||
}) => {
|
||||
const id = await dashboards.seedAndOpen(compactDashboard());
|
||||
|
||||
await dashboardPage.lock();
|
||||
|
||||
await expect(dashboardPage.toolbar.configure()).toBeDisabled();
|
||||
await expect(dashboardPage.toolbar.newPanel()).toBeDisabled();
|
||||
await dashboardPage.actions.open();
|
||||
for (const action of [
|
||||
DashboardAction.rename,
|
||||
DashboardAction.newSection,
|
||||
DashboardAction.delete,
|
||||
]) {
|
||||
await expect(dashboardPage.actions.item(action)).toBeDisabled();
|
||||
}
|
||||
await expect.poll(async () => (await dashboards.get(id)).locked).toBe(true);
|
||||
});
|
||||
|
||||
test('TC-03 renaming updates the title and breadcrumb', async ({
|
||||
dashboards,
|
||||
dashboardPage,
|
||||
}) => {
|
||||
const id = await dashboards.seedAndOpen(compactDashboard());
|
||||
const renamed = `Renamed-${Date.now()}`;
|
||||
|
||||
await dashboardPage.rename.to(renamed);
|
||||
|
||||
await expect(dashboardPage.breadcrumb()).toContainText(renamed);
|
||||
await expect
|
||||
.poll(async () => (await dashboards.get(id)).spec.display.name)
|
||||
.toBe(renamed);
|
||||
});
|
||||
|
||||
test('TC-04 cancelling a rename leaves the name unchanged', async ({
|
||||
dashboards,
|
||||
dashboardPage,
|
||||
}) => {
|
||||
const fixture = compactDashboard();
|
||||
const original = fixture.spec.display.name;
|
||||
await dashboards.seedAndOpen(fixture);
|
||||
|
||||
await dashboardPage.actions.run(DashboardAction.rename);
|
||||
await dashboardPage.rename.input().fill('Should Not Be Saved');
|
||||
await dashboardPage.rename.cancel().click();
|
||||
|
||||
await expect(dashboardPage.title()).toHaveText(original);
|
||||
await expect(dashboardPage.breadcrumb()).toContainText(original);
|
||||
});
|
||||
|
||||
test('TC-05 a section added from the Actions menu can be removed', async ({
|
||||
dashboards,
|
||||
dashboardPage,
|
||||
}) => {
|
||||
await dashboards.seedAndOpen(compactDashboard());
|
||||
const title = `e2e-section-${Date.now()}`;
|
||||
|
||||
await dashboardPage.addSection(title);
|
||||
await dashboardPage.section(title).delete();
|
||||
|
||||
await expect(dashboardPage.section(title).root()).toHaveCount(0);
|
||||
});
|
||||
|
||||
test('TC-06 the JSON drawer downloads the dashboard as .json', async ({
|
||||
dashboards,
|
||||
dashboardPage,
|
||||
}) => {
|
||||
await dashboards.seedAndOpen(compactDashboard());
|
||||
|
||||
await dashboardPage.json.open();
|
||||
|
||||
expect(await dashboardPage.json.download()).toMatch(/\.json$/);
|
||||
});
|
||||
|
||||
test('TC-07 the JSON drawer copies the dashboard JSON', async ({
|
||||
dashboards,
|
||||
dashboardPage,
|
||||
browserName,
|
||||
}) => {
|
||||
test.skip(
|
||||
browserName !== 'chromium',
|
||||
'clipboard-read is a Chromium-only permission',
|
||||
);
|
||||
const fixture = compactDashboard();
|
||||
await dashboards.seedAndOpen(fixture);
|
||||
|
||||
await dashboardPage.json.open();
|
||||
const copied = JSON.parse(await dashboardPage.json.copy()) as {
|
||||
spec?: { display?: { name?: string } };
|
||||
};
|
||||
|
||||
expect(copied.spec?.display?.name).toBe(fixture.spec.display.name);
|
||||
});
|
||||
|
||||
test('TC-08 Full screen shows only the grid and exits cleanly', async ({
|
||||
dashboards,
|
||||
dashboardPage,
|
||||
}) => {
|
||||
const collected = dashboardPage.collectPageErrors();
|
||||
await dashboards.seedAndOpen(compactDashboard());
|
||||
|
||||
await dashboardPage.actions.run(DashboardAction.fullScreen);
|
||||
|
||||
await expect.poll(() => dashboardPage.isFullScreen()).toBe(true);
|
||||
await expect(dashboardPage.title()).toBeHidden();
|
||||
await expect(dashboardPage.section('Charts').panels()).toHaveCount(1);
|
||||
|
||||
await dashboardPage.exitFullScreen();
|
||||
|
||||
await expect(dashboardPage.title()).toBeVisible();
|
||||
expect(collected.errors).toEqual([]);
|
||||
});
|
||||
|
||||
test('TC-09 lock then unlock restores the edit affordances', async ({
|
||||
dashboards,
|
||||
dashboardPage,
|
||||
}) => {
|
||||
const id = await dashboards.seedAndOpen(compactDashboard());
|
||||
|
||||
await dashboardPage.lock();
|
||||
await dashboardPage.actions.open();
|
||||
await expect(dashboardPage.actions.item(DashboardAction.lock)).toHaveText(
|
||||
'Unlock dashboard',
|
||||
);
|
||||
await dashboardPage.actions.item(DashboardAction.lock).click();
|
||||
await expect(dashboardPage.lockToggle()).toHaveAccessibleName(
|
||||
'Lock dashboard',
|
||||
);
|
||||
|
||||
await expect(dashboardPage.toolbar.newPanel()).toBeEnabled();
|
||||
await dashboardPage.actions.open();
|
||||
await expect(
|
||||
dashboardPage.actions.item(DashboardAction.rename),
|
||||
).toBeEnabled();
|
||||
await expect(
|
||||
dashboardPage.actions.item(DashboardAction.newSection),
|
||||
).toBeEnabled();
|
||||
await expect.poll(async () => (await dashboards.get(id)).locked).toBe(false);
|
||||
});
|
||||
|
||||
test('TC-10 a rename survives a reload', async ({
|
||||
dashboards,
|
||||
dashboardPage,
|
||||
}) => {
|
||||
const id = await dashboards.seedAndOpen(compactDashboard());
|
||||
const renamed = `Renamed-Persist-${Date.now()}`;
|
||||
|
||||
await dashboardPage.rename.to(renamed);
|
||||
await expect
|
||||
.poll(async () => (await dashboards.get(id)).spec.display.name)
|
||||
.toBe(renamed);
|
||||
|
||||
await dashboards.reload();
|
||||
|
||||
await expect(dashboardPage.title()).toHaveText(renamed);
|
||||
await expect.poll(() => dashboardPage.documentTitle()).toBe(renamed);
|
||||
});
|
||||
});
|
||||
141
tests/e2e/tests/dashboards/page/from-scratch.spec.ts
Normal file
141
tests/e2e/tests/dashboards/page/from-scratch.spec.ts
Normal file
@@ -0,0 +1,141 @@
|
||||
import {
|
||||
expect,
|
||||
GOLDEN,
|
||||
PANEL_TYPE_TILES,
|
||||
PanelKind,
|
||||
PATH,
|
||||
test,
|
||||
} from '../../../drivers/dashboard-list';
|
||||
|
||||
// Scope: a dashboard built entirely through the UI — the list page's Blank
|
||||
// create flow, one panel of every queryable kind, and deletion from the list.
|
||||
|
||||
/** Text carries no query. */
|
||||
const QUERYABLE = PANEL_TYPE_TILES.filter(
|
||||
(tile) => tile.kind !== PanelKind.Text,
|
||||
);
|
||||
|
||||
/** List seeds a logs query that saves as-is; every other kind seeds metrics. */
|
||||
const METRICS_TILES = QUERYABLE.filter((tile) => tile.kind !== PanelKind.List);
|
||||
|
||||
function uniqueName(label: string): string {
|
||||
return `e2e ${label} ${Date.now()}-${Math.floor(Math.random() * 1e6)}`;
|
||||
}
|
||||
|
||||
test.describe('Dashboards — dashboard from scratch', () => {
|
||||
test('TC-01 a blank dashboard is created from the list page', async ({
|
||||
authedPage: page,
|
||||
dashboardList,
|
||||
dashboards,
|
||||
}) => {
|
||||
const name = uniqueName('blank');
|
||||
await dashboardList.open();
|
||||
|
||||
const id = await dashboardList.create({
|
||||
name,
|
||||
description: 'Built through the UI',
|
||||
tags: ['team:e2e', 'env:test'],
|
||||
});
|
||||
|
||||
await expect(page).toHaveURL(PATH.onDashboard(id));
|
||||
await expect(dashboards.title()).toHaveText(name);
|
||||
await expect(dashboards.emptyAddPanel()).toBeVisible();
|
||||
|
||||
const saved = await dashboards.get(id);
|
||||
expect(saved.spec.display.name).toBe(name);
|
||||
expect(saved.spec.display.description).toBe('Built through the UI');
|
||||
expect(saved.tags).toEqual(
|
||||
expect.arrayContaining([
|
||||
{ key: 'team', value: 'e2e' },
|
||||
{ key: 'env', value: 'test' },
|
||||
]),
|
||||
);
|
||||
expect(saved.tags).toHaveLength(2);
|
||||
expect(Object.keys(saved.spec.panels)).toHaveLength(0);
|
||||
});
|
||||
|
||||
test('TC-02 Create is disabled until the dashboard has a name', async ({
|
||||
dashboardList,
|
||||
}) => {
|
||||
await dashboardList.open();
|
||||
await dashboardList.newDashboardButton().click();
|
||||
|
||||
await dashboardList.nameInput().fill(' ');
|
||||
await expect(dashboardList.submit()).toBeDisabled();
|
||||
|
||||
await dashboardList.nameInput().fill(uniqueName('named'));
|
||||
await expect(dashboardList.submit()).toBeEnabled();
|
||||
});
|
||||
|
||||
test('TC-03 one panel of every queryable kind is added and persists', async ({
|
||||
authedPage: page,
|
||||
dashboardList,
|
||||
dashboards,
|
||||
editor,
|
||||
newPanel,
|
||||
panels,
|
||||
}) => {
|
||||
await dashboardList.open();
|
||||
const id = await dashboardList.create({ name: uniqueName('all kinds') });
|
||||
|
||||
await dashboards.emptyAddPanel().click();
|
||||
await newPanel.choose(PanelKind.List);
|
||||
await editor.title().fill('List panel');
|
||||
await editor.save();
|
||||
await page.waitForURL(PATH.onDashboard(id));
|
||||
await expect(panels.titles().filter({ hasText: 'List panel' })).toBeVisible();
|
||||
|
||||
for (const { kind, label } of METRICS_TILES) {
|
||||
await dashboards.headerAddPanel().click();
|
||||
await newPanel.choose(kind);
|
||||
expect(dashboards.param('panelKind')).toBe(kind);
|
||||
await expect(editor.root()).toBeVisible();
|
||||
|
||||
await editor.title().fill(`${label} panel`);
|
||||
await editor.query.selectMetric(GOLDEN.metrics.calls);
|
||||
await editor.save();
|
||||
await page.waitForURL(PATH.onDashboard(id));
|
||||
await expect(
|
||||
panels.titles().filter({ hasText: `${label} panel` }),
|
||||
).toBeVisible();
|
||||
}
|
||||
|
||||
await dashboards.reload();
|
||||
for (const { label } of QUERYABLE) {
|
||||
await expect(
|
||||
panels.titles().filter({ hasText: `${label} panel` }),
|
||||
).toBeVisible();
|
||||
}
|
||||
await expect(panels.titles()).toHaveCount(QUERYABLE.length);
|
||||
|
||||
const saved = await dashboards.get(id);
|
||||
const persisted = Object.values(saved.spec.panels);
|
||||
expect(persisted.map((panel) => panel.spec.plugin.kind).sort()).toEqual(
|
||||
QUERYABLE.map((tile) => tile.kind).sort(),
|
||||
);
|
||||
for (const panel of persisted) {
|
||||
expect(panel.spec.queries).toHaveLength(1);
|
||||
}
|
||||
const items = saved.spec.layouts.flatMap((layout) => layout.spec.items);
|
||||
expect(items).toHaveLength(QUERYABLE.length);
|
||||
});
|
||||
|
||||
test('TC-04 a dashboard is deleted from the list and disappears', async ({
|
||||
dashboardList,
|
||||
dashboards,
|
||||
}) => {
|
||||
const name = uniqueName('doomed');
|
||||
await dashboardList.open();
|
||||
const id = await dashboardList.create({ name });
|
||||
|
||||
await dashboardList.open(name);
|
||||
await expect(dashboardList.rowTitle(name)).toBeVisible();
|
||||
|
||||
await dashboardList.delete(name);
|
||||
await expect(dashboardList.rowTitle(name)).toHaveCount(0);
|
||||
|
||||
await dashboardList.open(name);
|
||||
await expect(dashboardList.rowTitle(name)).toHaveCount(0);
|
||||
await expect(dashboards.get(id)).rejects.toThrow(/404/);
|
||||
});
|
||||
});
|
||||
106
tests/e2e/tests/dashboards/page/layout.spec.ts
Normal file
106
tests/e2e/tests/dashboards/page/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 — 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);
|
||||
});
|
||||
});
|
||||
87
tests/e2e/tests/dashboards/page/panel-header.spec.ts
Normal file
87
tests/e2e/tests/dashboards/page/panel-header.spec.ts
Normal file
@@ -0,0 +1,87 @@
|
||||
import {
|
||||
COMPACT_PANELS,
|
||||
PanelKind,
|
||||
QueryRange,
|
||||
compactDashboard,
|
||||
buildDashboard,
|
||||
expect,
|
||||
panel,
|
||||
ramp,
|
||||
test,
|
||||
} from '../../../drivers/panels';
|
||||
|
||||
// Scope: the panel header's identity and status affordances.
|
||||
|
||||
test.describe('Dashboards — panel header', () => {
|
||||
test('TC-01 header shows the panel title and description tooltip', async ({
|
||||
dashboards,
|
||||
panels,
|
||||
}) => {
|
||||
await dashboards.seedAndOpen(compactDashboard());
|
||||
|
||||
const target = panels.panel(COMPACT_PANELS.timeseries);
|
||||
await expect(target.title()).toHaveText('Calls by service');
|
||||
|
||||
const info = target.infoIcon();
|
||||
await expect(info).toBeVisible();
|
||||
await info.hover();
|
||||
await expect(
|
||||
panels.tooltip.text('Rate of signoz_calls_total grouped by service.name'),
|
||||
).toBeVisible();
|
||||
|
||||
await expect(panels.panel(COMPACT_PANELS.table).infoIcon()).toHaveCount(0);
|
||||
});
|
||||
|
||||
test('TC-02 a query warning surfaces the warning indicator and its popover', async ({
|
||||
dashboards,
|
||||
queryRange,
|
||||
panels,
|
||||
}) => {
|
||||
await queryRange.respond(
|
||||
QueryRange.withWarning(
|
||||
QueryRange.timeSeries([
|
||||
{ labels: { 'service.name': 'adservice' }, points: ramp(12, 1, 9) },
|
||||
]),
|
||||
'sampled result',
|
||||
),
|
||||
);
|
||||
await dashboards.seedAndOpen(compactDashboard());
|
||||
|
||||
const warning = panels.panel(COMPACT_PANELS.timeseries).statusWarning();
|
||||
await expect(warning).toBeVisible();
|
||||
await expect(warning).toHaveAttribute('aria-label', 'Panel warning');
|
||||
|
||||
// Hover tooltip, not a popover; Radix renders the content twice.
|
||||
await warning.hover();
|
||||
await expect(panels.tooltip.statusContent()).toContainText('sampled result');
|
||||
});
|
||||
|
||||
test('TC-03 the time-preference pill reflects a panel-scoped window', async ({
|
||||
dashboards,
|
||||
panels,
|
||||
}) => {
|
||||
// The pill only renders when a panel opts OUT of the global range, so the
|
||||
// unpinned neighbour is the control.
|
||||
await dashboards.seedAndOpen(
|
||||
buildDashboard({
|
||||
sections: [
|
||||
{
|
||||
title: 'Time preference',
|
||||
panels: {
|
||||
pinned: panel(PanelKind.TimeSeries, {
|
||||
name: 'Pinned to 15m',
|
||||
pluginSpec: { visualization: { timePreference: 'last_15_min' } },
|
||||
}),
|
||||
global: panel(PanelKind.TimeSeries, {
|
||||
name: 'Follows the dashboard',
|
||||
}),
|
||||
},
|
||||
},
|
||||
],
|
||||
}),
|
||||
);
|
||||
|
||||
await expect(panels.panel('pinned').timePreference()).toBeVisible();
|
||||
await expect(panels.panel('global').timePreference()).toHaveCount(0);
|
||||
});
|
||||
});
|
||||
146
tests/e2e/tests/dashboards/page/sections.spec.ts
Normal file
146
tests/e2e/tests/dashboards/page/sections.spec.ts
Normal file
@@ -0,0 +1,146 @@
|
||||
import {
|
||||
COMPACT_PANELS,
|
||||
SectionAction,
|
||||
compactDashboard,
|
||||
expect,
|
||||
test,
|
||||
} from '../../../drivers/dashboard-page';
|
||||
|
||||
// Collapse is a per-user preference kept in the browser, not in the spec;
|
||||
// rename, add and delete are patches to the spec.
|
||||
|
||||
test.describe('Dashboards — sections', () => {
|
||||
test('TC-01 collapsing a section hides its panels and keeps its header', async ({
|
||||
dashboards,
|
||||
dashboardPage,
|
||||
panels,
|
||||
}) => {
|
||||
await dashboards.seedAndOpen(compactDashboard());
|
||||
const tabular = dashboardPage.section('Tabular');
|
||||
|
||||
await tabular.collapse();
|
||||
|
||||
await expect(tabular.toggle()).toBeVisible();
|
||||
await expect(panels.panel(COMPACT_PANELS.table).root()).toBeHidden();
|
||||
await expect(panels.panel(COMPACT_PANELS.list).root()).toBeHidden();
|
||||
});
|
||||
|
||||
test('TC-02 expanding restores every panel the section held', async ({
|
||||
dashboards,
|
||||
dashboardPage,
|
||||
}) => {
|
||||
await dashboards.seedAndOpen(compactDashboard());
|
||||
const tabular = dashboardPage.section('Tabular');
|
||||
const before = await tabular.panels().count();
|
||||
|
||||
await tabular.collapse();
|
||||
await tabular.expand();
|
||||
|
||||
await expect(tabular.panels()).toHaveCount(before);
|
||||
});
|
||||
|
||||
test('TC-03 the section menu offers add, rename, clone and delete', async ({
|
||||
dashboards,
|
||||
dashboardPage,
|
||||
}) => {
|
||||
await dashboards.seedAndOpen(compactDashboard());
|
||||
const charts = dashboardPage.section('Charts');
|
||||
|
||||
await charts.openMenu();
|
||||
|
||||
for (const label of Object.values(SectionAction)) {
|
||||
await expect(charts.menuItem(label)).toBeEnabled();
|
||||
}
|
||||
});
|
||||
|
||||
test('TC-04 renaming a section updates its header and persists', async ({
|
||||
dashboards,
|
||||
dashboardPage,
|
||||
}) => {
|
||||
const id = await dashboards.seedAndOpen(compactDashboard());
|
||||
const renamed = `Renamed ${Date.now()}`;
|
||||
|
||||
await dashboardPage.section('Charts').rename(renamed);
|
||||
|
||||
await expect(dashboardPage.section(renamed).toggle()).toBeVisible();
|
||||
await expect(dashboardPage.section('Charts').root()).toHaveCount(0);
|
||||
await expect
|
||||
.poll(async () =>
|
||||
(await dashboards.get(id)).spec.layouts.map(
|
||||
(layout) => layout.spec.display.title,
|
||||
),
|
||||
)
|
||||
.toEqual([renamed, 'Tabular']);
|
||||
});
|
||||
|
||||
test('TC-05 cancelling a section rename leaves the title unchanged', async ({
|
||||
dashboards,
|
||||
dashboardPage,
|
||||
}) => {
|
||||
await dashboards.seedAndOpen(compactDashboard());
|
||||
const tabular = dashboardPage.section('Tabular');
|
||||
|
||||
await tabular.startRename();
|
||||
await dashboardPage.sectionModal.input().fill('Should not be applied');
|
||||
await dashboardPage.sectionModal.cancel().click();
|
||||
|
||||
await expect(dashboardPage.sectionModal.input()).toBeHidden();
|
||||
await expect(tabular.toggle()).toBeVisible();
|
||||
await expect(
|
||||
dashboardPage.section('Should not be applied').root(),
|
||||
).toHaveCount(0);
|
||||
});
|
||||
|
||||
test('TC-06 a section added from the Actions menu can be deleted', async ({
|
||||
dashboards,
|
||||
dashboardPage,
|
||||
}) => {
|
||||
const id = await dashboards.seedAndOpen(compactDashboard());
|
||||
const title = `Temp section ${Date.now()}`;
|
||||
|
||||
await dashboardPage.addSection(title);
|
||||
await expect
|
||||
.poll(async () => (await dashboards.get(id)).spec.layouts.length)
|
||||
.toBe(3);
|
||||
|
||||
await dashboardPage.section(title).delete();
|
||||
|
||||
await expect(dashboardPage.sectionTitles()).toHaveText(['Charts', 'Tabular']);
|
||||
await expect
|
||||
.poll(async () => (await dashboards.get(id)).spec.layouts.length)
|
||||
.toBe(2);
|
||||
});
|
||||
|
||||
test('TC-07 two sections collapse in sequence and both stay collapsed', async ({
|
||||
dashboards,
|
||||
dashboardPage,
|
||||
}) => {
|
||||
await dashboards.seedAndOpen(compactDashboard());
|
||||
const charts = dashboardPage.section('Charts');
|
||||
const tabular = dashboardPage.section('Tabular');
|
||||
|
||||
await charts.collapse();
|
||||
await tabular.collapse();
|
||||
|
||||
await expect(charts.panels()).toHaveCount(0);
|
||||
await expect(tabular.panels()).toHaveCount(0);
|
||||
|
||||
await charts.expand();
|
||||
await tabular.expand();
|
||||
await expect(charts.panels()).toHaveCount(1);
|
||||
await expect(tabular.panels()).toHaveCount(2);
|
||||
});
|
||||
|
||||
test('TC-08 a collapsed section unmounts its panels and spares the others', async ({
|
||||
dashboards,
|
||||
dashboardPage,
|
||||
panels,
|
||||
}) => {
|
||||
await dashboards.seedAndOpen(compactDashboard());
|
||||
|
||||
await dashboardPage.section('Charts').collapse();
|
||||
|
||||
await expect(panels.panel(COMPACT_PANELS.timeseries).root()).toHaveCount(0);
|
||||
await expect(panels.panel(COMPACT_PANELS.table).root()).toHaveCount(1);
|
||||
});
|
||||
});
|
||||
341
tests/e2e/tests/dashboards/page/settings.spec.ts
Normal file
341
tests/e2e/tests/dashboards/page/settings.spec.ts
Normal file
@@ -0,0 +1,341 @@
|
||||
import {
|
||||
BAR_VALUES,
|
||||
BAR_VARIABLES as V,
|
||||
emptyDashboard,
|
||||
expect,
|
||||
test,
|
||||
variablesBarDashboard,
|
||||
} from '../../../drivers/dashboard-settings';
|
||||
|
||||
// Scope: the Configure drawer — Overview, Variables and Publish tabs.
|
||||
|
||||
test.describe('Dashboards — settings drawer', () => {
|
||||
test('TC-01 Configure opens on Overview with every tab', async ({
|
||||
dashboards,
|
||||
settings,
|
||||
}) => {
|
||||
await dashboards.seedAndOpen(variablesBarDashboard());
|
||||
|
||||
await settings.open();
|
||||
|
||||
await expect(settings.heading()).toBeVisible();
|
||||
for (const tab of ['Overview', 'Variables', 'Publish'] as const) {
|
||||
await expect(settings.tab(tab)).toBeVisible();
|
||||
}
|
||||
await expect(settings.tab('Overview')).toHaveAttribute(
|
||||
'aria-selected',
|
||||
'true',
|
||||
);
|
||||
await expect(settings.overview.name()).toBeVisible();
|
||||
|
||||
await settings.close();
|
||||
});
|
||||
|
||||
test('TC-02 name, description and tag save and survive a reload', async ({
|
||||
dashboards,
|
||||
settings,
|
||||
}) => {
|
||||
const id = await dashboards.seedAndOpen(variablesBarDashboard());
|
||||
const name = `e2e-configured-${Date.now()}`;
|
||||
|
||||
await settings.open();
|
||||
await settings.overview.name().fill(name);
|
||||
await settings.overview.description().fill('Configured by e2e');
|
||||
await settings.overview.addTag('team:e2e');
|
||||
await expect(settings.overview.unsaved()).toHaveText('3 unsaved changes');
|
||||
await settings.overview.save().click();
|
||||
await expect(settings.overview.unsaved()).toBeHidden();
|
||||
await settings.close();
|
||||
|
||||
await dashboards.reload();
|
||||
await expect(dashboards.title()).toHaveText(name);
|
||||
const saved = await dashboards.get(id);
|
||||
expect(saved.spec.display.description).toBe('Configured by e2e');
|
||||
expect(saved.tags).toContainEqual({ key: 'team', value: 'e2e' });
|
||||
});
|
||||
|
||||
test('TC-03 Discard reverts unsaved Overview changes', async ({
|
||||
dashboards,
|
||||
settings,
|
||||
}) => {
|
||||
const dashboard = variablesBarDashboard();
|
||||
await dashboards.seedAndOpen(dashboard);
|
||||
await settings.open();
|
||||
const original = dashboard.spec.display.name;
|
||||
await expect(settings.overview.name()).toHaveValue(original);
|
||||
|
||||
await settings.overview.name().fill('Temporary name');
|
||||
await settings.overview.discard().click();
|
||||
|
||||
await expect(settings.overview.name()).toHaveValue(original);
|
||||
await expect(settings.overview.save()).toBeHidden();
|
||||
});
|
||||
|
||||
test('TC-04 the Variables tab lists every variable', async ({
|
||||
dashboards,
|
||||
settings,
|
||||
}) => {
|
||||
await dashboards.seedAndOpen(variablesBarDashboard());
|
||||
|
||||
await settings.open();
|
||||
await settings.openTab('Variables');
|
||||
|
||||
for (const name of Object.values(V)) {
|
||||
await expect(settings.variables.row(name)).toBeVisible();
|
||||
}
|
||||
});
|
||||
|
||||
test('TC-05 an added textbox variable joins the bar and takes a value', async ({
|
||||
dashboards,
|
||||
settings,
|
||||
variablesBar,
|
||||
}) => {
|
||||
await dashboards.seedAndOpen(variablesBarDashboard());
|
||||
const name = 'tb_added';
|
||||
|
||||
await settings.open();
|
||||
await settings.openTab('Variables');
|
||||
await settings.variables.add().click();
|
||||
await settings.form.name().fill(name);
|
||||
await settings.form.type('textbox').click();
|
||||
await expect(settings.form.save()).toBeEnabled();
|
||||
await settings.form.save().click();
|
||||
await expect(settings.variables.row(name)).toBeVisible();
|
||||
await settings.close();
|
||||
|
||||
await variablesBar.expand();
|
||||
await expect(variablesBar.label(name)).toBeVisible();
|
||||
await variablesBar.type(name, 'test-value');
|
||||
await expect(variablesBar.textbox(name)).toHaveValue('test-value');
|
||||
});
|
||||
|
||||
test('TC-06 an added custom variable joins the list and the bar', async ({
|
||||
dashboards,
|
||||
settings,
|
||||
variablesBar,
|
||||
}) => {
|
||||
await dashboards.seedAndOpen(variablesBarDashboard());
|
||||
const name = 'cu_added';
|
||||
|
||||
await settings.open();
|
||||
await settings.openTab('Variables');
|
||||
await settings.variables.add().click();
|
||||
await settings.form.name().fill(name);
|
||||
await settings.form.type('custom').click();
|
||||
await settings.form.customValues().fill('east,west');
|
||||
await settings.form.save().click();
|
||||
await expect(settings.variables.row(name)).toBeVisible();
|
||||
await settings.close();
|
||||
|
||||
await variablesBar.expand();
|
||||
await expect(variablesBar.select(name)).toContainText('east');
|
||||
});
|
||||
|
||||
test('TC-07 the Query type renders the query editor', async ({
|
||||
dashboards,
|
||||
settings,
|
||||
}) => {
|
||||
await dashboards.seedAndOpen(variablesBarDashboard());
|
||||
|
||||
await settings.open();
|
||||
await settings.openTab('Variables');
|
||||
await settings.variables.add().click();
|
||||
await settings.form.name().fill('query_added');
|
||||
await settings.form.type('query').click();
|
||||
|
||||
await expect(settings.form.queryEditor()).toBeVisible();
|
||||
await settings.form.discard().click();
|
||||
await expect(settings.variables.row('query_added')).toHaveCount(0);
|
||||
});
|
||||
|
||||
test('TC-08 Save Variable stays disabled without a name', async ({
|
||||
dashboards,
|
||||
settings,
|
||||
}) => {
|
||||
const id = await dashboards.seed(emptyDashboard());
|
||||
await dashboards.openEmpty(id);
|
||||
|
||||
await settings.open();
|
||||
await settings.openTab('Variables');
|
||||
await settings.variables.add().click();
|
||||
|
||||
await expect(settings.form.name()).toHaveValue('');
|
||||
await expect(settings.form.save()).toBeDisabled();
|
||||
});
|
||||
|
||||
test('TC-09 the Publish tab offers to publish a private dashboard', async ({
|
||||
dashboards,
|
||||
settings,
|
||||
}) => {
|
||||
await dashboards.seedAndOpen(variablesBarDashboard());
|
||||
|
||||
await settings.open();
|
||||
await settings.openTab('Publish');
|
||||
|
||||
await expect(
|
||||
settings.publish.text('This dashboard is private'),
|
||||
).toBeVisible();
|
||||
await expect(settings.publish.timeRange()).toBeVisible();
|
||||
await expect(
|
||||
settings.publish.text(
|
||||
"Dashboard variables aren't supported on public links.",
|
||||
),
|
||||
).toBeVisible();
|
||||
await expect(settings.publish.publish()).toBeVisible();
|
||||
});
|
||||
|
||||
test('TC-10 renaming a variable renames it in the bar', async ({
|
||||
dashboards,
|
||||
settings,
|
||||
variablesBar,
|
||||
}) => {
|
||||
const id = await dashboards.seedAndOpen(variablesBarDashboard());
|
||||
const renamed = `${V.textEnv}_renamed`;
|
||||
|
||||
await settings.open();
|
||||
await settings.openTab('Variables');
|
||||
await settings.variables.edit(V.textEnv);
|
||||
await expect(settings.form.name()).toHaveValue(V.textEnv);
|
||||
await settings.form.name().fill(renamed);
|
||||
await settings.form.save().click();
|
||||
await expect(settings.variables.row(renamed)).toBeVisible();
|
||||
await settings.close();
|
||||
|
||||
await expect(variablesBar.label(renamed)).toBeVisible();
|
||||
await expect(variablesBar.label(V.textEnv)).toHaveCount(0);
|
||||
await expect
|
||||
.poll(async () =>
|
||||
(await dashboards.get(id)).spec.variables.map((v) => v.spec.name),
|
||||
)
|
||||
.toContain(renamed);
|
||||
});
|
||||
|
||||
test('TC-11 switching a custom variable to Query swaps the form', async ({
|
||||
dashboards,
|
||||
settings,
|
||||
}) => {
|
||||
await dashboards.seedAndOpen(variablesBarDashboard());
|
||||
|
||||
await settings.open();
|
||||
await settings.openTab('Variables');
|
||||
await settings.variables.edit(V.single);
|
||||
await expect(settings.form.customValues()).toBeVisible();
|
||||
await settings.form.type('query').click();
|
||||
|
||||
await expect(settings.form.queryEditor()).toBeVisible();
|
||||
await expect(settings.form.customValues()).toHaveCount(0);
|
||||
await settings.form.discard().click();
|
||||
});
|
||||
|
||||
test('TC-12 a textbox default saved in Configure survives a reload', async ({
|
||||
dashboards,
|
||||
settings,
|
||||
}) => {
|
||||
const id = await dashboards.seedAndOpen(variablesBarDashboard());
|
||||
|
||||
await settings.open();
|
||||
await settings.openTab('Variables');
|
||||
await settings.variables.edit(V.textService);
|
||||
await expect(settings.form.textDefault()).toHaveValue(BAR_VALUES.textService);
|
||||
await settings.form.textDefault().fill('new-default');
|
||||
await settings.form.save().click();
|
||||
await expect(settings.variables.row(V.textService)).toBeVisible();
|
||||
await expect
|
||||
.poll(async () => {
|
||||
const saved = (await dashboards.get(id)).spec.variables.find(
|
||||
(v) => v.spec.name === V.textService,
|
||||
);
|
||||
return saved?.kind === 'TextVariable' ? saved.spec.value : undefined;
|
||||
})
|
||||
.toBe('new-default');
|
||||
await settings.close();
|
||||
|
||||
await dashboards.reload();
|
||||
await settings.open();
|
||||
await settings.openTab('Variables');
|
||||
await settings.variables.edit(V.textService);
|
||||
await expect(settings.form.textDefault()).toHaveValue('new-default');
|
||||
});
|
||||
|
||||
test('TC-13 a deleted variable leaves the bar, its siblings stay', async ({
|
||||
dashboards,
|
||||
settings,
|
||||
variablesBar,
|
||||
}) => {
|
||||
await dashboards.seedAndOpen(variablesBarDashboard());
|
||||
await expect(variablesBar.label(V.textEnv)).toBeVisible();
|
||||
|
||||
await settings.open();
|
||||
await settings.openTab('Variables');
|
||||
await settings.variables.remove(V.textEnv);
|
||||
await settings.close();
|
||||
|
||||
await expect(variablesBar.label(V.textEnv)).toHaveCount(0);
|
||||
await expect(variablesBar.label(V.textService)).toBeVisible();
|
||||
});
|
||||
|
||||
test('TC-14 a duplicate name keeps Save Variable disabled', async ({
|
||||
dashboards,
|
||||
settings,
|
||||
}) => {
|
||||
await dashboards.seedAndOpen(variablesBarDashboard());
|
||||
|
||||
await settings.open();
|
||||
await settings.openTab('Variables');
|
||||
await settings.variables.add().click();
|
||||
await settings.form.name().fill(V.textEnv);
|
||||
await settings.form.type('textbox').click();
|
||||
|
||||
await expect(
|
||||
settings.form.error('Variable name already exists'),
|
||||
).toBeVisible();
|
||||
await expect(settings.form.save()).toBeDisabled();
|
||||
});
|
||||
|
||||
test('TC-15 discarding an edit persists nothing', async ({
|
||||
dashboards,
|
||||
settings,
|
||||
variablesBar,
|
||||
}) => {
|
||||
const id = await dashboards.seedAndOpen(variablesBarDashboard());
|
||||
|
||||
await settings.open();
|
||||
await settings.openTab('Variables');
|
||||
await settings.variables.edit(V.textEnv);
|
||||
await settings.form.name().fill('SHOULD_NOT_PERSIST');
|
||||
await settings.form.discard().click();
|
||||
await expect(settings.variables.row(V.textEnv)).toBeVisible();
|
||||
await settings.close();
|
||||
|
||||
await expect(variablesBar.label(V.textEnv)).toBeVisible();
|
||||
await expect(variablesBar.label('SHOULD_NOT_PERSIST')).toHaveCount(0);
|
||||
const saved = await dashboards.get(id);
|
||||
expect(saved.spec.variables.map((v) => v.spec.name)).not.toContain(
|
||||
'SHOULD_NOT_PERSIST',
|
||||
);
|
||||
|
||||
await settings.open();
|
||||
await settings.openTab('Variables');
|
||||
await expect(settings.variables.row(V.textEnv)).toBeVisible();
|
||||
});
|
||||
|
||||
test.fixme('TC-16 the bar picks up a changed textbox default', async ({
|
||||
dashboards,
|
||||
settings,
|
||||
variablesBar,
|
||||
}) => {
|
||||
// Bug: the bar's selection, seeded from the old default on first open, is
|
||||
// persisted locally and outranks the new default on every later visit.
|
||||
await dashboards.seedAndOpen(variablesBarDashboard());
|
||||
|
||||
await settings.open();
|
||||
await settings.openTab('Variables');
|
||||
await settings.variables.edit(V.textService);
|
||||
await settings.form.textDefault().fill('new-default');
|
||||
await settings.form.save().click();
|
||||
await settings.close();
|
||||
await dashboards.reload();
|
||||
|
||||
await expect(variablesBar.textbox(V.textService)).toHaveValue('new-default');
|
||||
});
|
||||
});
|
||||
53
tests/e2e/tests/dashboards/page/states.spec.ts
Normal file
53
tests/e2e/tests/dashboards/page/states.spec.ts
Normal file
@@ -0,0 +1,53 @@
|
||||
import {
|
||||
PanelMessageText,
|
||||
QueryRange,
|
||||
SINGLE_PANEL_ID,
|
||||
expect,
|
||||
singlePanelDashboard,
|
||||
test,
|
||||
} from '../../../drivers/panels';
|
||||
|
||||
// Scope: the no-data state's Extend time range recovery.
|
||||
//
|
||||
// Mock-only: a healthy stack returns data, so no-data is unreachable live.
|
||||
|
||||
test.describe('Dashboards — panel states', () => {
|
||||
test('TC-01 Extend time range widens the dashboard window', async ({
|
||||
dashboards,
|
||||
queryRange,
|
||||
panels,
|
||||
}) => {
|
||||
await queryRange.respond(QueryRange.empty());
|
||||
await dashboards.seedAndOpen(singlePanelDashboard());
|
||||
|
||||
const extend = panels.panel(SINGLE_PANEL_ID).noDataAction();
|
||||
await expect(extend).toBeVisible();
|
||||
|
||||
const refetch = queryRange.nextRequest();
|
||||
await extend.click();
|
||||
await refetch;
|
||||
|
||||
// Widening walks the URL-backed zoom-out ladder.
|
||||
await expect.poll(() => dashboards.param('relativeTime')).not.toBeNull();
|
||||
});
|
||||
|
||||
test('TC-02 a panel pinned to a fixed window cannot extend, so Retry is primary', async ({
|
||||
dashboards,
|
||||
queryRange,
|
||||
panels,
|
||||
}) => {
|
||||
await queryRange.respond(QueryRange.empty());
|
||||
|
||||
// A panel-scoped window means extend has nothing to widen.
|
||||
await dashboards.seedAndOpen(
|
||||
singlePanelDashboard({
|
||||
pluginSpec: { visualization: { timePreference: 'last_15_min' } },
|
||||
}),
|
||||
);
|
||||
|
||||
const target = panels.panel(SINGLE_PANEL_ID);
|
||||
await expect(target.noData()).toBeVisible();
|
||||
await expect(target.noDataAction()).toHaveText(PanelMessageText.retryAction);
|
||||
await expect(target.noDataSecondaryAction()).toHaveCount(0);
|
||||
});
|
||||
});
|
||||
112
tests/e2e/tests/dashboards/page/time-range.spec.ts
Normal file
112
tests/e2e/tests/dashboards/page/time-range.spec.ts
Normal file
@@ -0,0 +1,112 @@
|
||||
import {
|
||||
expect,
|
||||
REFRESH_INTERVALS,
|
||||
SINGLE_PANEL_ID,
|
||||
singlePanelDashboard,
|
||||
test,
|
||||
} from '../../../drivers/dashboard-settings';
|
||||
|
||||
// Scope: the dashboard toolbar's time picker, refresh and auto-refresh.
|
||||
|
||||
test.describe('Dashboards — time range', () => {
|
||||
test.beforeEach(async ({ dashboards }) => {
|
||||
await dashboards.seedAndOpen(singlePanelDashboard());
|
||||
});
|
||||
|
||||
test('TC-01 a preset updates the label, the URL and refetches', async ({
|
||||
timePicker,
|
||||
queryRange,
|
||||
authedPage: page,
|
||||
}) => {
|
||||
const refetch = queryRange.nextRequest();
|
||||
await timePicker.selectPreset('1h');
|
||||
|
||||
await refetch;
|
||||
await expect(timePicker.input()).toHaveAccessibleName('Last 1 hour');
|
||||
await expect(page).toHaveURL(/relativeTime=1h/);
|
||||
});
|
||||
|
||||
test('TC-02 switching presets twice keeps only the last one', async ({
|
||||
timePicker,
|
||||
authedPage: page,
|
||||
}) => {
|
||||
await timePicker.selectPreset('6h');
|
||||
await expect(timePicker.input()).toHaveAccessibleName('Last 6 hours');
|
||||
await expect(page).toHaveURL(/relativeTime=6h/);
|
||||
|
||||
await timePicker.selectPreset('1d');
|
||||
await expect(timePicker.input()).toHaveAccessibleName('Last 1 day');
|
||||
await expect(page).toHaveURL(/relativeTime=1d/);
|
||||
await expect(page).not.toHaveURL(/relativeTime=6h/);
|
||||
});
|
||||
|
||||
test('TC-03 a custom date range switches the URL to absolute timestamps', async ({
|
||||
timePicker,
|
||||
queryRange,
|
||||
authedPage: page,
|
||||
}) => {
|
||||
const refetch = queryRange.nextRequest();
|
||||
await timePicker.selectPreviousMonthRange(14, 16);
|
||||
|
||||
await refetch;
|
||||
await expect(timePicker.input()).toHaveValue(
|
||||
/^14\/\d{2}\/\d{4} 00:00:00 - 16\/\d{2}\/\d{4} 23:59:59$/,
|
||||
);
|
||||
await expect(page).toHaveURL(/startTime=\d+/);
|
||||
await expect(page).toHaveURL(/endTime=\d+/);
|
||||
await expect(page).not.toHaveURL(/relativeTime=/);
|
||||
});
|
||||
|
||||
test('TC-04 changing the timezone updates the offset badge', async ({
|
||||
timePicker,
|
||||
}) => {
|
||||
await timePicker.selectTimezone('Tokyo', 'Asia/Tokyo');
|
||||
|
||||
await expect(timePicker.timezoneBadge()).toHaveText('UTC + 9:00');
|
||||
});
|
||||
|
||||
test('TC-05 the auto-refresh popup lists every interval, off by default', async ({
|
||||
timePicker,
|
||||
}) => {
|
||||
await timePicker.autoRefresh.open();
|
||||
|
||||
await expect(timePicker.autoRefresh.checkbox()).not.toBeChecked();
|
||||
for (const label of REFRESH_INTERVALS) {
|
||||
await expect(timePicker.autoRefresh.interval(label)).toBeVisible();
|
||||
}
|
||||
});
|
||||
|
||||
test('TC-06 auto-refresh toggles on, takes an interval and toggles off', async ({
|
||||
timePicker,
|
||||
}) => {
|
||||
await timePicker.autoRefresh.open();
|
||||
const checkbox = timePicker.autoRefresh.checkbox();
|
||||
|
||||
await checkbox.click();
|
||||
await expect(checkbox).toBeChecked();
|
||||
|
||||
await timePicker.autoRefresh.interval('1 minute').click();
|
||||
await expect(timePicker.autoRefresh.selected()).toHaveText('1 minute');
|
||||
await timePicker.autoRefresh.interval('5 minutes').click();
|
||||
await expect(timePicker.autoRefresh.selected()).toHaveText('5 minutes');
|
||||
await expect(checkbox).toBeChecked();
|
||||
|
||||
await checkbox.click();
|
||||
await expect(checkbox).not.toBeChecked();
|
||||
});
|
||||
|
||||
test('TC-07 manual refresh refetches the panels', async ({
|
||||
timePicker,
|
||||
panels,
|
||||
queryRange,
|
||||
}) => {
|
||||
const target = panels.panel(SINGLE_PANEL_ID);
|
||||
await expect(target.root()).toHaveAttribute('data-panel-visible', 'true');
|
||||
await expect(target.loading()).toBeHidden();
|
||||
|
||||
const refetch = queryRange.nextRequest();
|
||||
await timePicker.refresh().click();
|
||||
|
||||
await refetch;
|
||||
});
|
||||
});
|
||||
300
tests/e2e/tests/dashboards/page/variables.spec.ts
Normal file
300
tests/e2e/tests/dashboards/page/variables.spec.ts
Normal file
@@ -0,0 +1,300 @@
|
||||
import {
|
||||
BAR_VALUES,
|
||||
BAR_VARIABLES as V,
|
||||
expect,
|
||||
QUERY_CHILD_SUFFIX,
|
||||
test,
|
||||
variablesBarDashboard,
|
||||
} from '../../../drivers/dashboard-settings';
|
||||
|
||||
// Scope: the runtime variables bar. Selections live in the dashboard's local
|
||||
// store, not the URL: `?variables=` is read once from a share link and dropped,
|
||||
// and the share dialog's "Include variables" is what serializes them again.
|
||||
|
||||
const ALL = '__ALL__';
|
||||
const ORDER = Object.values(V);
|
||||
|
||||
test.describe('Dashboards — variables bar', () => {
|
||||
test('TC-01 the bar renders a control for every variable type', async ({
|
||||
dashboards,
|
||||
variablesBar,
|
||||
}) => {
|
||||
await dashboards.seedAndOpen(variablesBarDashboard());
|
||||
await variablesBar.expand();
|
||||
|
||||
for (const name of ORDER) {
|
||||
await expect(variablesBar.label(name)).toBeVisible();
|
||||
}
|
||||
await expect(variablesBar.textbox(V.textEnv)).toHaveValue(BAR_VALUES.textEnv);
|
||||
await expect(variablesBar.textbox(V.textService)).toHaveValue(
|
||||
BAR_VALUES.textService,
|
||||
);
|
||||
for (const name of [V.single, V.all, V.multi, V.queryParent, V.dynamic]) {
|
||||
await expect(variablesBar.select(name)).toBeVisible();
|
||||
}
|
||||
});
|
||||
|
||||
test('TC-02 picking a single value selects it and survives a reload', async ({
|
||||
dashboards,
|
||||
variablesBar,
|
||||
}) => {
|
||||
await dashboards.seedAndOpen(variablesBarDashboard());
|
||||
await variablesBar.expand();
|
||||
|
||||
await variablesBar.pick(V.single, 'mq-kafka');
|
||||
await expect(variablesBar.select(V.single)).toContainText('mq-kafka');
|
||||
|
||||
await variablesBar.open(V.single);
|
||||
await expect(variablesBar.option('mq-kafka')).toHaveAttribute(
|
||||
'aria-selected',
|
||||
'true',
|
||||
);
|
||||
|
||||
await dashboards.reload();
|
||||
await expect(variablesBar.select(V.single)).toContainText('mq-kafka');
|
||||
});
|
||||
|
||||
test('TC-03 a multi-select renders chips and shares its values as an array', async ({
|
||||
dashboards,
|
||||
variablesBar,
|
||||
}) => {
|
||||
const id = await dashboards.seed(variablesBarDashboard());
|
||||
await variablesBar.openWith(id, { [V.multi]: ['adservice', 'frontend'] });
|
||||
await variablesBar.expand();
|
||||
|
||||
await expect(variablesBar.chip(V.multi, 'adservice')).toBeVisible();
|
||||
await variablesBar.open(V.multi);
|
||||
await expect(variablesBar.option('adservice')).toHaveAttribute(
|
||||
'aria-selected',
|
||||
'true',
|
||||
);
|
||||
await expect(variablesBar.option('frontend')).toHaveAttribute(
|
||||
'aria-selected',
|
||||
'true',
|
||||
);
|
||||
await variablesBar.close(V.multi);
|
||||
|
||||
expect((await variablesBar.shared())[V.multi]).toEqual([
|
||||
'adservice',
|
||||
'frontend',
|
||||
]);
|
||||
});
|
||||
|
||||
test('TC-04 removing a chip drops that value once the dropdown closes', async ({
|
||||
dashboards,
|
||||
variablesBar,
|
||||
}) => {
|
||||
const id = await dashboards.seed(variablesBarDashboard());
|
||||
await variablesBar.openWith(id, { [V.multi]: ['adservice', 'frontend'] });
|
||||
await variablesBar.expand();
|
||||
|
||||
await variablesBar.chip(V.multi, 'adservice').click();
|
||||
await variablesBar.close(V.multi);
|
||||
|
||||
await expect(variablesBar.chip(V.multi, 'adservice')).toHaveCount(0);
|
||||
await expect(variablesBar.chip(V.multi, 'frontend')).toBeVisible();
|
||||
expect((await variablesBar.shared())[V.multi]).toEqual(['frontend']);
|
||||
});
|
||||
|
||||
test('TC-05 the ALL option selects every value', async ({
|
||||
dashboards,
|
||||
variablesBar,
|
||||
}) => {
|
||||
const id = await dashboards.seed(variablesBarDashboard());
|
||||
await variablesBar.openWith(id, { [V.all]: ['otel-demo'] });
|
||||
await variablesBar.expand();
|
||||
await expect(variablesBar.variable(V.all)).toContainText('otel-demo');
|
||||
|
||||
await variablesBar.open(V.all);
|
||||
await variablesBar.option('ALL').click();
|
||||
for (const value of ['ALL', ...BAR_VALUES.envs]) {
|
||||
await expect(variablesBar.option(value)).toHaveAttribute(
|
||||
'aria-selected',
|
||||
'true',
|
||||
);
|
||||
}
|
||||
await variablesBar.close(V.all);
|
||||
|
||||
await expect(variablesBar.variable(V.all)).toContainText('ALL');
|
||||
expect((await variablesBar.shared())[V.all]).toBe(ALL);
|
||||
});
|
||||
|
||||
test('TC-06 a textbox commits its value on Enter', async ({
|
||||
dashboards,
|
||||
variablesBar,
|
||||
}) => {
|
||||
await dashboards.seedAndOpen(variablesBarDashboard());
|
||||
|
||||
await variablesBar.type(V.textEnv, 'production');
|
||||
|
||||
await expect(variablesBar.textbox(V.textEnv)).toHaveValue('production');
|
||||
expect((await variablesBar.shared())[V.textEnv]).toBe('production');
|
||||
});
|
||||
|
||||
test('TC-07 a child query variable refetches after its parent changes', async ({
|
||||
dashboards,
|
||||
variablesBar,
|
||||
}) => {
|
||||
await dashboards.seedAndOpen(variablesBarDashboard());
|
||||
await variablesBar.expand();
|
||||
const [first, second] = BAR_VALUES.queryEnvs;
|
||||
await expect(variablesBar.variable(V.queryChild)).toContainText(
|
||||
`${first}${QUERY_CHILD_SUFFIX}`,
|
||||
);
|
||||
|
||||
await variablesBar.pick(V.queryParent, second);
|
||||
|
||||
await expect(variablesBar.variable(V.queryChild)).toContainText(
|
||||
`${second}${QUERY_CHILD_SUFFIX}`,
|
||||
);
|
||||
await variablesBar.open(V.queryChild);
|
||||
await expect(variablesBar.listbox().getByRole('option')).toHaveCount(1);
|
||||
await expect(
|
||||
variablesBar.option(`${second}${QUERY_CHILD_SUFFIX}`),
|
||||
).toBeVisible();
|
||||
});
|
||||
|
||||
test('TC-08 a shared link seeds the selection, which outlives the reload', async ({
|
||||
dashboards,
|
||||
variablesBar,
|
||||
authedPage: page,
|
||||
}) => {
|
||||
const id = await dashboards.seed(variablesBarDashboard());
|
||||
await variablesBar.openWith(id, { [V.all]: ['mq-kafka'] });
|
||||
await variablesBar.expand();
|
||||
|
||||
await expect(variablesBar.variable(V.all)).toContainText('mq-kafka');
|
||||
await expect(page).not.toHaveURL(/variables=/);
|
||||
|
||||
await dashboards.reload();
|
||||
await expect(variablesBar.variable(V.all)).toContainText('mq-kafka');
|
||||
});
|
||||
|
||||
test('TC-09 ALL to one value and back to ALL', async ({
|
||||
dashboards,
|
||||
variablesBar,
|
||||
}) => {
|
||||
await dashboards.seedAndOpen(variablesBarDashboard());
|
||||
await variablesBar.expand();
|
||||
await expect(variablesBar.variable(V.all)).toContainText('ALL');
|
||||
|
||||
await variablesBar.open(V.all);
|
||||
await variablesBar.option('ALL').click();
|
||||
await variablesBar.option('mq-kafka').click();
|
||||
await variablesBar.close(V.all);
|
||||
await expect(variablesBar.variable(V.all)).toContainText('mq-kafka');
|
||||
|
||||
await variablesBar.toggle(V.all, ['ALL']);
|
||||
await expect(variablesBar.variable(V.all)).toContainText('ALL');
|
||||
expect((await variablesBar.shared())[V.all]).toBe(ALL);
|
||||
});
|
||||
|
||||
test('TC-10 changes to two variables are both kept', async ({
|
||||
dashboards,
|
||||
variablesBar,
|
||||
}) => {
|
||||
await dashboards.seedAndOpen(variablesBarDashboard());
|
||||
await variablesBar.expand();
|
||||
|
||||
await variablesBar.pick(V.single, 'production');
|
||||
await variablesBar.toggle(V.multi, ['checkout']);
|
||||
|
||||
const shared = await variablesBar.shared();
|
||||
expect(shared[V.single]).toBe('production');
|
||||
expect(shared[V.multi]).toEqual(['adservice', 'checkout']);
|
||||
});
|
||||
|
||||
test('TC-11 leaving the dashboard and coming back keeps the selection', async ({
|
||||
dashboards,
|
||||
variablesBar,
|
||||
panels,
|
||||
authedPage: page,
|
||||
}) => {
|
||||
const id = await dashboards.seedAndOpen(variablesBarDashboard());
|
||||
await variablesBar.expand();
|
||||
await variablesBar.pick(V.single, 'mq-kafka');
|
||||
|
||||
await dashboards.visit('/dashboard');
|
||||
await expect(page).toHaveURL(/\/dashboard(\?|$)/);
|
||||
await panels.back();
|
||||
|
||||
await expect(page).toHaveURL(new RegExp(`/dashboard/${id}`));
|
||||
await expect(variablesBar.select(V.single)).toContainText('mq-kafka');
|
||||
});
|
||||
|
||||
test('TC-12 a query variable with no rows offers no options', async ({
|
||||
dashboards,
|
||||
variablesBar,
|
||||
}) => {
|
||||
await dashboards.seedAndOpen(variablesBarDashboard());
|
||||
await variablesBar.expand();
|
||||
|
||||
await variablesBar.open(V.queryEmpty);
|
||||
|
||||
await expect(variablesBar.listbox().getByRole('option')).toHaveCount(0);
|
||||
await expect(variablesBar.select(V.queryEmpty)).toContainText('Select value');
|
||||
});
|
||||
|
||||
test('TC-13 a dynamic variable resolves values from seeded telemetry', async ({
|
||||
dashboards,
|
||||
variablesBar,
|
||||
}) => {
|
||||
// Unique per test: seeded rows outlive it, and the list is virtualized.
|
||||
const label = `e2e_namespace_${Date.now()}_${process.pid}`;
|
||||
const namespaces = ['ns-alpha', 'ns-beta'];
|
||||
await variablesBar.seedMetricLabel(label, namespaces);
|
||||
await dashboards.seedAndOpen(
|
||||
variablesBarDashboard({ dynamicAttribute: label }),
|
||||
);
|
||||
await variablesBar.expand();
|
||||
|
||||
await variablesBar.open(V.dynamic);
|
||||
|
||||
for (const namespace of namespaces) {
|
||||
await expect(variablesBar.option(namespace)).toBeVisible();
|
||||
}
|
||||
});
|
||||
|
||||
test('TC-14 the bar follows the spec order', async ({
|
||||
dashboards,
|
||||
variablesBar,
|
||||
}) => {
|
||||
await dashboards.seedAndOpen(variablesBarDashboard());
|
||||
|
||||
expect(await variablesBar.names()).toEqual(ORDER.map((name) => `$${name}`));
|
||||
});
|
||||
|
||||
test('TC-15 a variable deleted in Configure leaves the bar', async ({
|
||||
dashboards,
|
||||
variablesBar,
|
||||
settings,
|
||||
}) => {
|
||||
await dashboards.seedAndOpen(variablesBarDashboard());
|
||||
await expect(variablesBar.label(V.textService)).toHaveCount(1);
|
||||
|
||||
await settings.open();
|
||||
await settings.openTab('Variables');
|
||||
await settings.variables.remove(V.textService);
|
||||
await settings.close();
|
||||
|
||||
await expect(variablesBar.label(V.textService)).toHaveCount(0);
|
||||
await expect(variablesBar.label(V.textEnv)).toBeVisible();
|
||||
});
|
||||
|
||||
test.fixme('TC-16 a first visit through a shared link applies its values', async ({
|
||||
dashboards,
|
||||
variablesBar,
|
||||
}) => {
|
||||
// Bug: the selectors' first auto-select batch (computed before the seed
|
||||
// commits) is flushed over the seeded link values, so they are lost.
|
||||
const id = await dashboards.seed(variablesBarDashboard());
|
||||
await variablesBar.firstVisitWith(id, {
|
||||
[V.multi]: ['adservice', 'frontend'],
|
||||
});
|
||||
|
||||
expect((await variablesBar.shared())[V.multi]).toEqual([
|
||||
'adservice',
|
||||
'frontend',
|
||||
]);
|
||||
});
|
||||
});
|
||||
177
tests/e2e/tests/dashboards/page/view-modal.spec.ts
Normal file
177
tests/e2e/tests/dashboards/page/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-08/TC-09 assert the carried
|
||||
// state AND that nothing was persisted.
|
||||
|
||||
const onEditor = (id: string): RegExp => new RegExp(`/dashboard/${id}/panel/`);
|
||||
|
||||
test.describe('Dashboards — View modal', () => {
|
||||
test('TC-01 View opens the modal and reflects it in the URL', async ({
|
||||
dashboards,
|
||||
panels,
|
||||
}) => {
|
||||
await dashboards.seedAndOpen(singlePanelDashboard());
|
||||
const modal = await panels.view.open(SINGLE_PANEL_ID);
|
||||
|
||||
await expect(modal).toBeVisible();
|
||||
await expect(panels.view.refresh()).toBeVisible();
|
||||
expect(dashboards.param('expandedWidgetId')).toBe(SINGLE_PANEL_ID);
|
||||
});
|
||||
|
||||
test('TC-02 the modal opens directly from a deep link', async ({
|
||||
dashboards,
|
||||
panels,
|
||||
}) => {
|
||||
const id = await dashboards.seedAndOpen(singlePanelDashboard());
|
||||
|
||||
await dashboards.visit(
|
||||
`${PATH.dashboard(id)}?expandedWidgetId=${SINGLE_PANEL_ID}&graphType=graph`,
|
||||
);
|
||||
await expect(panels.view.content()).toBeVisible();
|
||||
});
|
||||
|
||||
test('TC-03 Refresh re-issues the query', async ({
|
||||
dashboards,
|
||||
queryRange,
|
||||
panels,
|
||||
}) => {
|
||||
await dashboards.seedAndOpen(singlePanelDashboard());
|
||||
await panels.view.open(SINGLE_PANEL_ID);
|
||||
|
||||
const refetch = queryRange.nextRequest();
|
||||
await panels.view.refresh().click();
|
||||
const request = await refetch;
|
||||
expect(request.method()).toBe('POST');
|
||||
});
|
||||
|
||||
test('TC-04 switching the panel type in the modal does not persist', async ({
|
||||
dashboards,
|
||||
panels,
|
||||
}) => {
|
||||
const id = await dashboards.seedAndOpen(singlePanelDashboard());
|
||||
await panels.view.open(SINGLE_PANEL_ID);
|
||||
|
||||
await panels.view.switchType('Table');
|
||||
await expect(panels.view.renderer(PanelKind.Table)).toBeVisible();
|
||||
|
||||
await panels.back();
|
||||
const after = await dashboards.get(id);
|
||||
expect(after.spec.panels[SINGLE_PANEL_ID].spec.plugin.kind).toBe(
|
||||
'signoz/TimeSeriesPanel',
|
||||
);
|
||||
});
|
||||
|
||||
test('TC-05 Switch to Edit Mode hands off to the panel editor', async ({
|
||||
authedPage: page,
|
||||
dashboards,
|
||||
panels,
|
||||
editor,
|
||||
}) => {
|
||||
const id = await dashboards.seedAndOpen(singlePanelDashboard());
|
||||
await panels.view.open(SINGLE_PANEL_ID);
|
||||
|
||||
await panels.view.switchToEdit().click();
|
||||
|
||||
await page.waitForURL(onEditor(id));
|
||||
await expect(editor.root()).toBeVisible();
|
||||
});
|
||||
|
||||
test('TC-06 a locked panel opens in View but offers no Switch to Edit', async ({
|
||||
dashboards,
|
||||
panels,
|
||||
}) => {
|
||||
// `canSwitchToEdit = canEditDashboard && !isLocked`, but View itself is not
|
||||
// role-gated — so the modal opens and only the handoff button is disabled.
|
||||
const id = await dashboards.seed(singlePanelDashboard());
|
||||
await dashboards.setLocked(id, true);
|
||||
await dashboards.open(id);
|
||||
|
||||
await panels.panel(SINGLE_PANEL_ID).actions.run(PanelAction.view);
|
||||
await expect(panels.view.content()).toBeVisible();
|
||||
await expect(panels.view.switchToEdit()).toBeDisabled();
|
||||
await expect(panels.view.refresh()).toBeVisible();
|
||||
});
|
||||
|
||||
test('TC-07 View → Edit → View round-trips with no changes', async ({
|
||||
authedPage: page,
|
||||
dashboards,
|
||||
panels,
|
||||
editor,
|
||||
}) => {
|
||||
const id = await dashboards.seedAndOpen(
|
||||
singlePanelDashboard({ panelName: 'Round trip' }),
|
||||
);
|
||||
await panels.view.open(SINGLE_PANEL_ID);
|
||||
|
||||
await panels.view.switchToEdit().click();
|
||||
await page.waitForURL(onEditor(id));
|
||||
await expect(editor.title()).toHaveValue('Round trip');
|
||||
await expect(editor.unsavedBadge()).toHaveCount(0);
|
||||
|
||||
await editor.switchToView().click();
|
||||
await expect(panels.view.content()).toBeVisible();
|
||||
|
||||
const after = await dashboards.get(id);
|
||||
expect(after.spec.panels[SINGLE_PANEL_ID].spec.display.name).toBe(
|
||||
'Round trip',
|
||||
);
|
||||
});
|
||||
|
||||
test('TC-08 an unsaved change in the modal carries into the editor', async ({
|
||||
authedPage: page,
|
||||
dashboards,
|
||||
panels,
|
||||
editor,
|
||||
}) => {
|
||||
const id = await dashboards.seedAndOpen(singlePanelDashboard());
|
||||
await panels.view.open(SINGLE_PANEL_ID);
|
||||
|
||||
await panels.view.switchType('Table');
|
||||
await expect(panels.view.renderer(PanelKind.Table)).toBeVisible();
|
||||
|
||||
await panels.view.switchToEdit().click();
|
||||
await page.waitForURL(onEditor(id));
|
||||
|
||||
// Editor opens on the MODIFIED panel, and still nothing is committed.
|
||||
await expect(editor.preview.renderer(PanelKind.Table)).toBeVisible();
|
||||
const after = await dashboards.get(id);
|
||||
expect(after.spec.panels[SINGLE_PANEL_ID].spec.plugin.kind).toBe(
|
||||
'signoz/TimeSeriesPanel',
|
||||
);
|
||||
});
|
||||
|
||||
test('TC-09 an unsaved change in the editor carries back into the modal', async ({
|
||||
dashboards,
|
||||
panels,
|
||||
editor,
|
||||
}) => {
|
||||
const id = await dashboards.seedAndEdit(
|
||||
singlePanelDashboard(),
|
||||
SINGLE_PANEL_ID,
|
||||
);
|
||||
|
||||
await editor.title().fill('Edited but not saved');
|
||||
await expect(editor.unsavedBadge()).toBeVisible();
|
||||
|
||||
await editor.switchToView().click();
|
||||
await expect(panels.view.content()).toBeVisible();
|
||||
await expect(panels.view.dialog()).toContainText('Edited but not saved');
|
||||
|
||||
const after = await dashboards.get(id);
|
||||
expect(after.spec.panels[SINGLE_PANEL_ID].spec.display.name).toBe(
|
||||
'Solo panel',
|
||||
);
|
||||
});
|
||||
});
|
||||
103
tests/e2e/tests/dashboards/page/viewing.spec.ts
Normal file
103
tests/e2e/tests/dashboards/page/viewing.spec.ts
Normal file
@@ -0,0 +1,103 @@
|
||||
import {
|
||||
COMPACT_PANELS,
|
||||
compactDashboard,
|
||||
expect,
|
||||
test,
|
||||
} from '../../../drivers/dashboard-page';
|
||||
|
||||
test.describe('Dashboards — viewing', () => {
|
||||
test('TC-01 page chrome renders the breadcrumb, title and toolbar', async ({
|
||||
dashboards,
|
||||
dashboardPage,
|
||||
}) => {
|
||||
const fixture = compactDashboard();
|
||||
const name = fixture.spec.display.name;
|
||||
await dashboards.seedAndOpen(fixture);
|
||||
|
||||
await expect(dashboardPage.breadcrumbHome()).toBeVisible();
|
||||
await expect(dashboardPage.breadcrumb()).toContainText(name);
|
||||
await expect(dashboardPage.title()).toHaveText(name);
|
||||
await expect.poll(() => dashboardPage.documentTitle()).toBe(name);
|
||||
|
||||
const { toolbar } = dashboardPage;
|
||||
for (const control of [
|
||||
toolbar.timePicker(),
|
||||
toolbar.refresh(),
|
||||
toolbar.autoRefresh(),
|
||||
toolbar.actions(),
|
||||
toolbar.configure(),
|
||||
toolbar.json(),
|
||||
toolbar.newPanel(),
|
||||
toolbar.feedback(),
|
||||
toolbar.share(),
|
||||
]) {
|
||||
await expect(control).toBeVisible();
|
||||
}
|
||||
});
|
||||
|
||||
test('TC-02 the breadcrumb returns to the dashboards list', async ({
|
||||
dashboards,
|
||||
dashboardPage,
|
||||
authedPage: page,
|
||||
}) => {
|
||||
await dashboards.seedAndOpen(compactDashboard());
|
||||
|
||||
await dashboardPage.breadcrumbHome().click();
|
||||
|
||||
await expect(page).toHaveURL(/\/dashboard(\?|$)/);
|
||||
await expect(dashboardPage.listPage()).toBeVisible();
|
||||
});
|
||||
|
||||
test('TC-03 the tags bar shows two tags and folds the rest', async ({
|
||||
dashboards,
|
||||
dashboardPage,
|
||||
}) => {
|
||||
await dashboards.seedAndOpen({
|
||||
...compactDashboard(),
|
||||
tags: [
|
||||
{ key: 'team', value: 'apm' },
|
||||
{ key: 'latency', value: 'latency' },
|
||||
{ key: 'tier', value: 'gold' },
|
||||
],
|
||||
});
|
||||
|
||||
// A tag whose key equals its value renders as the bare key.
|
||||
await expect(dashboardPage.tags()).toContainText('team:apm');
|
||||
await expect(dashboardPage.tags()).toContainText('latency');
|
||||
await expect(dashboardPage.tags()).not.toContainText('tier:gold');
|
||||
await expect(dashboardPage.tagsOverflow()).toHaveText('+1');
|
||||
});
|
||||
|
||||
test('TC-04 section headers render', async ({ dashboards, dashboardPage }) => {
|
||||
await dashboards.seedAndOpen(compactDashboard());
|
||||
|
||||
await expect(dashboardPage.sectionTitles()).toHaveText(['Charts', 'Tabular']);
|
||||
});
|
||||
|
||||
test('TC-05 panels render inside their sections', async ({
|
||||
dashboards,
|
||||
dashboardPage,
|
||||
}) => {
|
||||
await dashboards.seedAndOpen(compactDashboard());
|
||||
|
||||
await expect(dashboardPage.section('Charts').panels()).toHaveCount(1);
|
||||
await expect(dashboardPage.section('Tabular').panels()).toHaveCount(2);
|
||||
await expect(
|
||||
dashboardPage.section('Charts').root().getByText('Calls by service'),
|
||||
).toBeVisible();
|
||||
});
|
||||
|
||||
test('TC-06 the initial load raises no page error', async ({
|
||||
dashboards,
|
||||
dashboardPage,
|
||||
panels,
|
||||
}) => {
|
||||
const collected = dashboardPage.collectPageErrors();
|
||||
|
||||
await dashboards.seedAndOpen(compactDashboard());
|
||||
await expect(panels.panel(COMPACT_PANELS.timeseries).title()).toBeVisible();
|
||||
await expect(panels.panel(COMPACT_PANELS.table).title()).toBeVisible();
|
||||
|
||||
expect(collected.errors).toEqual([]);
|
||||
});
|
||||
});
|
||||
152
tests/e2e/tests/dashboards/page/zoom.spec.ts
Normal file
152
tests/e2e/tests/dashboards/page/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 — 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();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user