mirror of
https://github.com/openobserve/openobserve.git
synced 2026-11-05 08:09:22 +00:00
## Alert Library A curated catalog at `/alert-library`: browse packs of ready-made alerts, see which ones can actually run against the streams this org already receives, preview a verdict against real data, and install one or many. The library is a **peer page** of Alerts / Notification Destinations / Templates — flat routing, presented as tabs via a shared `AlertSectionTabs` in `OPageLayout #header-tabs` (the pattern `PipelineSectionTabs` already ships). ### What landed, by phase | Phase | What | | --- | --- | | Data layer | S3 manifest fetch, cache, readiness resolution (`useAlertLibrary`, `constants/alertLibrary`, `types/alertLibrary`) | | Gallery | Rail (packs / categories, multi-select, segmented switch), stat-strip availability facets, cards | | Drawer | Detail view with query preview and customizable tunables | | Install | Bulk-install wizard — destination selection, per-alert tunables, stale-session hardening | | Prefill | Carry a library alert (query, trigger, threshold, severity) into the alert editor | ### Design decisions worth knowing - **Rail navigates, strip filters.** Availability is deliberately not in the rail — carrying both overflowed it and pushed a facet group below the fold. - **Colour marks the exception only.** No "Ready" pill (it would describe ~75 of 86 cards and signal nothing); only unusable cards carry a label. - **A failed `/streams` call leaves readiness unknown**, rather than claiming "none of your data matches" — that claim would be about the request, not the org, and it is the most alarming thing this page can say. Counts read `—` until there is an answer, and a failed *refresh* keeps the verdict it already had. - **No "Installed" tile** — installed detection doesn't exist yet, and a tile reading a permanent zero is dishonest UI. - **`severity: warning` renders as `medium`** — the badge group's scale is critical/high/medium/low/info, and medium is the amber it also gives P3, which is exactly the priority a warning alert installs as. - Pack and category names come from the manifest, so they render as `raw()` data — a pack added upstream needs no frontend release. ### Design-system changes (used by the library, applied app-wide) - **`warning-quiet` badge tone** — one variant whose values differ by theme: the soft amber wash in light, a neutral chip in dark. Neither the filled amber chip nor the neutral one was right in both, because the two ramps aren't symmetric. - **Border tokens now meet WCAG 2.1 SC 1.4.11.** Every border token measured under 3:1 (`border-strong` was 2.52:1). `border-default` firms to grey-300 and controls get a token that clears 3:1. - Nav: Reliability's flyout groups its four alerting entries under one **Alerts** header, declared in the same order as the tab strip those pages carry. Flyout levels are sized by depth. - `sensors-off` icon added to the `OIcon` registry. ### Fixes that rode along - **metrics**: a shared URL whose `metrics_data` blob omits panel query fields no longer blanks the whole metrics page — blob queries are filled out to the panel schema before being laid over. - **dashboards**: a PromQL series with no legend template was named `JSON.stringify(metric)`, so fifty series read identically for the width of the chip. Series are now named by the labels that actually differ, preferring the ones that say *which workload* it is (namespace / service / pod / host). Correlation's semantic groups refine that when loaded, but nothing is fetched to render a chart — that endpoint is enterprise-only and a chart must render the same on OSS. - **alerts**: preview evaluates against the alert's real trigger; deleting a row no longer reloads the list behind it; peer tabs no longer shift when switching alerting pages. - **ui**: badge-only select options no longer render bold. ### Tests - `test(search)`: pins the streaming-aggregate **wire contract** at the HTTP boundary. Under streaming aggregates each partition carries the cumulative merged state (last frame is the answer) while ordinary partitions are disjoint and must be summed. No pytest had ever received `streaming_aggs: true` — the emitter could have flipped to per-partition deltas with the whole suite green while dashboards, Logs and the alert preview all silently over-counted. - `test(e2e)`: four helpers that passed only while an org stayed small — an assertion reading past a 20-row page, an unfiltered `OSelect` that virtualises past 50 entries, a tab click landing on the rename pencil, and two builder tests asserting on whichever metric sorted first. - New unit specs across the library components, composable, facets, tunables and install path. ### Notes - **i18n**: en-US only; CI generates the other 14 locales. - **No backend changes** — this branch is entirely `web/` plus test helpers.
569 lines
25 KiB
JavaScript
569 lines
25 KiB
JavaScript
// metricsQueryEditorPage.js
|
|
// Page Object Model for Metrics Query Editor functionality
|
|
// This file contains helper functions for PromQL query persistence tests
|
|
|
|
const { expect } = require('@playwright/test');
|
|
|
|
/**
|
|
* MetricsQueryEditorPage - Page Object for Metrics Query Editor
|
|
*
|
|
* This class provides methods for interacting with the metrics query editor,
|
|
* including tab management, query entry, and mode switching.
|
|
*
|
|
* Used for testing PromQL query persistence across tabs.
|
|
*/
|
|
export class MetricsQueryEditorPage {
|
|
constructor(page) {
|
|
this.page = page;
|
|
this.testLogger = null;
|
|
|
|
// Try to load test logger if available
|
|
try {
|
|
this.testLogger = require('../../playwright-tests/utils/test-logger.js');
|
|
} catch (e) {
|
|
// Create a simple console-based logger fallback
|
|
this.testLogger = {
|
|
info: (msg) => console.log(`[INFO] ${msg}`),
|
|
warn: (msg) => console.warn(`[WARN] ${msg}`),
|
|
error: (msg) => console.error(`[ERROR] ${msg}`)
|
|
};
|
|
}
|
|
|
|
// Class-member locators (POM strict — §3)
|
|
this.runQueryButton = page.locator('[data-test="metrics-apply"]');
|
|
this.queryEditorContainer = page.locator('[data-test="dashboard-panel-query-editor"]');
|
|
this.customQueryTypeButton = page.locator('[data-test="dashboard-custom-query-type"]');
|
|
this.promqlQueryTypeButton = page.locator('[data-test="dashboard-promql-query-type"]');
|
|
this.addQueryTabButton = page.locator('[data-test="dashboard-panel-query-tab-add"]');
|
|
this.streamDropdown = page.locator('[data-test="index-dropdown-stream"]');
|
|
this.streamDropdownPopover = page.locator('[data-test="index-dropdown-stream-popover"]');
|
|
this.streamDropdownOption = page.locator('[data-test="index-dropdown-stream-option"]');
|
|
this.streamDropdownClear = page.locator('[data-test="index-dropdown-stream"] [data-test="o-icon-cancel"]');
|
|
this.tabList = page.locator('[data-test^="dashboard-panel-query-tab-"]');
|
|
}
|
|
|
|
/**
|
|
* Per-index tab factory helper — class member factory (POM strict)
|
|
* @param {number} zeroIdx - 0-based tab index
|
|
*/
|
|
getTabByIndex(zeroIdx) {
|
|
return this.page.locator(`[data-test="dashboard-panel-query-tab-${zeroIdx}"]`);
|
|
}
|
|
|
|
/**
|
|
* The tab's LABEL — the only part of the tab that selects it.
|
|
*
|
|
* A tab also carries rename, visibility and remove icons, each of which
|
|
* stops the click from reaching the tab. Those sit to the right of the
|
|
* label, so the tab element's own centre lands on the rename icon and a
|
|
* centre click opens the rename box instead of switching tabs.
|
|
*
|
|
* @param {number} zeroIdx - 0-based tab index
|
|
*/
|
|
getTabNameByIndex(zeroIdx) {
|
|
return this.page.locator(`[data-test="dashboard-panel-query-tab-name-${zeroIdx}"]`);
|
|
}
|
|
|
|
/**
|
|
* Switch to PromQL Custom mode
|
|
*
|
|
* In the metrics page, there are two toggle groups:
|
|
* - Query type: SQL / PromQL (data-test="dashboard-sql-query-type" / "dashboard-promql-query-type")
|
|
* - Builder mode: Builder / Custom (data-test="dashboard-builder-query-type" / "dashboard-custom-query-type")
|
|
*
|
|
* OToggleGroupItem (Reka UI) signals the active item via data-state="on", not a CSS class.
|
|
*
|
|
* @returns {Promise<boolean>} - True if successfully switched
|
|
*/
|
|
async switchToPromQLCustomMode() {
|
|
this.testLogger.info('Switching to PromQL Custom mode...');
|
|
|
|
// Primary selector — the Custom builder-mode button in QueryTypeSelector.vue
|
|
const customButton = this.customQueryTypeButton.first();
|
|
|
|
if (await customButton.isVisible({ timeout: 3000 })) {
|
|
// OToggleGroupItem uses data-state="on" (Reka UI) for active state
|
|
const dataState = await customButton.getAttribute('data-state') || '';
|
|
|
|
if (dataState === 'on') {
|
|
this.testLogger.info('✓ Already in PromQL Custom mode');
|
|
return true;
|
|
}
|
|
|
|
await customButton.click();
|
|
|
|
// Confirm active state flipped — deterministic wait
|
|
await expect.poll(async () => await customButton.getAttribute('data-state'), {
|
|
timeout: 5000,
|
|
}).toBe('on').catch(() => {
|
|
this.testLogger.warn('Custom mode button clicked but data-state did not become "on"');
|
|
});
|
|
this.testLogger.info('✓ Switched to PromQL Custom mode');
|
|
return true;
|
|
}
|
|
|
|
this.testLogger.warn('Could not find [data-test="dashboard-custom-query-type"] — assuming already in custom mode');
|
|
return false;
|
|
}
|
|
|
|
/**
|
|
* Clear any stream selection
|
|
* This prevents auto-populated queries from interfering
|
|
*
|
|
* @returns {Promise<boolean>} - True if cleared successfully
|
|
*/
|
|
async clearStreamSelection() {
|
|
// Try to clear stream selection if there's a clear/X button
|
|
const clearBtn = this.streamDropdownClear.first();
|
|
if (await clearBtn.isVisible({ timeout: 1000 }).catch(() => false)) {
|
|
await clearBtn.click();
|
|
this.testLogger.info('Cleared stream selection');
|
|
return true;
|
|
}
|
|
|
|
return false;
|
|
}
|
|
|
|
/**
|
|
* Enter PromQL query in metrics page
|
|
* This avoids triggering the "Add to Dashboard" dialog
|
|
* NOTE: Assumes we're already in PromQL Custom mode
|
|
*
|
|
* @param {string} query - The PromQL query to enter
|
|
*/
|
|
async enterPromQLQuery(query) {
|
|
this.testLogger.info(`Attempting to enter query: ${query}`);
|
|
|
|
// Try to clear any stream selection first (prevents auto-populated queries)
|
|
await this.clearStreamSelection();
|
|
|
|
// APPROACH 1: Try Monaco API - sets the model value directly
|
|
// Note: This only sets the internal model state, not the rendered view
|
|
const modelSetSuccess = await this.page.evaluate((queryText) => {
|
|
try {
|
|
const editors = window.monaco?.editor?.getEditors?.() || [];
|
|
if (editors.length === 0) return false;
|
|
const dashContainer = document.querySelector('[data-test="dashboard-panel-query-editor"]');
|
|
let editor = null;
|
|
for (const ed of editors) {
|
|
const dom = ed.getDomNode?.();
|
|
if (dom && dashContainer && dashContainer.contains(dom)) {
|
|
editor = ed;
|
|
break;
|
|
}
|
|
}
|
|
editor = editor || editors[0];
|
|
if (editor && typeof editor.setValue === 'function') {
|
|
editor.setValue(queryText);
|
|
// Model set attempted - view rendering happens asynchronously
|
|
return true;
|
|
}
|
|
} catch (error) {
|
|
console.log('Monaco API failed:', error.message);
|
|
}
|
|
return false;
|
|
}, query);
|
|
|
|
if (modelSetSuccess) {
|
|
this.testLogger.info('Monaco model set, polling for view rendering...');
|
|
|
|
// CRITICAL: Poll for actual DOM/view rendering, not model state
|
|
// Monaco renders asynchronously - we must verify via the editor model.
|
|
// Gated on the REAL signal (model contains the typed query) with a
|
|
// generous bounded timeout — 3s was too tight under afternoon cloud
|
|
// load and let the fast keyboard fallback fire unnecessarily.
|
|
try {
|
|
await expect.poll(async () => {
|
|
const text = await this.getCurrentQueryText();
|
|
return text.includes(query.substring(0, Math.min(10, query.length)));
|
|
}, { timeout: 15000, intervals: [100, 200, 500, 1000] }).toBe(true);
|
|
this.testLogger.info('✓ Query verified in rendered view');
|
|
return;
|
|
} catch {
|
|
const verifyText = await this.getCurrentQueryText();
|
|
this.testLogger.warn(`Monaco model set but view not rendered. Got: "${verifyText}"`);
|
|
}
|
|
}
|
|
|
|
// APPROACH 2: Direct keyboard input with aggressive clearing
|
|
this.testLogger.info('Using keyboard input approach');
|
|
|
|
// Find and focus the editor (use container scoped focus via evaluate)
|
|
const editorVisible = await this.queryEditorContainer.first().isVisible({ timeout: 3000 }).catch(() => false);
|
|
if (!editorVisible) {
|
|
this.testLogger.error('Editor not visible - cannot enter query');
|
|
return;
|
|
}
|
|
|
|
// Focus the editor through the Monaco API (target the dashboard panel
|
|
// editor specifically, not a logs/VRL editor that may also be present).
|
|
await this.page.evaluate(() => {
|
|
const editors = window.monaco?.editor?.getEditors?.() || [];
|
|
if (editors.length === 0) return;
|
|
const dashContainer = document.querySelector('[data-test="dashboard-panel-query-editor"]');
|
|
for (const ed of editors) {
|
|
const dom = ed.getDomNode?.();
|
|
if (dom && dashContainer && dashContainer.contains(dom)) {
|
|
ed.focus();
|
|
return;
|
|
}
|
|
}
|
|
editors[0].focus();
|
|
});
|
|
|
|
// Clear editor content: single select-all + backspace, then select-all to prepare for typing
|
|
const selectAllKey = process.platform === 'darwin' ? 'Meta+A' : 'Control+A';
|
|
await this.page.keyboard.press(selectAllKey);
|
|
await this.page.keyboard.press('Backspace');
|
|
|
|
// Select all again so typing will replace any remaining content
|
|
await this.page.keyboard.press(selectAllKey);
|
|
|
|
// Type the new query
|
|
await this.page.keyboard.type(query, { delay: 100 });
|
|
await this.page.keyboard.press('Escape');
|
|
|
|
// Final verification
|
|
const actualText = await this.getCurrentQueryText();
|
|
this.testLogger.info(`Expected: "${query}"`);
|
|
this.testLogger.info(`Actual: "${actualText}"`);
|
|
|
|
if (!actualText.includes(query.substring(0, Math.min(10, query.length)))) {
|
|
this.testLogger.error(`❌ Query mismatch - expected "${query}" but got "${actualText}"`);
|
|
} else {
|
|
this.testLogger.info('✓ Query successfully entered');
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Get current query text from editor
|
|
*
|
|
* @returns {Promise<string>} - The current query text
|
|
*/
|
|
async getCurrentQueryText() {
|
|
// Read from Monaco editor model directly (approved §5 pattern).
|
|
// Prefer the editor whose container has data-test="dashboard-panel-query-editor"
|
|
// so we don't read from a VRL/logs/secondary editor in the same page.
|
|
const text = await this.page.evaluate(() => {
|
|
try {
|
|
const editors = window.monaco?.editor?.getEditors?.() || [];
|
|
if (editors.length === 0) return '';
|
|
// Identify the dashboard panel query editor by its container's data-test.
|
|
const dashContainer = document.querySelector('[data-test="dashboard-panel-query-editor"]');
|
|
for (const ed of editors) {
|
|
const dom = ed.getDomNode?.();
|
|
if (dom && dashContainer && dashContainer.contains(dom)) {
|
|
return ed.getValue() || '';
|
|
}
|
|
}
|
|
// Fallback: return the first editor's value.
|
|
return editors[0].getValue() || '';
|
|
} catch (e) {
|
|
console.log('Monaco read failed:', e.message);
|
|
}
|
|
return '';
|
|
});
|
|
return (text || '').trim();
|
|
}
|
|
|
|
/**
|
|
* Wait for the dashboard-panel Monaco editor to be (re)mounted and visible.
|
|
*
|
|
* On tab create / tab switch, DashboardQueryEditor.vue tears down and remounts
|
|
* the Monaco instance for the newly-active tab. Reading getCurrentQueryText()
|
|
* before that remount completes yields empty/stale text. This gates on the REAL
|
|
* signal — a Monaco editor whose DOM node lives inside the dashboard query editor
|
|
* container AND is actually laid out (offsetParent != null, i.e. visible) — so
|
|
* callers read a live editor, not a mid-remount one.
|
|
*
|
|
* Best-effort: warns (never throws) if the editor isn't confirmed within the
|
|
* bounded timeout, leaving the caller's own assertions as the authority.
|
|
*
|
|
* @param {number} timeout - bounded max wait in ms
|
|
*/
|
|
async waitForEditorReady(timeout = 8000) {
|
|
try {
|
|
await expect.poll(async () => {
|
|
return await this.page.evaluate(() => {
|
|
const editors = window.monaco?.editor?.getEditors?.() || [];
|
|
const dashContainer = document.querySelector('[data-test="dashboard-panel-query-editor"]');
|
|
if (!dashContainer) return false;
|
|
for (const ed of editors) {
|
|
const dom = ed.getDomNode?.();
|
|
if (dom && dashContainer.contains(dom) && dom.offsetParent !== null) {
|
|
return true;
|
|
}
|
|
}
|
|
return false;
|
|
});
|
|
}, { timeout, intervals: [100, 200, 500] }).toBe(true);
|
|
} catch {
|
|
this.testLogger.warn('Editor not confirmed ready (mounted + visible) within timeout');
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Switch to a specific query tab (1-based)
|
|
*
|
|
* Tabs in DashboardQueryEditor.vue are rendered as OTab (Reka UI TabsTrigger)
|
|
* with data-test="dashboard-panel-query-tab-{0-based-index}" and
|
|
* data-state="active" when selected.
|
|
*
|
|
* @param {number} tabNumber - The tab number to switch to (1-based)
|
|
* @returns {Promise<boolean>} - True if successfully switched
|
|
*/
|
|
async switchToTab(tabNumber) {
|
|
this.testLogger.info(`Attempting to switch to tab ${tabNumber}`);
|
|
|
|
// Primary: use the data-test attribute (0-based index internally)
|
|
const zeroIdx = tabNumber - 1;
|
|
const tabByDataTest = this.getTabByIndex(zeroIdx).first();
|
|
|
|
if (await tabByDataTest.isVisible({ timeout: 3000 })) {
|
|
// Check if already active
|
|
const currentState = await tabByDataTest.getAttribute('data-state') || '';
|
|
if (currentState === 'active') {
|
|
this.testLogger.info(`✓ Tab ${tabNumber} is already active`);
|
|
return true;
|
|
}
|
|
|
|
const tabName = this.getTabNameByIndex(zeroIdx).first();
|
|
const clickTarget = (await tabName.count()) ? tabName : tabByDataTest;
|
|
await clickTarget.click({ force: true });
|
|
|
|
// Deterministic wait for active state
|
|
try {
|
|
await expect.poll(async () => await tabByDataTest.getAttribute('data-state'), {
|
|
timeout: 3000,
|
|
intervals: [100, 250, 500],
|
|
}).toBe('active');
|
|
// Tab is active — now wait for its Monaco editor to be remounted +
|
|
// visible before returning, so a caller reading getCurrentQueryText()
|
|
// right after the switch sees the re-rendered (persisted) query, not
|
|
// a mid-remount empty editor.
|
|
await this.waitForEditorReady();
|
|
this.testLogger.info(`✓ Switched to tab ${tabNumber} (verified active)`);
|
|
return true;
|
|
} catch {
|
|
const newState = await tabByDataTest.getAttribute('data-state') || '';
|
|
this.testLogger.warn(`Tab ${tabNumber} clicked but data-state is "${newState}"`);
|
|
}
|
|
}
|
|
|
|
this.testLogger.error(`Failed to switch to tab ${tabNumber}`);
|
|
return false;
|
|
}
|
|
|
|
/**
|
|
* Add a new query tab
|
|
*
|
|
* @returns {Promise<boolean>} - True if successfully added
|
|
*/
|
|
async addQueryTab() {
|
|
this.testLogger.info('Looking for Add Query button...');
|
|
|
|
const addButton = this.addQueryTabButton.first();
|
|
|
|
if (await addButton.isVisible({ timeout: 3000 })) {
|
|
// Track tab count before click for deterministic verification
|
|
const tabsBefore = await this.tabList.count();
|
|
|
|
await addButton.click({ force: true });
|
|
|
|
// Deterministic wait — count increased
|
|
try {
|
|
await expect.poll(async () => {
|
|
return await this.tabList.count();
|
|
}, { timeout: 5000, intervals: [100, 250, 500] }).toBeGreaterThan(tabsBefore);
|
|
// New tab created — wait for its freshly-mounted Monaco editor to be
|
|
// visible before returning so the caller can immediately act on it.
|
|
await this.waitForEditorReady();
|
|
this.testLogger.info('✓ Added new query tab successfully');
|
|
return true;
|
|
} catch {
|
|
this.testLogger.warn('Add tab clicked but tab count did not increase');
|
|
return false;
|
|
}
|
|
}
|
|
|
|
this.testLogger.error('❌ Add query button not found');
|
|
return false;
|
|
}
|
|
|
|
/**
|
|
* Click the Run Query button to save the query to state
|
|
* This ensures the query is properly saved before switching tabs
|
|
*
|
|
* @returns {Promise<boolean>} - True if successfully clicked
|
|
*/
|
|
async clickRunQuery() {
|
|
const runQueryBtn = this.runQueryButton.first();
|
|
if (await runQueryBtn.isVisible({ timeout: 2000 })) {
|
|
// Trigger and wait for the search response — deterministic
|
|
const responsePromise = this.page.waitForResponse(
|
|
resp => /\/_search|\/api\/.+\/prometheus|metrics_query_range/.test(resp.url()),
|
|
{ timeout: 5000 }
|
|
).catch(() => null);
|
|
await runQueryBtn.click();
|
|
this.testLogger.info('Clicked Run Query button');
|
|
await responsePromise;
|
|
return true;
|
|
}
|
|
this.testLogger.warn('Run Query button not found');
|
|
return false;
|
|
}
|
|
|
|
/**
|
|
* Enter query using keyboard input (simple approach)
|
|
* This is a simpler method that directly types into the editor
|
|
*
|
|
* @param {string} query - The query to enter
|
|
*/
|
|
async enterQueryViaKeyboard(query) {
|
|
// Focus the editor via Monaco API (avoid raw .monaco-editor class selector).
|
|
// Target the dashboard panel editor by container data-test so we don't
|
|
// hit a logs/VRL editor that may share the same page tree.
|
|
await this.queryEditorContainer.first().waitFor({ state: 'visible', timeout: 5000 });
|
|
|
|
// Clear ALL existing content and focus the editor using Monaco's API +
|
|
// executeEdits so Monaco fires the model change event that the Vue
|
|
// editor wrapper listens to (keeps `queries[i].query` in sync).
|
|
// Pure setValue() bypasses the change event and `handleQueryUpdate`
|
|
// wouldn't fire — leaving stale Vue state behind. Keyboard select-all
|
|
// by itself can also fail to cover the full range if the editor was
|
|
// just (re)mounted, leaving the new typed text concatenated with the
|
|
// stale placeholder (e.g. `metric_b` + `cpu_count{}`).
|
|
await this.page.evaluate(() => {
|
|
const editors = window.monaco?.editor?.getEditors?.() || [];
|
|
if (editors.length === 0) return;
|
|
const dashContainer = document.querySelector('[data-test="dashboard-panel-query-editor"]');
|
|
let target = null;
|
|
for (const ed of editors) {
|
|
const dom = ed.getDomNode?.();
|
|
if (dom && dashContainer && dashContainer.contains(dom)) {
|
|
target = ed;
|
|
break;
|
|
}
|
|
}
|
|
target = target || editors[0];
|
|
target.focus();
|
|
// Clear via executeEdits to emit the onDidChangeModelContent event
|
|
// (which the wrapper's @update:query listener uses).
|
|
const model = target.getModel?.();
|
|
if (model) {
|
|
const fullRange = model.getFullModelRange();
|
|
target.executeEdits('clear', [{ range: fullRange, text: '', forceMoveMarkers: true }]);
|
|
}
|
|
});
|
|
|
|
// Use insertText instead of type — keyboard.type() triggers Monaco
|
|
// autocomplete on cloud which corrupts the query text
|
|
await this.page.keyboard.insertText(query);
|
|
|
|
// Deterministic wait — verify Monaco model contains the query
|
|
await expect.poll(async () => {
|
|
return await this.getCurrentQueryText();
|
|
}, { timeout: 3000, intervals: [100, 250, 500] }).toContain(query);
|
|
let lastVal = '';
|
|
let stableCount = 0;
|
|
await expect.poll(async () => {
|
|
const v = await this.getCurrentQueryText();
|
|
if (v === lastVal && v.includes(query)) {
|
|
stableCount += 1;
|
|
} else {
|
|
stableCount = 0;
|
|
lastVal = v;
|
|
}
|
|
// 4 consecutive identical reads at 200ms intervals = 800ms of
|
|
// stability, comfortably past the 500ms debounce window.
|
|
return stableCount;
|
|
}, { timeout: 3000, intervals: [200] }).toBeGreaterThanOrEqual(4);
|
|
}
|
|
|
|
/**
|
|
* Poll for expected query text with retries
|
|
* Useful for waiting for async updates after tab switches
|
|
*
|
|
* @param {string} expectedText - The text to look for in the query
|
|
* @param {number} maxAttempts - Maximum number of attempts (default: 30 → 15s budget)
|
|
* @param {number} delayMs - Delay between attempts in ms (default: 500)
|
|
* @returns {Promise<{found: boolean, text: string}>} - Result with found status and actual text
|
|
*/
|
|
async pollForQueryText(expectedText, maxAttempts = 30, delayMs = 500) {
|
|
let currentText = '';
|
|
try {
|
|
await expect.poll(async () => {
|
|
currentText = await this.getCurrentQueryText();
|
|
this.testLogger.info(`Query read: "${currentText}"`);
|
|
return currentText.includes(expectedText);
|
|
}, {
|
|
timeout: maxAttempts * delayMs,
|
|
intervals: [delayMs],
|
|
}).toBe(true);
|
|
return { found: true, text: currentText };
|
|
} catch {
|
|
return { found: false, text: currentText };
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Take a screenshot for debugging purposes
|
|
*
|
|
* @param {string} prefix - Prefix for the screenshot filename
|
|
*/
|
|
async takeDebugScreenshot(prefix) {
|
|
await this.page.screenshot({ path: `${prefix}-${Date.now()}.png` });
|
|
}
|
|
|
|
/**
|
|
* Open the stream dropdown and select the first available stream.
|
|
* Returns the selected stream value (or empty string if no streams available).
|
|
*
|
|
* @returns {Promise<string>} - The selected stream value
|
|
*/
|
|
async selectFirstStream() {
|
|
const dropdown = this.streamDropdown.first();
|
|
if (!await dropdown.isVisible({ timeout: 3000 }).catch(() => false)) {
|
|
return '';
|
|
}
|
|
|
|
await dropdown.click();
|
|
await this.streamDropdownPopover.first().waitFor({ state: 'visible', timeout: 5000 });
|
|
|
|
const firstOption = this.streamDropdownOption.first();
|
|
if (!await firstOption.isVisible({ timeout: 3000 }).catch(() => false)) {
|
|
return '';
|
|
}
|
|
|
|
const streamValue = await firstOption.getAttribute('data-test-value') || '';
|
|
await firstOption.click();
|
|
|
|
// Wait for popover to close
|
|
await this.streamDropdownPopover.first().waitFor({ state: 'hidden', timeout: 5000 }).catch(() => {});
|
|
|
|
this.testLogger.info(`Selected stream: ${streamValue}`);
|
|
|
|
// Readiness gate (root cause #1): in PromQL custom mode, selecting a metric
|
|
// triggers an ASYNC app flow — fetch metric metadata, then auto-populate the
|
|
// query editor. The popover closing is NOT that signal. Gate on the REAL
|
|
// signal — the editor model becoming non-empty — with a generous bounded
|
|
// timeout so callers that assert on auto-populate don't race the async fill
|
|
// under cloud load (the 5s poll in P1 was too tight).
|
|
//
|
|
// Best-effort by design: if the query never auto-populates (e.g. no metadata
|
|
// for this metric, or auto-populate broken server-side) we log a warning and
|
|
// still return streamValue, so callers that do NOT expect auto-population are
|
|
// unaffected and their own assertions remain the authority.
|
|
try {
|
|
await expect.poll(async () => {
|
|
return (await this.getCurrentQueryText()).trim().length > 0;
|
|
}, { timeout: 18000, intervals: [200, 300, 500, 1000] }).toBe(true);
|
|
this.testLogger.info('✓ Auto-populated query detected after stream selection');
|
|
} catch {
|
|
this.testLogger.warn('Stream selected but query did not auto-populate within timeout — returning streamValue anyway');
|
|
}
|
|
|
|
return streamValue;
|
|
}
|
|
}
|