From 0b1e67bc2c9181acdf1908c62b76f006a3729329 Mon Sep 17 00:00:00 2001 From: Shrinath Rao Date: Tue, 22 Sep 2026 11:32:05 +0000 Subject: [PATCH] test(autoe2e): generated E2E tests for #14601 (dashboard-cell-explorer-log-detail) (#14703) Co-authored-by: Shrinath-O2 <214651540+Shrinath-O2@users.noreply.github.com> Co-authored-by: ktx-akshay Co-authored-by: Neha P --- tests/api-testing/tests/alerts/test_v2.py | 77 ++++-- tests/ui-testing/ci-matrix/ci_matrix.json | 3 +- .../dashboardPages/dashboard-cell-explorer.js | 248 ++++++++++++++++++ .../dashboard-cell-explorer-detail.spec.js | 231 ++++++++++++++++ 4 files changed, 539 insertions(+), 20 deletions(-) create mode 100644 tests/ui-testing/playwright-tests/Dashboards/dashboard-cell-explorer-detail.spec.js diff --git a/tests/api-testing/tests/alerts/test_v2.py b/tests/api-testing/tests/alerts/test_v2.py index 133fcb3a51..46a1467a9a 100644 --- a/tests/api-testing/tests/alerts/test_v2.py +++ b/tests/api-testing/tests/alerts/test_v2.py @@ -34,14 +34,14 @@ from __future__ import annotations import logging import time import uuid -from collections.abc import Generator +from collections.abc import Callable, Generator from typing import Any import pytest from support.client import OpenObserveClient from support.factories import unique_name -from support.wait import wait_until +from support.wait import WaitTimeout, wait_until logger = logging.getLogger(__name__) @@ -145,6 +145,37 @@ def _alert_payload( } +def _get_alert_until( + client: OpenObserveClient, + alert_id: str, + predicate: Callable[[dict[str, Any]], bool], + *, + msg: str, + timeout: float = 30.0, +) -> dict[str, Any]: + """GET the alert, polling until `predicate` holds over the response body. + + A write does not update the server's in-memory alert cache directly: it + writes the row and emits a coordinator watch event, and `get_by_id` serves + the cached copy until the watcher applies that event. So a GET issued right + after a PUT/PATCH can still return the pre-update alert. Every assertion + about a value a test just wrote has to poll for it. + """ + seen: dict[str, Any] = {} + + def _matches() -> bool: + resp = client.get(f"alerts/{alert_id}", prefix="api/v2/") + assert resp.status_code == 200, f"{resp.status_code}: {resp.text}" + seen["body"] = resp.json() + return predicate(seen["body"]) + + try: + wait_until(_matches, timeout=timeout, interval=0.5, msg=msg) + except WaitTimeout as e: + raise AssertionError(f"{e}; last alert body: {seen.get('body')!r}") from e + return seen["body"] + + # ----- fixtures ----- @@ -310,10 +341,12 @@ def test_update_alert_description_field( ) assert resp.status_code == 200, f"update failed: {resp.status_code} {resp.text}" - # Verify the change persisted - resp_get = client.get(f"alerts/{temp_alert['alert_id']}", prefix="api/v2/") - assert resp_get.json().get("description") == new_description, \ - "description should be persisted after update" + _get_alert_until( + client, + temp_alert["alert_id"], + lambda body: body.get("description") == new_description, + msg=f"description should be persisted after update: {new_description!r}", + ) # ----- enable / disable ----- @@ -332,13 +365,15 @@ def test_disable_alert_via_enable_endpoint( ) assert resp.status_code == 200, f"disable failed: {resp.status_code} {resp.text}" - resp_get = client.get(f"alerts/{alert_id}", prefix="api/v2/") - assert resp_get.status_code == 200 - enabled = resp_get.json().get("enabled") # OO returns boolean False (not the string 'False' — the original test # had that bug AND the assertion was unreachable due to an indentation # error inside an else block that never ran). - assert enabled is False, f"alert should be disabled (enabled=False), got: {enabled!r}" + _get_alert_until( + client, + alert_id, + lambda body: body.get("enabled") is False, + msg="alert should be disabled (enabled=False)", + ) def test_enable_alert_via_enable_endpoint( @@ -358,9 +393,12 @@ def test_enable_alert_via_enable_endpoint( ) assert resp.status_code == 200, f"enable failed: {resp.status_code} {resp.text}" - resp_get = client.get(f"alerts/{alert_id}", prefix="api/v2/") - enabled = resp_get.json().get("enabled") - assert enabled is True, f"alert should be enabled (enabled=True), got: {enabled!r}" + _get_alert_until( + client, + alert_id, + lambda body: body.get("enabled") is True, + msg="alert should be enabled (enabled=True)", + ) # ----- trigger ----- @@ -587,12 +625,13 @@ def test_alert_vrl_function_can_be_updated( assert resp_update.status_code == 200, f"update failed: {resp_update.status_code} {resp_update.text}" # Verify both VRL change AND description change persisted - resp_get = client.get(f"alerts/{alert_id}", prefix="api/v2/") - body = resp_get.json() - assert body.get("query_condition", {}).get("vrl_function") == vrl_updated, \ - "VRL should be updated to the new base64 string" - assert body.get("description") == "VRL update test - UPDATED", \ - "description should be updated" + _get_alert_until( + client, + alert_id, + lambda body: body.get("query_condition", {}).get("vrl_function") == vrl_updated + and body.get("description") == "VRL update test - UPDATED", + msg="VRL and description should both be updated after PUT", + ) finally: for a in client.alerts.list(): if a.get("name") == alert_name: diff --git a/tests/ui-testing/ci-matrix/ci_matrix.json b/tests/ui-testing/ci-matrix/ci_matrix.json index 1864876350..60c6c7664d 100644 --- a/tests/ui-testing/ci-matrix/ci_matrix.json +++ b/tests/ui-testing/ci-matrix/ci_matrix.json @@ -165,7 +165,8 @@ "dashboard-table-column-filtering.spec.js", "dashboard-table-pagination.spec.js", "dashboard-pivot-table.spec.js", - "interactive-dashboard-table.spec.js" + "interactive-dashboard-table.spec.js", + "dashboard-cell-explorer-detail.spec.js" ], "disabled": [] }, diff --git a/tests/ui-testing/pages/dashboardPages/dashboard-cell-explorer.js b/tests/ui-testing/pages/dashboardPages/dashboard-cell-explorer.js index cd4f80639d..698eaf9643 100644 --- a/tests/ui-testing/pages/dashboardPages/dashboard-cell-explorer.js +++ b/tests/ui-testing/pages/dashboardPages/dashboard-cell-explorer.js @@ -37,6 +37,31 @@ export default class DashboardCellExplorerPage { // DateTime renders its `data-test-name` prop as the element's data-test. this.dateTime = page.locator('[data-test="dashboard-log-drawer-date-time"]'); this.eventDetailDrawer = page.locator('[data-test="log-explorer-event-detail-drawer"]'); + + // In-place log-detail surface (replaces the results list when detailOpen) + this.rowOpen = page.locator('[data-test="log-explorer-row-open"]'); + this.detailBackBtn = page.locator('[data-test="log-explorer-detail-back"]'); + this.detailPrevBtn = page.locator('[data-test="log-explorer-detail-prev"]'); + this.detailNextBtn = page.locator('[data-test="log-explorer-detail-next"]'); + // Push-nav header counter ("n / m"); the span sits between the prev and + // next buttons and only renders while detail is open. + this.positionCounter = page + .locator('[data-test="log-explorer-detail-prev"] ~ span') + .first(); + this.insightsTab = page.locator('[data-test="log-detail-insights-tab"]'); + this.jsonTab = page.locator('[data-test="log-detail-json-tab"]'); + this.tableTab = page.locator('[data-test="log-detail-table-tab"]'); + this.jsonContent = page.locator('[data-test="log-detail-json-content"]'); + this.tableContent = page.locator('[data-test="log-detail-table-content"]'); + this.kvTable = page.locator('[data-test="log-detail-table"]'); + this.kvSearchInput = page.locator('[data-test="log-detail-table-search-input-field"]'); + // v-show gated on the table tab; present in the DOM on every tab. + this.wrapToggle = page.locator('[data-test="log-detail-wrap-values-toggle-btn"]'); + this.surroundWindowBtn = page.locator('[data-test="log-explorer-surround-window"]'); + // Copy-link OButton has no data-test; located by its i18n label. + this.copyLinkBtn = page.getByRole("button", { name: "Copy link" }); + this.anomalyProfileHeader = page.getByText("Field Anomaly Profile").first(); + this.surroundingHeader = page.getByText("Surrounding Events").first(); } /** @@ -136,4 +161,227 @@ export default class DashboardCellExplorerPage { ) .toBe(false); } + + // ── In-place log-detail surface (DashboardLogDrawer detailOpen mode) ────── + + /** Click the first result row's chevron and wait for the detail view. */ + async openDetailFromFirstRow() { + await expect(this.resultsTable).toBeVisible({ timeout: 30000 }); + await this.rowOpen.first().waitFor({ state: "visible", timeout: 15000 }); + await this.rowOpen.first().click(); + await expect(this.detailBackBtn).toBeVisible({ timeout: 30000 }); + } + + /** Detail is open: back button visible, results list hidden. */ + async expectDetailOpen() { + await expect(this.detailBackBtn).toBeVisible({ timeout: 15000 }); + await expect(this.resultsTable).toBeHidden({ timeout: 15000 }); + } + + /** Detail closed back to the list, but the cell-explorer drawer stays open. */ + async expectDetailClosedDrawerOpen() { + await expect(this.resultsTable).toBeVisible({ timeout: 15000 }); + await expect(this.detailBackBtn).toBeHidden({ timeout: 15000 }); + await expect(this.cellDrawer).toBeVisible({ timeout: 15000 }); + } + + async clickBack() { + await this.detailBackBtn.click(); + await expect(this.resultsTable).toBeVisible({ timeout: 30000 }); + } + + async pressEscape() { + await this.page.keyboard.press("Escape"); + await expect(this.resultsTable).toBeVisible({ timeout: 15000 }); + } + + /** Field-anomaly profile renders synchronously on detail open. */ + async expectAnomalyProfileVisible() { + await expect(this.anomalyProfileHeader).toBeVisible({ timeout: 15000 }); + } + + async expectDetailTabsVisible() { + await expect(this.insightsTab).toBeVisible({ timeout: 15000 }); + await expect(this.jsonTab).toBeVisible({ timeout: 15000 }); + await expect(this.tableTab).toBeVisible({ timeout: 15000 }); + } + + async clickJsonTab() { + await this.jsonTab.click(); + await expect(this.jsonContent).toBeVisible({ timeout: 15000 }); + } + + async clickTableTab() { + await this.tableTab.click(); + await expect(this.tableContent).toBeVisible({ timeout: 15000 }); + await expect(this.kvTable).toBeVisible({ timeout: 15000 }); + } + + async expectWrapToggleVisible() { + await expect(this.wrapToggle).toBeVisible({ timeout: 15000 }); + } + + async expectWrapToggleHidden() { + await expect(this.wrapToggle).toBeHidden({ timeout: 15000 }); + } + + // ── Push-nav (prev/next) ───────────────────────────────────────────────── + + async getPositionCurrent() { + const text = (await this.positionCounter.textContent()).trim(); + return parseInt(text.split("/")[0].trim(), 10); + } + + async getPositionTotal() { + const text = (await this.positionCounter.textContent()).trim(); + return parseInt(text.split("/")[1].trim(), 10); + } + + async expectNextEnabled() { + await expect(this.detailNextBtn).toBeEnabled({ timeout: 10000 }); + } + + async expectPrevEnabled() { + await expect(this.detailPrevBtn).toBeEnabled({ timeout: 10000 }); + } + + async expectPrevDisabled() { + await expect(this.detailPrevBtn).toBeDisabled({ timeout: 10000 }); + } + + async clickNext() { + await this.detailNextBtn.click(); + } + + async clickPrev() { + await this.detailPrevBtn.click(); + } + + // ── Shareable URL / copy-link ──────────────────────────────────────────── + + async expectCellEventTsInUrl() { + await expect + .poll(() => new URL(this.page.url()).searchParams.has("cell_event_ts"), { + timeout: 10000, + }) + .toBe(true); + } + + async expectCopyLinkVisible() { + await expect(this.copyLinkBtn).toBeVisible({ timeout: 15000 }); + } + + async clickCopyLink() { + await this.copyLinkBtn.click(); + } + + async getClipboardText() { + return this.page.evaluate(() => navigator.clipboard.readText()); + } + + // ── Surrounding events ─────────────────────────────────────────────────── + + async expectSurroundingSectionVisible() { + await expect(this.surroundingHeader).toBeVisible({ timeout: 30000 }); + await expect(this.surroundWindowBtn).toBeVisible({ timeout: 15000 }); + } + + /** + * Change the surrounding-events window to `value` minutes. Returns the observed + * search window span (end_time - start_time, microseconds) of the re-fetch the + * change triggered, so the caller can assert the re-fetch actually used the NEW + * window rather than the initial ±3 min load. + */ + async changeSurroundWindow(value = "5") { + const minutes = Number(value); + const expectedSpan = 2 * minutes * 60 * 1_000_000; + const label = { + 1: "±1 min", + 2: "±2 min", + 3: "±3 min", + 5: "±5 min", + 10: "±10 min", + }[minutes]; + + // Wait for the re-fetch: a surrounding-events search (`ORDER BY _timestamp + // ASC`) whose time window spans the NEW value, not the initial ±3 min. + const refetch = this.page.waitForResponse( + (resp) => { + const req = resp.request(); + if (!req.url().includes("/_search")) return false; + let body; + try { + body = req.postDataJSON(); + } catch { + return false; + } + if ( + typeof body?.query?.sql !== "string" || + !body.query.sql.includes("ORDER BY _timestamp ASC") + ) { + return false; + } + const span = + Number(body.query.end_time) - Number(body.query.start_time); + return span === expectedSpan; + }, + { timeout: 30000 } + ); + + await this.surroundWindowBtn.click(); + const option = this.page.locator( + `[data-test="log-explorer-surround-window-${value}"]` + ); + // Reka dropdowns re-mount their items during the reveal animation, so a + // naive click flakes with "element was detached". Poll the click until it + // lands (option selected -> menu closes). + await expect(async () => { + await option.click({ timeout: 3000 }); + }).toPass({ timeout: 15000, intervals: [500] }); + // State guard: confirm the selection actually landed before trusting the + // downstream result. + await expect(this.surroundWindowBtn).toContainText(label); + + const response = await refetch; + const body = response.request().postDataJSON(); + return Number(body.query.end_time) - Number(body.query.start_time); + } + + /** Surrounding section settled into rows or the (valid) empty state. */ + async expectSurroundingResolved() { + const rows = this.page.locator(".dld-ctx-row"); + const empty = this.page.getByText("No surrounding events found"); + await expect + .poll( + async () => + (await rows.count()) > 0 || (await empty.isVisible().catch(() => false)), + { timeout: 30000 } + ) + .toBe(true); + } + + // ── Deep-link restore ──────────────────────────────────────────────────── + + async reloadAndWaitForDetail() { + await this.page.reload(); + await expect(this.detailBackBtn).toBeVisible({ timeout: 60000 }); + } + + // ── KV table search ────────────────────────────────────────────────────── + + async fillKvSearch(text) { + await this.kvSearchInput.fill(text); + } + + async expectKvKeyVisible(field) { + await expect( + this.page.locator(`[data-test="log-detail-${field}-key"]`) + ).toBeVisible({ timeout: 15000 }); + } + + async expectKvKeyHidden(field) { + await expect( + this.page.locator(`[data-test="log-detail-${field}-key"]`) + ).toBeHidden({ timeout: 15000 }); + } } diff --git a/tests/ui-testing/playwright-tests/Dashboards/dashboard-cell-explorer-detail.spec.js b/tests/ui-testing/playwright-tests/Dashboards/dashboard-cell-explorer-detail.spec.js new file mode 100644 index 0000000000..4982192252 --- /dev/null +++ b/tests/ui-testing/playwright-tests/Dashboards/dashboard-cell-explorer-detail.spec.js @@ -0,0 +1,231 @@ +// E2E — Dashboard Cell Explorer Log Detail (in-place log-detail surface) +// Feature slug: dashboard-cell-explorer-log-detail +// +// Covers the detail view INSIDE the cell-explorer drawer: open detail from a +// result row, back/Escape return, JSON/Table tabs, prev/next push-nav, +// copy-link URL share, surrounding-events window, deep-link restore, and KV +// search. Reuses the same e2e_automate fixture + drillable table panel as +// interactive-dashboard-table.spec.js (drawer-open path), which this spec does +// NOT duplicate. + +const { + test, + expect, + navigateToBase, +} = require("../utils/enhanced-baseFixtures.js"); +import PageManager from "../../pages/page-manager"; +import { ingestion } from "./utils/dashIngestion.js"; +import { cleanupTestDashboard } from "./utils/dashCreation.js"; +import { + generateDashboardName, + setupTablePanelWithDimension, +} from "./utils/configPanelHelpers.js"; +const testLogger = require("../utils/test-logger.js"); + +test.describe.configure({ mode: "parallel" }); +test.describe.configure({ retries: 1 }); + +/** + * Build a drillable table panel, save it, and land on the dashboard VIEW page + * with the table rendered. + */ +async function buildDrillableTableAndView(page, pm, dashboardName) { + await setupTablePanelWithDimension(page, pm, dashboardName); + await pm.dashboardPanelActions.waitForChartToRender(); + await pm.dashboardPanelActions.savePanel(); + await pm.dashboardCellExplorer.waitForTableOnViewPage(); +} + +/** + * Drill into the first cell, then open the detail view for the first result. + */ +async function openDetail(pm) { + await pm.dashboardCellExplorer.openDrawerFromFirstDrillableCell(); + await pm.dashboardCellExplorer.openDetailFromFirstRow(); +} + +test.describe("Dashboard Cell Explorer Log Detail", { tag: ["@all", "@dashboard", "@dashboard-cell-explorer-log-detail"] }, () => { + test.beforeEach(async ({ page }) => { + await navigateToBase(page); + await ingestion(page); + }); + + test("open detail from a drilled result row renders the detail surface", { + tag: ["@smoke", "@P0"], + }, async ({ page }) => { + const pm = new PageManager(page); + const dashboardName = generateDashboardName(); + + await buildDrillableTableAndView(page, pm, dashboardName); + await openDetail(pm); + + await pm.dashboardCellExplorer.expectDetailOpen(); + await pm.dashboardCellExplorer.expectAnomalyProfileVisible(); + await pm.dashboardCellExplorer.expectDetailTabsVisible(); + testLogger.info("Detail surface rendered with back header, insights, and tabs"); + + await pm.dashboardCellExplorer.closeDrawer(); + await cleanupTestDashboard(page, pm, dashboardName); + }); + + test("back returns to the results list and keeps the drawer open", { + tag: ["@smoke", "@P0"], + }, async ({ page }) => { + const pm = new PageManager(page); + const dashboardName = generateDashboardName(); + + await buildDrillableTableAndView(page, pm, dashboardName); + await openDetail(pm); + + await pm.dashboardCellExplorer.clickBack(); + await pm.dashboardCellExplorer.expectDetailClosedDrawerOpen(); + testLogger.info("Back returned to the results list without closing the drawer"); + + await pm.dashboardCellExplorer.closeDrawer(); + await cleanupTestDashboard(page, pm, dashboardName); + }); + + test("JSON and Table tabs switch and render their content", { + tag: ["@functional", "@P1"], + }, async ({ page }) => { + const pm = new PageManager(page); + const dashboardName = generateDashboardName(); + + await buildDrillableTableAndView(page, pm, dashboardName); + await openDetail(pm); + + await pm.dashboardCellExplorer.clickJsonTab(); + await pm.dashboardCellExplorer.expectWrapToggleHidden(); + + await pm.dashboardCellExplorer.clickTableTab(); + await pm.dashboardCellExplorer.expectWrapToggleVisible(); + testLogger.info("JSON and Table tabs render content; wrap toggle gated to table tab"); + + await pm.dashboardCellExplorer.closeDrawer(); + await cleanupTestDashboard(page, pm, dashboardName); + }); + + test("prev/next steps between events and respects the position bounds", { + tag: ["@functional", "@P1"], + }, async ({ page }) => { + const pm = new PageManager(page); + const dashboardName = generateDashboardName(); + + await buildDrillableTableAndView(page, pm, dashboardName); + await openDetail(pm); + + const total = await pm.dashboardCellExplorer.getPositionTotal(); + expect(total).toBeGreaterThanOrEqual(2); + + await pm.dashboardCellExplorer.expectNextEnabled(); + await pm.dashboardCellExplorer.clickNext(); + await pm.dashboardCellExplorer.expectPrevEnabled(); + expect(await pm.dashboardCellExplorer.getPositionCurrent()).toBe(2); + + await pm.dashboardCellExplorer.clickPrev(); + await pm.dashboardCellExplorer.expectPrevDisabled(); + expect(await pm.dashboardCellExplorer.getPositionCurrent()).toBe(1); + testLogger.info("Prev/next steps between events with correct bound handling"); + + await pm.dashboardCellExplorer.closeDrawer(); + await cleanupTestDashboard(page, pm, dashboardName); + }); + + test("copy link exposes a shareable URL with cell_event_ts", { + tag: ["@functional", "@P1"], + }, async ({ page }) => { + const pm = new PageManager(page); + const dashboardName = generateDashboardName(); + + await page.context().grantPermissions(["clipboard-read", "clipboard-write"]); + + await buildDrillableTableAndView(page, pm, dashboardName); + await openDetail(pm); + + await pm.dashboardCellExplorer.expectCellEventTsInUrl(); + await pm.dashboardCellExplorer.expectCopyLinkVisible(); + await pm.dashboardCellExplorer.clickCopyLink(); + await expect + .poll(() => pm.dashboardCellExplorer.getClipboardText(), { timeout: 10000 }) + .toBe(page.url()); + testLogger.info("Copy link shares a URL carrying cell_event_ts"); + + await pm.dashboardCellExplorer.closeDrawer(); + await cleanupTestDashboard(page, pm, dashboardName); + }); + + test("surrounding events section and window dropdown render and re-fetch", { + tag: ["@functional", "@P1"], + }, async ({ page }) => { + const pm = new PageManager(page); + const dashboardName = generateDashboardName(); + + await buildDrillableTableAndView(page, pm, dashboardName); + await openDetail(pm); + + await pm.dashboardCellExplorer.expectSurroundingSectionVisible(); + await pm.dashboardCellExplorer.expectSurroundingResolved(); + const reFetchSpan = await pm.dashboardCellExplorer.changeSurroundWindow("5"); + expect(reFetchSpan).toBe(2 * 5 * 60 * 1_000_000); + await pm.dashboardCellExplorer.expectSurroundingResolved(); + testLogger.info("Surrounding events section rendered and re-fetched on window change"); + + await pm.dashboardCellExplorer.closeDrawer(); + await cleanupTestDashboard(page, pm, dashboardName); + }); + + test("deep-link reload restores the detail view via cell_event_ts", { + tag: ["@functional", "@P2"], + }, async ({ page }) => { + const pm = new PageManager(page); + const dashboardName = generateDashboardName(); + + await buildDrillableTableAndView(page, pm, dashboardName); + await openDetail(pm); + await pm.dashboardCellExplorer.expectCellEventTsInUrl(); + + await pm.dashboardCellExplorer.reloadAndWaitForDetail(); + await pm.dashboardCellExplorer.expectDetailOpen(); + testLogger.info("Detail view restored after reload from cell_event_ts URL param"); + + await pm.dashboardCellExplorer.closeDrawer(); + await cleanupTestDashboard(page, pm, dashboardName); + }); + + test("Escape returns to the results list without closing the drawer", { + tag: ["@functional", "@P2"], + }, async ({ page }) => { + const pm = new PageManager(page); + const dashboardName = generateDashboardName(); + + await buildDrillableTableAndView(page, pm, dashboardName); + await openDetail(pm); + + await pm.dashboardCellExplorer.pressEscape(); + await pm.dashboardCellExplorer.expectDetailClosedDrawerOpen(); + testLogger.info("Escape returned to the list while the drawer stayed open"); + + await pm.dashboardCellExplorer.closeDrawer(); + await cleanupTestDashboard(page, pm, dashboardName); + }); + + test("Table tab KV search filters the field list", { + tag: ["@functional", "@P2"], + }, async ({ page }) => { + const pm = new PageManager(page); + const dashboardName = generateDashboardName(); + + await buildDrillableTableAndView(page, pm, dashboardName); + await openDetail(pm); + await pm.dashboardCellExplorer.clickTableTab(); + + await pm.dashboardCellExplorer.expectKvKeyVisible("_timestamp"); + await pm.dashboardCellExplorer.fillKvSearch("kubernetes_namespace_name"); + await pm.dashboardCellExplorer.expectKvKeyVisible("kubernetes_namespace_name"); + await pm.dashboardCellExplorer.expectKvKeyHidden("_timestamp"); + testLogger.info("KV table search narrows the field list to matching keys"); + + await pm.dashboardCellExplorer.closeDrawer(); + await cleanupTestDashboard(page, pm, dashboardName); + }); +});