diff --git a/tests/ui-testing/ci-matrix/ci_matrix.json b/tests/ui-testing/ci-matrix/ci_matrix.json
index 5729d51df9..e5bda46f0c 100644
--- a/tests/ui-testing/ci-matrix/ci_matrix.json
+++ b/tests/ui-testing/ci-matrix/ci_matrix.json
@@ -358,7 +358,9 @@
"logsRefreshCacheDropdown.spec.js",
"logs-highlighting.spec.js",
"logs-search-around-multistream.spec.js",
- "logs-default-columns.spec.js"
+ "logs-default-columns.spec.js",
+ "logsResultsToolbar.spec.js",
+ "logsSavedFunctionOverwrite.spec.js"
],
"disabled": [
{
@@ -386,7 +388,9 @@
"logsActionReasons.spec.js",
"logsSearchCancelled.spec.js",
"logsToolbarPins.spec.js",
- "logs-query-editor-keyboard.spec.js"
+ "logs-query-editor-keyboard.spec.js",
+ "logsCrossLinks.spec.js",
+ "logsFieldValuePaging.spec.js"
],
"disabled": [
{
diff --git a/tests/ui-testing/pages/logsPages/logsCrossLinkPage.js b/tests/ui-testing/pages/logsPages/logsCrossLinkPage.js
new file mode 100644
index 0000000000..6242489bfc
--- /dev/null
+++ b/tests/ui-testing/pages/logsPages/logsCrossLinkPage.js
@@ -0,0 +1,102 @@
+// Copyright 2026 OpenObserve Inc.
+//
+// This program is free software: you can redistribute it and/or modify
+// it under the terms of the GNU Affero General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// This program is distributed in the hope that it will be useful
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+// GNU Affero General Public License for more details.
+//
+// You should have received a copy of the GNU Affero General Public License
+// along with this program. If not, see .
+
+// Page object for org-level cross-links as they reach the log-detail drawer.
+
+import { expect } from '@playwright/test';
+const testLogger = require('../../playwright-tests/utils/test-logger.js');
+const { getAuthHeaders, getOrgIdentifier } = require('../../playwright-tests/utils/cloud-auth.js');
+
+export class LogsCrossLinkPage {
+ constructor(page) {
+ this.page = page;
+
+ this.detailDialog = '[data-test="logs-search-result-detail-dialog"]';
+ this.detailTableTab = '[data-test="log-detail-table-tab"]';
+ this.detailTableContent = '[data-test="log-detail-table-content"]';
+ this.fieldMenuTrigger = (field) => `[data-test="log-details-include-exclude-field-btn-${field}"]`;
+ this.fieldValue = (field) => `[data-test="log-detail-${field}-value"]`;
+ this.crossLinkItem = (name) => `[data-test="log-details-cross-link-${name}"]`;
+ this.anyCrossLinkItem = '[data-test^="log-details-cross-link-"]';
+ }
+
+ // ===== ORG SETTINGS (POST /settings replaces the whole cross_links array) =====
+
+ async readOrgCrossLinks() {
+ const url = `${process.env.ZO_BASE_URL}/api/${getOrgIdentifier()}/settings`;
+ const response = await this.page.request.get(url, { headers: getAuthHeaders() });
+ expect(response.status(), 'could not read org settings').toBe(200);
+ const body = await response.json();
+ return body?.data?.cross_links ?? [];
+ }
+
+ async writeOrgCrossLinks(links) {
+ const url = `${process.env.ZO_BASE_URL}/api/${getOrgIdentifier()}/settings`;
+ const response = await this.page.request.post(url, {
+ headers: getAuthHeaders(),
+ data: { cross_links: links },
+ });
+ expect(response.status(), 'could not write org settings').toBe(200);
+ testLogger.debug('Org cross_links written', { count: links.length });
+ }
+
+ // ===== DRAWER =====
+
+ async openFieldTableTab() {
+ await this.page.locator(this.detailDialog).locator(this.detailTableTab).click();
+ await expect(this.page.locator(this.detailTableContent)).toBeVisible({ timeout: 15000 });
+ }
+
+ async openFieldMenu(field) {
+ const trigger = this.page.locator(this.fieldMenuTrigger(field)).first();
+ await trigger.scrollIntoViewIfNeeded().catch(() => {});
+ await trigger.click();
+ }
+
+
+
+ async expectCrossLinkOffered(name) {
+ await expect(this.page.locator(this.crossLinkItem(name))).toBeVisible({ timeout: 10000 });
+ }
+
+ async readFieldValue(field) {
+ const cell = this.page.locator(this.fieldValue(field)).first();
+ await expect(cell).toBeVisible({ timeout: 15000 });
+ return (await cell.innerText()).trim();
+ }
+
+ /**
+ * Clicks the cross-link item and returns the page window.open actually created.
+ * The item passes `noopener`, so the new tab arrives on the context, not as a popup
+ * of this page.
+ */
+ async openCrossLinkAndReadUrl(name) {
+ const context = this.page.context();
+ const [opened] = await Promise.all([
+ context.waitForEvent('page', { timeout: 20000 }),
+ this.page.locator(this.crossLinkItem(name)).first().click(),
+ ]);
+ await expect
+ .poll(() => opened.url(), {
+ timeout: 15000,
+ message: 'the opened tab never committed a URL',
+ })
+ .not.toBe('about:blank');
+ const url = opened.url();
+ await opened.close();
+ testLogger.info('Cross-link opened a real tab', { url });
+ return url;
+ }
+}
diff --git a/tests/ui-testing/pages/logsPages/logsFieldValuesPage.js b/tests/ui-testing/pages/logsPages/logsFieldValuesPage.js
new file mode 100644
index 0000000000..c8740c4d76
--- /dev/null
+++ b/tests/ui-testing/pages/logsPages/logsFieldValuesPage.js
@@ -0,0 +1,106 @@
+// Copyright 2026 OpenObserve Inc.
+//
+// This program is free software: you can redistribute it and/or modify
+// it under the terms of the GNU Affero General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// This program is distributed in the hope that it will be useful
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+// GNU Affero General Public License for more details.
+//
+// You should have received a copy of the GNU Affero General Public License
+// along with this program. If not, see .
+
+import { expect } from '@playwright/test';
+
+export class LogsFieldValuesPage {
+ constructor(page) {
+ this.page = page;
+
+ this.valuesPanel = '[data-test="field-values-panel-container"]';
+ // OSearchInput renders no data-test of its own, so the wrapper class is the hook.
+ this.valueSearchInput = `${this.valuesPanel} .value-search-input-wrap input`;
+ }
+
+ expandButton(field) {
+ return `[data-test="log-search-expand-${field}-field-btn"]`;
+ }
+
+ loadMoreButton(field) {
+ return `[data-test="log-search-subfield-load-more-${field}"]`;
+ }
+
+ valueRowPrefix(field) {
+ return `logs-search-subfield-add-${field}-`;
+ }
+
+ async openLogs(org, stream) {
+ const url =
+ `/web/logs?org_identifier=${org}&stream=${stream}&stream_type=logs` +
+ '&period=15m&refresh=0&sql_mode=false&quick_mode=false&show_histogram=true';
+ await this.page.goto(url, { waitUntil: 'domcontentloaded' });
+ }
+
+ async expandField(field) {
+ const button = this.page.locator(this.expandButton(field));
+ await button.waitFor({ state: 'visible', timeout: 60000 });
+ await button.click();
+ await this.page
+ .locator(this.valuesPanel)
+ .first()
+ .waitFor({ state: 'visible', timeout: 30000 });
+ }
+
+ /** The distinct value keys currently rendered for `field`, in display order. */
+ async valueKeys(field) {
+ const prefix = this.valueRowPrefix(field);
+ return this.page
+ .locator(`[data-test^="${prefix}"]`)
+ .evaluateAll(
+ (elements, len) => elements.map((el) => el.getAttribute('data-test').slice(len)),
+ prefix.length,
+ );
+ }
+
+ async waitForValues(field, timeout = 60000) {
+ await expect.poll(async () => (await this.valueKeys(field)).length, { timeout }).toBeGreaterThan(0);
+ return this.valueKeys(field);
+ }
+
+ async expectLoadMoreVisible(field) {
+ await expect(this.page.locator(this.loadMoreButton(field))).toBeVisible({ timeout: 30000 });
+ }
+
+ async clickLoadMore(field) {
+ const button = this.page.locator(this.loadMoreButton(field));
+ await button.waitFor({ state: 'visible', timeout: 30000 });
+ await button.click();
+ }
+
+ async waitForMoreThan(field, count, timeout = 60000) {
+ await expect.poll(async () => (await this.valueKeys(field)).length, { timeout }).toBeGreaterThan(count);
+ return this.valueKeys(field);
+ }
+
+ async searchValues(term) {
+ const input = this.page.locator(this.valueSearchInput).first();
+ await input.waitFor({ state: 'visible', timeout: 30000 });
+ await input.fill(term);
+ }
+
+ /** Polls until every rendered key contains `term` and at least one is rendered. */
+ async waitForValuesMatching(field, term, timeout = 60000) {
+ await expect
+ .poll(
+ async () => {
+ const keys = await this.valueKeys(field);
+ return keys.length > 0 && keys.every((key) => key.includes(term));
+ },
+ { timeout },
+ )
+ .toBe(true);
+ return this.valueKeys(field);
+ }
+}
diff --git a/tests/ui-testing/pages/logsPages/logsResultsToolbarPage.js b/tests/ui-testing/pages/logsPages/logsResultsToolbarPage.js
new file mode 100644
index 0000000000..798eecdbb3
--- /dev/null
+++ b/tests/ui-testing/pages/logsPages/logsResultsToolbarPage.js
@@ -0,0 +1,65 @@
+// Copyright 2026 OpenObserve Inc.
+//
+// This program is free software: you can redistribute it and/or modify
+// it under the terms of the GNU Affero General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// This program is distributed in the hope that it will be useful
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+// GNU Affero General Public License for more details.
+//
+// You should have received a copy of the GNU Affero General Public License
+// along with this program. If not, see .
+
+// Page object for the results-header actions and the overflow menu they collapse into
+// below SearchResult.vue's measured 700px container width.
+
+import { expect } from '@playwright/test';
+const testLogger = require('../../playwright-tests/utils/test-logger.js');
+
+export class LogsResultsToolbarPage {
+ constructor(page) {
+ this.page = page;
+
+ this.wrapContentKey = 'wrapContent';
+ this.inlineWrapButton = '[data-test="logs-search-result-wrap-table-content-btn"]';
+ this.overflowMenuButton = '[data-test="logs-result-actions-menu-btn"]';
+ this.overflowWrapItem = '[data-test="logs-result-wrap-menu-item"]';
+ this.overflowRefreshItem = '[data-test="logs-result-refresh-menu-item"]';
+ }
+
+ /**
+ * Resizes, then waits for the header to settle: containerWidth comes from a
+ * ResizeObserver, so reading the toolbar at once races the recompute.
+ */
+ async setViewportWidthAndSettle(width, { expectOverflow }) {
+ await this.page.setViewportSize({ width, height: 1024 });
+ await this.page.evaluate(
+ () =>
+ new Promise((resolve) =>
+ requestAnimationFrame(() => requestAnimationFrame(() => resolve(true))),
+ ),
+ );
+ const appears = expectOverflow ? this.overflowMenuButton : this.inlineWrapButton;
+ const disappears = expectOverflow ? this.inlineWrapButton : this.overflowMenuButton;
+ await expect(this.page.locator(appears)).toBeVisible({ timeout: 20000 });
+ await expect(this.page.locator(disappears)).toHaveCount(0);
+ testLogger.debug('Results header settled', { width, expectOverflow });
+ }
+
+ async openOverflowMenu() {
+ await this.page.locator(this.overflowMenuButton).click();
+ await expect(this.page.locator(this.overflowRefreshItem)).toBeVisible({ timeout: 10000 });
+ }
+
+ async clickOverflowWrapItem() {
+ await this.page.locator(this.overflowWrapItem).click();
+ await expect(this.page.locator(this.overflowRefreshItem)).toHaveCount(0);
+ }
+
+ async readStoredWrapContent() {
+ return await this.page.evaluate((key) => localStorage.getItem(key), this.wrapContentKey);
+ }
+}
diff --git a/tests/ui-testing/pages/logsPages/logsSavedFunctionPage.js b/tests/ui-testing/pages/logsPages/logsSavedFunctionPage.js
new file mode 100644
index 0000000000..4a735a84be
--- /dev/null
+++ b/tests/ui-testing/pages/logsPages/logsSavedFunctionPage.js
@@ -0,0 +1,102 @@
+// Copyright 2026 OpenObserve Inc.
+//
+// This program is free software: you can redistribute it and/or modify
+// it under the terms of the GNU Affero General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// This program is distributed in the hope that it will be useful
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+// GNU Affero General Public License for more details.
+//
+// You should have received a copy of the GNU Affero General Public License
+// along with this program. If not, see .
+
+import { expect } from '@playwright/test';
+
+export class LogsSavedFunctionPage {
+ constructor(page) {
+ this.page = page;
+
+ this.functionEditor = '[data-test="logs-vrl-function-editor"]';
+ this.saveFunctionButton = '[data-test="logs-search-bar-save-function-btn"]';
+ this.functionDropdown = '[data-test="logs-search-bar-function-dropdown"]';
+
+ this.savedFunctionDialog = '[data-test="search-bar-store-state-saved-function-dialog"]';
+ this.updateConfirmDialog = '[data-test="search-bar-function-update-confirm-dialog"]';
+ this.dialogPrimaryButton = '[data-test="o-dialog-primary-btn"]';
+
+ this.nameInput = '[data-test="saved-function-name-input-field"]';
+ this.updateModeButton = '[data-test="saved-function-action-toggle"] button[value="update"]';
+ this.nameSelectTrigger = '[data-test="saved-function-name-select-trigger"]';
+ }
+
+ savedFunctionItem(name) {
+ return `[data-test="logs-search-saved-function-${name}"]`;
+ }
+
+ nameSelectOption(name) {
+ return `[data-test="saved-function-name-select-option"][data-test-value="${name}"]`;
+ }
+
+ async openLogs(org, stream) {
+ const url =
+ `/web/logs?org_identifier=${org}&stream=${stream}&stream_type=logs` +
+ '&period=15m&refresh=0&sql_mode=false&quick_mode=false&show_histogram=true&fn_editor=true';
+ await this.page.goto(url, { waitUntil: 'domcontentloaded' });
+ await this.page
+ .locator(`${this.functionEditor} .monaco-editor`)
+ .first()
+ .waitFor({ state: 'visible', timeout: 60000 });
+ }
+
+ async setFunctionBody(text) {
+ const editor = this.page.locator(`${this.functionEditor} .monaco-editor`).first();
+ await editor.click();
+ await this.page.keyboard.press(process.platform === 'darwin' ? 'Meta+A' : 'Control+A');
+ // Typed, not filled: SearchBar reads tempFunctionContent from Monaco's change events, which a direct value write never fires.
+ const textarea = this.page.locator(`${this.functionEditor} .monaco-editor textarea`);
+ await textarea.pressSequentially(text);
+ await expect.poll(() => this.functionBody(), { timeout: 15000 }).toContain(text);
+ // CodeQueryEditor holds the edit for its 500ms debounce; blur flushes it to tempFunctionContent, which the save dialog gates on.
+ await textarea.blur();
+ }
+
+ async functionBody() {
+ const text = await this.page.locator(`${this.functionEditor} .view-lines`).first().innerText();
+ return text.replace(/\u00a0/g, ' ').trim();
+ }
+
+ async openSaveDialog() {
+ await this.page.locator(this.saveFunctionButton).click();
+ await this.page.locator(this.savedFunctionDialog).waitFor({ state: 'visible', timeout: 30000 });
+ }
+
+ async submitDialog(dialog) {
+ await this.page.locator(`${dialog} ${this.dialogPrimaryButton}`).click();
+ }
+
+ async createFunction(name) {
+ await this.openSaveDialog();
+ await this.page.locator(this.nameInput).fill(name);
+ await this.submitDialog(this.savedFunctionDialog);
+ await this.page.locator(this.savedFunctionDialog).waitFor({ state: 'hidden', timeout: 30000 });
+ }
+
+ async overwriteFunction(name) {
+ await this.openSaveDialog();
+ await this.page.locator(this.updateModeButton).click();
+ await this.page.locator(this.nameSelectTrigger).click();
+ await this.page.locator(this.nameSelectOption(name)).click();
+ await this.submitDialog(this.savedFunctionDialog);
+ await this.page.locator(this.updateConfirmDialog).waitFor({ state: 'visible', timeout: 30000 });
+ await this.submitDialog(this.updateConfirmDialog);
+ await this.page.locator(this.savedFunctionDialog).waitFor({ state: 'hidden', timeout: 30000 });
+ }
+
+ async applySavedFunction(name) {
+ await this.page.locator(this.functionDropdown).click();
+ await this.page.locator(this.savedFunctionItem(name)).click();
+ }
+}
diff --git a/tests/ui-testing/playwright-tests/Logs/logsCrossLinks.spec.js b/tests/ui-testing/playwright-tests/Logs/logsCrossLinks.spec.js
new file mode 100644
index 0000000000..751097e605
--- /dev/null
+++ b/tests/ui-testing/playwright-tests/Logs/logsCrossLinks.spec.js
@@ -0,0 +1,115 @@
+// Copyright 2026 OpenObserve Inc.
+//
+// This program is free software: you can redistribute it and/or modify
+// it under the terms of the GNU Affero General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// This program is distributed in the hope that it will be useful
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+// GNU Affero General Public License for more details.
+//
+// You should have received a copy of the GNU Affero General Public License
+// along with this program. If not, see .
+
+// Cross-links reach the drawer only if the server returns stream_links/org_links for
+// the queried fields, which no unit test can stand in for.
+
+const { test, expect, navigateToBase } = require('../utils/enhanced-baseFixtures.js');
+const testLogger = require('../utils/test-logger.js');
+const PageManager = require('../../pages/page-manager.js');
+const logData = require('../../fixtures/log.json');
+const { ingestTestData: ingestion } = require('../utils/data-ingestion.js');
+const { getAuthHeaders, getOrgIdentifier } = require('../utils/cloud-auth.js');
+const { LogsCrossLinkPage } = require('../../pages/logsPages/logsCrossLinkPage.js');
+
+const STREAM = 'e2e_automate';
+const LINKED_FIELD = 'kubernetes_namespace_name';
+
+// The org-level cross_links array is shared, so no two workers may hold a snapshot of it.
+test.describe.configure({ mode: 'serial' });
+
+test.describe('Logs cross-links', () => {
+ let pm;
+ let crossLinkPage;
+ let linkName;
+ let savedCrossLinks;
+
+ test.beforeEach(async ({ page }, testInfo) => {
+ testLogger.testStart(testInfo.title, testInfo.file);
+ await navigateToBase(page);
+ pm = new PageManager(page);
+ crossLinkPage = new LogsCrossLinkPage(page);
+
+ const configResponse = await page.request.get(
+ `${process.env.ZO_BASE_URL}/api/${getOrgIdentifier()}/config`,
+ { headers: getAuthHeaders() },
+ );
+ const zoConfig = await configResponse.json();
+ // Server env, not org settings: a test cannot turn this on, so off means misconfigured.
+ expect(
+ zoConfig?.enable_cross_linking,
+ 'ZO_ENABLE_CROSS_LINKING is off on this environment, so no cross-link is ever offered. ' +
+ 'It is set true by .github/workflows/playwright.yml:63 and ' +
+ 'playwright_bugfix_regression.yml:43 — set the env var on the environment under test. ' +
+ 'Do not skip or delete this spec.',
+ ).toBeTruthy();
+
+ linkName = `e2e_crosslink_${Date.now()}`;
+ savedCrossLinks = await crossLinkPage.readOrgCrossLinks();
+ await crossLinkPage.writeOrgCrossLinks([
+ ...savedCrossLinks,
+ {
+ name: linkName,
+ // A cheap same-origin target: the assertion is the resolved URL, not what it renders.
+ url:
+ `${process.env.ZO_BASE_URL}/config?xl_field=\${field.__name}` +
+ '&xl_value=${field.__value}&xl_from=${start_time}&xl_to=${end_time}&xl_query=${query}',
+ fields: [{ name: LINKED_FIELD }],
+ },
+ ]);
+
+ await ingestion(page);
+ await page.goto(`${logData.logsUrl}?org_identifier=${getOrgIdentifier()}`);
+ await pm.logsPage.selectStream(STREAM);
+ await pm.logsPage.clickDateTimeButton();
+ await pm.logsPage.clickRelative15MinButton();
+ await pm.logsPage.clickSearchBarRefreshButton();
+ await pm.logsPage.expectResultsGridSettledWithRows();
+ await pm.logsPage.openLogDetailSidebar();
+ await crossLinkPage.openFieldTableTab();
+ });
+
+ test.afterEach(async () => {
+ // Restore the exact array that was there; a POST replaces cross_links wholesale.
+ if (savedCrossLinks) await crossLinkPage.writeOrgCrossLinks(savedCrossLinks);
+ });
+
+ test('a configured cross-link opens the resolved URL in a real tab', {
+ tag: ['@crossLinks', '@logs', '@P1', '@all'],
+ }, async () => {
+ const fieldValue = await crossLinkPage.readFieldValue(LINKED_FIELD);
+ expect(fieldValue, 'the linked field rendered no value to substitute').not.toBe('');
+
+ await crossLinkPage.openFieldMenu(LINKED_FIELD);
+ await crossLinkPage.expectCrossLinkOffered(linkName);
+
+ const openedUrl = await crossLinkPage.openCrossLinkAndReadUrl(linkName);
+ // A literal space would mean the SQL was spliced in unencoded and the href is invalid.
+ expect(openedUrl, 'the resolved URL was not percent-encoded').not.toMatch(/\s/);
+
+ const params = new URL(openedUrl).searchParams;
+ expect(params.get('xl_field')).toBe(LINKED_FIELD);
+ expect(params.get('xl_value')).toBe(fieldValue);
+ expect(params.get('xl_query')).toContain(STREAM);
+
+ const from = Number(params.get('xl_from'));
+ const to = Number(params.get('xl_to'));
+ expect(String(from), 'start_time was not substituted as microseconds').toMatch(/^\d{16}$/);
+ expect(String(to), 'end_time was not substituted as microseconds').toMatch(/^\d{16}$/);
+ expect(from).toBeLessThan(to);
+
+ testLogger.info('Cross-link resolved and opened with the row value and search window');
+ });
+});
diff --git a/tests/ui-testing/playwright-tests/Logs/logsFieldValuePaging.spec.js b/tests/ui-testing/playwright-tests/Logs/logsFieldValuePaging.spec.js
new file mode 100644
index 0000000000..4c3e3c1354
--- /dev/null
+++ b/tests/ui-testing/playwright-tests/Logs/logsFieldValuePaging.spec.js
@@ -0,0 +1,115 @@
+// Copyright 2026 OpenObserve Inc.
+//
+// This program is free software: you can redistribute it and/or modify
+// it under the terms of the GNU Affero General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// This program is distributed in the hope that it will be useful
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+// GNU Affero General Public License for more details.
+//
+// You should have received a copy of the GNU Affero General Public License
+// along with this program. If not, see .
+
+const { test, expect, navigateToBase } = require('../utils/enhanced-baseFixtures.js');
+const testLogger = require('../utils/test-logger.js');
+const { ingestRows, waitForSearchable } = require('../utils/auto-run-helpers.js');
+const { getAuthHeaders, getOrgIdentifier } = require('../utils/cloud-auth.js');
+const { LogsFieldValuesPage } = require('../../pages/logsPages/logsFieldValuesPage.js');
+
+const ORG = getOrgIdentifier();
+const STREAM = `fvpaging${Date.now()}`;
+const FIELD = 'host';
+// Matches db-10..db-12 only, which no page of this fixture ever rendered, so the panel's local cache filter cannot produce a hit.
+const SEARCH_TERM = 'db-1';
+
+function apiBase() {
+ const url = process.env.ZO_BASE_URL;
+ return url.endsWith('/') ? url.slice(0, -1) : url;
+}
+
+// Distinct per-value counts pin the backend rank order, so page 2 is the next values down and never a reshuffle of page 1.
+function fixtureRows() {
+ const now = Date.now() * 1000;
+ const rows = [];
+ let offset = 0;
+ const push = (host, count) => {
+ for (let i = 0; i < count; i++) {
+ rows.push({ _timestamp: now - 5 * 60e6 + offset++ * 1000, host, body: `probe ${host} ${i}` });
+ }
+ };
+ for (let i = 1; i <= 12; i++) push(`web-${String(i).padStart(2, '0')}`, 25 - i);
+ for (let i = 1; i <= 12; i++) push(`db-${String(i).padStart(2, '0')}`, 13 - i);
+ return rows;
+}
+
+const FIXTURE = fixtureRows();
+
+// beforeAll/afterAll run per worker, and this file shares one ingested stream.
+test.describe.configure({ mode: 'serial' });
+
+test.describe('Logs field-value paging and in-field search', () => {
+ let fieldValues;
+
+ test.beforeAll(async ({ request }) => {
+ await ingestRows(request, ORG, STREAM, FIXTURE);
+ await waitForSearchable(request, ORG, STREAM, FIXTURE.length);
+ });
+
+ test.afterAll(async ({ request }) => {
+ const response = await request.delete(`${apiBase()}/api/${ORG}/streams/${STREAM}?type=logs`, {
+ headers: getAuthHeaders(),
+ });
+ testLogger.info('Deleted the stream this file created', {
+ stream: STREAM,
+ status: response.status(),
+ });
+ });
+
+ test.beforeEach(async ({ page }, testInfo) => {
+ testLogger.testStart(testInfo.title, testInfo.file);
+ await navigateToBase(page);
+ fieldValues = new LogsFieldValuesPage(page);
+ await fieldValues.openLogs(ORG, STREAM);
+ });
+
+ test('"load more" fetches additional distinct values from the backend', {
+ tag: ['@fieldValuePaging', '@all', '@logs'],
+ }, async () => {
+ await fieldValues.expandField(FIELD);
+ const firstPage = await fieldValues.waitForValues(FIELD);
+ await fieldValues.expectLoadMoreVisible(FIELD);
+
+ await fieldValues.clickLoadMore(FIELD);
+ const afterLoadMore = await fieldValues.waitForMoreThan(FIELD, firstPage.length);
+
+ // A backend ignoring the larger `size` would answer the same page and leave the list unchanged, so the delta is the whole assertion.
+ const added = afterLoadMore.filter((key) => !firstPage.includes(key));
+ expect(added.length).toBeGreaterThan(0);
+ expect(new Set(afterLoadMore).size).toBe(afterLoadMore.length);
+ for (const key of firstPage) expect(afterLoadMore).toContain(key);
+
+ testLogger.info('Load more appended distinct values', {
+ firstPage: firstPage.length,
+ added: added.length,
+ });
+ });
+
+ test('in-field value search returns values the first page never held', {
+ tag: ['@fieldValuePaging', '@all', '@logs'],
+ }, async () => {
+ await fieldValues.expandField(FIELD);
+ const firstPage = await fieldValues.waitForValues(FIELD);
+ expect(firstPage.some((key) => key.includes(SEARCH_TERM))).toBe(false);
+
+ await fieldValues.searchValues(SEARCH_TERM);
+ const narrowed = await fieldValues.waitForValuesMatching(FIELD, SEARCH_TERM);
+
+ expect(narrowed.length).toBeGreaterThan(0);
+ for (const key of narrowed) expect(firstPage).not.toContain(key);
+
+ testLogger.info('In-field search narrowed to server-side matches', { narrowed });
+ });
+});
diff --git a/tests/ui-testing/playwright-tests/Logs/logsResultsToolbar.spec.js b/tests/ui-testing/playwright-tests/Logs/logsResultsToolbar.spec.js
new file mode 100644
index 0000000000..68bbdd0fc3
--- /dev/null
+++ b/tests/ui-testing/playwright-tests/Logs/logsResultsToolbar.spec.js
@@ -0,0 +1,80 @@
+// Copyright 2026 OpenObserve Inc.
+//
+// This program is free software: you can redistribute it and/or modify
+// it under the terms of the GNU Affero General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// This program is distributed in the hope that it will be useful
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+// GNU Affero General Public License for more details.
+//
+// You should have received a copy of the GNU Affero General Public License
+// along with this program. If not, see .
+
+// The results-header breakpoint is a measured container width, so only a real viewport
+// resize can fire it.
+
+const { test, expect, navigateToBase } = require('../utils/enhanced-baseFixtures.js');
+const testLogger = require('../utils/test-logger.js');
+const PageManager = require('../../pages/page-manager.js');
+const logData = require('../../fixtures/log.json');
+const { ingestTestData: ingestion } = require('../utils/data-ingestion.js');
+const { getOrgIdentifier } = require('../utils/cloud-auth.js');
+const { LogsResultsToolbarPage } = require('../../pages/logsPages/logsResultsToolbarPage.js');
+
+const STREAM = 'e2e_automate';
+const WIDE_VIEWPORT = 1500;
+// Below SearchResult.vue's 700px container threshold once the field-list sidebar is paid for.
+const NARROW_VIEWPORT = 800;
+
+test.describe('Logs results header overflow actions', () => {
+ let pm;
+ let toolbar;
+
+ test.beforeEach(async ({ page }, testInfo) => {
+ testLogger.testStart(testInfo.title, testInfo.file);
+ await navigateToBase(page);
+ pm = new PageManager(page);
+ toolbar = new LogsResultsToolbarPage(page);
+
+ await ingestion(page);
+ await page.setViewportSize({ width: WIDE_VIEWPORT, height: 1024 });
+ await page.goto(`${logData.logsUrl}?org_identifier=${getOrgIdentifier()}`);
+ await pm.logsPage.selectStream(STREAM);
+ await pm.logsPage.clickDateTimeButton();
+ await pm.logsPage.clickRelative15MinButton();
+ await pm.logsPage.clickSearchBarRefreshButton();
+ await pm.logsPage.expectResultsGridSettledWithRows();
+ });
+
+ test('a narrow viewport moves the results actions into a working overflow menu', {
+ tag: ['@resultsToolbar', '@logs', '@P2', '@all'],
+ }, async ({ page }) => {
+ await toolbar.setViewportWidthAndSettle(WIDE_VIEWPORT, { expectOverflow: false });
+ const wrapBefore = await toolbar.readStoredWrapContent();
+
+ let searchCalls = 0;
+ page.on('request', (request) => {
+ if (request.url().includes('/_search')) searchCalls += 1;
+ });
+
+ await toolbar.setViewportWidthAndSettle(NARROW_VIEWPORT, { expectOverflow: true });
+ await toolbar.openOverflowMenu();
+ await toolbar.clickOverflowWrapItem();
+
+ await expect
+ .poll(() => toolbar.readStoredWrapContent(), {
+ timeout: 15000,
+ message: 'the overflow wrap item did not change the stored wrap preference',
+ })
+ .not.toBe(wrapBefore);
+ // Wrap is a render-only concern; re-querying on it would be a regression.
+ expect(searchCalls, 'wrapping from the overflow menu issued a search').toBe(0);
+
+ await toolbar.setViewportWidthAndSettle(WIDE_VIEWPORT, { expectOverflow: false });
+
+ testLogger.info('Actions collapsed below the breakpoint, stayed usable, and came back');
+ });
+});
diff --git a/tests/ui-testing/playwright-tests/Logs/logsSavedFunctionOverwrite.spec.js b/tests/ui-testing/playwright-tests/Logs/logsSavedFunctionOverwrite.spec.js
new file mode 100644
index 0000000000..0fd6a77e13
--- /dev/null
+++ b/tests/ui-testing/playwright-tests/Logs/logsSavedFunctionOverwrite.spec.js
@@ -0,0 +1,122 @@
+// Copyright 2026 OpenObserve Inc.
+//
+// This program is free software: you can redistribute it and/or modify
+// it under the terms of the GNU Affero General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// This program is distributed in the hope that it will be useful
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+// GNU Affero General Public License for more details.
+//
+// You should have received a copy of the GNU Affero General Public License
+// along with this program. If not, see .
+
+const { test, expect, navigateToBase } = require('../utils/enhanced-baseFixtures.js');
+const testLogger = require('../utils/test-logger.js');
+const { ingestRows, waitForSearchable } = require('../utils/auto-run-helpers.js');
+const { getAuthHeaders, getOrgIdentifier } = require('../utils/cloud-auth.js');
+const { LogsSavedFunctionPage } = require('../../pages/logsPages/logsSavedFunctionPage.js');
+
+const ORG = getOrgIdentifier();
+const STAMP = Date.now();
+const STREAM = `fnovr${STAMP}`;
+const FUNCTION_NAME = `fnovr${STAMP}`;
+// No quotes or brackets: Monaco auto-closes them, which would corrupt what the keystrokes leave in the editor.
+const BODY_BEFORE = '.overwrite_marker = 101';
+const BODY_AFTER = '.overwrite_marker = 202';
+
+function apiBase() {
+ const url = process.env.ZO_BASE_URL;
+ return url.endsWith('/') ? url.slice(0, -1) : url;
+}
+
+function trackFunctionWrites(page) {
+ const sent = [];
+ page.on('request', (request) => {
+ if (/\/api\/[^/]+\/functions(\/|\?|$)/.test(request.url())) {
+ sent.push({ method: request.method(), url: request.url() });
+ }
+ });
+ return { of: (method) => sent.filter((entry) => entry.method === method) };
+}
+
+async function storedFunction(request, name) {
+ const response = await request.get(
+ `${apiBase()}/api/${ORG}/functions?page_num=1&page_size=1000&name=${name}`,
+ { headers: getAuthHeaders() },
+ );
+ expect(response.ok(), await response.text()).toBe(true);
+ const body = await response.json();
+ return (body.list || []).filter((entry) => entry.name === name);
+}
+
+// beforeAll/afterAll run per worker, and this file shares one ingested stream plus one saved function.
+test.describe.configure({ mode: 'serial' });
+
+test.describe('Logs saved-function overwrite', () => {
+ let savedFunction;
+
+ test.beforeAll(async ({ request }) => {
+ const now = Date.now() * 1000;
+ const rows = Array.from({ length: 10 }, (_, i) => ({
+ _timestamp: now - 60e6 + i * 1000,
+ host: `web-${i}`,
+ body: `overwrite fixture ${i}`,
+ }));
+ await ingestRows(request, ORG, STREAM, rows);
+ await waitForSearchable(request, ORG, STREAM, rows.length);
+ });
+
+ test.afterAll(async ({ request }) => {
+ const fn = await request.delete(`${apiBase()}/api/${ORG}/functions/${FUNCTION_NAME}`, {
+ headers: getAuthHeaders(),
+ });
+ const stream = await request.delete(`${apiBase()}/api/${ORG}/streams/${STREAM}?type=logs`, {
+ headers: getAuthHeaders(),
+ });
+ testLogger.info('Deleted exactly what this file created', {
+ function: [FUNCTION_NAME, fn.status()],
+ stream: [STREAM, stream.status()],
+ });
+ });
+
+ test('overwriting a saved function replaces the stored body and survives a reload', {
+ tag: ['@savedFunctionOverwrite', '@all', '@logs'],
+ }, async ({ page, request }) => {
+ testLogger.testStart('saved-function overwrite', __filename);
+ const writes = trackFunctionWrites(page);
+ await navigateToBase(page);
+ savedFunction = new LogsSavedFunctionPage(page);
+ await savedFunction.openLogs(ORG, STREAM);
+
+ await savedFunction.setFunctionBody(BODY_BEFORE);
+ await savedFunction.createFunction(FUNCTION_NAME);
+ await expect
+ .poll(async () => (await storedFunction(request, FUNCTION_NAME)).length, { timeout: 30000 })
+ .toBe(1);
+
+ await savedFunction.setFunctionBody(BODY_AFTER);
+ await savedFunction.overwriteFunction(FUNCTION_NAME);
+
+ // The PUT is what separates an overwrite from a second create.
+ await expect.poll(() => writes.of('PUT').length, { timeout: 30000 }).toBeGreaterThan(0);
+
+ const stored = await storedFunction(request, FUNCTION_NAME);
+ expect(stored).toHaveLength(1);
+ expect(stored[0].function).toContain(BODY_AFTER);
+ expect(stored[0].function).not.toContain(BODY_BEFORE);
+
+ await savedFunction.openLogs(ORG, STREAM);
+ await savedFunction.applySavedFunction(FUNCTION_NAME);
+ await expect
+ .poll(async () => savedFunction.functionBody(), { timeout: 30000 })
+ .toContain(BODY_AFTER);
+ expect(await savedFunction.functionBody()).not.toContain(BODY_BEFORE);
+
+ testLogger.info('Overwritten body round-tripped and survived the reload', {
+ function: FUNCTION_NAME,
+ });
+ });
+});