Compare commits

...

1 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
2 changed files with 101 additions and 484 deletions

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

@@ -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);
});
});