Merge branch 'main' into test/fleet-missed-cases

This commit is contained in:
Shrinath Rao
2026-11-04 15:52:15 +05:30
committed by GitHub
13 changed files with 186 additions and 51 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
+58
View File
@@ -2352,6 +2352,64 @@ describe("OTable", () => {
});
expect(wrapper.find('[data-test="o2-table-root"]').exists()).toBe(true);
});
it("switches a pinned cell's background instantly, in step with its row", () => {
const cols: OTableColumnDef<TestRow>[] = [
{ id: "id", header: "ID", accessorKey: "id" },
{ id: "actions", header: "", isAction: true },
];
wrapper = mount(OTable, {
props: { data: makeRows(3), columns: cols, selection: "multiple", selectedIds: ["2"] },
});
const pinnedCell = (row: number) =>
wrapper.find(`[data-test="o2-table-row-${row}"] [data-test="o2-table-cell-actions"]`);
expect(pinnedCell(0).classes()).toContain("group-hover/row:bg-table-row-hover-bg");
expect(pinnedCell(1).classes()).toContain("bg-table-row-selected-bg");
for (const row of [0, 1]) {
const motion = pinnedCell(row)
.classes()
.filter((c) => /(^|:)(transition|duration-)/.test(c));
expect(motion).toEqual([]);
}
});
it("paints a pinned cell with its row's active, striped and keyboard-focus backgrounds", () => {
const cols: OTableColumnDef<TestRow>[] = [
{ id: "id", header: "ID", accessorKey: "id" },
{ id: "actions", header: "", isAction: true },
];
wrapper = mount(OTable, {
props: {
data: makeRows(4),
columns: cols,
striped: true,
activeRowIndex: 2,
onRowClick: vi.fn(),
},
});
const pinnedCell = (row: number) =>
wrapper
.find(`[data-test="o2-table-row-${row}"] [data-test="o2-table-cell-actions"]`)
.classes();
expect(pinnedCell(2)).toContain("bg-table-row-selected-bg");
expect(pinnedCell(2)).not.toContain("group-hover/row:bg-table-row-hover-bg");
expect(pinnedCell(1)).toContain("bg-table-row-striped-bg");
expect(pinnedCell(1)).not.toContain("bg-table-cell-bg");
expect(pinnedCell(0)).toContain("bg-table-cell-bg");
expect(pinnedCell(0)).toContain("group-focus-visible/row:bg-table-row-hover-bg");
});
it("leaves keyboard-focus fill off a pinned cell whose row cannot take focus", () => {
const cols: OTableColumnDef<TestRow>[] = [
{ id: "id", header: "ID", accessorKey: "id" },
{ id: "actions", header: "", isAction: true },
];
wrapper = mount(OTable, { props: { data: makeRows(2), columns: cols } });
const pinned = wrapper.find(
'[data-test="o2-table-row-0"] [data-test="o2-table-cell-actions"]',
);
expect(pinned.classes()).not.toContain("group-focus-visible/row:bg-table-row-hover-bg");
});
});
// ── Highlighting ────────────────────────────────────────────
@@ -52,6 +52,9 @@ const props = defineProps<{
cell: Cell<any, any>;
row: Row<any>;
rowSelected?: boolean;
rowActive?: boolean;
rowStriped?: boolean;
rowFocusable?: boolean;
highlightText?: string;
shouldHighlight?: boolean;
getHighlightedHtml?: (columnId: string, cellValue: any) => string | null;
@@ -104,6 +107,16 @@ const slotContentClass = computed(() =>
const isPinned = computed(() => props.cell.column.getIsPinned?.() ?? false);
// A sticky cell paints its own opaque background over the row's, so it must repeat every row state.
const pinnedBgClass = computed(() => {
if (props.rowSelected || props.rowActive) return "bg-table-row-selected-bg";
return [
props.rowStriped ? "bg-table-row-striped-bg" : "bg-table-cell-bg",
"group-hover/row:bg-table-row-hover-bg",
props.rowFocusable ? "group-focus-visible/row:bg-table-row-hover-bg" : "",
];
});
const pinOffset = computed(() => {
if (!isPinned.value) return 0;
if (isPinned.value === "left") return props.cell.column.getStart?.("left") ?? 0;
@@ -458,11 +471,7 @@ onBeforeUnmount(() => window.removeEventListener("scroll", onScrollDismiss, true
bordered && !pivotMerge?.hideBorder ? 'border-table-row-divider border-b' : '',
alignClass,
isAction ? 'w-0 whitespace-nowrap' : '',
isPinned
? rowSelected
? 'bg-table-row-selected-bg transition-colors duration-(--duration-hover-out)'
: 'bg-table-cell-bg group-hover/row:bg-table-row-hover-bg transition-colors duration-(--duration-hover-out) group-hover/row:duration-(--duration-hover-in)'
: '',
isPinned ? pinnedBgClass : '',
wrap
? 'wrap-anywhere whitespace-normal'
: horizontalScroll?.value
@@ -119,7 +119,7 @@ describe("OTableBodyRow selection marking", () => {
}
});
it("gives a clickable row an inset focus-visible outline and no outline transition", () => {
it("gives a clickable row an inset focus-visible outline and no transition", () => {
const classes = mountSelectable({ clickable: true });
expect(classes).toEqual(
expect.arrayContaining([
@@ -129,14 +129,12 @@ describe("OTableBodyRow selection marking", () => {
"focus-visible:outline-focus-ring-accent",
]),
);
expect(classes).toContain("transition-[background-color]");
expect(classes).not.toContain("transition-colors");
expect(classes.filter((c) => /(^|:)transition/.test(c))).toEqual([]);
});
it("puts the hover-in and hover-out motion tokens on the background transition", () => {
it("switches the hover background instantly so a fast pointer leaves no trail of fading rows", () => {
const classes = mountSelectable({});
expect(classes).toContain("duration-(--duration-hover-out)");
expect(classes).toContain("hover:duration-(--duration-hover-in)");
expect(classes.filter((c) => /(^|:)(transition|duration-)/.test(c))).toEqual([]);
expect(classes.some((c) => c.startsWith("focus-visible:outline"))).toBe(false);
});
});
@@ -231,7 +231,6 @@ function onRowMouseleave() {
:tabindex="clickable ? 0 : undefined"
:class="[
'group/row',
'transition-[background-color] duration-(--duration-hover-out) hover:duration-(--duration-hover-in)',
clickable ? 'cursor-pointer' : '',
isRowSelected || isActive
? 'bg-table-row-selected-bg [--color-text-secondary:var(--color-table-row-text-secondary-tinted)]'
@@ -331,6 +330,9 @@ function onRowMouseleave() {
:cell="cell"
:row="row"
:row-selected="isRowSelected"
:row-active="isActive"
:row-striped="isStriped"
:row-focusable="clickable"
:highlight-text="highlightText"
:should-highlight="shouldHighlightColumn?.(cell.column.id) ?? false"
:get-highlighted-html="getHighlightedHtml"
+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);