Merge remote-tracking branch 'origin/main' into feat/unified-notification-templates

This commit is contained in:
Loakesh Indiran
2026-11-04 15:36:52 +05:30
9 changed files with 107 additions and 39 deletions
@@ -1,4 +1,5 @@
import { expect } from '@playwright/test';
const testLogger = require('../../playwright-tests/utils/test-logger.js');
export class IngestionConfigPage {
constructor(page) {
@@ -77,6 +78,7 @@ export class IngestionConfigPage {
this.rumContentText = this.legacyCodeBlockPre.first();
// Shared by every link in the test so 429 waits stay well under the test timeout
this.rateLimitRetryBudgetMs = 60000;
this.networkErrorRetryBudgetMs = 60000;
}
// ==================== Navigation ====================
@@ -460,8 +462,7 @@ export class IngestionConfigPage {
}
async headWithRateLimitRetry(href, maxRetries = 2) {
// 5 second timeout to prevent hanging
let response = await this.page.request.head(href, { timeout: 5000 });
let response = await this.headWithNetworkErrorRetry(href);
for (let attempt = 0; attempt < maxRetries && response.status() === 429; attempt++) {
const retryAfter = Number(response.headers()['retry-after']);
const waitMs = Number.isFinite(retryAfter) && retryAfter > 0 ? retryAfter * 1000 : 2000;
@@ -478,4 +479,20 @@ export class IngestionConfigPage {
}
return response;
}
async headWithNetworkErrorRetry(href, maxRetries = 2) {
for (let attempt = 0; ; attempt++) {
try {
// 5 second timeout to prevent hanging
return await this.page.request.head(href, { timeout: 5000 });
} catch (error) {
const retryCostMs = 1000 * (attempt + 1) + 5000;
// A spent budget would push the whole test past its timeout, so the real error is reported instead
if (attempt >= maxRetries || retryCostMs > this.networkErrorRetryBudgetMs) throw error;
testLogger.warn(`[doc-links] HEAD failed (${error.message}), retry ${attempt + 1}/${maxRetries}: ${href}`);
this.networkErrorRetryBudgetMs -= retryCostMs;
await this.page.waitForTimeout(1000 * (attempt + 1));
}
}
}
}
@@ -2,6 +2,10 @@ import { expect } from '@playwright/test';
import testLogger from '../../playwright-tests/utils/test-logger.js';
const { getAuthHeaders, getOrgIdentifier } = require('../../playwright-tests/utils/cloud-auth.js');
// The search-job worker starts one queued job per 10 s tick, and parallel tests queue several jobs.
const SEARCH_JOB_START_TIMEOUT_MS = 120000;
const JOB_STATUS = { PENDING: 0, RUNNING: 1, FINISHED: 2 };
export class JobSchedulerPage {
constructor(page) {
this.page = page;
@@ -96,6 +100,16 @@ export class JobSchedulerPage {
return jobs.findIndex(job => job.trace_id === trace_id);
}
async _getJobStatus(trace_id) {
const orgId = getOrgIdentifier();
const url = `${process.env["ZO_BASE_URL"]}/api/${orgId}/search_jobs?type=logs&search_type=UI&use_cache=true`;
const response = await this.page.request.get(url, { headers: getAuthHeaders() });
expect(response.status(), `GET search_jobs failed: ${await response.text()}`).toBe(200);
const job = (await response.json()).find(j => j.trace_id === trace_id);
expect(job, `Job with trace ID ${trace_id} not found in search_jobs`).toBeTruthy();
return job.status;
}
async deleteJobSearch(trace_id) {
const rowIndex = await this._getJobRowIndex(trace_id);
@@ -165,33 +179,27 @@ async cancelJobSearch(trace_id) {
}
async exploreJob(trace_id) {
// The explore button is disabled while the job is pending (status=0) or failed (status=3).
// Retry: each attempt re-fetches the list and checks whether the button is enabled.
const maxRetries = 5;
let clicked = false;
// Explore is enabled only once the job has left pending (0) and was not cancelled (3).
let status;
await expect.poll(async () => {
status = await this._getJobStatus(trace_id);
return status;
}, {
message: `Search job ${trace_id} never left the pending queue`,
timeout: SEARCH_JOB_START_TIMEOUT_MS,
intervals: [2000, 5000],
}).not.toBe(JOB_STATUS.PENDING);
expect([JOB_STATUS.RUNNING, JOB_STATUS.FINISHED], `Search job ${trace_id} ended in status ${status}`).toContain(status);
for (let attempt = 0; attempt < maxRetries; attempt++) {
const rowIndex = await this._getJobRowIndex(trace_id, 15000);
if (rowIndex === -1) throw new Error(`Job with trace ID ${trace_id} not found in scheduler list`);
const rowIndex = await this._getJobRowIndex(trace_id, 15000);
if (rowIndex === -1) throw new Error(`Job with trace ID ${trace_id} not found in scheduler list`);
const row = this.page.locator(`[data-test="search-scheduler-table"] [data-test="o2-table-row-${rowIndex}"]`);
await row.waitFor({ state: 'visible', timeout: 10000 });
const row = this.page.locator(`[data-test="search-scheduler-table"] [data-test="o2-table-row-${rowIndex}"]`);
await row.waitFor({ state: 'visible', timeout: 10000 });
const exploreButton = row.locator('[data-test="search-scheduler-explore-btn"]');
const isEnabled = await exploreButton.isEnabled();
if (isEnabled) {
await exploreButton.click();
clicked = true;
break;
}
testLogger.warn(`Explore button for trace ID ${trace_id} is not enabled (attempt ${attempt + 1}/${maxRetries}).`);
if (attempt < maxRetries - 1) await this.page.waitForTimeout(5000);
}
if (!clicked) {
throw new Error(`Explore button for trace ID ${trace_id} remained disabled after ${maxRetries} attempts.`);
}
const exploreButton = row.locator('[data-test="search-scheduler-explore-btn"]');
await expect(exploreButton, `Explore button for trace ID ${trace_id} is disabled`).toBeEnabled({ timeout: 10000 });
await exploreButton.click();
await expect(
this.page.locator('[data-test="o-toast-message"]').filter({ hasText: 'Search Job have been applied successfully' }).first()
+1 -5
View File
@@ -4819,11 +4819,7 @@ export class LogsPage {
// When the show-query toggle is clicked, it switches to results view
// Check if we're in results view by looking for the logs table
const logsTable = this.page.locator('[data-test="logs-search-result-logs-table"]');
const isResultsVisible = await logsTable.isVisible();
if (!isResultsVisible) {
throw new Error('Expected results view to be visible after toggling query editor off');
}
await expect(logsTable, 'Expected results view to be visible after toggling query editor off').toBeVisible({ timeout: 15000 });
testLogger.info('Results view is visible - query editor toggled off successfully');
return true;
@@ -118,7 +118,8 @@ test.describe("Create Alert from Dashboard Panel testcases", () => {
// is a span, not an <input>, so read the committed value via the helper.
const alertNameValue = pm.alertsPage.getAlertNameValueLocator();
await expect(alertNameValue).toBeVisible({ timeout: 30000 });
expect((await alertNameValue.textContent()) ?? "").toContain("Alert_from_");
// The temporary auto-name shows first until the prefill resetForm applies the panel name
await expect(alertNameValue).toContainText("Alert_from_", { timeout: 30000 });
testLogger.info("Landed on the alert form pre-filled from the panel");
// Navigate back to the dashboard folder and clean up.
@@ -107,6 +107,8 @@ async function openResults(page) {
).toHaveCount(0, {
timeout: 60000,
});
// Stale rows stay on screen during the re-run and the app ignores J/K until the hits settle.
expect(await logs.getResultHitsCount()).toBe(50);
await page.evaluate(
() =>
document.activeElement instanceof HTMLElement &&
@@ -2,11 +2,16 @@ const { test, expect, navigateToBase } = require('../utils/enhanced-baseFixtures
const testLogger = require('../utils/test-logger.js');
const PageManager = require('../../pages/page-manager.js');
const { ensureMetricsIngested } = require('../utils/shared-metrics-setup.js');
const metricsIngestion = require('../utils/metrics-ingestion.js');
const { verifyDataOnUIWithValues: verifyDataOnUI } = require('../utils/metrics-assertions.js');
test.describe("Metrics Aggregation and Grouping Tests", () => {
test.beforeAll(async () => {
await ensureMetricsIngested();
// The random seed puts ~1 point per series, and rate() needs 2+ points in one series.
const seeded = await metricsIngestion.ingestFixedRequestCountSeries();
expect(seeded.success, `fixed request_count seed failed: ${seeded.error}`).toBe(true);
expect(await metricsIngestion.waitForMetricQueryable('sum by (service) (rate(request_count[5m]))'), 'rate(request_count[5m]) never became queryable').toBe(true);
});
/** Create fresh PageManager per test — avoids data races in parallel workers. */
@@ -328,7 +328,7 @@ test.describe("Ingestion Configuration Tests", () => {
testLogger.info(` Path: ${item.path}`);
for (const link of item.brokenLinks) {
testLogger.info(` ✗ ${link.url}`);
testLogger.info(` Status: ${link.status || link.error}`);
testLogger.info(` Status: ${link.error ? `${link.status} (${link.error})` : link.status}`);
}
}
testLogger.info('\n' + '='.repeat(80));
@@ -585,6 +585,49 @@ class MetricsIngestion {
};
}
/**
* Send several request_count points into one fixed series so rate() over it is never empty.
*/
async ingestFixedRequestCountSeries(points = 5, spacingMs = 15000) {
const now = Date.now();
const dataPoints = [];
for (let i = 0; i < points; i++) {
dataPoints.push({
timeUnixNano: ((now - (points - 1 - i) * spacingMs) * 1000000).toString(),
asDouble: 100 + i * 10,
attributes: [
{ key: "node", value: { stringValue: "server-01" } },
{ key: "service", value: { stringValue: "api-gateway" } },
{ key: "region", value: { stringValue: "us-east-1" } }
]
});
}
const metricsData = {
resourceMetrics: [{
resource: {
attributes: [
{ key: "service.name", value: { stringValue: "api-gateway" } },
{ key: "service.version", value: { stringValue: "1.0.0" } },
{ key: "environment", value: { stringValue: "production" } },
{ key: "host.name", value: { stringValue: "server-01" } },
{ key: "region", value: { stringValue: "us-east-1" } }
]
},
scopeMetrics: [{
scope: { name: "opentelemetry-instrumentation", version: "1.0.0" },
metrics: [{ name: "request_count", unit: "count", gauge: { dataPoints } }]
}]
}]
};
const result = await this.sendMetrics(metricsData);
if (result.success) {
testLogger.info('Sent fixed request_count series', { points });
} else {
testLogger.error('Failed to send fixed request_count series', { error: result.error });
}
return result;
}
/**
* Continuous metrics ingestion
* Uses while loop with await to prevent overlapping executions
+2 -6
View File
@@ -216,7 +216,7 @@ describe("Logs Index — opening a line link (4c C5)", { timeout: 30000 }, () =>
} as any);
gridRuns.length = 0;
await land(linkQuery(), {}, { realExecutor: true });
await vi.waitFor(() => expect(gridRuns.length).toBeGreaterThan(0));
expect(gridRuns.length).toBeGreaterThan(0);
expect(gridRuns[0][2]).toMatch(/^permalink-init:/);
});
@@ -239,11 +239,7 @@ describe("Logs Index — opening a line link (4c C5)", { timeout: 30000 }, () =>
await vi.waitFor(() =>
expect(document.querySelector('[data-test="logs-permalink-detail-dialog"]')).not.toBeNull(),
);
await vi.waitFor(() =>
expect(
document.querySelector('[data-test="log-detail-json-content"]')?.textContent,
).toContain("payment declined"),
);
await vi.waitFor(() => expect(sharedLineRecord.value).toEqual(line));
logs.mockClear();
wrapper.vm.onGuardRunAnyway();
expect(logs).toHaveBeenCalledTimes(1);