diff --git a/.github/workflows/playwright.yml b/.github/workflows/playwright.yml index 9d64488191..ee32194889 100644 --- a/.github/workflows/playwright.yml +++ b/.github/workflows/playwright.yml @@ -142,8 +142,10 @@ jobs: "usersOrg.spec.js", "websocket.spec.js", "alertsNew.spec.js", - # "remotepipeline.spec.js", - + "pipeline-dynamic.spec.js", + "searchpartition.spec.js" + # "remotepipeline.spec.js", + ] steps: diff --git a/tests/ui-testing/pages/logsPage.js b/tests/ui-testing/pages/logsPage.js index ea84595c82..4a89682479 100644 --- a/tests/ui-testing/pages/logsPage.js +++ b/tests/ui-testing/pages/logsPage.js @@ -2,6 +2,7 @@ import { expect } from '@playwright/test'; const { chromium } = require('playwright'); import { dateTimeButtonLocator, relative30SecondsButtonLocator, absoluteTabLocator, Past30SecondsValue } from '../pages/CommonLocator.js'; +import logData from '../cypress/fixtures/log.json'; export class LogsPage { constructor(page) { @@ -13,6 +14,8 @@ export class LogsPage { this.logsMenuItem = page.locator('[data-test="menu-link-\\/logs-item"]'); this.indexDropDown = page.locator('[data-test="logs-search-index-list"]').getByText('arrow_drop_down'); this.streamToggle = page.locator('[data-test="log-search-index-list-stream-toggle-default"] div'); + this.exploreButton = page.getByRole('button', { name: 'Explore' }); + this.timestampColumnMenu = page.locator('[data-test="log-table-column-1-_timestamp"] [data-test="table-row-expand-menu"]'); this.filterMessage = page.locator('div:has-text("info Adjust filter parameters and click \'Run query\'")'); @@ -47,6 +50,15 @@ export class LogsPage { this.profileButton = page.locator('[data-test="header-my-account-profile-icon"]'); this.signOutButton = page.getByText('Sign Out'); + // Search Partition locators + this.searchPartitionButton = page.locator('[data-test="logs-search-partition-btn"]'); + this.partitionDropdown = page.locator('[data-test="logs-search-partition-dropdown"]'); + this.partitionOption = (option) => page.getByRole('option', { name: option }); + this.partitionValueInput = page.locator('[data-test="logs-search-partition-value-input"]'); + this.partitionApplyButton = page.locator('[data-test="logs-search-partition-apply-btn"]'); + this.partitionClearButton = page.locator('[data-test="logs-search-partition-clear-btn"]'); + this.partitionActiveIndicator = page.locator('[data-test="logs-search-partition-active"]'); + } async selectOrganization() { @@ -205,15 +217,12 @@ export class LogsPage { } async applyQuery() { - const search = this.page.waitForResponse("**/api/default/_search**"); + const search = this.page.waitForResponse(logData.applyQuery); await this.page.waitForTimeout(3000); - await this.page.locator(this.queryButton).click({ force: true }); - try { - const response = await search; - await expect.poll(async () => response.status()).toBe(200); - } catch (error) { - throw new Error(`Failed to get response: ${error.message}`); - } + await this.page.locator("[data-test='logs-search-bar-refresh-btn']").click({ + force: true, + }); + await expect.poll(async () => (await search).status()).toBe(200); } async applyQueryButton(expectedUrl) { @@ -586,4 +595,126 @@ async executeQueryWithErrorHandling() { await this.page.locator('[data-test="logs-search-result-detail-undefined"]').click(); } +// New methods for histogram query test +async executeHistogramQuery(query) { + await this.page.locator('[data-test="logs-search-bar-query-editor"] > .monaco-editor').click(); + await this.page.keyboard.type(query); + await this.page.waitForTimeout(2000); +} + +async toggleHistogramAndExecute() { + await this.page.locator('[data-test="logs-search-bar-show-histogram-toggle-btn"] div').nth(2).click(); + await this.page.waitForTimeout(1000); + await this.page.locator('[data-test="logs-search-bar-refresh-btn"]').click(); +} + +async verifyHistogramState() { + const isHistogramOff = await this.page.locator('[data-test="logs-search-bar-show-histogram-toggle-btn"]') + .evaluate(el => el.getAttribute('aria-checked') === 'false'); + expect(isHistogramOff).toBeTruthy(); +} + +async verifySearchPartitionResponse() { + const searchPartitionPromise = this.page.waitForResponse(response => + response.url().includes('/api/default/_search_partition') && + response.request().method() === 'POST' + ); + + const searchPartitionResponse = await searchPartitionPromise; + const searchPartitionData = await searchPartitionResponse.json(); + + expect(searchPartitionData).toHaveProperty('partitions'); + expect(searchPartitionData).toHaveProperty('histogram_interval'); + expect(searchPartitionData).toHaveProperty('order_by', 'asc'); + + return searchPartitionData; +} + +async captureSearchCalls() { + const searchCalls = []; + this.page.on('response', async response => { + if (response.url().includes('/api/default/_search') && + response.request().method() === 'POST') { + const requestData = await response.request().postDataJSON(); + searchCalls.push({ + start_time: requestData.query.start_time, + end_time: requestData.query.end_time, + sql: requestData.query.sql + }); + } + }); + await this.page.waitForTimeout(2000); + return searchCalls; +} + +async verifyStreamingModeResponse() { + const searchPromise = this.page.waitForResponse(response => + response.url().includes('/api/default/_search') && + response.request().method() === 'POST' + ); + + const searchResponse = await searchPromise; + expect(searchResponse.status()).toBe(200); + + const searchData = await searchResponse.json(); + expect(searchData).toBeDefined(); + expect(searchData.hits).toBeDefined(); +} + +async clickExplore() { + try { + // Wait for the explore button to be visible and clickable + await this.exploreButton.first().waitFor({ state: 'visible', timeout: 10000 }); + await this.exploreButton.first().waitFor({ state: 'attached', timeout: 10000 }); + + // Click the button and wait for any network requests to complete + await Promise.all([ + this.page.waitForLoadState('networkidle'), + this.exploreButton.first().click() + ]); + + // Wait for the results to load + await this.page.waitForTimeout(3000); + } catch (error) { + console.error('Error in clickExplore:', error); + throw error; + } +} + +async openTimestampMenu() { + try { + // Wait for the table to be visible first + await this.page.waitForSelector('[data-test="logs-search-result-logs-table"]', { state: 'visible', timeout: 10000 }); + + // Wait for the timestamp column to be visible + await this.page.waitForSelector('[data-test="log-table-column-1-_timestamp"]', { state: 'visible', timeout: 10000 }); + + // Wait for the menu button to be visible and clickable + await this.timestampColumnMenu.waitFor({ state: 'visible', timeout: 10000 }); + + // Ensure the menu button is in viewport + await this.timestampColumnMenu.scrollIntoViewIfNeeded(); + + // Click the menu and wait for any network requests to complete + await Promise.all([ + this.page.waitForLoadState('networkidle'), + this.timestampColumnMenu.click({ force: true }) + ]); + + // Wait for the menu to open + await this.page.waitForTimeout(1000); + } catch (error) { + console.error('Error in openTimestampMenu:', error); + // Try alternative approach if first attempt fails + try { + await this.page.waitForTimeout(2000); // Give more time for the table to load + await this.timestampColumnMenu.click({ force: true }); + await this.page.waitForTimeout(1000); + } catch (retryError) { + console.error('Error in openTimestampMenu retry:', retryError); + throw retryError; + } + } +} + } diff --git a/tests/ui-testing/pages/pipelinePage.js b/tests/ui-testing/pages/pipelinePage.js index 833b0d16eb..7c8657f9b4 100644 --- a/tests/ui-testing/pages/pipelinePage.js +++ b/tests/ui-testing/pages/pipelinePage.js @@ -84,9 +84,18 @@ class PipelinePage { this.headerKeyInput = page.locator('[data-test="add-destination-header--key-input"]'); this.headerValueInput = page.locator('[data-test="add-destination-header-Authorization-value-input"]'); this.submitButton = page.locator('[data-test="add-destination-submit-btn"]'); - - - + this.streamsMenuItem = page.locator('[data-test="menu-link-\\/streams-item"]'); + this.refreshStatsButton = page.locator('[data-test="log-stream-refresh-stats-btn"]'); + this.searchStreamInput = page.getByPlaceholder('Search Stream'); + this.exploreButton = page.getByRole('button', { name: 'Explore' }); + this.timestampColumnMenu = page.locator('[data-test="log-table-column-1-_timestamp"] [data-test="table-row-expand-menu"]'); + this.streamIcon = page.getByRole("img", { name: "Stream", exact: true }); + this.outputStreamIcon = page.getByRole("img", { name: "Output Stream" }); + this.containsOption = page.getByText("Contains", { exact: true }); + this.valueInput = page.getByPlaceholder("Value"); + this.kubernetesContainerNameOption = page.getByRole("option", { name: "kubernetes_container_name" }); + this.conditionText = page.getByRole("button", { name: "kubernetes_container_name" }); + this.pipelineSavedMessage = page.getByText('Pipeline saved successfully'); } async openPipelineMenu() { @@ -403,6 +412,134 @@ async createRemoteDestination(randomNodeName, AuthorizationToken) { await this.submitButton.click(); } +async navigateToStreams() { + await this.streamsMenuItem.click(); + await this.page.waitForTimeout(1000); +} + +async refreshStreamStats() { + await this.refreshStatsButton.click(); + await this.page.waitForTimeout(1000); +} + +async searchStream(streamName) { + await this.searchStreamInput.click(); + await this.searchStreamInput.fill(streamName); + await this.page.waitForTimeout(1000); +} + +async clickExplore() { + await this.exploreButton.first().click(); + await this.page.waitForTimeout(3000); +} + +async openTimestampMenu() { + await this.timestampColumnMenu.click(); +} + +async navigateToPipeline() { + await this.page.locator('[data-test="menu-link-\\/pipeline-item"]').click(); +} + +async setupContainerNameCondition() { + await this.editButton.hover(); + await this.streamIcon.click(); + await this.columnInput.click(); + await this.columnInput.fill("container_name"); + await this.page.waitForTimeout(1000); + await this.kubernetesContainerNameOption.click(); + await this.page.locator("div:nth-child(2) > div:nth-child(2) > .q-field > .q-field__inner > .q-field__control > .q-field__control-container > .q-field__native").click(); + await this.containsOption.click(); + await this.valueInput.click(); + await this.valueInput.fill("ziox"); + await this.saveCondition(); + await this.page.waitForTimeout(2000); + await this.conditionText.hover(); +} + +async setupDestinationStream(dynamicDestinationName) { + await this.outputStreamIcon.click(); + await this.streamNameInput.click(); + await this.page.waitForTimeout(1000); + await this.streamNameInput.fill(dynamicDestinationName); + await this.page.waitForTimeout(1000); + await this.clickInputNodeStreamSave(); +} + +async waitForPipelineSaved() { + await this.pipelineSavedMessage.waitFor({ state: "visible" }); +} + +async exploreStreamAndNavigateToPipeline(streamName) { + // Ingestion for the specific stream + const orgId = process.env["ORGNAME"]; + const headers = { + "Authorization": `Basic ${Buffer.from( + `${process.env["ZO_ROOT_USER_EMAIL"]}:${process.env["ZO_ROOT_USER_PASSWORD"]}` + ).toString('base64')}`, + "Content-Type": "application/json", + }; + + const url = `${process.env.INGESTION_URL}/api/${orgId}/${streamName}/_json`; + await this.page.evaluate(async ({ url, headers, logsdata }) => { + const response = await fetch(url, { + method: 'POST', + headers: headers, + body: JSON.stringify(logsdata) + }); + return await response.json(); + }, { + url: url, + headers: headers, + logsdata: require('../../test-data/logs_data.json') + }); + await this.page.waitForTimeout(2000); + + // Navigate and explore + await this.navigateToStreams(); + await this.refreshStreamStats(); + await this.searchStream(streamName); + await this.clickExplore(); + await this.openTimestampMenu(); + await this.navigateToPipeline(); +} + +async setupPipelineWithSourceStream(sourceStream) { + await this.openPipelineMenu(); + await this.page.waitForTimeout(1000); + await this.addPipeline(); + await this.selectStream(); + await this.dragStreamToTarget(this.streamButton); + await this.selectLogs(); + await this.enterStreamName(sourceStream); + await this.page.waitForTimeout(2000); + await this.page.getByRole("option", { name: sourceStream, exact: true }).click(); + await this.saveInputNodeStream(); + await this.page.waitForTimeout(2000); + await this.page.locator("button").filter({ hasText: "delete" }).nth(1).click(); + await this.page.locator('[data-test="confirm-button"]').click(); +} + +async createAndVerifyPipeline(expectedStreamName, sourceStream) { + const pipelineName = `pipeline-${Math.random().toString(36).substring(7)}`; + await this.enterPipelineName(pipelineName); + await this.savePipeline(); + await this.waitForPipelineSaved(); + + // Verify the dynamic destination stream exists + await this.exploreStreamAndNavigateToPipeline(expectedStreamName); + + // Verify pipeline creation and cleanup + await this.searchPipeline(pipelineName); + await this.page.waitForTimeout(1000); + const deletePipelineButton = this.page.locator( + `[data-test="pipeline-list-${pipelineName}-delete-pipeline"]` + ); + await deletePipelineButton.waitFor({ state: "visible" }); + await deletePipelineButton.click(); + await this.confirmDeletePipeline(); + await this.verifyPipelineDeleted(); +} } export default PipelinePage; \ No newline at end of file diff --git a/tests/ui-testing/playwright-tests/pipeline-dynamic.spec.js b/tests/ui-testing/playwright-tests/pipeline-dynamic.spec.js new file mode 100644 index 0000000000..aa6173d9f8 --- /dev/null +++ b/tests/ui-testing/playwright-tests/pipeline-dynamic.spec.js @@ -0,0 +1,107 @@ +import { test, expect } from '@playwright/test'; +import PipelinePage from '../pages/pipelinePage.js'; +import logsdata from '../../test-data/logs_data.json'; +import { getHeaders, getIngestionUrl, sendRequest } from '../utils/apiUtils.js'; + +test.describe('Pipeline Dynamic Stream Names', () => { + let page; + let pipelinePage; + + test.beforeAll(async ({ browser }) => { + page = await browser.newPage(); + pipelinePage = new PipelinePage(page); + + // Login - happens only once before all tests + await page.goto(process.env["ZO_BASE_URL"]); + if (await page.getByText('Login as internal user').isVisible()) { + await page.getByText('Login as internal user').click(); + } + await page.waitForTimeout(1000); + await page + .locator('[data-cy="login-user-id"]') + .fill(process.env["ZO_ROOT_USER_EMAIL"]); + await page + .locator('[data-cy="login-password"]') + .fill(process.env["ZO_ROOT_USER_PASSWORD"]); + await page.locator('[data-cy="login-sign-in"]').click(); + // Wait for login to complete and page to be ready + await page.waitForTimeout(4000); + }); + + test.beforeEach(async () => { + // Ensure page is still open + if (page.isClosed()) { + throw new Error('Page was closed unexpectedly'); + } + + // Ingestion - happens before each test + const orgId = process.env["ORGNAME"]; + const streamNames = ["e2e_automate", "e2e_automate1", "e2e_automate2", "e2e_automate3"]; + const headers = getHeaders(); + + for(const streamName of streamNames) { + const url = getIngestionUrl(orgId, streamName); + const response = await sendRequest(page, url, logsdata, headers); + console.log(response); + } + await page.waitForTimeout(2000); + }); + + test.afterEach(async () => { + // Add a small wait after each test to ensure operations are complete + await page.waitForTimeout(1000); + }); + + test.afterAll(async () => { + // Add a wait before closing to ensure all operations are complete + await page.waitForTimeout(2000); + if (!page.isClosed()) { + await page.close(); + } + }); + + test('Verify pipeline with dynamic destination name using kubernetes_container_name', async () => { + // Navigate to stream and pipeline + await pipelinePage.exploreStreamAndNavigateToPipeline('e2e_automate1'); + await page.waitForLoadState('networkidle'); + + // Setup source stream + await pipelinePage.setupPipelineWithSourceStream('e2e_automate1'); + await page.waitForLoadState('networkidle'); + + // Setup container name condition + await pipelinePage.setupContainerNameCondition(); + await page.waitForLoadState('networkidle'); + + // Setup destination stream + await pipelinePage.setupDestinationStream('dynamic_ziox_dynamic'); + await page.waitForLoadState('networkidle'); + + // Create and verify pipeline + await pipelinePage.createAndVerifyPipeline('dynamic_ziox_dynamic', 'e2e_automate1'); + }); + + test('Verify pipeline with dynamic destination name using kubernetes_container_name with underscores', async () => { + await pipelinePage.exploreStreamAndNavigateToPipeline('e2e_automate2'); + await page.waitForLoadState('networkidle'); + await pipelinePage.setupPipelineWithSourceStream('e2e_automate2'); + await page.waitForLoadState('networkidle'); + await pipelinePage.setupContainerNameCondition(); + await page.waitForLoadState('networkidle'); + await pipelinePage.setupDestinationStream('dynamic_ziox_dynamic'); + await page.waitForLoadState('networkidle'); + await pipelinePage.createAndVerifyPipeline('dynamic_ziox_dynamic', 'e2e_automate2'); + }); + + test('Verify pipeline with dynamic destination name using kubernetes_container_name directly', async () => { + await pipelinePage.exploreStreamAndNavigateToPipeline('e2e_automate3'); + await page.waitForLoadState('networkidle'); + await pipelinePage.setupPipelineWithSourceStream('e2e_automate3'); + await page.waitForLoadState('networkidle'); + await pipelinePage.setupContainerNameCondition(); + await page.waitForLoadState('networkidle'); + await pipelinePage.setupDestinationStream('ziox'); + await page.waitForLoadState('networkidle'); + await pipelinePage.createAndVerifyPipeline('ziox', 'e2e_automate3'); + }); +}); \ No newline at end of file diff --git a/tests/ui-testing/playwright-tests/searchpartition.spec.js b/tests/ui-testing/playwright-tests/searchpartition.spec.js new file mode 100644 index 0000000000..4bcefaa125 --- /dev/null +++ b/tests/ui-testing/playwright-tests/searchpartition.spec.js @@ -0,0 +1,105 @@ +import { test, expect } from './baseFixtures.js'; +import logData from "../cypress/fixtures/log.json"; +import logsdata from "../../test-data/logs_data.json"; +import { LogsPage } from '../pages/logsPage.js'; + +test.describe.configure({ mode: "parallel" }); + +// Helper functions +async function setupTest(page) { + const logsPage = new LogsPage(page); + await page.goto(process.env["ZO_BASE_URL"]); + + // Login + if (await page.getByText('Login as internal user').isVisible()) { + await page.getByText('Login as internal user').click(); + } + await page.waitForTimeout(1000); + await page.locator('[data-cy="login-user-id"]').fill(process.env["ZO_ROOT_USER_EMAIL"]); + await page.locator('[data-cy="login-password"]').fill(process.env["ZO_ROOT_USER_PASSWORD"]); + await page.locator('[data-cy="login-sign-in"]').click(); + await page.waitForTimeout(4000); + + return logsPage; +} + +async function ingestTestData(page) { + const orgId = process.env["ORGNAME"]; + const streamName = "e2e_automate"; + const basicAuthCredentials = Buffer.from( + `${process.env["ZO_ROOT_USER_EMAIL"]}:${process.env["ZO_ROOT_USER_PASSWORD"]}` + ).toString('base64'); + + const headers = { + "Authorization": `Basic ${basicAuthCredentials}`, + "Content-Type": "application/json", + }; + + await page.evaluate(async ({ url, headers, orgId, streamName, logsdata }) => { + const fetchResponse = await fetch(`${url}/api/${orgId}/${streamName}/_json`, { + method: 'POST', + headers: headers, + body: JSON.stringify(logsdata) + }); + return await fetchResponse.json(); + }, { + url: process.env.INGESTION_URL, + headers: headers, + orgId: orgId, + streamName: streamName, + logsdata: logsdata + }); +} + +async function applyQuery(page) { + const search = page.waitForResponse(logData.applyQuery); + await page.waitForTimeout(3000); + await page.locator("[data-test='logs-search-bar-refresh-btn']").click({ force: true }); + await expect.poll(async () => (await search).status()).toBe(200); +} + +test.describe("Search Partition Tests", () => { + let logsPage; + + test.beforeEach(async ({ page }) => { + logsPage = await setupTest(page); + await ingestTestData(page); + await page.waitForTimeout(2000); + await page.goto(`${logData.logsUrl}?org_identifier=${process.env["ORGNAME"]}`); + await logsPage.selectStreamAndStreamTypeForLogs("e2e_automate"); + await applyQuery(page); + }); + + test("should verify search partition and search API calls for histogram query", async ({ page }) => { + const isStreamingEnabled = process.env["ZO_STREAMING_ENABLED"] === "true"; + const histogramQuery = `SELECT histogram(_timestamp) as "x_axis_1", count(distinct(kubernetes_container_name)) as "y_axis_1" FROM "e2e_automate" GROUP BY x_axis_1 ORDER BY x_axis_1 ASC`; + + // Setup and execute query + await logsPage.executeHistogramQuery(histogramQuery); + await logsPage.toggleHistogramAndExecute(); + + if (!isStreamingEnabled) { + // Verify search partition response + const searchPartitionData = await logsPage.verifySearchPartitionResponse(); + const searchCalls = await logsPage.captureSearchCalls(); + + expect(searchCalls.length).toBe(searchPartitionData.partitions.length); + + // Verify each partition has a matching search call + for (const partition of searchPartitionData.partitions) { + const matchingCall = searchCalls.find(call => + call.start_time === partition[0] && + call.end_time === partition[1] + ); + expect(matchingCall).toBeTruthy(); + expect(matchingCall.sql).toContain('SELECT histogram(_timestamp'); + } + } else { + await logsPage.verifyStreamingModeResponse(); + } + + // Verify histogram state + await logsPage.verifyHistogramState(); + }); +}); +