mirror of
https://github.com/openobserve/openobserve.git
synced 2026-11-03 16:10:21 +00:00
test(logs): cover four E2E gaps the component specs cannot reach (#15955)
This commit is contained in:
@@ -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,
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user