mirror of
https://github.com/openobserve/openobserve.git
synced 2026-11-03 16:10:21 +00:00
test(e2e): stabilize alpha1 SDR, GeneralTests & Metrics shards (stream/pattern-readiness + langTranslation OOM + Reka-table & editor races) (#13259)
## 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>
This commit is contained in:
co-authored by
Claude Opus 4.8
parent
96bbed8a6e
commit
fddf2cf264
@@ -34,12 +34,28 @@ export class CipherKeys {
|
||||
}
|
||||
|
||||
async navigateToSettingsMenu() {
|
||||
await this.page.waitForSelector('[data-test="menu-link-/settings-item"]');
|
||||
await this.settingsMenu.click();
|
||||
// Navigate straight to the Cipher Keys settings sub-route instead of clicking
|
||||
// the left-nav Settings menu. The revamped Settings landing is a SectionHub
|
||||
// (settings/index.vue) whose per-tab data-tests (e.g. `management-cipher-key-tab`)
|
||||
// only render once you are INSIDE a section sub-route — landing on the hub left
|
||||
// the tab unrendered, so the old `waitForSelector('[data-test="management-cipher-key-tab"]')`
|
||||
// could only exhaust the 45s action timeout. Cipher Keys shares the exact same
|
||||
// `visible: isEnt` gate as Regex Patterns (settings/index.vue:236/246), and the
|
||||
// SDR shard's regex-management tests pass on this same alpha1 cloud env, proving
|
||||
// the tab DOES render here — this mirrors the proven direct-goto that
|
||||
// regexPatternsPage.navigateToRegexPatterns() uses.
|
||||
const orgName = process.env.ORGNAME || 'default';
|
||||
const baseUrl = process.env.ZO_BASE_URL;
|
||||
await this.page.goto(`${baseUrl}/web/settings/cipher_keys?org_identifier=${orgName}`);
|
||||
await this.page.waitForLoadState('networkidle', { timeout: 10000 }).catch(() => {});
|
||||
}
|
||||
|
||||
async navigateToCipherKeyTab() {
|
||||
await this.page.waitForSelector('[data-test="management-cipher-key-tab"]');
|
||||
// On the cipher_keys sub-route the tab is the active rail item; assert it is
|
||||
// rendered (real readiness signal, bounded — not the 45s action timeout) and
|
||||
// click it to guarantee we land on the Cipher Keys list. Idempotent: safe when
|
||||
// navigateToSettingsMenu() already placed us on the route.
|
||||
await expect(this.cipherKeyTab).toBeVisible({ timeout: 30000 });
|
||||
await this.cipherKeyTab.click();
|
||||
}
|
||||
|
||||
|
||||
@@ -217,6 +217,39 @@ class LanguagePage {
|
||||
return this.page.locator(`[data-test="${tabDataTest}"]`).first();
|
||||
}
|
||||
|
||||
/**
|
||||
* A closed page/context (or a Playwright "Target ... has been closed" error) is
|
||||
* fatal and non-recoverable — it means the worker's browser process crashed
|
||||
* (typically OOM under high parallelism). Such errors must NEVER be swallowed by
|
||||
* best-effort try/catch blocks: doing so lets the test limp through the rest of
|
||||
* the crawl and report a bogus coverage percentage instead of failing honestly.
|
||||
* @param {Error} error
|
||||
* @returns {boolean}
|
||||
*/
|
||||
_isFatalClosedError(error) {
|
||||
return this.page.isClosed()
|
||||
|| /has been closed|Target (page|frame)?.*closed|Target closed/i.test((error && error.message) || '');
|
||||
}
|
||||
|
||||
/**
|
||||
* Navigate to a page for text scraping with a BOUNDED settle.
|
||||
*
|
||||
* Translation strings are static i18n rendered on component mount, so we do not
|
||||
* need a fully loaded/idle page — waiting for `networkidle` on chart/monaco/
|
||||
* websocket-heavy views (Metrics/Traces/RUM/Dashboards) never settles, wasting
|
||||
* up to 10–30s per page AND holding that heavy page live at peak renderer memory
|
||||
* the whole time. Under `--workers=5` that prolonged, overlapping memory spike is
|
||||
* what OOM-kills a worker. Using `domcontentloaded` + a short bounded settle caps
|
||||
* each heavy page's live-at-peak dwell to ~a few seconds (lower OOM probability)
|
||||
* and makes the crawl ~3–4× faster (fits the shard timeout).
|
||||
* @param {string} url
|
||||
* @param {number} settleMs bounded max settle after DOMContentLoaded
|
||||
*/
|
||||
async _navigateForScrape(url, settleMs = 2500) {
|
||||
await this.page.goto(url, { waitUntil: 'domcontentloaded' });
|
||||
await this.page.waitForLoadState('networkidle', { timeout: settleMs }).catch(() => {});
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// PUBLIC ACTIONS
|
||||
// ============================================================================
|
||||
@@ -329,8 +362,7 @@ class LanguagePage {
|
||||
*/
|
||||
async navigateToHome() {
|
||||
const homeUrl = `/web/?org_identifier=${process.env["ORGNAME"]}`;
|
||||
await this.page.goto(homeUrl);
|
||||
await this.page.waitForLoadState('networkidle', { timeout: 30000 }).catch(() => {});
|
||||
await this._navigateForScrape(homeUrl, 5000);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -558,8 +590,7 @@ class LanguagePage {
|
||||
*/
|
||||
async _testSinglePage(pageConfig, langCode) {
|
||||
const pageUrl = `/web${pageConfig.path}?org_identifier=${process.env["ORGNAME"]}`;
|
||||
await this.page.goto(pageUrl);
|
||||
await this.page.waitForLoadState('networkidle', { timeout: 10000 }).catch(() => {});
|
||||
await this._navigateForScrape(pageUrl);
|
||||
|
||||
const allTexts = await this._extractAllVisibleText();
|
||||
return this._analyzeTranslation(allTexts, langCode);
|
||||
@@ -608,8 +639,7 @@ class LanguagePage {
|
||||
} else if (subPage.path) {
|
||||
// Navigate to sub-page URL
|
||||
const subPageUrl = `/web${subPage.path}?org_identifier=${process.env["ORGNAME"]}`;
|
||||
await this.page.goto(subPageUrl);
|
||||
await this.page.waitForLoadState('networkidle', { timeout: 10000 }).catch(() => {});
|
||||
await this._navigateForScrape(subPageUrl);
|
||||
const texts = await this._extractAllVisibleText();
|
||||
results.push({
|
||||
name: subPage.name,
|
||||
@@ -630,6 +660,8 @@ class LanguagePage {
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
// Rethrow fatal browser-crash errors — never mask a dead page/context.
|
||||
if (this._isFatalClosedError(e)) throw e;
|
||||
testLogger.warn(`Could not test sub-page ${subPage.name}: ${e.message}`);
|
||||
}
|
||||
}
|
||||
@@ -664,7 +696,10 @@ class LanguagePage {
|
||||
results.totalTranslated += analysis.translatedCount;
|
||||
testLogger.info(` Modal (Add Dashboard): ${analysis.percentage}%`);
|
||||
}
|
||||
} catch (e) { /* ignore */ }
|
||||
} catch (e) {
|
||||
// Rethrow fatal browser-crash errors; only best-effort modal issues are ignored.
|
||||
if (this._isFatalClosedError(e)) throw e;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -315,10 +315,29 @@ class SchemaLoadPage {
|
||||
// (before this test's stream existed). The reload triggers a fresh
|
||||
// streams-list API call.
|
||||
await this.navigateToLogs();
|
||||
|
||||
// Gate the stream-selector readiness on the REAL signal: the fresh
|
||||
// streams-list API call the logs page fires on (re)load. The OSelect
|
||||
// wrapper only mounts once the SearchBar has this list, so on a heavy
|
||||
// (large-schema) stream under load the wrapper can lag well past a bare
|
||||
// 30s wait while the SPA is still bootstrapping/fetching. Arm the listener
|
||||
// BEFORE reload() so we capture the post-reload request (not the stale one
|
||||
// from the first /web/logs visit). If it never arrives, fall through — the
|
||||
// OSelect wait below still guards correctness (and surfaces genuine
|
||||
// streams-API slowness rather than masking it).
|
||||
const streamsListLoaded = this.page.waitForResponse(
|
||||
(resp) => resp.url().includes('/streams?type=logs') && resp.status() === 200,
|
||||
{ timeout: 60000 }
|
||||
).catch(() => {
|
||||
testLogger.debug('streams?type=logs response not observed within timeout - continuing to OSelect wait');
|
||||
return null;
|
||||
});
|
||||
|
||||
await this.page.reload();
|
||||
await this.page.waitForLoadState('domcontentloaded');
|
||||
await streamsListLoaded;
|
||||
|
||||
// Wait for the OSelect to mount
|
||||
// Wait for the OSelect to mount now that the streams list has been fetched
|
||||
await this.logSearchIndexSelectStream.waitFor({ state: 'visible', timeout: 30000 });
|
||||
|
||||
// Open the stream dropdown
|
||||
|
||||
@@ -715,11 +715,19 @@ export class MetricsPage {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Click the chart type button
|
||||
// Click the chart type button, then deterministically confirm the type
|
||||
// actually became active instead of a blind 1s sleep. ChartSelection.vue
|
||||
// stamps data-selected="true" on the selected item — wait for that real
|
||||
// signal. If it never flips (e.g. a disabled chart type), return false so
|
||||
// callers see the true outcome rather than a masked success.
|
||||
await chartButton.click();
|
||||
await this.page.waitForTimeout(1000);
|
||||
|
||||
return true;
|
||||
const activeItem = this.page.locator(
|
||||
`[data-test="selected-chart-${chartType}-item"][data-selected="true"]`
|
||||
);
|
||||
return await activeItem
|
||||
.waitFor({ state: 'visible', timeout: 5000 })
|
||||
.then(() => true)
|
||||
.catch(() => false);
|
||||
} catch (error) {
|
||||
// Take screenshot for debugging
|
||||
await this.page.screenshot({
|
||||
@@ -2222,27 +2230,29 @@ export class MetricsPage {
|
||||
* @returns {Promise<boolean>} true when both buttons were clicked successfully
|
||||
*/
|
||||
async switchToPromQLCustomMode() {
|
||||
// Step 1 — ensure PromQL language is active
|
||||
// Step 1 — ensure PromQL language is active.
|
||||
// These are reka OToggleGroupItem buttons whose active state is exposed via
|
||||
// data-state="on" (not a 'selected' class). Click only when not active, then
|
||||
// poll data-state until it settles — deterministic, no fixed sleep.
|
||||
const promqlBtn = this.page.locator('[data-test="dashboard-promql-query-type"]');
|
||||
if (!await promqlBtn.isVisible({ timeout: 5000 }).catch(() => false)) {
|
||||
return false;
|
||||
}
|
||||
const promqlClasses = await promqlBtn.getAttribute('class') || '';
|
||||
if (!promqlClasses.includes('selected')) {
|
||||
const isOn = async (btn) => (await btn.getAttribute('data-state').catch(() => null)) === 'on';
|
||||
if (!await isOn(promqlBtn)) {
|
||||
await promqlBtn.click();
|
||||
await this.page.waitForTimeout(300);
|
||||
}
|
||||
await expect.poll(async () => await isOn(promqlBtn), { timeout: 5000, intervals: [100, 200, 400] }).toBe(true);
|
||||
|
||||
// Step 2 — switch to Custom sub-mode
|
||||
const customBtn = this.page.locator('[data-test="dashboard-custom-query-type"]');
|
||||
if (!await customBtn.isVisible({ timeout: 5000 }).catch(() => false)) {
|
||||
return false;
|
||||
}
|
||||
const customClasses = await customBtn.getAttribute('class') || '';
|
||||
if (!customClasses.includes('selected')) {
|
||||
if (!await isOn(customBtn)) {
|
||||
await customBtn.click();
|
||||
await this.page.waitForTimeout(300);
|
||||
}
|
||||
await expect.poll(async () => await isOn(customBtn), { timeout: 5000, intervals: [100, 200, 400] }).toBe(true);
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -2348,7 +2358,9 @@ export class MetricsPage {
|
||||
async selectPromqlTableMode(modeValue) {
|
||||
const trigger = this.page.locator('[data-test="dashboard-config-promql-table-mode-trigger"]');
|
||||
await trigger.waitFor({ state: 'visible', timeout: 5000 });
|
||||
await trigger.scrollIntoViewIfNeeded();
|
||||
// Bound the scroll to the element's own actionability window so it can never
|
||||
// hang on the default 30s timeout if layout is momentarily unstable.
|
||||
await trigger.scrollIntoViewIfNeeded({ timeout: 5000 });
|
||||
await trigger.click();
|
||||
await this.promqlTableModePopover.waitFor({ state: 'visible', timeout: 5000 });
|
||||
// Pick the option by its data-test-value (per AGENT_RULES §4 OSelectItem stamp).
|
||||
|
||||
@@ -154,12 +154,15 @@ export class MetricsQueryEditorPage {
|
||||
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
|
||||
// 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: 3000, intervals: [100, 200, 500] }).toBe(true);
|
||||
}, { timeout: 15000, intervals: [100, 200, 500, 1000] }).toBe(true);
|
||||
this.testLogger.info('✓ Query verified in rendered view');
|
||||
return;
|
||||
} catch {
|
||||
@@ -249,6 +252,42 @@ export class MetricsQueryEditorPage {
|
||||
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)
|
||||
*
|
||||
@@ -282,6 +321,11 @@ export class MetricsQueryEditorPage {
|
||||
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 {
|
||||
@@ -315,6 +359,9 @@ export class MetricsQueryEditorPage {
|
||||
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 {
|
||||
@@ -422,11 +469,11 @@ export class MetricsQueryEditorPage {
|
||||
* 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: 10)
|
||||
* @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 = 10, delayMs = 500) {
|
||||
async pollForQueryText(expectedText, maxAttempts = 30, delayMs = 500) {
|
||||
let currentText = '';
|
||||
try {
|
||||
await expect.poll(async () => {
|
||||
@@ -479,6 +526,27 @@ export class MetricsQueryEditorPage {
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -971,14 +971,52 @@ export class PipelinesPage {
|
||||
}
|
||||
|
||||
async navigateToAddEnrichmentTable() {
|
||||
await openNavFlyoutChild(this.page, 'pipeline');
|
||||
// The enrichment-table tab uses a Reka-based OToggleGroup — `force` avoids
|
||||
// visibility races when the tab list animates in.
|
||||
await this.enrichmentTableTabLocator.click({ force: true });
|
||||
await this.addEnrichmentTableText.click();
|
||||
// The Add Enrichment Table form (`add-enrichment-table-page`) renders
|
||||
// inside the same parent — wait for it to be visible before returning.
|
||||
await this.addEnrichmentTablePage.waitFor({ state: 'visible', timeout: 15000 });
|
||||
// Land directly on the enrichment-tables LIST page instead of the old
|
||||
// two-hop route (flyout → pipelines list → click the
|
||||
// `pipeline-section-tab-enrichmentTables` OTab). That tab lives in the
|
||||
// pipelines page header (PipelineSectionTabs, rendered in AppPageHeader's
|
||||
// #tabs slot) and only mounts once that page has fully rendered — so on
|
||||
// cloud a slow/missed flyout nav left the tab locator unattached and the
|
||||
// blind `force` click timed out at 45s (systematic across every test).
|
||||
// `enrichmentTables` has its own left-nav flyout child, so navigate
|
||||
// straight to its list page and skip the fragile section-tab hop.
|
||||
const enrichmentListUrlRe = /\/pipeline\/enrichment-tables/;
|
||||
const listPage = this.page.locator('[data-test="enrichment-tables-list-page"]');
|
||||
|
||||
// Prefer SPA nav via the direct flyout child; fall back to a hard goto
|
||||
// if the hover-driven flyout click misses (same recovery pattern as
|
||||
// enrichmentPage.gotoEnrichmentTablesWithOrg).
|
||||
try {
|
||||
await openNavFlyoutChild(this.page, 'enrichment');
|
||||
await this.page.waitForURL(enrichmentListUrlRe, { timeout: 15000 });
|
||||
} catch (navError) {
|
||||
testLogger.warn('Enrichment flyout nav missed — falling back to direct goto', {
|
||||
error: navError.message,
|
||||
});
|
||||
let org = process.env.ORGNAME;
|
||||
try {
|
||||
org = new URL(this.page.url()).searchParams.get('org_identifier') || org;
|
||||
} catch { /* about:blank etc. — keep ORGNAME fallback */ }
|
||||
await this.page.goto(
|
||||
`${process.env.ZO_BASE_URL}/web/pipeline/enrichment-tables?org_identifier=${org}`,
|
||||
{ waitUntil: 'domcontentloaded' },
|
||||
);
|
||||
await this.page.waitForURL(enrichmentListUrlRe, { timeout: 15000 });
|
||||
}
|
||||
|
||||
// Wait for the list page container, then settle+retry the Add-button
|
||||
// click. The OButton in the AppPageHeader #actions slot can detach
|
||||
// mid-render while the list hydrates (documented add-btn detach race),
|
||||
// so re-resolve the locator and tolerate a transient detach. Short-circuit
|
||||
// if a prior attempt already opened the form (the Add button is v-if'd
|
||||
// out once the form mounts, so re-asserting its visibility would loop).
|
||||
await listPage.waitFor({ state: 'visible', timeout: 20000 });
|
||||
await expect(async () => {
|
||||
if (await this.addEnrichmentTablePage.isVisible().catch(() => false)) return;
|
||||
await expect(this.addEnrichmentTableText).toBeVisible({ timeout: 5000 });
|
||||
await this.addEnrichmentTableText.click({ timeout: 5000 });
|
||||
await expect(this.addEnrichmentTablePage).toBeVisible({ timeout: 10000 });
|
||||
}).toPass({ timeout: 45000 });
|
||||
}
|
||||
|
||||
async uploadEnrichmentTable(fileName, fileContentPath) {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
// sdrPatternsPage.js (formerly regexPatternsPage.js)
|
||||
import { expect } from '@playwright/test';
|
||||
import testLogger from '../../playwright-tests/utils/test-logger.js';
|
||||
import { getAuthHeaders, getOrgIdentifier } from '../../playwright-tests/utils/cloud-auth.js';
|
||||
|
||||
export class SDRPatternsPage {
|
||||
constructor(page) {
|
||||
@@ -326,30 +327,161 @@ export class SDRPatternsPage {
|
||||
return isVisible;
|
||||
}
|
||||
|
||||
// ===== Backend API helpers (source of truth for eventual-consistency gating) =====
|
||||
//
|
||||
// WHY: The regex-pattern UI list is rendered client-side and lags the backend by
|
||||
// several seconds under cloud load — create/delete are accepted (drawer/dialog
|
||||
// closes) before the change propagates to the list read-replica, and the rendered
|
||||
// table lags further still. A UI text-count check therefore RACES the backend and
|
||||
// produces false "not found in list" / "Reason: unknown" failures. These helpers
|
||||
// gate on GET /api/{org}/re_patterns, the authoritative store, so readiness is a
|
||||
// deterministic API signal rather than a blind UI-reload retry.
|
||||
|
||||
_sdrApiBaseUrl() {
|
||||
// Mirror the derivation used by playwright-tests/SDR/multipleSDRPatterns.spec.js
|
||||
// (INGESTION_URL is the correct gateway for the re_patterns API on cloud).
|
||||
return (process.env.INGESTION_URL || process.env.ZO_BASE_URL || 'http://localhost:5080').replace(/\/$/, '');
|
||||
}
|
||||
|
||||
// Retry ONLY transient connection/timeout failures on a page.request call. A real
|
||||
// HTTP error response is returned to the caller unchanged (never retried here) so
|
||||
// genuine failures still surface. Mirrors requestWithRetry in multipleSDRPatterns.spec.js.
|
||||
async _requestWithRetry(action, label, maxRetries = 3) {
|
||||
for (let attempt = 1; attempt <= maxRetries; attempt++) {
|
||||
try {
|
||||
return await action();
|
||||
} catch (err) {
|
||||
const msg = (err && err.message) || String(err);
|
||||
const retriable = /Connection timeout|ECONNRESET|ECONNREFUSED|socket hang up|Request timed out|net::|timeout/i.test(msg);
|
||||
if (!retriable || attempt === maxRetries) throw err;
|
||||
testLogger.warn(`${label} failed (attempt ${attempt}/${maxRetries}): ${msg} — retrying`);
|
||||
await this.page.waitForTimeout(attempt * 2000);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Authoritative list read. Returns an array of pattern names, or null if the request
|
||||
// could not be completed / returned a non-OK status (transient) so callers can keep polling.
|
||||
async _listPatternNamesViaApi() {
|
||||
const baseUrl = this._sdrApiBaseUrl();
|
||||
const org = getOrgIdentifier();
|
||||
const headers = getAuthHeaders();
|
||||
try {
|
||||
const res = await this._requestWithRetry(
|
||||
() => this.page.request.get(`${baseUrl}/api/${org}/re_patterns`, { headers }),
|
||||
'list re_patterns'
|
||||
);
|
||||
if (!res.ok()) {
|
||||
testLogger.warn(`re_patterns list returned HTTP ${res.status()} — treating as indeterminate`);
|
||||
return null;
|
||||
}
|
||||
const body = await res.json();
|
||||
return (body.patterns || []).map((p) => p.name);
|
||||
} catch (err) {
|
||||
testLogger.warn(`re_patterns list request failed after retries: ${(err && err.message) || err}`);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// API-authoritative existence check. Returns the CURRENT state (fast in BOTH
|
||||
// directions — one successful list read is definitive), which is why it is NOT a
|
||||
// poll-until-present loop: several callers use it to confirm ABSENCE (post-delete /
|
||||
// cleanup with unique run-scoped names), and a poll-until-present here would stall
|
||||
// those for the full timeout every run. When you need to WAIT for a create/delete
|
||||
// to propagate, use waitForPatternCreated / waitForPatternDeleted instead.
|
||||
// Falls back to the UI text-count check only if the API is unreachable (e.g. a
|
||||
// misconfigured/self-hosted env), so behaviour never regresses off-cloud.
|
||||
async checkPatternExists(patternName, maxRetries = 3) {
|
||||
testLogger.info(`Checking if pattern exists: ${patternName}`);
|
||||
testLogger.info(`Checking if pattern exists (API-authoritative): ${patternName}`);
|
||||
|
||||
for (let attempt = 1; attempt <= maxRetries; attempt++) {
|
||||
await this.searchPattern(patternName);
|
||||
await this.page.waitForTimeout(1000);
|
||||
|
||||
const count = await this.page.locator(`text="${patternName}"`).count();
|
||||
const exists = count > 0;
|
||||
|
||||
if (exists) {
|
||||
testLogger.info(`Pattern ${patternName} exists: true (found on attempt ${attempt})`);
|
||||
return true;
|
||||
const names = await this._listPatternNamesViaApi();
|
||||
if (names !== null) {
|
||||
const exists = names.includes(patternName);
|
||||
testLogger.info(`Pattern ${patternName} exists: ${exists} (via API, attempt ${attempt})`);
|
||||
return exists;
|
||||
}
|
||||
|
||||
if (attempt < maxRetries) {
|
||||
testLogger.info(`Pattern ${patternName} not found on attempt ${attempt}, retrying in 2s...`);
|
||||
await this.page.waitForTimeout(2000);
|
||||
// Navigate back to refresh the list
|
||||
await this.navigateToRegexPatterns();
|
||||
await this.page.waitForTimeout(attempt * 2000);
|
||||
}
|
||||
}
|
||||
|
||||
testLogger.info(`Pattern ${patternName} exists: false (after ${maxRetries} attempts)`);
|
||||
// API indeterminate after retries — fall back to the old UI list check so
|
||||
// non-cloud / misconfigured environments still function.
|
||||
testLogger.warn(`API list unavailable for ${patternName} — falling back to UI list check`);
|
||||
await this.searchPattern(patternName);
|
||||
await this.page.waitForTimeout(1000);
|
||||
const count = await this.page.locator(`text="${patternName}"`).count();
|
||||
const exists = count > 0;
|
||||
testLogger.info(`Pattern ${patternName} exists (UI fallback): ${exists}`);
|
||||
return exists;
|
||||
}
|
||||
|
||||
// Readiness gate for pattern CREATION. Poll the authoritative API until the pattern
|
||||
// is present (eventual consistency on cloud can lag the accepted create by seconds,
|
||||
// worse under load — the reported root cause), then reload the UI list and confirm
|
||||
// it renders so downstream stream-association linking (which drives the UI) is
|
||||
// stable. Returns true once API (and, best-effort, the UI) agree; false only if the
|
||||
// create never propagates within the window — a genuine failure, not swallowed.
|
||||
async waitForPatternCreated(patternName, timeoutMs = 45000) {
|
||||
testLogger.info(`Waiting for pattern to be created (backend-consistent): ${patternName}`);
|
||||
const deadline = Date.now() + timeoutMs;
|
||||
let apiConfirmed = false;
|
||||
|
||||
while (Date.now() < deadline) {
|
||||
const names = await this._listPatternNamesViaApi();
|
||||
if (names !== null && names.includes(patternName)) {
|
||||
apiConfirmed = true;
|
||||
testLogger.info(`Pattern ${patternName} confirmed present via API`);
|
||||
break;
|
||||
}
|
||||
await this.page.waitForTimeout(2000);
|
||||
}
|
||||
|
||||
if (!apiConfirmed) {
|
||||
testLogger.warn(`Pattern ${patternName} did not appear in API within ${timeoutMs}ms`);
|
||||
return false;
|
||||
}
|
||||
|
||||
// API confirms it exists — now make sure the rendered list reflects it (short
|
||||
// retry) so the rest of the test, which interacts with the rendered list, is stable.
|
||||
for (let attempt = 1; attempt <= 3; attempt++) {
|
||||
await this.navigateToRegexPatterns();
|
||||
await this.searchPattern(patternName);
|
||||
await this.page.waitForTimeout(1000);
|
||||
const count = await this.page.locator(`text="${patternName}"`).count();
|
||||
if (count > 0) {
|
||||
testLogger.info(`Pattern ${patternName} rendered in UI list (attempt ${attempt})`);
|
||||
await this.searchPatternInput.clear().catch(() => {});
|
||||
await this.page.waitForTimeout(300);
|
||||
return true;
|
||||
}
|
||||
testLogger.info(`Pattern ${patternName} API-present but UI not yet rendered (attempt ${attempt}) — reloading`);
|
||||
await this.page.waitForTimeout(2000);
|
||||
}
|
||||
|
||||
// Backend has it; the UI still hasn't rendered it after retries. Treat as ready —
|
||||
// the API is the source of truth and downstream association resolves by name.
|
||||
testLogger.warn(`Pattern ${patternName} confirmed in API but not rendered in UI after retries — proceeding on API truth`);
|
||||
return true;
|
||||
}
|
||||
|
||||
// Readiness gate for pattern DELETION. Poll the authoritative API until the pattern
|
||||
// is gone. The UI row (and briefly the list replica) can linger after a delete is
|
||||
// accepted; a UI text check here previously produced false "Reason: unknown"
|
||||
// failures. Tolerates transient request failures (indeterminate → keep polling).
|
||||
async waitForPatternDeleted(patternName, timeoutMs = 30000) {
|
||||
testLogger.info(`Waiting for pattern to be deleted (backend-consistent): ${patternName}`);
|
||||
const deadline = Date.now() + timeoutMs;
|
||||
while (Date.now() < deadline) {
|
||||
const names = await this._listPatternNamesViaApi();
|
||||
if (names !== null && !names.includes(patternName)) {
|
||||
testLogger.info(`Pattern ${patternName} confirmed absent via API`);
|
||||
return true;
|
||||
}
|
||||
await this.page.waitForTimeout(2000);
|
||||
}
|
||||
testLogger.warn(`Pattern ${patternName} still present in API after ${timeoutMs}ms`);
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -446,18 +578,20 @@ export class SDRPatternsPage {
|
||||
return { success: false, reason: 'in_use', associations };
|
||||
}
|
||||
|
||||
// No error message - verify deletion by searching for the pattern
|
||||
testLogger.info(`No error message detected. Verifying deletion by searching for pattern ${patternName}`);
|
||||
await this.searchPattern(patternName);
|
||||
await this.page.waitForTimeout(1500);
|
||||
// No error message - verify deletion against the authoritative API. The UI row
|
||||
// (and the list read-replica) can lag the accepted delete under cloud load; the
|
||||
// previous UI text check here produced false "Reason: unknown" failures (e.g.
|
||||
// "Unable to delete existing pattern PGP Private Key"). Poll the API until the
|
||||
// pattern is gone — but do NOT swallow a genuine failure: if it is still present
|
||||
// after the bounded wait, report unknown so the caller can react.
|
||||
testLogger.info(`No error message detected. Verifying deletion of ${patternName} via API`);
|
||||
const gone = await this.waitForPatternDeleted(patternName);
|
||||
|
||||
const stillExists = await this.checkPatternExists(patternName);
|
||||
|
||||
if (!stillExists) {
|
||||
testLogger.info(`✓ Pattern ${patternName} deleted successfully (verified by search)`);
|
||||
if (gone) {
|
||||
testLogger.info(`✓ Pattern ${patternName} deleted successfully (verified via API)`);
|
||||
return { success: true, reason: 'deleted', associations: [] };
|
||||
} else {
|
||||
testLogger.warn(`✗ Pattern ${patternName} still exists after deletion attempt`);
|
||||
testLogger.warn(`✗ Pattern ${patternName} still present in API after deletion attempt`);
|
||||
return { success: false, reason: 'unknown', associations: [] };
|
||||
}
|
||||
}
|
||||
|
||||
@@ -60,9 +60,24 @@ export class StreamAssociationPage {
|
||||
testLogger.info(`Opening stream detail for: ${streamName}`);
|
||||
await this.page.waitForLoadState('networkidle', { timeout: 10000 }).catch(() => {});
|
||||
|
||||
// Find the cell with stream name
|
||||
// Find the cell with stream name. A freshly-ingested stream can lag the
|
||||
// /streams table under cloud load — the row may be absent on first load even
|
||||
// after search (the caller already navigated + searched). Reload the streams
|
||||
// page and re-search until the stream's row actually appears (bounded ~90s), so
|
||||
// the row is reliably present before we resolve its Stream Detail button.
|
||||
// NOTE: use waitFor (auto-waits up to timeout), NOT isVisible (instant check) —
|
||||
// the stream needs time to appear in the table after each reload.
|
||||
const streamCell = this.page.getByRole('cell', { name: streamName }).first();
|
||||
await streamCell.waitFor({ state: 'visible', timeout: 5000 });
|
||||
let cellVisible = await streamCell.waitFor({ state: 'visible', timeout: 8000 }).then(() => true).catch(() => false);
|
||||
for (let attempt = 1; attempt <= 6 && !cellVisible; attempt++) {
|
||||
testLogger.info(`Stream "${streamName}" row not yet in /streams (attempt ${attempt}) — reloading + re-searching`);
|
||||
await this.navigateToStreams();
|
||||
await this.searchForStream(streamName);
|
||||
cellVisible = await streamCell.waitFor({ state: 'visible', timeout: 12000 }).then(() => true).catch(() => false);
|
||||
}
|
||||
if (!cellVisible) {
|
||||
throw new Error(`openStreamDetail: stream "${streamName}" row not found in /streams after reload + re-search retries (backend never listed the ingested stream)`);
|
||||
}
|
||||
testLogger.info(`Found stream cell for: ${streamName}`);
|
||||
|
||||
// Navigate to parent row
|
||||
@@ -92,31 +107,24 @@ export class StreamAssociationPage {
|
||||
throw new Error('Stream Detail button not found in the row');
|
||||
}
|
||||
|
||||
await detailButton.waitFor({ state: 'visible', timeout: 5000 });
|
||||
testLogger.info('Stream Detail button is visible, attempting click with JavaScript...');
|
||||
|
||||
// Try JavaScript click first - sometimes Playwright's click doesn't work if element is partially obscured
|
||||
await detailButton.evaluate(el => el.click());
|
||||
testLogger.info('Executed JavaScript click on Stream Detail button');
|
||||
|
||||
// Wait for sidebar to open - just use waitFor, no fixed timeout
|
||||
const updateSettingsButton = this.page.locator('[data-test="schema-update-settings-button"]');
|
||||
|
||||
// Wait up to 3 seconds for sidebar to appear, if not try regular click
|
||||
const opened = await updateSettingsButton.waitFor({ state: 'visible', timeout: 3000 }).then(() => true).catch(() => false);
|
||||
|
||||
if (!opened) {
|
||||
testLogger.warn('Sidebar did not open with JavaScript click, trying regular click...');
|
||||
await detailButton.scrollIntoViewIfNeeded();
|
||||
await detailButton.click();
|
||||
testLogger.info('Executed Playwright click on Stream Detail button');
|
||||
await updateSettingsButton.waitFor({ state: 'visible', timeout: 5000 });
|
||||
}
|
||||
// The revamped Reka-based streams table re-renders/detaches rows mid-animation,
|
||||
// so a one-shot Playwright `click()` can spin on actionability until the 45s
|
||||
// actionTimeout, and a single JS click can race the sidebar-open on a slow cloud.
|
||||
// Re-resolve the row's Stream Detail button FRESH each attempt (the row may have
|
||||
// detached), fire a native DOM click (no actionability polling to hang on), and
|
||||
// verify the detail sidebar actually opened — retry the whole sequence until it
|
||||
// does. Deterministic: keyed to the real "sidebar open" signal, not blind waits.
|
||||
await expect(async () => {
|
||||
const btn = streamRow.getByRole('button', { name: 'Stream Detail' }).first();
|
||||
await expect(btn).toBeVisible({ timeout: 5000 });
|
||||
await btn.evaluate((el) => el.scrollIntoView({ block: 'center', inline: 'nearest' })).catch(() => {});
|
||||
await btn.evaluate((el) => el.click());
|
||||
await expect(updateSettingsButton).toBeVisible({ timeout: 5000 });
|
||||
}).toPass({ timeout: 45000 });
|
||||
|
||||
testLogger.info('Stream detail sidebar opened successfully');
|
||||
|
||||
// Wait for schema table to load (minimal wait)
|
||||
await this.page.waitForTimeout(300);
|
||||
}
|
||||
|
||||
async clickAddPatternCell(fieldName) {
|
||||
@@ -156,11 +164,15 @@ export class StreamAssociationPage {
|
||||
throw new Error(`Add Pattern button not found for field ${fieldName}`);
|
||||
}
|
||||
|
||||
await addPatternButton.waitFor({ state: 'visible', timeout: 5000 });
|
||||
// Assert the real readiness signal (the Add Pattern control is visible) before scrolling.
|
||||
await expect(addPatternButton).toBeVisible({ timeout: 10000 });
|
||||
|
||||
// Scroll into view before clicking
|
||||
await addPatternButton.scrollIntoViewIfNeeded();
|
||||
await this.page.waitForTimeout(500);
|
||||
// Scroll into view before clicking. Use an instant native scrollIntoView rather than
|
||||
// Playwright's scrollIntoViewIfNeeded: the schema table inside the (still-animating) stream
|
||||
// detail ODrawer re-renders rows mid-animation, so scrollIntoViewIfNeeded's attached+stable
|
||||
// polling can spin until the 45s actionTimeout. Native scroll resolves the element once and
|
||||
// returns immediately; the JS/Playwright click below drives the actual interaction.
|
||||
await addPatternButton.evaluate((el) => el.scrollIntoView({ block: 'center', inline: 'nearest' })).catch(() => {});
|
||||
|
||||
testLogger.info(`Clicking Add Pattern button for field: ${fieldName}`);
|
||||
|
||||
@@ -722,9 +734,14 @@ export class StreamAssociationPage {
|
||||
throw new Error(`Field ${fieldName} does not have any patterns linked`);
|
||||
}
|
||||
|
||||
await viewPatternsLink.waitFor({ state: 'visible', timeout: 5000 });
|
||||
await viewPatternsLink.scrollIntoViewIfNeeded();
|
||||
await this.page.waitForTimeout(500);
|
||||
// Assert the real readiness signal (the "View N Patterns" link is visible) before scrolling.
|
||||
await expect(viewPatternsLink).toBeVisible({ timeout: 10000 });
|
||||
|
||||
// Instant native scrollIntoView instead of scrollIntoViewIfNeeded: the schema table inside
|
||||
// the stream detail ODrawer re-renders/detaches rows mid-animation, so the attached+stable
|
||||
// polling of scrollIntoViewIfNeeded can spin until the 45s actionTimeout even though the link
|
||||
// is already visible. Native scroll returns immediately; the click below drives interaction.
|
||||
await viewPatternsLink.evaluate((el) => el.scrollIntoView({ block: 'center', inline: 'nearest' })).catch(() => {});
|
||||
|
||||
testLogger.info(`Clicking View Patterns link for field: ${fieldName}`);
|
||||
|
||||
|
||||
@@ -2,27 +2,86 @@ const { test, expect, navigateToBase } = require('../utils/enhanced-baseFixtures
|
||||
const PageManager = require('../../pages/page-manager.js');
|
||||
const testLogger = require('../utils/test-logger.js');
|
||||
const { v4: uuidv4 } = require('uuid');
|
||||
const { getOrgIdentifier } = require('../utils/cloud-auth.js');
|
||||
const { getOrgIdentifier, getAuthHeaders } = require('../utils/cloud-auth.js');
|
||||
|
||||
test.describe.configure({ mode: "parallel" });
|
||||
|
||||
test.describe("Enrichment data testcases", () => {
|
||||
let pm;
|
||||
|
||||
// Backend-readiness gate: on cloud the e2e_automate stream is indexed
|
||||
// asynchronously AFTER ingestion returns. Selecting it in the logs UI before it
|
||||
// is actually queryable means applyQuery() never fires a real _search — which is
|
||||
// exactly why the page-armed waitForResponse timed out. Poll the same streams API
|
||||
// that logsPage.waitForStreamAvailable uses (bounded, tolerant of transient cloud
|
||||
// connection resets) until the stream is present, so the UI selection is
|
||||
// deterministic. Returns true once the stream is confirmed selectable.
|
||||
async function waitForStreamQueryable(page, streamName, maxWaitMs = 60000, pollIntervalMs = 3000) {
|
||||
const apiUrl = process.env["INGESTION_URL"] || process.env["ZO_BASE_URL"];
|
||||
const org = getOrgIdentifier();
|
||||
const url = `${apiUrl}/api/${org}/streams?type=logs&keyword=${streamName}`;
|
||||
const deadline = Date.now() + maxWaitMs;
|
||||
let attempt = 0;
|
||||
|
||||
while (Date.now() < deadline) {
|
||||
attempt++;
|
||||
try {
|
||||
const response = await page.request.get(url, { headers: getAuthHeaders() });
|
||||
if (response.ok()) {
|
||||
const body = await response.json().catch(() => ({}));
|
||||
if ((body.list || []).some((s) => s.name === streamName)) {
|
||||
testLogger.info('Stream confirmed queryable via streams API', { streamName, attempt });
|
||||
return true;
|
||||
}
|
||||
} else {
|
||||
testLogger.debug('Stream readiness poll non-OK', { status: response.status(), attempt });
|
||||
}
|
||||
} catch (err) {
|
||||
// Tolerate transient cloud errors (Connection timeout / ECONNRESET) and retry.
|
||||
testLogger.debug('Stream readiness poll error (retrying)', { error: err.message, attempt });
|
||||
}
|
||||
// Documented consistency backoff between readiness polls (cloud indexing lag).
|
||||
await page.waitForTimeout(pollIntervalMs);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
// Helper function for tests that need logs data setup
|
||||
async function setupLogsData(page, pm) {
|
||||
await pm.ingestionPage.ingestion();
|
||||
|
||||
// Gate on backend readiness BEFORE driving the logs UI so the stream is
|
||||
// actually selectable and the subsequent search fires against a real stream.
|
||||
const streamReady = await waitForStreamQueryable(page, "e2e_automate");
|
||||
if (!streamReady) {
|
||||
throw new Error('setupLogsData: e2e_automate stream not queryable via streams API within readiness window; cannot deterministically select it in the logs UI');
|
||||
}
|
||||
|
||||
const logsUrl = `${process.env["ZO_BASE_URL"]}/web/logs?org_identifier=${getOrgIdentifier()}`;
|
||||
testLogger.navigation('Navigating to logs page', { url: logsUrl });
|
||||
|
||||
try {
|
||||
await page.goto(logsUrl);
|
||||
const searchPattern = `**/api/${getOrgIdentifier()}/_search**`;
|
||||
const allsearch = page.waitForResponse(searchPattern, { timeout: 30000 });
|
||||
|
||||
// Select the confirmed-present stream. selectStream throws after its
|
||||
// internal retries, so a silent mis-selection now surfaces here.
|
||||
await pm.logsPage.selectStream("e2e_automate");
|
||||
|
||||
// Arm the search-response wait IMMEDIATELY BEFORE the action that triggers
|
||||
// it. Previously it was armed before the slow selectStream, so its 30s timer
|
||||
// elapsed during selectStream's up-to-120s cloud stream-indexing wait and
|
||||
// timed out even when the search later succeeded. Gate on a 200 for the
|
||||
// org's _search so we only accept a successful search against the selected
|
||||
// stream — a real backend signal, not a weakened assertion.
|
||||
const org = getOrgIdentifier();
|
||||
const successfulSearch = page.waitForResponse(
|
||||
(response) =>
|
||||
response.url().includes(`/api/${org}/_search`) &&
|
||||
response.status() === 200,
|
||||
{ timeout: 30000 }
|
||||
);
|
||||
await pm.enrichmentPage.applyQuery();
|
||||
await allsearch;
|
||||
await successfulSearch;
|
||||
testLogger.info('Logs data setup completed');
|
||||
} catch (error) {
|
||||
testLogger.error('Logs data setup failed', {
|
||||
|
||||
@@ -100,9 +100,22 @@ test.describe("Landing Page Test Cases", () => {
|
||||
/**
|
||||
* Returns to logs page via sidebar with retry logic
|
||||
*/
|
||||
// Fatal, non-recoverable page/context death (worker browser crash, e.g. OOM).
|
||||
// Retrying navigation against a dead target only masks the real failure and
|
||||
// burns three timeout cycles, so we detect it and fail fast.
|
||||
function isFatalClosedError(page, error) {
|
||||
return page.isClosed()
|
||||
|| /has been closed|Target (page|frame)?.*closed|Target closed/i.test(error.message || '');
|
||||
}
|
||||
|
||||
async function returnToLogs(page, pm) {
|
||||
const maxRetries = 3;
|
||||
for (let attempt = 1; attempt <= maxRetries; attempt++) {
|
||||
// Fail fast: never retry against a dead page/context. A closed page means
|
||||
// the worker's browser crashed — surface it immediately instead of looping.
|
||||
if (page.isClosed()) {
|
||||
throw new Error('Cannot return to logs page: page/context already closed (browser likely crashed).');
|
||||
}
|
||||
try {
|
||||
// Wait for the sidebar logs menu to be visible/interactive before clicking.
|
||||
await pm.homePage.getLogsMenuItem().waitFor({ state: 'visible', timeout: 5000 });
|
||||
@@ -121,6 +134,12 @@ test.describe("Landing Page Test Cases", () => {
|
||||
testLogger.info(`Successfully returned to logs page on attempt ${attempt}`);
|
||||
return; // Success, exit function
|
||||
} catch (error) {
|
||||
// A closed page/context is fatal and non-recoverable — surface it now
|
||||
// rather than retrying (and eventually reporting a misleading
|
||||
// "Failed after 3 attempts" that hides the real crash).
|
||||
if (isFatalClosedError(page, error)) {
|
||||
throw new Error(`Return to logs aborted: page/context closed (browser crashed): ${error.message}`);
|
||||
}
|
||||
testLogger.warn(`Return to logs attempt ${attempt} failed`, { error: error.message });
|
||||
if (attempt === maxRetries) {
|
||||
throw new Error(`Failed to return to logs page after ${maxRetries} attempts`);
|
||||
|
||||
@@ -1,14 +1,22 @@
|
||||
/**
|
||||
* Language Translation Coverage Tests
|
||||
*
|
||||
* Tests translation coverage for all supported languages.
|
||||
* Runs in parallel for faster execution.
|
||||
* Tests translation coverage for all supported languages. Runs in parallel.
|
||||
*/
|
||||
const { test, expect, navigateToBase } = require('../utils/enhanced-baseFixtures.js');
|
||||
const testLogger = require('../utils/test-logger.js');
|
||||
const PageManager = require('../../pages/page-manager.js');
|
||||
|
||||
// Configure parallel execution within this file
|
||||
// Keep the 10 language cases running in parallel (fast, fits the shard timeout).
|
||||
// The OOM cascade seen in run 29481437880 (a worker browser process killed →
|
||||
// "Target page, context or browser has been closed" across 8 cases) is addressed
|
||||
// WITHOUT serialising: the crawl (languagePage.js) now navigates with
|
||||
// `waitUntil:'domcontentloaded'` + a bounded settle instead of waiting up to 10–30s
|
||||
// for `networkidle` on chart/websocket-heavy pages. That cuts each heavy page's
|
||||
// live-at-peak-memory dwell from ~10s to ~3s (so 5 concurrent crawls are far less
|
||||
// likely to spike memory simultaneously) and makes each crawl ~3–4× faster. The
|
||||
// fail-fast on a closed page/context (languagePage.js / landingPage.spec.js) stays
|
||||
// as defence-in-depth so any residual crash surfaces at its true origin.
|
||||
test.describe.configure({ mode: 'parallel' });
|
||||
|
||||
const languages = [
|
||||
|
||||
@@ -11,7 +11,7 @@ test.describe("Schema testcases", () => {
|
||||
const streamName = `stream${Date.now()}`;
|
||||
|
||||
// Ingestion helper function
|
||||
async function ingestion(page, testStreamName) {
|
||||
async function ingestion(page, testStreamName, maxRetries = 5) {
|
||||
testLogger.debug('Starting ingestion for test stream', { testStreamName });
|
||||
const orgId = getOrgIdentifier();
|
||||
|
||||
@@ -20,33 +20,141 @@ test.describe("Schema testcases", () => {
|
||||
"Content-Type": "application/json",
|
||||
};
|
||||
|
||||
const response = await page.evaluate(async ({ url, headers, orgId, streamName, logsdata }) => {
|
||||
const fetchResponse = await fetch(`${url}/api/${orgId}/${streamName}/_json`, {
|
||||
method: 'POST',
|
||||
// Retry until the ingest is ACCEPTED (HTTP 200). The stream names are now
|
||||
// unique-per-invocation (test1_<uniq> etc.) so a POST no longer collides with a
|
||||
// prior run's still-deleting "test1/2/3" stream — the local run proved that
|
||||
// fixed names returned 400 "stream is being deleted" for ~45s per attempt.
|
||||
// This retry now only guards genuine transient cloud errors (connection blip /
|
||||
// 5xx); the old single-shot POST swallowed failures, leaving the stream with no
|
||||
// data so it never listed and the readiness gate / selectStream timed out.
|
||||
for (let attempt = 1; attempt <= maxRetries; attempt++) {
|
||||
const response = await page.evaluate(async ({ url, headers, orgId, streamName, logsdata }) => {
|
||||
try {
|
||||
const fetchResponse = await fetch(`${url}/api/${orgId}/${streamName}/_json`, {
|
||||
method: 'POST',
|
||||
headers: headers,
|
||||
body: JSON.stringify(logsdata)
|
||||
});
|
||||
const contentType = fetchResponse.headers.get('content-type');
|
||||
const body = (contentType && contentType.includes('application/json'))
|
||||
? await fetchResponse.json()
|
||||
: { error: await fetchResponse.text() };
|
||||
return { status: fetchResponse.status, body };
|
||||
} catch (e) {
|
||||
return { status: 0, body: { error: String((e && e.message) || e) } };
|
||||
}
|
||||
}, {
|
||||
url: process.env.INGESTION_URL,
|
||||
headers: headers,
|
||||
body: JSON.stringify(logsdata)
|
||||
orgId: orgId,
|
||||
streamName: testStreamName,
|
||||
logsdata: logsdata
|
||||
});
|
||||
|
||||
const contentType = fetchResponse.headers.get('content-type');
|
||||
if (contentType && contentType.includes('application/json')) {
|
||||
return await fetchResponse.json();
|
||||
} else {
|
||||
const text = await fetchResponse.text();
|
||||
return { error: text, status: fetchResponse.status };
|
||||
testLogger.debug('Ingestion response', { attempt, response });
|
||||
|
||||
if (response.status === 200) {
|
||||
return; // accepted — stream will index and become listable
|
||||
}
|
||||
}, {
|
||||
url: process.env.INGESTION_URL,
|
||||
headers: headers,
|
||||
orgId: orgId,
|
||||
streamName: testStreamName,
|
||||
logsdata: logsdata
|
||||
});
|
||||
|
||||
testLogger.debug('Ingestion response', { response });
|
||||
|
||||
const msg = JSON.stringify(response.body || {});
|
||||
const retriable = response.status === 0
|
||||
|| response.status >= 500
|
||||
|| /being deleted|Connection|timeout|ECONNRESET|socket hang up/i.test(msg);
|
||||
if (!retriable || attempt === maxRetries) {
|
||||
throw new Error(`Ingestion into "${testStreamName}" failed after ${attempt} attempt(s): status=${response.status} body=${msg}`);
|
||||
}
|
||||
testLogger.warn(`Ingestion into ${testStreamName} not accepted (status ${response.status}) — retrying`, { attempt });
|
||||
await page.waitForTimeout(attempt * 2000);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Backend-readiness gate: poll the streams list API until the freshly-created
|
||||
* stream is actually listed by the backend.
|
||||
*
|
||||
* WHY: On cloud under load (--workers=5), a just-ingested stream is not
|
||||
* immediately visible in the logs stream-list dropdown due to streams-list
|
||||
* indexing/consistency lag. logsPage.selectStream() reloads the logs page 5x
|
||||
* internally, but if the backend still hasn't listed the stream those retries
|
||||
* are exhausted and the test fails with
|
||||
* `selectStream: Failed to find stream "..." after N attempts`.
|
||||
*
|
||||
* This gates on the REAL readiness signal the dropdown itself consumes
|
||||
* (GET /api/{org}/streams?type=logs -> body.list contains the stream), so the
|
||||
* subsequent selectStream() succeeds deterministically instead of racing.
|
||||
* Not a blind retry bump.
|
||||
*
|
||||
* @param {import('@playwright/test').Page} page
|
||||
* @param {string} targetStreamName stream that must appear in the list
|
||||
*/
|
||||
async function waitForStreamListed(page, targetStreamName) {
|
||||
const orgId = getOrgIdentifier();
|
||||
// Use the SAME base the app's own stream-readiness check uses
|
||||
// (logsPage.waitForStreamAvailable: INGESTION_URL || ZO_BASE_URL) so we poll
|
||||
// the exact host/endpoint that feeds selectStream's dropdown. On cloud these
|
||||
// two hosts differ, so matching the canonical order avoids hitting the wrong one.
|
||||
const rawBase = process.env.INGESTION_URL || process.env.ZO_BASE_URL || '';
|
||||
const baseUrl = rawBase.endsWith('/') ? rawBase.slice(0, -1) : rawBase;
|
||||
const streamsUrl = `${baseUrl}/api/${orgId}/streams?type=logs`;
|
||||
const headers = getAuthHeaders();
|
||||
|
||||
// Bounded poll: ~60s worst case (20 attempts x 3s backoff), well above the
|
||||
// observed consistency lag while still failing fast if the stream is truly
|
||||
// never created. Transient network errors are tolerated and retried.
|
||||
const maxAttempts = 30; // ~90s: freshly-ingested streams can lag the streams-list under cloud load
|
||||
const backoffMs = 3000;
|
||||
|
||||
for (let attempt = 1; attempt <= maxAttempts; attempt++) {
|
||||
let listed = false;
|
||||
try {
|
||||
const response = await page.request.get(streamsUrl, {
|
||||
headers,
|
||||
timeout: 15000,
|
||||
});
|
||||
if (response.ok()) {
|
||||
const body = await response.json().catch(() => null);
|
||||
const list = (body && body.list) || [];
|
||||
listed = list.some((s) => s && s.name === targetStreamName);
|
||||
} else {
|
||||
testLogger.debug('waitForStreamListed non-OK response', {
|
||||
status: response.status(),
|
||||
targetStreamName,
|
||||
attempt,
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
// Transient Connection timeout / ECONNRESET etc. — retry.
|
||||
testLogger.debug('waitForStreamListed request error (will retry)', {
|
||||
error: error.message,
|
||||
targetStreamName,
|
||||
attempt,
|
||||
});
|
||||
}
|
||||
|
||||
if (listed) {
|
||||
testLogger.info('Stream is listed by backend — safe to selectStream', {
|
||||
targetStreamName,
|
||||
attempt,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (attempt < maxAttempts) {
|
||||
// Documented consistency backoff (streams-list indexing lag on cloud).
|
||||
await page.waitForTimeout(backoffMs);
|
||||
}
|
||||
}
|
||||
|
||||
throw new Error(
|
||||
`waitForStreamListed: stream "${targetStreamName}" was not listed by ` +
|
||||
`GET ${streamsUrl} after ${maxAttempts} attempts (~${(maxAttempts * backoffMs) / 1000}s). ` +
|
||||
`Backend never made the ingested stream available.`
|
||||
);
|
||||
}
|
||||
|
||||
test('stream schema settings updated to be displayed under logs', async ({ page }, testInfo) => {
|
||||
const testStreamName = "test1";
|
||||
const testStreamName = `test1_${Date.now().toString(36)}${Math.random().toString(36).slice(2, 6)}`;
|
||||
|
||||
// Initialize test setup
|
||||
testLogger.testStart(testInfo.title, testInfo.file);
|
||||
@@ -64,6 +172,9 @@ test.describe("Schema testcases", () => {
|
||||
await pm.logsPage.navigateToLogs(getOrgIdentifier());
|
||||
await page.waitForLoadState('networkidle', { timeout: 30000 }).catch(() => {});
|
||||
|
||||
// Backend-readiness gate: ensure the just-ingested stream is actually listed
|
||||
// by the streams API before selecting it, avoiding streams-list lag races.
|
||||
await waitForStreamListed(page, testStreamName);
|
||||
await pm.logsPage.selectStream(testStreamName);
|
||||
|
||||
const allsearch = page.waitForResponse(`**/api/${getOrgIdentifier()}/_search**`, { timeout: 60000 });
|
||||
@@ -81,7 +192,7 @@ test.describe("Schema testcases", () => {
|
||||
});
|
||||
|
||||
test('should display stream details on navigating from blank stream to stream with details', async ({ page }, testInfo) => {
|
||||
const testStreamName = "test2";
|
||||
const testStreamName = `test2_${Date.now().toString(36)}${Math.random().toString(36).slice(2, 6)}`;
|
||||
|
||||
// Initialize test setup
|
||||
testLogger.testStart(testInfo.title, testInfo.file);
|
||||
@@ -98,6 +209,9 @@ test.describe("Schema testcases", () => {
|
||||
// Navigate to logs page with VRL editor enabled
|
||||
await pm.logsPage.navigateToLogs(getOrgIdentifier());
|
||||
|
||||
// Backend-readiness gate: ensure the just-ingested stream is actually listed
|
||||
// by the streams API before selecting it, avoiding streams-list lag races.
|
||||
await waitForStreamListed(page, testStreamName);
|
||||
await pm.logsPage.selectStream(testStreamName);
|
||||
const allsearch2 = page.waitForResponse(`**/api/${getOrgIdentifier()}/_search**`, { timeout: 60000 });
|
||||
await pm.schemaPage.applyQuery();
|
||||
@@ -112,7 +226,7 @@ test.describe("Schema testcases", () => {
|
||||
});
|
||||
|
||||
test('should add a new field and delete it from schema', async ({ page }, testInfo) => {
|
||||
const testStreamName = "test3";
|
||||
const testStreamName = `test3_${Date.now().toString(36)}${Math.random().toString(36).slice(2, 6)}`;
|
||||
|
||||
// Initialize test setup
|
||||
testLogger.testStart(testInfo.title, testInfo.file);
|
||||
@@ -129,6 +243,9 @@ test.describe("Schema testcases", () => {
|
||||
// Navigate to logs page with VRL editor enabled
|
||||
await pm.logsPage.navigateToLogs(getOrgIdentifier());
|
||||
|
||||
// Backend-readiness gate: ensure the just-ingested stream is actually listed
|
||||
// by the streams API before selecting it, avoiding streams-list lag races.
|
||||
await waitForStreamListed(page, testStreamName);
|
||||
await pm.logsPage.selectStream(testStreamName);
|
||||
const allsearch3 = page.waitForResponse(`**/api/${getOrgIdentifier()}/_search**`, { timeout: 60000 });
|
||||
await pm.schemaPage.applyQuery();
|
||||
|
||||
@@ -66,10 +66,12 @@ test.describe('Metrics PromQL Query Persistence Tests', () => {
|
||||
return;
|
||||
}
|
||||
|
||||
// Verify auto-populated query exists in the editor model
|
||||
// Verify auto-populated query exists in the editor model.
|
||||
// selectFirstStream() already gates on this signal; this poll is the load-tolerant
|
||||
// assertion (15s) that the async auto-populate actually landed in the editor.
|
||||
await expect.poll(async () => {
|
||||
return await queryEditor.getCurrentQueryText();
|
||||
}, { timeout: 5000, intervals: [200, 500] }).not.toBe('');
|
||||
}, { timeout: 15000, intervals: [200, 500, 1000] }).not.toBe('');
|
||||
|
||||
const queryText = await queryEditor.getCurrentQueryText();
|
||||
expect(queryText.length).toBeGreaterThan(0);
|
||||
@@ -99,7 +101,12 @@ test.describe('Metrics PromQL Query Persistence Tests', () => {
|
||||
const testQuery = 'up{job="prometheus"}';
|
||||
await queryEditor.enterPromQLQuery(testQuery);
|
||||
|
||||
// Store the query from Tab 1
|
||||
// Store the query from Tab 1. Poll first so the read waits for Monaco to settle
|
||||
// the entered query under load (same assertion, just made load-tolerant).
|
||||
await expect.poll(async () => {
|
||||
return (await queryEditor.getCurrentQueryText()).length;
|
||||
}, { timeout: 15000, intervals: [200, 500, 1000] }).toBeGreaterThan(0);
|
||||
|
||||
const tab1OriginalQuery = await queryEditor.getCurrentQueryText();
|
||||
|
||||
expect(tab1OriginalQuery.length).toBeGreaterThan(0);
|
||||
@@ -131,14 +138,23 @@ test.describe('Metrics PromQL Query Persistence Tests', () => {
|
||||
// Step 5: Verify query is still there (THE CRITICAL CHECK)
|
||||
testLogger.info('Verifying query persistence in Tab 1 (CRITICAL VALIDATION)');
|
||||
|
||||
const expectedNormalized = testQuery.replace(/\s+/g, ' ').trim();
|
||||
|
||||
// Poll the read on the SAME containment assertion so Monaco has time to
|
||||
// re-render the persisted query after the tab switch under load. The final
|
||||
// expect below remains the real persistence check (unchanged in substance).
|
||||
await expect.poll(async () => {
|
||||
return (await queryEditor.getCurrentQueryText()).replace(/\s+/g, ' ').trim();
|
||||
}, { timeout: 15000, intervals: [200, 500, 1000] }).toContain(expectedNormalized);
|
||||
|
||||
const tab1QueryAfterSwitch = await queryEditor.getCurrentQueryText();
|
||||
|
||||
// Normalize whitespace for comparison
|
||||
const normalizedOriginal = tab1OriginalQuery.replace(/\s+/g, ' ').trim();
|
||||
const normalizedAfterSwitch = tab1QueryAfterSwitch.replace(/\s+/g, ' ').trim();
|
||||
|
||||
// The query should be the same as before
|
||||
expect(normalizedAfterSwitch).toContain(testQuery.replace(/\s+/g, ' ').trim());
|
||||
// The query should be the same as before (CRITICAL persistence assertion)
|
||||
expect(normalizedAfterSwitch).toContain(expectedNormalized);
|
||||
|
||||
testLogger.info('✓ SUCCESS: Query persisted correctly in Tab 1 after tab switch');
|
||||
testLogger.info(`Original: ${normalizedOriginal.substring(0, 100)}...`);
|
||||
@@ -173,7 +189,11 @@ test.describe('Metrics PromQL Query Persistence Tests', () => {
|
||||
// Click Run Query to save the query to state
|
||||
await queryEditor.clickRunQuery();
|
||||
|
||||
// Verify Tab 1 query was entered
|
||||
// Verify Tab 1 query was entered (poll so the read tolerates Monaco settle
|
||||
// after clickRunQuery under load; assertion unchanged in substance).
|
||||
await expect.poll(async () => {
|
||||
return await queryEditor.getCurrentQueryText();
|
||||
}, { timeout: 15000, intervals: [200, 500, 1000] }).toContain(queryA);
|
||||
let tab1Initial = await queryEditor.getCurrentQueryText();
|
||||
testLogger.info(`Tab 1 query after entry: "${tab1Initial}"`);
|
||||
expect(tab1Initial).toContain(queryA);
|
||||
@@ -194,7 +214,11 @@ test.describe('Metrics PromQL Query Persistence Tests', () => {
|
||||
// Click Run Query to save the query to state
|
||||
await queryEditor.clickRunQuery();
|
||||
|
||||
// Verify Tab 2 query was entered
|
||||
// Verify Tab 2 query was entered (poll so the read tolerates Monaco settle
|
||||
// after clickRunQuery under load; assertion unchanged in substance).
|
||||
await expect.poll(async () => {
|
||||
return await queryEditor.getCurrentQueryText();
|
||||
}, { timeout: 15000, intervals: [200, 500, 1000] }).toContain(queryB);
|
||||
let tab2Initial = await queryEditor.getCurrentQueryText();
|
||||
testLogger.info(`Tab 2 query after entry: "${tab2Initial}"`);
|
||||
expect(tab2Initial).toContain(queryB);
|
||||
@@ -248,7 +272,11 @@ test.describe('Metrics PromQL Query Persistence Tests', () => {
|
||||
// Click Run Query to save state
|
||||
await queryEditor.clickRunQuery();
|
||||
|
||||
// Verify Tab 1 query
|
||||
// Verify Tab 1 query (poll so the read tolerates Monaco settle after
|
||||
// clickRunQuery under load; assertion unchanged in substance).
|
||||
await expect.poll(async () => {
|
||||
return await queryEditor.getCurrentQueryText();
|
||||
}, { timeout: 15000, intervals: [200, 500, 1000] }).toContain(queryTab1);
|
||||
let tab1Initial = await queryEditor.getCurrentQueryText();
|
||||
testLogger.info(`Tab 1 query: "${tab1Initial}"`);
|
||||
expect(tab1Initial).toContain(queryTab1);
|
||||
@@ -296,7 +324,11 @@ test.describe('Metrics PromQL Query Persistence Tests', () => {
|
||||
// Click Run Query to save state
|
||||
await queryEditor.clickRunQuery();
|
||||
|
||||
// Verify it was entered
|
||||
// Verify it was entered (poll so the read tolerates Monaco settle after
|
||||
// clickRunQuery under load; assertion unchanged in substance).
|
||||
await expect.poll(async () => {
|
||||
return await queryEditor.getCurrentQueryText();
|
||||
}, { timeout: 15000, intervals: [200, 500, 1000] }).toContain(customQuery);
|
||||
let currentQuery = await queryEditor.getCurrentQueryText();
|
||||
testLogger.info(`Custom query entered: "${currentQuery}"`);
|
||||
expect(currentQuery).toContain(customQuery);
|
||||
|
||||
@@ -422,8 +422,12 @@ test.describe("Metrics Share & Deep-Link testcases", () => {
|
||||
testLogger.warn('Share success toast not detected after loading');
|
||||
});
|
||||
|
||||
// Loading should be cleared after completion
|
||||
await page.waitForTimeout(500);
|
||||
// Loading should be cleared after completion. Poll the real loading state until
|
||||
// it clears rather than sleeping a fixed 500ms and asserting into a race.
|
||||
await expect.poll(
|
||||
async () => await pm.metricsPage.isShareButtonLoading(),
|
||||
{ timeout: 10000, intervals: [200, 400, 800] }
|
||||
).toBe(false);
|
||||
const isLoadingAfter = await pm.metricsPage.isShareButtonLoading();
|
||||
testLogger.info(`Share button loading after completion: ${isLoadingAfter}`);
|
||||
expect(isLoadingAfter).toBe(false);
|
||||
|
||||
@@ -115,7 +115,7 @@ test.describe("Ingestion Time Drop - Combined Test", { tag: '@enterprise' }, ()
|
||||
|
||||
// Verify the pattern actually exists in the list
|
||||
await pm.sdrPatternsPage.navigateToRegexPatterns();
|
||||
const exists = await pm.sdrPatternsPage.checkPatternExists(patternConfig.name);
|
||||
const exists = await pm.sdrPatternsPage.waitForPatternCreated(patternConfig.name); // backend-consistent create gate (API source of truth)
|
||||
if (!exists) {
|
||||
testLogger.error(`Pattern ${patternConfig.name} was not created successfully!`);
|
||||
throw new Error(`Pattern ${patternConfig.name} creation failed - not found in list`);
|
||||
|
||||
@@ -113,7 +113,7 @@ test.describe("Ingestion Time Hash - Combined Test", { tag: '@enterprise' }, ()
|
||||
await pm.sdrPatternsPage.verifyPatternCreatedSuccess();
|
||||
|
||||
await pm.sdrPatternsPage.navigateToRegexPatterns();
|
||||
const exists = await pm.sdrPatternsPage.checkPatternExists(patternConfig.name);
|
||||
const exists = await pm.sdrPatternsPage.waitForPatternCreated(patternConfig.name); // backend-consistent create gate (API source of truth)
|
||||
if (!exists) {
|
||||
testLogger.error(`Pattern ${patternConfig.name} was not created successfully!`);
|
||||
throw new Error(`Pattern ${patternConfig.name} creation failed - not found in list`);
|
||||
|
||||
@@ -115,7 +115,7 @@ test.describe("Ingestion Time Redaction - Combined Test", { tag: '@enterprise' }
|
||||
|
||||
// Verify the pattern actually exists in the list
|
||||
await pm.sdrPatternsPage.navigateToRegexPatterns();
|
||||
const exists = await pm.sdrPatternsPage.checkPatternExists(patternConfig.name);
|
||||
const exists = await pm.sdrPatternsPage.waitForPatternCreated(patternConfig.name); // backend-consistent create gate (API source of truth)
|
||||
if (!exists) {
|
||||
testLogger.error(`Pattern ${patternConfig.name} was not created successfully!`);
|
||||
throw new Error(`Pattern ${patternConfig.name} creation failed - not found in list`);
|
||||
|
||||
@@ -66,6 +66,26 @@ async function ingestSingleLog(page, streamName, fieldName, fieldValue, maxRetri
|
||||
}
|
||||
}
|
||||
|
||||
// Wrap a page.request API call with retry tolerance for transient cloud-network
|
||||
// failures (e.g. "apiRequestContext.post: Connection timeout" / ECONNRESET on the
|
||||
// shared cloud gateway). Only retries genuine connection/timeout errors — a real
|
||||
// HTTP error response still surfaces via the caller's res.ok() check. Prevents a
|
||||
// single network blip from failing an expensive @slow flow (previously only
|
||||
// recovered by a full, minutes-long test-level retry).
|
||||
async function requestWithRetry(page, action, label, maxRetries = 3) {
|
||||
for (let attempt = 1; attempt <= maxRetries; attempt++) {
|
||||
try {
|
||||
return await action();
|
||||
} catch (err) {
|
||||
const msg = (err && err.message) || String(err);
|
||||
const retriable = /Connection timeout|ECONNRESET|ECONNREFUSED|socket hang up|Request timed out|net::|timeout/i.test(msg);
|
||||
if (!retriable || attempt === maxRetries) throw err;
|
||||
testLogger.warn(`${label} failed (attempt ${attempt}/${maxRetries}): ${msg} — retrying`);
|
||||
await page.waitForTimeout(attempt * 2000);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
test.describe("Multiple Patterns on One Field", { tag: '@enterprise' }, () => {
|
||||
test.describe.configure({ mode: 'serial' });
|
||||
let pm;
|
||||
@@ -109,10 +129,14 @@ test.describe("Multiple Patterns on One Field", { tag: '@enterprise' }, () => {
|
||||
const allPatternsToCreate = [...queryTimePatterns, ...ingestionTimePatterns];
|
||||
|
||||
for (const patternDef of allPatternsToCreate) {
|
||||
const res = await page.request.post(`${baseUrl}/api/${org}/re_patterns`, {
|
||||
headers,
|
||||
data: { name: patternDef.name, description: patternDef.description, pattern: patternDef.pattern }
|
||||
});
|
||||
const res = await requestWithRetry(
|
||||
page,
|
||||
() => page.request.post(`${baseUrl}/api/${org}/re_patterns`, {
|
||||
headers,
|
||||
data: { name: patternDef.name, description: patternDef.description, pattern: patternDef.pattern }
|
||||
}),
|
||||
`create pattern ${patternDef.name}`
|
||||
);
|
||||
if (!res.ok()) {
|
||||
const body = await res.text();
|
||||
throw new Error(`Failed to create pattern ${patternDef.name}: ${res.status()} ${body}`);
|
||||
@@ -121,7 +145,11 @@ test.describe("Multiple Patterns on One Field", { tag: '@enterprise' }, () => {
|
||||
}
|
||||
|
||||
// Verify all patterns exist
|
||||
const listRes = await page.request.get(`${baseUrl}/api/${org}/re_patterns`, { headers });
|
||||
const listRes = await requestWithRetry(
|
||||
page,
|
||||
() => page.request.get(`${baseUrl}/api/${org}/re_patterns`, { headers }),
|
||||
'list patterns'
|
||||
);
|
||||
const listData = await listRes.json();
|
||||
const existingNames = new Set((listData.patterns || []).map(p => p.name));
|
||||
for (const p of allPatternsToCreate) {
|
||||
|
||||
@@ -126,7 +126,7 @@ test.describe("Query Time Drop - Combined Test", { tag: '@enterprise' }, () => {
|
||||
|
||||
// Verify pattern was created
|
||||
await pm.sdrPatternsPage.navigateToRegexPatterns();
|
||||
const exists = await pm.sdrPatternsPage.checkPatternExists(patternConfig.name);
|
||||
const exists = await pm.sdrPatternsPage.waitForPatternCreated(patternConfig.name); // backend-consistent create gate (API source of truth)
|
||||
expect(exists).toBeTruthy();
|
||||
testLogger.info(`✓ Created and verified pattern: ${patternConfig.name}`);
|
||||
}
|
||||
|
||||
@@ -126,7 +126,7 @@ test.describe("Query Time Hash - Combined Test", { tag: '@enterprise' }, () => {
|
||||
|
||||
// Verify pattern was created
|
||||
await pm.sdrPatternsPage.navigateToRegexPatterns();
|
||||
const exists = await pm.sdrPatternsPage.checkPatternExists(patternConfig.name);
|
||||
const exists = await pm.sdrPatternsPage.waitForPatternCreated(patternConfig.name); // backend-consistent create gate (API source of truth)
|
||||
expect(exists).toBeTruthy();
|
||||
testLogger.info(`✓ Created and verified pattern: ${patternConfig.name}`);
|
||||
}
|
||||
|
||||
@@ -125,7 +125,7 @@ test.describe("Query Time Redaction - Combined Test", { tag: '@enterprise' }, ()
|
||||
|
||||
// Verify pattern was created
|
||||
await pm.sdrPatternsPage.navigateToRegexPatterns();
|
||||
const exists = await pm.sdrPatternsPage.checkPatternExists(patternConfig.name);
|
||||
const exists = await pm.sdrPatternsPage.waitForPatternCreated(patternConfig.name); // backend-consistent create gate (API source of truth)
|
||||
expect(exists).toBeTruthy();
|
||||
testLogger.info(`✓ Created and verified pattern: ${patternConfig.name}`);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user