mirror of
https://github.com/openobserve/openobserve.git
synced 2026-11-05 16:10:43 +00:00
615 lines
26 KiB
JavaScript
615 lines
26 KiB
JavaScript
// methods: createDashboard, searchDashboard, AddPanel, applyButton
|
|
|
|
import testLogger from "../../playwright-tests/utils/test-logger.js";
|
|
|
|
export default class DashboardCreate {
|
|
/**
|
|
* Constructor for the DashboardCreate object
|
|
* @param {Page} page - The page object to interact with
|
|
*/
|
|
constructor(page) {
|
|
this.page = page;
|
|
this.dashCreateBtn = this.page.locator('[data-test="dashboard-new"]');
|
|
this.dashName = this.page.locator('[data-test="add-dashboard-name"] input');
|
|
this.submitBtn = this.page.locator(
|
|
'[data-test="dashboard-add-dialog"] [data-test="o-dialog-primary-btn"]'
|
|
);
|
|
this.deleteIcon = this.page.locator('[data-test="dashboard-delete"]');
|
|
this.confirmDelete = this.page.locator(
|
|
'[data-test="dashboard-confirm-dialog"] [data-test="o-dialog-primary-btn"]'
|
|
);
|
|
this.searchDash = this.page.locator('[data-test="dashboard-search"]');
|
|
this.addPanelIfEmptyBtn = this.page.locator(
|
|
'[data-test="dashboard-if-no-panel-add-panel-btn"]'
|
|
);
|
|
this.applyQueryBtn = this.page.locator('[data-test="dashboard-apply"]');
|
|
this.backBtn = this.page.locator('[data-test="dashboard-back-btn"]');
|
|
this.defaultFolderTab = this.page.locator(
|
|
'button[data-test="dashboard-folder-tab-default"]'
|
|
);
|
|
this.defaultDashboardTab = this.page.locator(
|
|
'[data-test="dashboard-tab-default"]'
|
|
);
|
|
}
|
|
|
|
// Wait for the default tab inside an opened dashboard to be visible
|
|
async waitForDefaultDashboardTabVisible() {
|
|
await this.defaultDashboardTab.waitFor({ state: "visible" });
|
|
}
|
|
|
|
// Wait for the "add panel" button on an empty dashboard to be visible
|
|
async waitForAddPanelIfEmptyVisible(timeout) {
|
|
await this.addPanelIfEmptyBtn.waitFor({ state: "visible", timeout });
|
|
}
|
|
|
|
// Wait for the dashboard search input to be visible
|
|
async waitForSearchVisible(timeout = 30000) {
|
|
await this.searchDash.waitFor({ state: "visible", timeout });
|
|
}
|
|
|
|
// Wait for the dashboard list table to be visible
|
|
async waitForDashboardTableVisible(timeout = 10000) {
|
|
await this.page
|
|
.locator('[data-test="dashboard-table"]')
|
|
.waitFor({ state: "visible", timeout });
|
|
}
|
|
|
|
// Wait for the default folder tab on the dashboard list to be visible
|
|
async waitForDefaultFolderTabVisible() {
|
|
await this.defaultFolderTab.waitFor({ state: "visible" });
|
|
}
|
|
|
|
/**
|
|
* A one-line snapshot of what the dashboards list actually rendered, for failure
|
|
* messages. A bare `locator.waitFor` timeout says only "the search box never
|
|
* showed" — this says what was on screen instead.
|
|
*/
|
|
async describeDashboardList() {
|
|
const state = await this.page
|
|
.evaluate(() => ({
|
|
url: location.href,
|
|
table: !!document.querySelector('[data-test="dashboard-table"]'),
|
|
importBtn: !!document.querySelector('[data-test="dashboard-import"]'),
|
|
createBtn: !!document.querySelector('[data-test="dashboard-new"]'),
|
|
rows: document.querySelectorAll('[data-test^="dashboard-name-cell-"]').length,
|
|
toast:
|
|
document.querySelector("[data-test-variant]")?.getAttribute("data-test-message") ?? null,
|
|
}))
|
|
.catch((e) => ({ evaluateFailed: e.message }));
|
|
return JSON.stringify(state);
|
|
}
|
|
|
|
/**
|
|
* Wait for the dashboards list to be fully interactive.
|
|
*
|
|
* `dashboard-search` lives in the toolbar slot of the dashboard TABLE, so this is
|
|
* really a wait for the list data to have loaded. When that load fails on the
|
|
* shared alpha deployment (a transient on a busy environment) the table never
|
|
* mounts and no amount of extra waiting helps — waitForDashboardPage() upstream
|
|
* only warns and continues when the URL looks right, so a failed load reaches
|
|
* here and then dies on an opaque 30s timeout. That accounted for every failure
|
|
* in a 21-run baseline of the axis spec: 10 of them, all at this one line.
|
|
*
|
|
* Re-navigating re-runs the list fetch and clears it. Fail with what was actually
|
|
* on screen if it still doesn't come up.
|
|
*/
|
|
async waitForDashboardUIStable() {
|
|
try {
|
|
await this.#waitForDashboardListControls(30000);
|
|
return;
|
|
} catch {
|
|
// A billing redirect is an environment/account state, not a transient — the
|
|
// app bounces EVERY route to /billings/plans, so re-navigating just lands
|
|
// there again and doubles the wait. Fail immediately and name the cause,
|
|
// rather than reporting it as a dashboards-list timeout.
|
|
this.#assertNotRedirectedToBilling();
|
|
testLogger.warn("Dashboards list did not become interactive, re-opening it", {
|
|
state: await this.describeDashboardList(),
|
|
});
|
|
}
|
|
|
|
const orgId =
|
|
process.env["ORGNAME"] ?? this.page.url().match(/org_identifier=([^&]+)/)?.[1];
|
|
// Dashboards.vue expects a `folder` query param — without it the list can fail
|
|
// to render (same reasoning as the goto fallback in dashboardList.menuItem).
|
|
await this.page
|
|
.goto(
|
|
`${process.env["ZO_BASE_URL"]}/web/dashboards?org_identifier=${orgId}&folder=default`,
|
|
{ waitUntil: "domcontentloaded" }
|
|
)
|
|
.catch(() => {});
|
|
|
|
try {
|
|
await this.#waitForDashboardListControls(30000);
|
|
testLogger.info("Dashboards list became interactive after re-opening it");
|
|
} catch {
|
|
this.#assertNotRedirectedToBilling();
|
|
throw new Error(
|
|
`waitForDashboardUIStable: the dashboards list never became interactive, even after ` +
|
|
`re-opening it. Dashboards list state: ${await this.describeDashboardList()}`
|
|
);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Throw a self-explanatory error when the app has bounced us to the billing page.
|
|
*
|
|
* On cloud deployments an expired trial / lapsed subscription redirects every
|
|
* route to /billings/plans. Every dashboard test then fails somewhere deep in
|
|
* setup on an unrelated-looking selector timeout, which reads as flakiness and
|
|
* sends you hunting through the specs. It is neither flaky nor a test bug — the
|
|
* environment needs its plan renewed.
|
|
*/
|
|
#assertNotRedirectedToBilling() {
|
|
const url = this.page.url();
|
|
if (!/\/billings?\//.test(url)) return;
|
|
throw new Error(
|
|
`The app redirected to the billing page (${url}). This is an environment/account ` +
|
|
`state, not a test failure: the org's trial or subscription has lapsed, so every ` +
|
|
`route bounces to /billings/plans and no dashboard test can run. Renew the plan ` +
|
|
`for org "${process.env["ORGNAME"] ?? "(unset)"}" or point ORGNAME at an active org.`
|
|
);
|
|
}
|
|
|
|
/** The three controls that together mean "the list is loaded and interactive". */
|
|
async #waitForDashboardListControls(timeout) {
|
|
await this.searchDash.waitFor({ state: "visible", timeout });
|
|
await this.dashCreateBtn.waitFor({ state: "visible", timeout });
|
|
const importBtn = this.page.locator('[data-test="dashboard-import"]');
|
|
await importBtn.waitFor({ state: "visible", timeout: 10000 });
|
|
}
|
|
|
|
//Create Dashboard
|
|
// Open the "New Dashboard" dialog and wait until its name input is usable.
|
|
// Split out of createDashboard so the submit loop can re-open a dialog that
|
|
// was destroyed under it by a background re-render.
|
|
//
|
|
// Opening is itself flaky: the list behind this button re-renders as
|
|
// folders/dashboards settle, and a click landing on a button that is being
|
|
// torn down silently no-ops. The dialog is client-side, so a click that lands
|
|
// always opens it — a 30s wait expiring means the CLICK was lost, and waiting
|
|
// longer cannot help. Re-click with a fresh lookup instead.
|
|
async openCreateDashboardDialog() {
|
|
const dialogAttempts = 3;
|
|
let dialogError;
|
|
for (let attempt = 1; attempt <= dialogAttempts; attempt++) {
|
|
try {
|
|
// Re-click ONLY when the dialog is genuinely absent. Once it is open,
|
|
// ODialog renders a `fixed inset-0` overlay that covers this button, so
|
|
// a second click cannot land — it fails the actionability check and
|
|
// burns the attempt. Without this guard a dialog that merely opened
|
|
// slowly (just past the per-attempt window) would be turned into a hard
|
|
// failure with a perfectly usable dialog on screen.
|
|
if (!(await this.dashName.isVisible().catch(() => false))) {
|
|
await this.dashCreateBtn.click({ timeout: 10000 });
|
|
}
|
|
await this.dashName.waitFor({ state: "attached", timeout: 15000 });
|
|
await this.dashName.waitFor({ state: "visible", timeout: 15000 });
|
|
dialogError = undefined;
|
|
break;
|
|
} catch (e) {
|
|
dialogError = e;
|
|
}
|
|
}
|
|
if (dialogError) {
|
|
throw new Error(
|
|
`createDashboard: "New Dashboard" dialog did not open after ${dialogAttempts} clicks. Last error: ${dialogError.message}`
|
|
);
|
|
}
|
|
|
|
// Wait for the input to be enabled (not disabled)
|
|
await this.page.waitForFunction(
|
|
(selector) => {
|
|
const element = document.querySelector(selector);
|
|
return element && !element.disabled && element.offsetParent !== null;
|
|
},
|
|
'[data-test="add-dashboard-name"] input',
|
|
{ timeout: 10000 }
|
|
);
|
|
}
|
|
|
|
async createDashboard(dashboardName) {
|
|
// Wait for the dashboard page to be fully loaded by checking for the search input
|
|
await this.searchDash.waitFor({ state: "visible", timeout: 30000 });
|
|
|
|
// Wait for the "New Dashboard" button to be ready and enabled
|
|
await this.dashCreateBtn.waitFor({ state: "visible", timeout: 30000 });
|
|
|
|
// Wait for network idle to ensure page is fully loaded
|
|
await this.page.waitForLoadState('networkidle', { timeout: 10000 }).catch(() => {
|
|
// Ignore timeout - continue anyway
|
|
});
|
|
|
|
// The list resolves its folder selection after mount and router-replaces
|
|
// `&folder=default` onto the URL. That is a client-side push, so the
|
|
// networkidle wait above does not cover it — and a dialog opened before it
|
|
// lands is torn down by the re-render, losing the submit: no create POST
|
|
// goes out, the dialog is gone so the retry loop below sees "submitted",
|
|
// and the navigation wait at the end expires with the list still on screen.
|
|
// The folder tab is the DOM signal that folder resolution has finished.
|
|
await this.defaultFolderTab
|
|
.waitFor({ state: "visible", timeout: 30000 })
|
|
.catch(() => {
|
|
// Not every list renders a folder strip; the retry loop stays the gate.
|
|
});
|
|
|
|
await this.openCreateDashboardDialog();
|
|
|
|
// Submitting is where this flakes, in two ways that look identical from the
|
|
// outside — an opaque waitForURL timeout with no navigation in the log:
|
|
//
|
|
// 1. AddDashboard.vue seeds OForm from :default-values, and a fill that
|
|
// lands before that seeding is overwritten with an empty name.
|
|
// 2. The dialog's primary button submits the form by id, and a click
|
|
// issued while that wiring is still settling silently no-ops.
|
|
//
|
|
// Either way submit is gated by the form's Zod schema, so onSubmit never
|
|
// runs, no POST goes out, and updateDashboardList() — the only thing that
|
|
// routes to /dashboards/view — is never called. Re-assert the name and
|
|
// watch for the create POST, so a lost submit is retried rather than
|
|
// waited out, and a rejected one fails with the actual status.
|
|
//
|
|
// The retry gate matches on the REQUEST, not the response: a request that
|
|
// has been sent but not yet answered still proves the click landed, so a
|
|
// slow server can never be mistaken for a lost submit and re-submitted
|
|
// into a duplicate dashboard.
|
|
const isCreateCall = (target) =>
|
|
/\/api\/[^/]+\/dashboards(?:\?|$)/.test(target.url()) &&
|
|
(target.method?.() ?? target.request().method()) === "POST";
|
|
|
|
let createRequest = null;
|
|
let createResponse = null;
|
|
|
|
const submitAttempts = 3;
|
|
for (let attempt = 1; attempt <= submitAttempts; attempt++) {
|
|
// Re-assert on every attempt: if the form seeded over our value, the
|
|
// retry has to repair the field, not just click again.
|
|
if ((await this.dashName.inputValue()) !== dashboardName) {
|
|
await this.dashName.fill(dashboardName);
|
|
}
|
|
|
|
const requestPromise = this.page
|
|
.waitForRequest(isCreateCall, { timeout: 10000 })
|
|
.catch(() => null);
|
|
const responsePromise = this.page
|
|
.waitForResponse(isCreateCall, { timeout: 30000 })
|
|
.catch(() => null);
|
|
|
|
await this.submitBtn.waitFor({ state: "visible", timeout: 30000 });
|
|
await this.submitBtn.click();
|
|
|
|
createRequest = await requestPromise;
|
|
if (createRequest) {
|
|
createResponse = await responsePromise;
|
|
// Fresh-org concurrent creates race server-side default-folder creation → the loser 500s on a unique-constraint; the folder now exists so a re-submit lands. Retry transient 5xx while attempts remain (nothing was created, so no duplicate); a persistent error still throws below.
|
|
if (createResponse && createResponse.status() >= 500 && attempt < submitAttempts) {
|
|
const stillOpen = await this.submitBtn.isVisible().catch(() => false);
|
|
if (!stillOpen) await this.openCreateDashboardDialog();
|
|
continue;
|
|
}
|
|
break;
|
|
}
|
|
|
|
// Nothing hit the wire, and the dialog is still up: the click no-opped, so
|
|
// click again.
|
|
const dialogStillOpen = await this.submitBtn
|
|
.isVisible()
|
|
.catch(() => false);
|
|
if (dialogStillOpen) continue;
|
|
|
|
// Nothing hit the wire AND the dialog is gone. Treating that as a landed
|
|
// submit (as this loop used to) is wrong: a background re-render — the
|
|
// list re-resolving its folder is the common one — can destroy the dialog
|
|
// with the form never submitted, and the navigation wait below then
|
|
// expires with the list still on screen and nothing created. No request
|
|
// went out, so nothing was created and re-opening cannot duplicate.
|
|
if (/\/dashboards\/view/.test(this.page.url())) break;
|
|
if (attempt < submitAttempts) await this.openCreateDashboardDialog();
|
|
}
|
|
|
|
// No POST ever reached the wire — say so, rather than letting the URL wait
|
|
// below expire with an opaque "navigation timed out".
|
|
if (!createRequest && !/\/dashboards\/view/.test(this.page.url())) {
|
|
throw new Error(
|
|
`createDashboard: submit never reached the network after ${submitAttempts} attempts (dialog kept being torn down); no dashboard was created.`
|
|
);
|
|
}
|
|
|
|
if (createResponse && !createResponse.ok()) {
|
|
throw new Error(
|
|
`Dashboard creation failed: POST ${createResponse.url()} returned ${createResponse.status()}`
|
|
);
|
|
}
|
|
|
|
// Wait for the success notification to confirm dashboard was created
|
|
// OToast root carries both data-test="o-toast-success" and data-test-message="<text>"
|
|
// so we can assert type + content in one selector (getByText is banned per selector policy)
|
|
await this.page.locator('[data-test-variant="success"][data-test-message="Dashboard added successfully."]').first().waitFor({ state: 'visible', timeout: 15000 }).catch(() => {
|
|
// Toast may have appeared and disappeared before waitFor evaluated — the
|
|
// waitForURL check below is the real gate for whether creation succeeded.
|
|
});
|
|
|
|
// Wait for navigation to the new dashboard view page.
|
|
//
|
|
// The create can SUCCEED and still not land here: AddDashboard's
|
|
// updateDashboardList() pushes /dashboards/view, but the list's own
|
|
// `&folder=default` router-replace races that push and can win, leaving us
|
|
// back on the list with the dashboard created. Failing here would abandon a
|
|
// dashboard that exists and is perfectly usable, so open it from the list
|
|
// instead — and only then give up.
|
|
const reachedView = await this.page
|
|
.waitForURL(/\/dashboards\/view/, { timeout: 30000 })
|
|
.then(() => true)
|
|
.catch(() => false);
|
|
|
|
if (!reachedView) {
|
|
const searchInput = this.page
|
|
.locator('[data-test="dashboard-search-field"]')
|
|
.first();
|
|
if (await searchInput.count()) {
|
|
await searchInput.fill(dashboardName).catch(() => {});
|
|
}
|
|
|
|
const nameCell = this.page
|
|
.locator(`[data-test="dashboard-name-cell-${dashboardName}"]`)
|
|
.first();
|
|
await nameCell.waitFor({ state: "visible", timeout: 15000 }).catch(() => {
|
|
throw new Error(
|
|
`createDashboard: POST succeeded but the app stayed on the dashboards list, and "${dashboardName}" never appeared there.`
|
|
);
|
|
});
|
|
// The list can swallow this click while its own router-replace is still
|
|
// settling, so re-click instead of spending the whole budget on one attempt.
|
|
let opened = false;
|
|
for (let attempt = 1; attempt <= 3 && !opened; attempt++) {
|
|
await nameCell.click().catch(() => {});
|
|
opened = await this.page
|
|
.waitForURL(/\/dashboards\/view/, { timeout: 10000 })
|
|
.then(() => true)
|
|
.catch(() => false);
|
|
}
|
|
|
|
if (!opened) {
|
|
throw new Error(
|
|
`createDashboard: "${dashboardName}" was created and is listed, but opening it never navigated to the dashboard view.`
|
|
);
|
|
}
|
|
}
|
|
|
|
// Wait for the page to be fully loaded
|
|
await this.page.waitForLoadState('networkidle', { timeout: 30000 }).catch(() => {
|
|
// Ignore timeout - continue anyway
|
|
});
|
|
|
|
// Wait for Vue components to mount — use deterministic check on panel editor or back button
|
|
await this.page.locator('[data-test="dashboard-back-btn"]').waitFor({ state: 'visible', timeout: 15000 });
|
|
|
|
// back-btn only proves the OPageLayout header mounted, and ViewDashboard renders
|
|
// that header immediately — before any dashboard data arrives. Everything callers
|
|
// actually reach for next lives in the CONTENT subtree: RenderDashboardCharts is
|
|
// `v-if="selectedDate"`, and loadDashboard() only sets selectedDate once the
|
|
// dashboard GET resolves. So returning here left every caller racing that load —
|
|
// the empty-state add-panel button and the variable selectors simply do not exist
|
|
// yet, and the wait expires on a page that looks perfectly fine in a screenshot.
|
|
// The date-time picker sits behind the same `v-if="selectedDate"`, so it is the
|
|
// signal that the content subtree is committed to render.
|
|
await this.waitForDashboardContentLoaded();
|
|
}
|
|
|
|
// Gate on the dashboard's data having loaded (see createDashboard for why the
|
|
// header alone is not enough). Safe to call on any ViewDashboard page.
|
|
async waitForDashboardContentLoaded(timeout = 30000) {
|
|
await this.page
|
|
.locator('[data-test="dashboard-global-date-time-picker"]')
|
|
.waitFor({ state: "visible", timeout });
|
|
}
|
|
|
|
//back to dashboard list
|
|
async backToDashboardList() {
|
|
// "Back" is not one hop from everywhere. The panel editor and the
|
|
// dashboard view both render data-test="dashboard-back-btn", but
|
|
// AddPanel.vue's goBack() pushes /dashboards/view while ViewDashboard.vue's
|
|
// goBackToDashboardList() pushes /dashboards — so reaching the list from
|
|
// the editor genuinely takes TWO clicks. On top of that, a click issued
|
|
// mid page-transition can land on a header that is being torn down and
|
|
// silently no-op. Click, re-check the URL, and repeat with a fresh locator
|
|
// lookup until we're on the list.
|
|
//
|
|
// Leaving add_panel with unsaved panel edits fires AddPanel.vue's
|
|
// onBeforeRouteLeave native window.confirm. Playwright auto-dismisses
|
|
// dialogs by default, and a dismissed confirm means next(false) — the
|
|
// route change is cancelled, so every back click no-ops and the loop
|
|
// burns all its attempts still sitting on add_panel. Accept the dialog
|
|
// for the duration of the navigation (same handling as
|
|
// dashboard-multi-sql.js discardPanel()).
|
|
const LIST_URL = /\/dashboards(?:\?|$)/;
|
|
const maxClicks = 4;
|
|
let lastError;
|
|
|
|
const dialogHandler = (dialog) => dialog.accept();
|
|
this.page.on("dialog", dialogHandler);
|
|
try {
|
|
for (let attempt = 1; attempt <= maxClicks; attempt++) {
|
|
if (LIST_URL.test(this.page.url())) return;
|
|
|
|
const backBtn = this.page.locator('[data-test="dashboard-back-btn"]');
|
|
try {
|
|
await backBtn.waitFor({ state: "visible", timeout: 20000 });
|
|
await backBtn.click({ timeout: 10000 });
|
|
} catch (e) {
|
|
// Header may be remounting between the editor and view pages — fall
|
|
// through to the URL check and try again with a fresh lookup.
|
|
lastError = e;
|
|
}
|
|
|
|
try {
|
|
await this.page.waitForURL(LIST_URL, { timeout: 8000 });
|
|
return;
|
|
} catch (e) {
|
|
lastError = e;
|
|
}
|
|
}
|
|
} finally {
|
|
this.page.off("dialog", dialogHandler);
|
|
}
|
|
|
|
if (LIST_URL.test(this.page.url())) return;
|
|
throw new Error(
|
|
`backToDashboardList: still at ${this.page.url()} after ${maxClicks} back clicks. Last error: ${lastError?.message}`
|
|
);
|
|
}
|
|
|
|
//wait for back button to be visible (no click)
|
|
async waitForBackBtnVisible() {
|
|
await this.backBtn.waitFor({ state: "visible" });
|
|
}
|
|
|
|
//Search the Folder
|
|
async searchDashboard(dashboardName) {
|
|
await this.page
|
|
.locator('button[data-test="dashboard-folder-tab-default"]')
|
|
.waitFor({ state: "visible" });
|
|
|
|
await this.searchDash.locator('input').click();
|
|
await this.searchDash.locator('input').fill(dashboardName);
|
|
}
|
|
|
|
//Delete Dashboard
|
|
async deleteDashboard() {
|
|
await this.page
|
|
.locator('button[data-test="dashboard-folder-tab-default"]')
|
|
.waitFor({ state: "visible" });
|
|
const dashboardRow = this.page.locator('[data-test="dashboard-table"]');
|
|
await dashboardRow.waitFor({ state: "visible" });
|
|
await dashboardRow.locator('[data-test="dashboard-delete"]').first().click();
|
|
const confirmDialog = this.page.locator(
|
|
'[data-test="dashboard-confirm-dialog"]'
|
|
);
|
|
await confirmDialog.waitFor({ state: "visible" });
|
|
const confirmDeleteButton = confirmDialog.locator(
|
|
'[data-test="o-dialog-primary-btn"]'
|
|
);
|
|
await confirmDeleteButton.waitFor({ state: "visible" });
|
|
await confirmDeleteButton.click();
|
|
}
|
|
|
|
//Add Panel to dashboard (when dashboard is empty)
|
|
async addPanel() {
|
|
// Nothing below exists until the dashboard's data has loaded: the whole
|
|
// content subtree is `v-if="selectedDate"`, set by loadDashboard(). Gate on
|
|
// that first so the waits below fail on their own merits, not on arriving early.
|
|
await this.waitForDashboardContentLoaded();
|
|
|
|
// Clicking the empty-state button before tabs exist makes ViewDashboard.vue's
|
|
// addPanelData() read tabs[0] and throw inside the handler, so router.push
|
|
// never runs: the click reports success and the URL never changes, burning
|
|
// every retry below. The tab strip only renders once tabs have loaded, so
|
|
// waiting for it gates the click on the data it depends on.
|
|
await this.defaultDashboardTab
|
|
.waitFor({ state: "visible", timeout: 30000 })
|
|
.catch(() => {
|
|
// Non-fatal: some dashboards legitimately render no tab strip. The
|
|
// retry loop below is still the real gate on navigation succeeding.
|
|
});
|
|
|
|
// Retry pattern for clicking add panel button
|
|
const maxRetries = 3;
|
|
for (let attempt = 1; attempt <= maxRetries; attempt++) {
|
|
await this.addPanelIfEmptyBtn.waitFor({ state: "visible", timeout: 15000 });
|
|
await this.addPanelIfEmptyBtn.scrollIntoViewIfNeeded();
|
|
|
|
// Click the button
|
|
await this.addPanelIfEmptyBtn.click();
|
|
|
|
// Wait for URL to contain add_panel
|
|
try {
|
|
await this.page.waitForURL(/add_panel/, { timeout: 10000 });
|
|
break; // Success
|
|
} catch (e) {
|
|
if (attempt === maxRetries) {
|
|
throw new Error(`addPanel: Failed to navigate to add_panel after ${maxRetries} attempts. Last error: ${e.message}`);
|
|
}
|
|
// Retry - the click may not have worked
|
|
}
|
|
}
|
|
|
|
// Wait for panel editor to be ready
|
|
await this.page.locator('[data-test="dashboard-apply"]').or(
|
|
this.page.locator('[data-test^="selected-chart-"]').first()
|
|
).first().waitFor({ state: "visible", timeout: 15000 });
|
|
}
|
|
|
|
//Add Panel to dashboard (when dashboard already has panels)
|
|
async addPanelToExistingDashboard() {
|
|
const addPanelBtn = this.page.locator('[data-test="dashboard-panel-add"]');
|
|
|
|
// Retry pattern for clicking add panel button
|
|
const maxRetries = 3;
|
|
for (let attempt = 1; attempt <= maxRetries; attempt++) {
|
|
await addPanelBtn.waitFor({ state: "visible", timeout: 15000 });
|
|
await addPanelBtn.scrollIntoViewIfNeeded();
|
|
|
|
// Click the button
|
|
await addPanelBtn.click();
|
|
|
|
// Wait for URL to contain add_panel
|
|
try {
|
|
await this.page.waitForURL(/add_panel/, { timeout: 10000 });
|
|
break; // Success
|
|
} catch (e) {
|
|
if (attempt === maxRetries) {
|
|
throw new Error(`addPanelToExistingDashboard: Failed to navigate to add_panel after ${maxRetries} attempts. Last error: ${e.message}`);
|
|
}
|
|
// Retry - the click may not have worked
|
|
}
|
|
}
|
|
|
|
// Wait for panel editor to be ready
|
|
await this.page.locator('[data-test="dashboard-apply"]').or(
|
|
this.page.locator('[data-test^="selected-chart-"]').first()
|
|
).first().waitFor({ state: "visible", timeout: 15000 });
|
|
}
|
|
|
|
//Add Panel - works for both empty and non-empty dashboards
|
|
async addPanelSmart() {
|
|
const addPanelBtn = this.page.locator('[data-test="dashboard-panel-add"]');
|
|
const addPanelIfEmptyBtn = this.addPanelIfEmptyBtn;
|
|
|
|
// Determine which button to click
|
|
const addBtnVisible = await addPanelBtn.isVisible({ timeout: 2000 }).catch(() => false);
|
|
const targetBtn = addBtnVisible ? addPanelBtn : addPanelIfEmptyBtn;
|
|
|
|
// Retry pattern for clicking add panel button
|
|
const maxRetries = 3;
|
|
for (let attempt = 1; attempt <= maxRetries; attempt++) {
|
|
await targetBtn.waitFor({ state: "visible", timeout: 15000 });
|
|
await targetBtn.scrollIntoViewIfNeeded();
|
|
|
|
// Click the button
|
|
await targetBtn.click();
|
|
|
|
// Wait for URL to contain add_panel
|
|
try {
|
|
await this.page.waitForURL(/add_panel/, { timeout: 10000 });
|
|
break; // Success
|
|
} catch (e) {
|
|
if (attempt === maxRetries) {
|
|
throw new Error(`addPanelSmart: Failed to navigate to add_panel after ${maxRetries} attempts. Last error: ${e.message}`);
|
|
}
|
|
// Retry - the click may not have worked
|
|
}
|
|
}
|
|
|
|
// Wait for panel editor to be ready
|
|
await this.page.locator('[data-test="dashboard-apply"]').or(
|
|
this.page.locator('[data-test^="selected-chart-"]').first()
|
|
).first().waitFor({ state: "visible", timeout: 15000 });
|
|
}
|
|
|
|
//Apply dashboard button
|
|
async applyButton() {
|
|
await this.applyQueryBtn.waitFor({ state: "visible", timeout: 15000 });
|
|
await this.applyQueryBtn.click();
|
|
}
|
|
}
|