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, + }); + }); +});