mirror of
https://github.com/openobserve/openobserve.git
synced 2026-11-06 00:09:22 +00:00
## What
Stabilizes the **SDR**, **GeneralTests** and **Metrics** shards of the
alpha1 cloud
E2E suite (`playwright_alpha1.yml`) to green — the last three shards
remaining after
the rest of the suite was greened. Rebased on `main`.
The original failing run had, across these shards: **SDR** 1 hard fail +
1 flake,
**GeneralTests** 21 fails + 2 flakes, **Metrics** a load-sensitive
query-persistence
flake. Root causes were cloud data/UI-readiness races (freshly-created
streams &
regex patterns lagging their list/table; browser OOM under parallelism;
revamped Reka
UI re-rendering mid-interaction), all fixed with deterministic waits on
real signals.
**No `waitForTimeout` as a fix, no `force:true`, no `.first()`/`.nth()`
band-aids, no
retry-count bumps to hide failures, no weakened assertions. No
worker-count,
parallelism, playwright-config or CI/yml changes** — test code only.
## Root cause & fixes
**SDR**
1. **`scrollIntoViewIfNeeded` 45s timeout** (`multipleSDRPatterns`,
`queryTimeDrop`).
The revamped Reka streams/schema table re-renders rows mid-animation, so
the
scroll's stability polling spun to the 45s action timeout even though
the element
was visible. Guard with `expect(el).toBeVisible()` + an instant native
`scrollIntoView`; removed the trailing sleeps.
(`streamAssociationPage.js`)
2. **Stream Detail `click()` 45s timeout.** One-shot JS-then-Playwright
click raced the
re-rendering row. Replaced with a `toPass` retry that re-resolves the
button fresh,
fires a native DOM click, and verifies the detail sidebar opened.
3. **Regex-pattern create→list eventual consistency** (6 combined tests:
`ingestionTime*`,
`queryTime*`). Create is accepted (`verifyPatternCreatedSuccess` passes)
before it
propagates to the list read-replica, so the UI text-count check threw
"not found in list". Gate on the backend source of truth:
`waitForPatternCreated`
polls `GET /re_patterns` until present then confirms the UI rendered;
`waitForPatternDeleted` fixes the symmetric "Unable to delete … Reason:
unknown"
delete race. (`sdrPatternsPage.js`)
4. **Freshly-ingested stream lags the `/streams` table**
(`multipleSDRPatterns` @slow).
`openStreamDetail` reloads + re-searches until the stream's row appears
(bounded),
instead of a one-shot 5s wait. (Local-caught gotcha:
`isVisible({timeout})` does
**not** auto-wait — switched to `waitFor({state:'visible'})`.)
5. Transient cloud-gateway **connection timeouts** on `page.request`
pattern calls wrapped
in a connection-error-only retry.
**GeneralTests**
6. **cipherKeys ×6 — tab never visible.** Cipher Keys shares the exact
`visible: isEnt`
gate as Regex Patterns (`settings/index.vue`), and SDR's regex
management passes on
this same cloud env — so the tab **does** render; the test was clicking
the Settings
menu, which lands on the SectionHub where per-tab data-tests aren't
rendered. Fixed
by a **direct `goto('/web/settings/cipher_keys')`**, mirroring the
working regex page.
(Not skipped — that would have masked a real, fixable nav bug.)
7. **enrichment ×7 — `pipeline-section-tab-enrichmentTables` 45s
force-click.** The tab
only mounts after the pipelines page header renders; the
flyout→pipelines→section-tab
nav is fragile under load. Replaced with direct SPA nav to the
enrichment-tables route
+ `goto` fallback + a `toPass` settle-retry Add-button click.
(`pipelinesPage.js`)
8. **languageTranslation ×8 + landingPage ×1 — browser OOM cascade**
("Target page/context/
browser has been closed"). 10 exhaustive ~27-page full-app crawls ran
`mode:parallel`
under `--workers=5`; a worker's browser was OOM-killed and every test
after it
cascaded. **Kept parallel** (serializing overran the shard timeout) —
the crawl now
navigates `waitUntil:'domcontentloaded'` + a bounded settle instead of
waiting up to
10–30s for `networkidle` on chart/websocket-heavy pages, cutting each
heavy page's
peak-memory dwell ~10s→~3s (so 5 concurrent crawls no longer spike
together) and making
each crawl ~3–4× faster. Added fail-fast on a closed page/context so any
residual crash
surfaces at its true origin instead of being swallowed/retried.
9. **schema ×2 — `selectStream` can't find just-created
`test1`/`test3`.** Local reproduction
proved the root cause: the **fixed** stream names hit `400 "stream is
being deleted"`
(a prior run's cleanup still finishing) → the stream got no data → never
listed. Fixed
with **unique per-invocation stream names**; also hardened the ingestion
helper
(status-check + transient retry) and added a streams-API readiness gate
before
`selectStream`. (`schema.spec.js`)
10. **enrichment `@upload` ×2 — `_search` `waitForResponse` timeout.**
The 30s search wait
was armed **before** the up-to-120s `selectStream`, so it expired
mid-selection. Re-armed
it (200-gated) immediately before `applyQuery`, plus a streams-API
readiness gate for
`e2e_automate`. `schemaload` gated on the `streams?type=logs` response
before reload.
**Metrics**
11. Replaced `waitForTimeout`s in `metricsPage.js` with real signals
(`data-selected="true"`,
`data-state="on"`) and fixed a reka `OToggleGroupItem` state-detection
bug (active state
is `data-state="on"`, not a `selected` class).
12. **`metrics-promql-query-persistence` load-sensitive flake.**
`selectFirstStream` returned
before the async auto-populate finished, and persistence tests read the
query editor
before Monaco re-rendered after tab switches. Added editor-readiness
gates on real
signals (auto-populated query present; Monaco mounted + laid out) in
`metricsQueryEditorPage.js`; assertions unchanged.
## Validation
- **CI — SDR / GeneralTests / Metrics green (alpha cloud):**
https://github.com/openobserve/o2-enterprise/actions/runs/29633444186
- **Original failing run:**
https://github.com/openobserve/o2-enterprise/actions/runs/29481437880
- **Local (alpha cloud, CI conditions, single-worker):** each
previously-failing test re-run
to green — `schema.spec.js` 3/3, `multipleSDRPatterns.spec.js` 2/2.
## Notes
- 21 test files change (8 page objects + 13 specs). No shared
cleanup/setup, config, or
workflow files touched.
- RegressionSet was unaffected by this branch — a transient hang in the
workflow's
"pre-test cleanup" step (before any test runs), unrelated to these
changes.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
---------
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
553 lines
24 KiB
JavaScript
553 lines
24 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}"]`);
|
|
}
|
|
|
|
/**
|
|
* 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;
|
|
}
|
|
|
|
await tabByDataTest.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;
|
|
}
|
|
}
|