Compare commits

...

2 Commits

Author SHA1 Message Date
Ashwin Bhatkal
ff05357ea0 test(e2e): rewrite the sections spec for V2
Seven tests over the sections and the panels inside them: each section renders with
its title, each panel renders, a panel belongs to the section whose layout references
it, collapsing hides that section's panels and leaves its neighbours alone, expanding
brings them back, a panel exposes its actions menu, and a panel with nothing to show
renders its no-data state rather than an error.

Replaces the V1 file, including its two `test.skip` placeholders — collapse and the
per-section add/delete — and drops its parked-specs.json entry. The collapse one is
covered here for real; adding a panel through the editor belongs with the panel-editor
spec, not this file.

The last test is worth keeping honest about: the fixture's queries target signals a
test stack holds nothing for, so the panels resolve empty. That the empty case reads
as "no data" and not as a failure is exactly the assertion.
2026-08-06 12:53:42 +05:30
Ashwin Bhatkal
908f421dd8 test(e2e): real section/panel locators, and a sections fixture
The panel and section locators I first wrote keyed off `[data-panel-id]` and
`[data-section-id]`, which match nothing — invented from reading the components rather
than from the DOM. The rendered structure is:

  dashboard-section-sec-<firstPanelKey>          the section
  dashboard-section-toggle-sec-<firstPanelKey>   its collapse control
  panel-actions-<panelKey>                       a panel's actions trigger

A section's id derives from its first panel's key, which is stable for a seeded
fixture because the keys are ours.

sections-dashboard-v2.json is trimmed out of a real V2 dashboard: three grid sections,
six panels across six plugin kinds, layout $refs intact, variables pared back to text
and custom so option lists stay fixed. Panel queries target signals a test stack holds
nothing for, which is deliberate — specs on it assert structure and chrome, and the
empty result is itself worth asserting.
2026-08-06 12:53:11 +05:30
4 changed files with 480 additions and 488 deletions

View File

@@ -268,11 +268,23 @@ export async function typeVariableValue(
// ─── Panels and sections ──────────────────────────────────────────────────
export const panelByTitle = (page: Page, title: string): Locator =>
page.locator('[data-panel-id]').filter({ hasText: title });
// A section's id is derived from its first panel's key, e.g. panel `p-timeseries` gives
// section `sec-p-timeseries` — stable for a seeded fixture, since the keys are ours.
export const sectionId = (firstPanelKey: string): string =>
`sec-${firstPanelKey}`;
export const sectionByName = (page: Page, name: string): Locator =>
page.locator('[data-section-id]').filter({ hasText: name });
export const section = (page: Page, firstPanelKey: string): Locator =>
page.getByTestId(`dashboard-section-${sectionId(firstPanelKey)}`);
export const sectionToggle = (page: Page, firstPanelKey: string): Locator =>
page.getByTestId(`dashboard-section-toggle-${sectionId(firstPanelKey)}`);
export const panelActions = (page: Page, panelKey: string): Locator =>
page.getByTestId(`panel-actions-${panelKey}`);
/** A panel by its display name — panels carry no per-panel testid on the card itself. */
export const panelByTitle = (page: Page, title: string): Locator =>
page.getByText(title, { exact: true });
/** Resolved when no panel on the page is still fetching. */
export async function awaitPanelsSettled(page: Page): Promise<void> {

View File

@@ -3,7 +3,6 @@
"specs": [
"**/tests/dashboards/list.spec.ts",
"**/tests/dashboards/details/03-viewing.spec.ts",
"**/tests/dashboards/details/12-sections.spec.ts",
"**/tests/dashboards/details/21-panel-actions.spec.ts",
"**/tests/dashboards/details/35-add-panel.spec.ts",
"**/tests/dashboards/details/44-edit-panel.spec.ts",

View File

@@ -0,0 +1,363 @@
{
"$comment": "Three grid sections with two panels each, trimmed from a real V2 dashboard so the structure (sections, panels, layout refs, collapse state, plugin kinds) is faithful without depending on any particular telemetry. Panel queries target signals this stack may hold nothing for — that is fine: these specs assert on structure and chrome, never on chart values. Variables are text + custom, whose options come from the definition.",
"spec": {
"display": { "name": "sections-v2", "description": "" },
"variables": [
{
"kind": "TextVariable",
"spec": {
"name": "textbox.environment",
"display": { "name": "textbox.environment", "description": "" },
"value": "prod",
"constant": false
}
},
{
"kind": "ListVariable",
"spec": {
"name": "custom.service.name",
"display": { "name": "custom.service.name", "description": "" },
"allowMultiple": true,
"allowAllValue": true,
"sort": "alphabetical-asc",
"plugin": {
"kind": "signoz/CustomVariable",
"spec": { "customValue": "checkout,payments,cart" }
}
}
}
],
"panels": {
"p-timeseries": {
"kind": "Panel",
"spec": {
"display": { "name": "Requests over time", "description": "" },
"plugin": {
"kind": "signoz/TimeSeriesPanel",
"spec": {
"visualization": { "timePreference": "global_time", "fillSpans": false },
"formatting": { "unit": "none", "decimalPrecision": "2" },
"legend": { "position": "bottom", "mode": "list", "customColors": null }
}
},
"queries": [
{
"kind": "time_series",
"spec": {
"name": "A",
"plugin": {
"kind": "signoz/BuilderQuery",
"spec": {
"name": "A",
"signal": "logs",
"source": "",
"aggregations": [{ "expression": "count()" }],
"disabled": false,
"filter": { "expression": "service.name IN $custom.service.name" },
"groupBy": [
{
"name": "service.name",
"signal": "",
"fieldContext": "resource",
"fieldDataType": "string"
}
],
"order": [],
"having": { "expression": "" },
"functions": [],
"legend": ""
}
}
}
}
],
"links": []
}
},
"p-table": {
"kind": "Panel",
"spec": {
"display": { "name": "Requests by pod", "description": "" },
"plugin": {
"kind": "signoz/TablePanel",
"spec": {
"visualization": { "timePreference": "global_time" },
"formatting": { "columnUnits": { "A": "" }, "decimalPrecision": "2" },
"thresholds": null
}
},
"queries": [
{
"kind": "scalar",
"spec": {
"name": "A",
"plugin": {
"kind": "signoz/BuilderQuery",
"spec": {
"name": "A",
"signal": "logs",
"source": "",
"aggregations": [{ "expression": "count()" }],
"disabled": false,
"filter": { "expression": "service.name IN $custom.service.name" },
"groupBy": [
{
"name": "k8s.pod.name",
"signal": "",
"fieldContext": "resource",
"fieldDataType": "string"
}
],
"order": [],
"having": { "expression": "" },
"functions": [],
"legend": ""
}
}
}
}
],
"links": []
}
},
"p-promql": {
"kind": "Panel",
"spec": {
"display": { "name": "Duration rate (PromQL)", "description": "" },
"plugin": {
"kind": "signoz/TimeSeriesPanel",
"spec": {
"visualization": { "timePreference": "global_time", "fillSpans": false },
"formatting": { "unit": "none", "decimalPrecision": "2" },
"legend": { "position": "bottom", "mode": "list", "customColors": null }
}
},
"queries": [
{
"kind": "time_series",
"spec": {
"name": "A",
"plugin": {
"kind": "signoz/PromQLQuery",
"spec": {
"name": "A",
"query": "sum by (\"service.name\") (rate({\"http.server.duration.count\"}[5m]))",
"disabled": false,
"step": 0,
"stats": false,
"legend": ""
}
}
}
}
],
"links": []
}
},
"p-number": {
"kind": "Panel",
"spec": {
"display": { "name": "Total requests", "description": "" },
"plugin": {
"kind": "signoz/NumberPanel",
"spec": {
"visualization": { "timePreference": "global_time" },
"formatting": { "unit": "none", "decimalPrecision": "2" },
"thresholds": null
}
},
"queries": [
{
"kind": "scalar",
"spec": {
"name": "A",
"plugin": {
"kind": "signoz/PromQLQuery",
"spec": {
"name": "A",
"query": "sum(rate({\"http.server.duration.count\"}[5m]))",
"disabled": false,
"step": 0,
"stats": false,
"legend": ""
}
}
}
}
],
"links": []
}
},
"p-pie": {
"kind": "Panel",
"spec": {
"display": { "name": "Split by environment", "description": "" },
"plugin": {
"kind": "signoz/PieChartPanel",
"spec": {
"visualization": { "timePreference": "global_time" },
"formatting": { "unit": "none", "decimalPrecision": "2" },
"legend": { "position": "bottom", "mode": "list", "customColors": null }
}
},
"queries": [
{
"kind": "scalar",
"spec": {
"name": "A",
"plugin": {
"kind": "signoz/BuilderQuery",
"spec": {
"name": "A",
"signal": "logs",
"source": "",
"aggregations": [{ "expression": "count()" }],
"disabled": false,
"filter": {
"expression": "deployment.environment = $textbox.environment"
},
"groupBy": [
{
"name": "service.name",
"signal": "",
"fieldContext": "resource",
"fieldDataType": "string"
}
],
"order": [],
"having": { "expression": "" },
"functions": [],
"legend": ""
}
}
}
}
],
"links": []
}
},
"p-bar": {
"kind": "Panel",
"spec": {
"display": { "name": "Stacked by pod", "description": "" },
"plugin": {
"kind": "signoz/BarChartPanel",
"spec": {
"visualization": {
"timePreference": "global_time",
"fillSpans": false,
"stackedBarChart": true
},
"formatting": { "unit": "none", "decimalPrecision": "2" },
"legend": { "position": "bottom", "mode": "list", "customColors": null },
"thresholds": null
}
},
"queries": [
{
"kind": "time_series",
"spec": {
"name": "A",
"plugin": {
"kind": "signoz/BuilderQuery",
"spec": {
"name": "A",
"signal": "logs",
"source": "",
"aggregations": [{ "expression": "count()" }],
"disabled": false,
"filter": { "expression": "" },
"groupBy": [
{
"name": "k8s.pod.name",
"signal": "",
"fieldContext": "resource",
"fieldDataType": "string"
}
],
"order": [],
"having": { "expression": "" },
"functions": [],
"legend": ""
}
}
}
}
],
"links": []
}
}
},
"layouts": [
{
"kind": "Grid",
"spec": {
"display": { "title": "Query Builder", "collapse": { "open": true } },
"items": [
{
"x": 0,
"y": 0,
"width": 6,
"height": 6,
"content": { "$ref": "#/spec/panels/p-timeseries" }
},
{
"x": 6,
"y": 0,
"width": 6,
"height": 6,
"content": { "$ref": "#/spec/panels/p-table" }
}
]
}
},
{
"kind": "Grid",
"spec": {
"display": { "title": "PromQL", "collapse": { "open": true } },
"items": [
{
"x": 0,
"y": 0,
"width": 6,
"height": 6,
"content": { "$ref": "#/spec/panels/p-promql" }
},
{
"x": 6,
"y": 0,
"width": 6,
"height": 6,
"content": { "$ref": "#/spec/panels/p-number" }
}
]
}
},
{
"kind": "Grid",
"spec": {
"display": { "title": "Mixed", "collapse": { "open": true } },
"items": [
{
"x": 0,
"y": 0,
"width": 6,
"height": 6,
"content": { "$ref": "#/spec/panels/p-pie" }
},
{
"x": 6,
"y": 0,
"width": 6,
"height": 6,
"content": { "$ref": "#/spec/panels/p-bar" }
}
]
}
}
],
"duration": "",
"refreshInterval": "",
"links": []
}
}

View File

@@ -2,538 +2,156 @@ import type { Page } from '@playwright/test';
import { expect, test } from '../../../fixtures/auth';
import { newAdminContext } from '../../../helpers/auth';
import { authToken } from '../../../helpers/dashboards';
import {
authToken,
createApmMetricsDashboardViaApi,
deleteDashboardViaApi,
} from '../../../helpers/dashboards';
createDashboardV2ViaApi,
dashboardV2Path,
deleteDashboardV2ViaApi,
panelActions,
panelByTitle,
section,
sectionToggle,
variablesBar,
WIDE_VIEWPORT,
} from '../../../helpers/dashboards-v2';
import sectionsFixture from '../../../testdata/sections-dashboard-v2.json';
// ─── 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;
// Sections and the panels inside them, seeded from a spec trimmed out of a real V2
// dashboard: three grid sections, six panels across six plugin kinds. Assertions are
// structural — titles, membership, collapse — never chart values, so nothing here
// depends on the stack holding telemetry.
test.beforeEach(async ({ browser }) => {
const ctx = await newAdminContext(browser);
const page = await ctx.newPage();
try {
apmDashboardId = await createApmMetricsDashboardViaApi(page);
} finally {
await ctx.close();
}
});
test.use({ viewport: WIDE_VIEWPORT });
test.afterEach(async ({ browser }) => {
if (!apmDashboardId) {
const seedIds = new Set<string>();
const SECTIONS = [
{ title: 'Query Builder', firstPanel: 'p-timeseries' },
{ title: 'PromQL', firstPanel: 'p-promql' },
{ title: 'Mixed', firstPanel: 'p-pie' },
];
const PANEL_TITLES = [
'Requests over time',
'Requests by pod',
'Duration rate (PromQL)',
'Total requests',
'Split by environment',
'Stacked by pod',
];
async function seedAndOpen(page: Page, label: string): Promise<string> {
const id = await createDashboardV2ViaApi(
page,
`detail-sections-${label}-${process.env.TEST_WORKER_INDEX ?? '0'}`,
sectionsFixture.spec,
);
seedIds.add(id);
await page.goto(dashboardV2Path(id));
await expect(variablesBar(page)).toBeVisible();
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);
await deleteDashboardViaApi(ctx.request, apmDashboardId, token);
} catch {
// Best-effort cleanup — a failing delete should not mask test
// failures the user actually needs to see.
for (const id of seedIds) {
await deleteDashboardV2ViaApi(ctx.request, id, token);
seedIds.delete(id);
}
} 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('..');
}
test.describe('Dashboard detail — sections and panels', () => {
test('TC-01 every section in the spec renders with its title', async ({
authedPage: page,
}) => {
await seedAndOpen(page, 'render');
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;
for (const { title, firstPanel } of SECTIONS) {
await expect(section(page, firstPanel)).toBeVisible();
await expect(section(page, firstPanel)).toContainText(title);
}
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 ({
test('TC-02 every panel in the spec renders with its title', async ({
authedPage: page,
}) => {
await gotoApmDashboard(page);
await seedAndOpen(page, 'panels');
// 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);
for (const title of PANEL_TITLES) {
await expect(panelByTitle(page, title).first()).toBeVisible();
}
});
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 ({
test('TC-03 a panel belongs to the section that references it', async ({
authedPage: page,
}) => {
await gotoApmDashboard(page);
await seedAndOpen(page, 'membership');
// 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');
// The layout puts these two in "PromQL" and nothing else there.
const promql = section(page, 'p-promql');
await expect(promql).toContainText('Duration rate (PromQL)');
await expect(promql).toContainText('Total requests');
await expect(promql).not.toContainText('Requests over time');
});
test('TC-05 rename a section, restore original name', async ({
test('TC-04 collapsing a section hides the panels inside it', async ({
authedPage: page,
}) => {
await gotoApmDashboard(page);
await seedAndOpen(page, 'collapse');
await expect(panelByTitle(page, 'Requests over time').first()).toBeVisible();
const renamed = `Renamed Section ${Date.now()}`;
await sectionToggle(page, 'p-timeseries').click();
// 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);
await expect(panelByTitle(page, 'Requests over time').first()).toBeHidden();
// Its neighbours are untouched.
await expect(panelByTitle(page, 'Total requests').first()).toBeVisible();
});
test('TC-06 cancel section rename leaves name unchanged', async ({
test('TC-05 expanding a collapsed section brings its panels back', async ({
authedPage: page,
}) => {
await gotoApmDashboard(page);
await seedAndOpen(page, 'expand');
const toggle = sectionToggle(page, 'p-timeseries');
await clickSectionSettings(sectionRow(page, 'External calls'));
await page
.getByRole('tooltip')
.getByRole('button', { name: 'Rename' })
.click();
await toggle.click();
await expect(panelByTitle(page, 'Requests over time').first()).toBeHidden();
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);
await toggle.click();
await expect(panelByTitle(page, 'Requests over time').first()).toBeVisible();
});
// 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 ({
test('TC-06 a panel exposes its actions menu', async ({
authedPage: page,
}) => {
await gotoApmDashboard(page);
await seedAndOpen(page, 'actions');
const panelName = `Test Panel ${Date.now()}`;
await panelByTitle(page, 'Requests over time').first().hover();
await panelActions(page, 'p-timeseries').click();
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);
// Assert the affordances the menu offers rather than a container testid: the one
// in the source is not rendered on this path, and the items are what users act on.
await expect(page.getByRole('menu')).toBeVisible();
for (const item of ['View', 'Edit panel', 'Clone', 'Delete panel']) {
await expect(page.getByRole('menuitem', { name: item })).toBeVisible();
}
});
// ─── New section in edit mode ────────────────────────────────────────────
test('TC-08 add a new section via edit mode, then remove it', async ({
test('TC-07 a panel with nothing to show renders its no-data state, not an error', async ({
authedPage: page,
}) => {
await gotoApmDashboard(page);
await seedAndOpen(page, 'nodata');
const sectionName = `Temp Section ${Date.now()}`;
await page.getByTestId('options').click();
await page.getByRole('button', { 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();
// The seeded queries target signals this stack holds nothing for, so the panels
// resolve empty — that must read as "no data", never as a failure.
await expect(page.getByTestId('panel-no-data').first()).toBeVisible();
await expect(page.getByTestId('panel-error')).toHaveCount(0);
});
});