test(logs): cover four E2E gaps the component specs cannot reach (#15955)

This commit is contained in:
Neha P
2026-10-28 11:53:29 +00:00
committed by GitHub
parent dfa5930aa4
commit 0396cc2993
9 changed files with 813 additions and 2 deletions
+6 -2
View File
@@ -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": [
{
@@ -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 <http://www.gnu.org/licenses/>.
// 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;
}
}
@@ -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 <http://www.gnu.org/licenses/>.
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);
}
}
@@ -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 <http://www.gnu.org/licenses/>.
// 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);
}
}
@@ -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 <http://www.gnu.org/licenses/>.
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();
}
}
@@ -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 <http://www.gnu.org/licenses/>.
// 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');
});
});
@@ -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 <http://www.gnu.org/licenses/>.
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 });
});
});
@@ -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 <http://www.gnu.org/licenses/>.
// 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');
});
});
@@ -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 <http://www.gnu.org/licenses/>.
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,
});
});
});