diff --git a/tests/ui-testing/pages/generalPages/ingestionConfigPage.js b/tests/ui-testing/pages/generalPages/ingestionConfigPage.js
index 978b7dca5d..08b816b1a0 100644
--- a/tests/ui-testing/pages/generalPages/ingestionConfigPage.js
+++ b/tests/ui-testing/pages/generalPages/ingestionConfigPage.js
@@ -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));
+ }
+ }
+ }
}
diff --git a/tests/ui-testing/pages/logsPages/jobScheduler.js b/tests/ui-testing/pages/logsPages/jobScheduler.js
index 66e0960c38..9f32e21e7a 100644
--- a/tests/ui-testing/pages/logsPages/jobScheduler.js
+++ b/tests/ui-testing/pages/logsPages/jobScheduler.js
@@ -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()
diff --git a/tests/ui-testing/pages/logsPages/logsPage.js b/tests/ui-testing/pages/logsPages/logsPage.js
index aef687db06..5aff5cec76 100644
--- a/tests/ui-testing/pages/logsPages/logsPage.js
+++ b/tests/ui-testing/pages/logsPages/logsPage.js
@@ -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;
diff --git a/tests/ui-testing/playwright-tests/Alerts/alerts-from-panel.spec.js b/tests/ui-testing/playwright-tests/Alerts/alerts-from-panel.spec.js
index c57b67da8a..8b0498dbd6 100644
--- a/tests/ui-testing/playwright-tests/Alerts/alerts-from-panel.spec.js
+++ b/tests/ui-testing/playwright-tests/Alerts/alerts-from-panel.spec.js
@@ -118,7 +118,8 @@ test.describe("Create Alert from Dashboard Panel testcases", () => {
// is a span, not an , 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.
diff --git a/tests/ui-testing/playwright-tests/Logs/logs-jk-navigation.spec.js b/tests/ui-testing/playwright-tests/Logs/logs-jk-navigation.spec.js
index c2005dff61..834949a3c2 100644
--- a/tests/ui-testing/playwright-tests/Logs/logs-jk-navigation.spec.js
+++ b/tests/ui-testing/playwright-tests/Logs/logs-jk-navigation.spec.js
@@ -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 &&
diff --git a/tests/ui-testing/playwright-tests/Metrics/metrics-aggregations.spec.js b/tests/ui-testing/playwright-tests/Metrics/metrics-aggregations.spec.js
index 37cad4e4af..49ad941c83 100644
--- a/tests/ui-testing/playwright-tests/Metrics/metrics-aggregations.spec.js
+++ b/tests/ui-testing/playwright-tests/Metrics/metrics-aggregations.spec.js
@@ -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. */
diff --git a/tests/ui-testing/playwright-tests/Streams/ingestion-config.spec.js b/tests/ui-testing/playwright-tests/Streams/ingestion-config.spec.js
index a672f7a7ae..332f9a8fc9 100644
--- a/tests/ui-testing/playwright-tests/Streams/ingestion-config.spec.js
+++ b/tests/ui-testing/playwright-tests/Streams/ingestion-config.spec.js
@@ -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));
diff --git a/tests/ui-testing/playwright-tests/utils/metrics-ingestion.js b/tests/ui-testing/playwright-tests/utils/metrics-ingestion.js
index 5eab069c5d..376b694847 100644
--- a/tests/ui-testing/playwright-tests/utils/metrics-ingestion.js
+++ b/tests/ui-testing/playwright-tests/utils/metrics-ingestion.js
@@ -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
diff --git a/web/src/lib/core/Table/OTable.spec.ts b/web/src/lib/core/Table/OTable.spec.ts
index 71a4f5114a..6bff081664 100644
--- a/web/src/lib/core/Table/OTable.spec.ts
+++ b/web/src/lib/core/Table/OTable.spec.ts
@@ -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[] = [
+ { 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[] = [
+ { 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[] = [
+ { 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 ────────────────────────────────────────────
diff --git a/web/src/lib/core/Table/sub-components/OTableBodyCell.vue b/web/src/lib/core/Table/sub-components/OTableBodyCell.vue
index 434e080bcf..b8d6b3bca4 100644
--- a/web/src/lib/core/Table/sub-components/OTableBodyCell.vue
+++ b/web/src/lib/core/Table/sub-components/OTableBodyCell.vue
@@ -52,6 +52,9 @@ const props = defineProps<{
cell: Cell;
row: Row;
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
diff --git a/web/src/lib/core/Table/sub-components/OTableBodyRow.spec.ts b/web/src/lib/core/Table/sub-components/OTableBodyRow.spec.ts
index b1b8e403c7..41bf768ce7 100644
--- a/web/src/lib/core/Table/sub-components/OTableBodyRow.spec.ts
+++ b/web/src/lib/core/Table/sub-components/OTableBodyRow.spec.ts
@@ -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);
});
});
diff --git a/web/src/lib/core/Table/sub-components/OTableBodyRow.vue b/web/src/lib/core/Table/sub-components/OTableBodyRow.vue
index 560ea64e06..7c3c402d6e 100644
--- a/web/src/lib/core/Table/sub-components/OTableBodyRow.vue
+++ b/web/src/lib/core/Table/sub-components/OTableBodyRow.vue
@@ -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"
diff --git a/web/src/plugins/logs/Index.permalink.spec.ts b/web/src/plugins/logs/Index.permalink.spec.ts
index 4d22be8619..7f45cf47ad 100644
--- a/web/src/plugins/logs/Index.permalink.spec.ts
+++ b/web/src/plugins/logs/Index.permalink.spec.ts
@@ -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);