From c90a6fa949934efd23d98d142e12d613baf30487 Mon Sep 17 00:00:00 2001 From: Shrinath Rao Date: Thu, 24 Sep 2026 05:17:03 +0000 Subject: [PATCH] test(autoe2e): generated E2E tests for #14745 (folder-pagination-reset) (#14763) Co-authored-by: Shrinath-O2 <214651540+Shrinath-O2@users.noreply.github.com> Co-authored-by: ktx-akshay --- tests/ui-testing/ci-matrix/ci_matrix.json | 8 +- .../alertsPages/alertDestinationsPage.js | 44 +++ .../pages/alertsPages/alertTemplatesPage.js | 44 +++ .../pages/alertsPages/alertsPage.js | 31 ++ tests/ui-testing/pages/apiCleanup.js | 327 +++++++++++++++++- .../pages/dashboardPages/dashboard-folder.js | 17 + .../pages/dashboardPages/dashboard-list.js | 61 ++++ .../alertDestinationsPagination.spec.js | 97 ++++++ .../Alerts/alertTemplatesPagination.spec.js | 83 +++++ .../Alerts/alertsPagination.spec.js | 107 ++++++ .../Dashboards/dashboardPagination.spec.js | 153 ++++++++ 11 files changed, 969 insertions(+), 3 deletions(-) create mode 100644 tests/ui-testing/playwright-tests/Alerts/alertDestinationsPagination.spec.js create mode 100644 tests/ui-testing/playwright-tests/Alerts/alertTemplatesPagination.spec.js create mode 100644 tests/ui-testing/playwright-tests/Alerts/alertsPagination.spec.js create mode 100644 tests/ui-testing/playwright-tests/Dashboards/dashboardPagination.spec.js diff --git a/tests/ui-testing/ci-matrix/ci_matrix.json b/tests/ui-testing/ci-matrix/ci_matrix.json index b9ea76bc8f..799481d856 100644 --- a/tests/ui-testing/ci-matrix/ci_matrix.json +++ b/tests/ui-testing/ci-matrix/ci_matrix.json @@ -23,7 +23,10 @@ "alerts-composite-preview.spec.js", "alerts-composite-detail.spec.js", "alerts-evaluation-chart.spec.js", - "alerts-chart-error-state.spec.js" + "alerts-chart-error-state.spec.js", + "alertsPagination.spec.js", + "alertTemplatesPagination.spec.js", + "alertDestinationsPagination.spec.js" ], "disabled": [ { @@ -100,7 +103,8 @@ "crossLinkMultiStream.spec.js", "dashboards-form-validation.spec.js", "dashboard-refresh-without-cache.spec.js", - "dashboard-print-layout.spec.js" + "dashboard-print-layout.spec.js", + "dashboardPagination.spec.js" ], "disabled": [ { diff --git a/tests/ui-testing/pages/alertsPages/alertDestinationsPage.js b/tests/ui-testing/pages/alertsPages/alertDestinationsPage.js index 210567b196..6e653d07ef 100644 --- a/tests/ui-testing/pages/alertsPages/alertDestinationsPage.js +++ b/tests/ui-testing/pages/alertsPages/alertDestinationsPage.js @@ -471,6 +471,50 @@ export class AlertDestinationsPage { await this.page.locator(this.addDestinationButton).waitFor({ state: 'visible', timeout: 30000 }); } + /** + * Deep-link straight to the destinations list with a given `page` query param, + * exercising AlertsDestinationList's URL-restored `currentPage` on a cold mount. + * @param {number} page + */ + async gotoDestinationsWithPageParam(page) { + const baseUrl = process.env.ZO_BASE_URL || 'http://localhost:5080'; + const orgIdentifier = process.env.ORGNAME || 'default'; + await this.page.goto( + `${baseUrl}/web/alert-destinations?org_identifier=${orgIdentifier}&page=${page}`, + { waitUntil: 'domcontentloaded' } + ); + await this.waitForDestinationListReady(); + } + + // ==================== LIST PAGINATION (OTable pagination bar) ==================== + + async clickNextPage() { + const nextPageBtn = this.page.locator('[data-test="o2-table-next-page-btn"]'); + await nextPageBtn.waitFor({ state: 'visible', timeout: 15000 }); + await nextPageBtn.click(); + } + + async getPaginationInfoText() { + const text = await this.page.locator('[data-test="o2-table-pagination-info"]').textContent().catch(() => null); + return (text || '').replace(/\s+/g, ' ').trim(); + } + + async expectPaginationInfoToMatch(pattern) { + await expect + .poll(async () => await this.getPaginationInfoText(), { timeout: 20000 }) + .toMatch(pattern); + } + + async expectUrlHasPageParam(pageValue) { + await expect.poll(() => this.page.url(), { timeout: 15000 }).toContain(`page=${pageValue}`); + } + + async expectAtLeastOneListRow() { + await expect( + this.page.locator('[data-test^="o2-table-row-"]').first(), + ).toBeVisible({ timeout: 20000 }); + } + /** @param {string} destinationName @param {string} url @param {string} templateName */ async createDestination(destinationName, url, templateName) { await this.navigateToDestinations(); diff --git a/tests/ui-testing/pages/alertsPages/alertTemplatesPage.js b/tests/ui-testing/pages/alertsPages/alertTemplatesPage.js index aa82c4b617..5d327c0193 100644 --- a/tests/ui-testing/pages/alertsPages/alertTemplatesPage.js +++ b/tests/ui-testing/pages/alertsPages/alertTemplatesPage.js @@ -145,6 +145,50 @@ export class AlertTemplatesPage { await this.page.locator(this.addTemplateButton).waitFor({ state: 'visible', timeout: 30000 }); } + /** + * Deep-link straight to the templates list with a given `page` query param, + * exercising TemplateList's URL-restored `currentPage` on a cold mount. + * @param {number} page + */ + async gotoTemplatesWithPageParam(page) { + const baseUrl = process.env.ZO_BASE_URL || 'http://localhost:5080'; + const orgIdentifier = process.env.ORGNAME || 'default'; + await this.page.goto( + `${baseUrl}/web/alert-templates?org_identifier=${orgIdentifier}&page=${page}`, + { waitUntil: 'domcontentloaded' } + ); + await this.waitForTemplateListReady(); + } + + // ==================== LIST PAGINATION (OTable pagination bar) ==================== + + async clickNextPage() { + const nextPageBtn = this.page.locator('[data-test="o2-table-next-page-btn"]'); + await nextPageBtn.waitFor({ state: 'visible', timeout: 15000 }); + await nextPageBtn.click(); + } + + async getPaginationInfoText() { + const text = await this.page.locator('[data-test="o2-table-pagination-info"]').textContent().catch(() => null); + return (text || '').replace(/\s+/g, ' ').trim(); + } + + async expectPaginationInfoToMatch(pattern) { + await expect + .poll(async () => await this.getPaginationInfoText(), { timeout: 20000 }) + .toMatch(pattern); + } + + async expectUrlHasPageParam(pageValue) { + await expect.poll(() => this.page.url(), { timeout: 15000 }).toContain(`page=${pageValue}`); + } + + async expectAtLeastOneListRow() { + await expect( + this.page.locator('[data-test^="o2-table-row-"]').first(), + ).toBeVisible({ timeout: 20000 }); + } + /** * Create a template via API first, fall back to UI if API fails * @param {string} templateName - Name of the template diff --git a/tests/ui-testing/pages/alertsPages/alertsPage.js b/tests/ui-testing/pages/alertsPages/alertsPage.js index 60e3504ff1..af9b7c27a5 100644 --- a/tests/ui-testing/pages/alertsPages/alertsPage.js +++ b/tests/ui-testing/pages/alertsPages/alertsPage.js @@ -1673,6 +1673,37 @@ export class AlertsPage { await expect(this.page.locator('[data-test="o2-empty-state"]')).toBeVisible(); } + // ==================== LIST PAGINATION (OTable pagination bar) ==================== + + // Click the list's next-page button. Disabled on the last page, so callers must + // assert they are on a multi-page folder first. + async clickNextPage() { + const nextPageBtn = this.page.locator('[data-test="o2-table-next-page-btn"]'); + await nextPageBtn.waitFor({ state: 'visible', timeout: 15000 }); + await nextPageBtn.click(); + } + + // Read the pagination info text ("Showing X - Y of Z"), whitespace-normalised. + async getPaginationInfoText() { + const text = await this.page.locator('[data-test="o2-table-pagination-info"]').textContent().catch(() => null); + return (text || '').replace(/\s+/g, ' ').trim(); + } + + // Assert the pagination info matches a regex (auto-retrying). + async expectPaginationInfoToMatch(pattern) { + await expect + .poll(async () => await this.getPaginationInfoText(), { timeout: 20000 }) + .toMatch(pattern); + } + + // Assert the list rendered at least one data row (not the empty state), which + // proves a page switch/clamp landed on a populated page rather than an empty flash. + async expectAtLeastOneListRow() { + await expect( + this.page.locator('[data-test^="o2-table-row-"]').first(), + ).toBeVisible({ timeout: 20000 }); + } + async verifyFolderExistsError() { await expect(this.page.getByText(this.locators.folderExistsError)).toBeVisible(); } diff --git a/tests/ui-testing/pages/apiCleanup.js b/tests/ui-testing/pages/apiCleanup.js index 5a973c3e65..3b4d1751dc 100644 --- a/tests/ui-testing/pages/apiCleanup.js +++ b/tests/ui-testing/pages/apiCleanup.js @@ -221,6 +221,262 @@ class APICleanup { } } + /** + * Create an alerts folder via the v2 folders API. + * @param {string} name - Unique folder name + * @returns {Promise<{folderId: string, name: string}>} + */ + async createAlertFolder(name) { + const response = await this._fetch( + `${this.baseUrl}/api/v2/${this.org}/folders/alerts`, + { + method: 'POST', + headers: { 'Authorization': this.authHeader, 'Content-Type': 'application/json' }, + body: JSON.stringify({ name, description: '' }) + } + ); + if (!response.ok) { + const body = await response.text(); + throw new Error(`createAlertFolder: HTTP ${response.status} — ${body}`); + } + const folder = await response.json(); + testLogger.info('Created alert folder', { name, folderId: folder.folderId }); + return { folderId: folder.folderId, name: folder.name }; + } + + /** + * Seed `count` minimal scheduled alerts into a folder. + * Alert creation validates the source stream exists, so a single row is ingested + * into a dedicated stream first. + * @param {string} folderId + * @param {number} count + * @param {string} prefix - Alert name prefix (deterministic ordering by name); also used + * to derive the source stream name + * @returns {Promise>} + */ + async seedAlertsInFolder(folderId, count, prefix) { + const streamName = `${prefix}_stream`; + await this._fetch(`${this.baseUrl}/api/${this.org}/${streamName}/_json`, { + method: 'POST', + headers: { 'Authorization': this.authHeader, 'Content-Type': 'application/json' }, + body: JSON.stringify([{ message: 'e2e pagination seed row' }]) + }); + await this.waitForStreamSchema(streamName); + + // Alert creation requires at least one destination (or workflow) attached. + const templateName = `${prefix}_tmpl`; + const destinationName = `${prefix}_dest`; + await this.createAlertTemplate(templateName); + await this.createAlertDestination(destinationName, templateName); + + const created = []; + for (let i = 0; i < count; i++) { + const name = `${prefix}_${i}`; + const payload = { + name, + stream_type: 'logs', + stream_name: streamName, + is_real_time: false, + query_condition: { + type: 'custom', + conditions: { version: 2, conditions: { filterType: 'group', logicalOperator: 'AND', conditions: [] } }, + sql: null, promql: null, promql_condition: null, aggregation: null, + vrl_function: null, search_event_type: null, multi_time_range: [], + }, + trigger_condition: { + period: 10, operator: '>=', threshold: 3, frequency: 10, cron: '', + frequency_type: 'minutes', silence: 10, timezone: 'UTC', align_time: true, + }, + destinations: [destinationName], context_attributes: {}, row_template: '', enabled: false, + }; + const response = await this._fetch( + `${this.baseUrl}/api/v2/${this.org}/alerts?folder=${folderId}`, + { + method: 'POST', + headers: { 'Authorization': this.authHeader, 'Content-Type': 'application/json' }, + body: JSON.stringify(payload) + } + ); + if (!response.ok) { + const body = await response.text(); + throw new Error(`seedAlertsInFolder: HTTP ${response.status} — ${body}`); + } + created.push({ name }); + } + return { alerts: created, templateName, destinationName, streamName }; + } + + /** + * Poll a freshly-ingested stream's settings endpoint until its schema is + * registered. Alert creation validates the source stream exists, and schema + * registration can lag a moment behind the ingest write on a busy shared env. + * @param {string} streamName + * @param {number} maxWaitMs + */ + async waitForStreamSchema(streamName, maxWaitMs = 15000) { + const deadline = Date.now() + maxWaitMs; + while (Date.now() < deadline) { + const response = await this._fetch( + `${this.baseUrl}/api/${this.org}/streams/${streamName}/settings?type=logs`, + { method: 'GET', headers: { 'Authorization': this.authHeader, 'Content-Type': 'application/json' } } + ); + if (response.ok) return; + await new Promise((resolve) => setTimeout(resolve, 1000)); + } + testLogger.warn('Stream schema did not register in time', { streamName, maxWaitMs }); + } + + /** + * Fetch all alert templates (prebuilt + custom). + * @returns {Promise} Array of template objects + */ + async fetchAlertTemplates() { + try { + const response = await this._fetch(`${this.baseUrl}/api/${this.org}/alerts/templates`, { + method: 'GET', + headers: { 'Authorization': this.authHeader, 'Content-Type': 'application/json' } + }); + if (!response.ok) { + testLogger.error('Failed to fetch alert templates', { status: response.status }); + return []; + } + return await response.json(); + } catch (error) { + testLogger.error('Failed to fetch alert templates', { error: error.message }); + return []; + } + } + + /** + * Create an alert notification template via API. + * @param {string} name + * @param {string} [body] - Defaults to a minimal valid template body + */ + async createAlertTemplate(name, body = null) { + const templateBody = body || '{"text": "{alert_name} is active"}'; + const response = await this._fetch( + `${this.baseUrl}/api/${this.org}/alerts/templates`, + { + method: 'POST', + headers: { 'Authorization': this.authHeader, 'Content-Type': 'application/json' }, + body: JSON.stringify({ name, body: templateBody }) + } + ); + if (!response.ok) { + const errBody = await response.text(); + throw new Error(`createAlertTemplate: HTTP ${response.status} — ${errBody}`); + } + testLogger.info('Created alert template via API', { name }); + } + + /** + * Seed `count` minimal alert templates. + * @param {number} count + * @param {string} prefix + * @returns {Promise>} + */ + async seedAlertTemplates(count, prefix) { + const created = []; + for (let i = 0; i < count; i++) { + const name = `${prefix}_${i}`; + await this.createAlertTemplate(name); + created.push({ name }); + } + return created; + } + + /** + * Delete a single alert template by name. + * @param {string} name + * @returns {Promise} { code, message } + */ + async deleteAlertTemplate(name) { + try { + const response = await this._fetch( + `${this.baseUrl}/api/${this.org}/alerts/templates/${encodeURIComponent(name)}`, + { + method: 'DELETE', + headers: { 'Authorization': this.authHeader, 'Content-Type': 'application/json' } + } + ); + const text = await response.text(); + return { code: response.ok ? 200 : response.status, message: text }; + } catch (error) { + testLogger.error('Failed to delete alert template', { name, error: error.message }); + return { code: 500, error: error.message }; + } + } + + /** + * Create an HTTP alert destination via API. example.com is IANA-reserved and never + * contacted — no spec fires these alerts, so the destination just needs a URL the + * server's SSRF guard accepts (it blocks localhost/private targets). + * @param {string} name + * @param {string} templateName - Must reference an existing template + */ + async createAlertDestination(name, templateName) { + const payload = { + name, + url: 'http://example.com/e2e_pag_sink', + method: 'post', + template: templateName, + type: 'http', + headers: {} + }; + const response = await this._fetch( + `${this.baseUrl}/api/${this.org}/alerts/destinations`, + { + method: 'POST', + headers: { 'Authorization': this.authHeader, 'Content-Type': 'application/json' }, + body: JSON.stringify(payload) + } + ); + if (!response.ok) { + const errBody = await response.text(); + throw new Error(`createAlertDestination: HTTP ${response.status} — ${errBody}`); + } + testLogger.info('Created alert destination via API', { name }); + } + + /** + * Seed `count` minimal HTTP alert destinations, all sharing one template. + * @param {number} count + * @param {string} prefix + * @param {string} templateName + * @returns {Promise>} + */ + async seedAlertDestinations(count, prefix, templateName) { + const created = []; + for (let i = 0; i < count; i++) { + const name = `${prefix}_${i}`; + await this.createAlertDestination(name, templateName); + created.push({ name }); + } + return created; + } + + /** + * Delete a single alert destination by name. + * @param {string} name + * @returns {Promise} { code, message } + */ + async deleteAlertDestination(name) { + try { + const response = await this._fetch( + `${this.baseUrl}/api/${this.org}/alerts/destinations/${encodeURIComponent(name)}`, + { + method: 'DELETE', + headers: { 'Authorization': this.authHeader, 'Content-Type': 'application/json' } + } + ); + const text = await response.text(); + return { code: response.ok ? 200 : response.status, message: text }; + } catch (error) { + testLogger.error('Failed to delete alert destination', { name, error: error.message }); + return { code: 500, error: error.message }; + } + } + /** * Fetch all dashboard folders * @returns {Promise} Array of folder objects @@ -248,6 +504,73 @@ class APICleanup { } } + /** + * Create a dashboards folder via the v2 folders API. + * @param {string} name - Unique folder name + * @returns {Promise<{folderId: string, name: string}>} + */ + async createDashboardFolder(name) { + const response = await this._fetch( + `${this.baseUrl}/api/v2/${this.org}/folders/dashboards`, + { + method: 'POST', + headers: { 'Authorization': this.authHeader, 'Content-Type': 'application/json' }, + body: JSON.stringify({ name, description: '', icon: null }) + } + ); + if (!response.ok) { + const body = await response.text(); + throw new Error(`createDashboardFolder: HTTP ${response.status} — ${body}`); + } + const folder = await response.json(); + testLogger.info('Created dashboard folder', { name, folderId: folder.folderId }); + return { folderId: folder.folderId, name: folder.name }; + } + + /** + * Seed `count` minimal dashboards into a folder via createMinimalDashboard. + * @param {string} folderId + * @param {number} count + * @param {string} prefix - Dashboard title prefix (deterministic ordering by name) + * @returns {Promise>} + */ + async seedDashboardsInFolder(folderId, count, prefix) { + const created = []; + for (let i = 0; i < count; i++) { + const title = `${prefix}_${i}`; + const { dashboardId } = await this.createMinimalDashboard(title, folderId); + created.push({ dashboardId, title }); + } + return created; + } + + /** + * Delete a dashboards folder by id. Dashboards inside must be removed first, + * otherwise the backend returns 400 ("Folder contains dashboards"). + * @param {string} folderId + * @returns {Promise} { code, message } + */ + async deleteDashboardFolder(folderId) { + try { + const response = await this._fetch( + `${this.baseUrl}/api/v2/${this.org}/folders/dashboards/${folderId}`, + { + method: 'DELETE', + headers: { 'Authorization': this.authHeader, 'Content-Type': 'application/json' } + } + ); + const text = await response.text(); + if (response.ok) { + return { code: 200, message: text }; + } + testLogger.warn('Failed to delete dashboard folder', { folderId, status: response.status, body: text }); + return { code: response.status, message: text }; + } catch (error) { + testLogger.error('Failed to delete dashboard folder', { folderId, error: error.message }); + return { code: 500, error: error.message }; + } + } + /** * Fetch all dashboards in a specific folder * @param {string} folderName - The folder name (e.g., 'default') @@ -302,7 +625,9 @@ class APICleanup { throw new Error(`createMinimalDashboard: HTTP ${response.status} — ${body}`); } const result = await response.json(); - const dashboardId = result.dashboard_id || result.dashboardId || result.id; + // MetaDashboard nests the actual data under the version key (e.g. "v8"). + const inner = result[`v${result.version}`] || result; + const dashboardId = inner.dashboardId || inner.dashboard_id || result.dashboard_id || result.id; testLogger.info('Created minimal dashboard', { dashboardId, folderId }); return { dashboardId, folderId }; } diff --git a/tests/ui-testing/pages/dashboardPages/dashboard-folder.js b/tests/ui-testing/pages/dashboardPages/dashboard-folder.js index a0c1c97152..e8e1b2a733 100644 --- a/tests/ui-testing/pages/dashboardPages/dashboard-folder.js +++ b/tests/ui-testing/pages/dashboardPages/dashboard-folder.js @@ -69,8 +69,25 @@ export default class DashboardFolder { await expect(this.getFolderTitleByName(folderName)).toHaveCount(0); } + // The rail's ONavGroup flyout is anchored over the folder sidebar and only + // closes on mouseleave, so park the pointer clear of it before clicking a + // folder — otherwise the flyout overlay intercepts the click. + async dismissNavFlyout() { + const flyout = this.page + .locator('[data-test^="nav-group-flyout-"]') + .first(); + if (!(await flyout.isVisible().catch(() => false))) return; + const viewport = this.page.viewportSize(); + await this.page.mouse.move( + Math.floor((viewport?.width ?? 1280) * 0.75), + Math.floor((viewport?.height ?? 720) / 2) + ); + await flyout.waitFor({ state: "hidden", timeout: 5000 }).catch(() => {}); + } + // Click on a folder row by name to open it async openFolderByName(folderName) { + await this.dismissNavFlyout(); const folderCard = this.getFolderCardByName(folderName); await folderCard.waitFor({ state: "visible", timeout: 10000 }); await folderCard.click(); diff --git a/tests/ui-testing/pages/dashboardPages/dashboard-list.js b/tests/ui-testing/pages/dashboardPages/dashboard-list.js index e8f66fe7b5..aa4a8b5d58 100644 --- a/tests/ui-testing/pages/dashboardPages/dashboard-list.js +++ b/tests/ui-testing/pages/dashboardPages/dashboard-list.js @@ -7,6 +7,10 @@ const { gotoWithRetry } = require("../../playwright-tests/utils/navigation.js"); export default class DashboardListPage { constructor(page) { this.page = page; + // List-level pagination controls (OTable pagination bar) and the pagination + // info span that renders "Showing X - Y of Z". + this.nextPageBtn = page.locator('[data-test="o2-table-next-page-btn"]'); + this.paginationInfo = page.locator('[data-test="o2-table-pagination-info"]'); } // Duplicate dashboard @@ -132,4 +136,61 @@ export default class DashboardListPage { await nameCell.waitFor({ state: "visible", timeout: 15000 }); await nameCell.click(); } + + // Click the list's next-page button (OTable pagination bar). Disabled on the + // last page, so callers assert they are on a multi-page folder first. + async clickNextPage() { + await this.nextPageBtn.waitFor({ state: "visible", timeout: 15000 }); + await this.nextPageBtn.click(); + } + + // Read the pagination info text ("Showing X - Y of Z"), whitespace-normalised. + // The span interpolates across template newlines, so collapse runs of + // whitespace to single spaces before returning. + async getPaginationInfoText() { + const text = await this.paginationInfo.textContent().catch(() => null); + return (text || "").replace(/\s+/g, " ").trim(); + } + + // Assert the pagination info matches a regex (auto-retrying). The span renders + // "Showing {from} - {to} of {count}", e.g. "Showing 21 - 25 of 25". + async expectPaginationInfoToMatch(pattern) { + await expect + .poll(async () => await this.getPaginationInfoText(), { timeout: 20000 }) + .toMatch(pattern); + } + + // Assert the current URL carries `page=`. + async expectUrlHasPageParam(pageValue) { + await expect.poll(() => this.page.url(), { timeout: 15000 }).toContain(`page=${pageValue}`); + } + + // Assert the current URL no longer carries `page=`. + async expectUrlNotHasPageParam(pageValue) { + await expect.poll(() => this.page.url(), { timeout: 15000 }).not.toContain(`page=${pageValue}`); + } + + // Assert the current URL carries no `page=` query param at all. + async expectUrlHasNoPageParam() { + await expect.poll(() => this.page.url(), { timeout: 15000 }).not.toMatch(/[?&]page=\d+/); + } + + // Click the first visible dashboard name cell in the list (used to open a + // dashboard from the current page without searching, which would change the + // pagination state). + async clickFirstDashboardNameCell() { + const firstCell = this.page + .locator('[data-test^="dashboard-name-cell-"]') + .first(); + await firstCell.waitFor({ state: "visible", timeout: 15000 }); + await firstCell.click(); + } + + // Assert the list rendered at least one data row (not the empty state), which + // proves a page switch landed on a populated page rather than an empty flash. + async expectAtLeastOneRow() { + await expect( + this.page.locator('[data-test^="o2-table-row-"]').first(), + ).toBeVisible({ timeout: 20000 }); + } } diff --git a/tests/ui-testing/playwright-tests/Alerts/alertDestinationsPagination.spec.js b/tests/ui-testing/playwright-tests/Alerts/alertDestinationsPagination.spec.js new file mode 100644 index 0000000000..e41c6e5f7f --- /dev/null +++ b/tests/ui-testing/playwright-tests/Alerts/alertDestinationsPagination.spec.js @@ -0,0 +1,97 @@ +const { + test, + expect, + navigateToBase, +} = require("../utils/enhanced-baseFixtures.js"); +const testLogger = require("../utils/test-logger.js"); +const PageManager = require("../../pages/page-manager.js"); + +// Page size for the Destinations list is 20 (AlertsDestinationList.vue :page-size="20"). +// Like TemplateList, AlertsDestinationList restores `currentPage` from the URL `page` +// query param on mount, so a stale deep link reproduces the bug directly. Covers the +// same OTable restorePage fix (#14745) as Dashboards/dashboardPagination.spec.js and +// Alerts/alertsPagination.spec.js. +const SINGLE_PAGE_COUNT = 2; +const MULTI_PAGE_COUNT = 25; + +const uniqueSeed = () => + `e2e_dest_pag_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`; + +test.describe("Alert Destinations Pagination Reset testcases", () => { + test.describe.configure({ mode: "parallel" }); + let pm; + let destinationNames = []; + let templateName; + + test.beforeEach(async ({ page }, testInfo) => { + testLogger.testStart(testInfo.title, testInfo.file); + await navigateToBase(page); + pm = new PageManager(page); + destinationNames = []; + + const seed = uniqueSeed(); + templateName = `${seed}_tmpl`; + await pm.apiCleanup.createAlertTemplate(templateName); + }); + + test.afterEach(async () => { + for (const name of destinationNames) { + await pm.apiCleanup.deleteAlertDestination(name); + } + await pm.apiCleanup.deleteAlertTemplate(templateName); + }); + + test( + "should clamp a stale deep-linked page to page 1 on a single-page destination list", + { tag: ["@destinations-pagination-reset", "@all"] }, + async ({ page }) => { + const seed = uniqueSeed(); + const seeded = await pm.apiCleanup.seedAlertDestinations( + SINGLE_PAGE_COUNT, + seed, + templateName, + ); + destinationNames = seeded.map((d) => d.name); + + // Compute a page guaranteed to be past the end, rather than a hardcoded number: + // other specs/parallel tests share this org's destination list, so a fixed page + // could accidentally land on a real page. + const currentTotal = (await pm.apiCleanup.fetchDestinationsWithTemplateMapping()).destinations.length; + const stalePage = Math.ceil(currentTotal / 20) + 10; + + testLogger.info("Deep-linking to destinations with a stale page", { stalePage }); + await pm.alertDestinationsPage.gotoDestinationsWithPageParam(stalePage); + + await pm.alertDestinationsPage.expectPaginationInfoToMatch( + new RegExp(`Showing 1 - \\d+ of \\d+`), + ); + await pm.alertDestinationsPage.expectAtLeastOneListRow(); + await pm.alertDestinationsPage.expectUrlHasPageParam("1"); + testLogger.info("Test completed"); + }, + ); + + test( + "should navigate to page 2 and show the remaining destinations", + { tag: ["@destinations-pagination-reset", "@all"] }, + async ({ page }) => { + const seed = uniqueSeed(); + const seeded = await pm.apiCleanup.seedAlertDestinations( + MULTI_PAGE_COUNT, + seed, + templateName, + ); + destinationNames = seeded.map((d) => d.name); + + testLogger.info("Navigating to destinations and going to page 2"); + await pm.alertDestinationsPage.navigateToDestinations(); + await pm.alertDestinationsPage.waitForDestinationListReady(); + + await pm.alertDestinationsPage.clickNextPage(); + await pm.alertDestinationsPage.expectPaginationInfoToMatch(/Showing 21 - /); + await pm.alertDestinationsPage.expectUrlHasPageParam("2"); + await pm.alertDestinationsPage.expectAtLeastOneListRow(); + testLogger.info("Test completed"); + }, + ); +}); diff --git a/tests/ui-testing/playwright-tests/Alerts/alertTemplatesPagination.spec.js b/tests/ui-testing/playwright-tests/Alerts/alertTemplatesPagination.spec.js new file mode 100644 index 0000000000..da8d67b3d1 --- /dev/null +++ b/tests/ui-testing/playwright-tests/Alerts/alertTemplatesPagination.spec.js @@ -0,0 +1,83 @@ +const { + test, + expect, + navigateToBase, +} = require("../utils/enhanced-baseFixtures.js"); +const testLogger = require("../utils/test-logger.js"); +const PageManager = require("../../pages/page-manager.js"); + +// Page size for the Templates list is 20 (TemplateList.vue :page-size="20"). Unlike +// Dashboards/AlertList, TemplateList restores `currentPage` from the URL `page` query +// param on mount, so a stale deep link reproduces the bug directly with no folder +// switch needed. Covers the same OTable restorePage fix (#14745) as +// Dashboards/dashboardPagination.spec.js and Alerts/alertsPagination.spec.js. +const SINGLE_PAGE_COUNT = 2; +const MULTI_PAGE_COUNT = 25; + +const uniqueSeed = () => + `e2e_tmpl_pag_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`; + +test.describe("Alert Templates Pagination Reset testcases", () => { + test.describe.configure({ mode: "parallel" }); + let pm; + let templateNames = []; + + test.beforeEach(async ({ page }, testInfo) => { + testLogger.testStart(testInfo.title, testInfo.file); + await navigateToBase(page); + pm = new PageManager(page); + templateNames = []; + }); + + test.afterEach(async () => { + for (const name of templateNames) { + await pm.apiCleanup.deleteAlertTemplate(name); + } + }); + + test( + "should clamp a stale deep-linked page to page 1 on a single-page template list", + { tag: ["@templates-pagination-reset", "@all"] }, + async ({ page }) => { + const seed = uniqueSeed(); + const seeded = await pm.apiCleanup.seedAlertTemplates(SINGLE_PAGE_COUNT, seed); + templateNames = seeded.map((t) => t.name); + + // Compute a page guaranteed to be past the end, rather than a hardcoded number: + // other specs/parallel tests share this org's template list, so a fixed page + // could accidentally land on a real page. + const currentTotal = (await pm.apiCleanup.fetchAlertTemplates()).length; + const stalePage = Math.ceil(currentTotal / 20) + 10; + + testLogger.info("Deep-linking to templates with a stale page", { stalePage }); + await pm.alertTemplatesPage.gotoTemplatesWithPageParam(stalePage); + + await pm.alertTemplatesPage.expectPaginationInfoToMatch( + new RegExp(`Showing 1 - \\d+ of \\d+`), + ); + await pm.alertTemplatesPage.expectAtLeastOneListRow(); + await pm.alertTemplatesPage.expectUrlHasPageParam("1"); + testLogger.info("Test completed"); + }, + ); + + test( + "should navigate to page 2 and show the remaining templates", + { tag: ["@templates-pagination-reset", "@all"] }, + async ({ page }) => { + const seed = uniqueSeed(); + const seeded = await pm.apiCleanup.seedAlertTemplates(MULTI_PAGE_COUNT, seed); + templateNames = seeded.map((t) => t.name); + + testLogger.info("Navigating to templates and going to page 2"); + await pm.alertTemplatesPage.navigateToTemplates(); + await pm.alertTemplatesPage.waitForTemplateListReady(); + + await pm.alertTemplatesPage.clickNextPage(); + await pm.alertTemplatesPage.expectPaginationInfoToMatch(/Showing 21 - /); + await pm.alertTemplatesPage.expectUrlHasPageParam("2"); + await pm.alertTemplatesPage.expectAtLeastOneListRow(); + testLogger.info("Test completed"); + }, + ); +}); diff --git a/tests/ui-testing/playwright-tests/Alerts/alertsPagination.spec.js b/tests/ui-testing/playwright-tests/Alerts/alertsPagination.spec.js new file mode 100644 index 0000000000..2c0b3bf6d7 --- /dev/null +++ b/tests/ui-testing/playwright-tests/Alerts/alertsPagination.spec.js @@ -0,0 +1,107 @@ +const { + test, + expect, + navigateToBase, +} = require("../utils/enhanced-baseFixtures.js"); +const testLogger = require("../utils/test-logger.js"); +const PageManager = require("../../pages/page-manager.js"); + +// Page size for the Alerts list is 20. Folder A has more than one page and +// Folder B has a single page so "reset to page 1" and "clamp a stale page" are +// both observable and unambiguous. Mirrors Dashboards/dashboardPagination.spec.js, +// which covers the same OTable restorePage fix (#14745) for the Dashboards list. +const FOLDER_A_COUNT = 25; +const FOLDER_B_COUNT = 2; + +// Unique per-test seed prefix. Tests run in parallel against a shared org, so +// every test seeds its own folders/alerts/streams and tears them down in afterEach. +const uniqueSeed = () => + `e2e_alert_pag_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`; + +test.describe("Alerts Folder Switch Pagination Reset testcases", () => { + test.describe.configure({ mode: "parallel" }); + let pm; + let folderA; + let folderB; + let streamA; + let streamB; + + test.beforeEach(async ({ page }, testInfo) => { + testLogger.testStart(testInfo.title, testInfo.file); + await navigateToBase(page); + pm = new PageManager(page); + + // Seed per test: each test is fully independent under parallel workers. + const seed = uniqueSeed(); + const prefixA = `${seed}_a`; + const prefixB = `${seed}_b`; + streamA = `${prefixA}_stream`; + streamB = `${prefixB}_stream`; + + folderA = await pm.apiCleanup.createAlertFolder(`${seed}_A`); + folderB = await pm.apiCleanup.createAlertFolder(`${seed}_B`); + await pm.apiCleanup.seedAlertsInFolder(folderA.folderId, FOLDER_A_COUNT, prefixA); + await pm.apiCleanup.seedAlertsInFolder(folderB.folderId, FOLDER_B_COUNT, prefixB); + + await pm.alertsPage.navigateToAlertsPage(); + testLogger.info("Test setup completed", { + folderA: folderA.name, + folderB: folderB.name, + }); + }); + + test.afterEach(async () => { + // Delete seeded alerts first: the folder delete is rejected while it still + // contains alerts. + const alertsA = await pm.apiCleanup.fetchAlertsInFolder(folderA.folderId); + for (const alert of alertsA) { + await pm.apiCleanup.deleteAlert(alert.alert_id, folderA.folderId); + } + const alertsB = await pm.apiCleanup.fetchAlertsInFolder(folderB.folderId); + for (const alert of alertsB) { + await pm.apiCleanup.deleteAlert(alert.alert_id, folderB.folderId); + } + await pm.apiCleanup.deleteFolder(folderA.folderId); + await pm.apiCleanup.deleteFolder(folderB.folderId); + await pm.apiCleanup.deleteStream(streamA); + await pm.apiCleanup.deleteStream(streamB); + }); + + test( + "should reset the list to page 1 when switching folders", + { tag: ["@alerts-pagination-reset", "@all"] }, + async ({ page }) => { + testLogger.info("Opening folder A and navigating to page 2"); + await pm.alertsPage.navigateToFolder(folderA.name); + await pm.alertsPage.expectPaginationInfoToMatch(/Showing 1 - 20 of 25/); + + await pm.alertsPage.clickNextPage(); + await pm.alertsPage.expectPaginationInfoToMatch(/Showing 21 - /); + + testLogger.info("Switching to folder B"); + await pm.alertsPage.navigateToFolder(folderB.name); + await pm.alertsPage.expectPaginationInfoToMatch(/Showing 1 - 2 of 2/); + testLogger.info("Test completed"); + }, + ); + + test( + "should clamp to page 1 and render rows when switching to a shorter single-page folder", + { tag: ["@alerts-pagination-reset", "@all"] }, + async ({ page }) => { + testLogger.info("Opening folder A and navigating to page 2"); + await pm.alertsPage.navigateToFolder(folderA.name); + await pm.alertsPage.expectPaginationInfoToMatch(/Showing 1 - 20 of 25/); + + await pm.alertsPage.clickNextPage(); + await pm.alertsPage.expectPaginationInfoToMatch(/Showing 21 - /); + + testLogger.info("Switching to the shorter folder B"); + await pm.alertsPage.navigateToFolder(folderB.name); + + await pm.alertsPage.expectPaginationInfoToMatch(/Showing 1 - 2 of 2/); + await pm.alertsPage.expectAtLeastOneListRow(); + testLogger.info("Test completed"); + }, + ); +}); diff --git a/tests/ui-testing/playwright-tests/Dashboards/dashboardPagination.spec.js b/tests/ui-testing/playwright-tests/Dashboards/dashboardPagination.spec.js new file mode 100644 index 0000000000..8e0ac5c774 --- /dev/null +++ b/tests/ui-testing/playwright-tests/Dashboards/dashboardPagination.spec.js @@ -0,0 +1,153 @@ +const { + test, + expect, + navigateToBase, +} = require("../utils/enhanced-baseFixtures.js"); +const testLogger = require("../utils/test-logger.js"); +const PageManager = require("../../pages/page-manager.js"); +const { waitForDashboardPage } = require("./utils/dashCreation.js"); + +// Page size for the Dashboards list is 20. Folder A has more than one page and +// Folder B has a single page so "reset to page 1" and "clamp a stale page" are +// both observable and unambiguous. +const FOLDER_A_COUNT = 25; +const FOLDER_B_COUNT = 2; + +// Unique per-test seed prefix. Tests run in parallel against a shared org, so +// every test seeds its own folders/dashboards and tears them down in afterEach. +const uniqueSeed = () => + `e2e_pag_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`; + +test.describe("Folder Switch Pagination Reset testcases", () => { + test.describe.configure({ mode: "parallel" }); + let pm; + let folderA; + let folderB; + let dashboardsA = []; + let dashboardsB = []; + + test.beforeEach(async ({ page }, testInfo) => { + testLogger.testStart(testInfo.title, testInfo.file); + await navigateToBase(page); + pm = new PageManager(page); + + // Seed per test: each test is fully independent under parallel workers. + const seed = uniqueSeed(); + folderA = await pm.apiCleanup.createDashboardFolder(`${seed}_A`); + folderB = await pm.apiCleanup.createDashboardFolder(`${seed}_B`); + dashboardsA = await pm.apiCleanup.seedDashboardsInFolder( + folderA.folderId, + FOLDER_A_COUNT, + `${seed}_a`, + ); + dashboardsB = await pm.apiCleanup.seedDashboardsInFolder( + folderB.folderId, + FOLDER_B_COUNT, + `${seed}_b`, + ); + + await pm.dashboardList.menuItem("dashboards-item"); + await waitForDashboardPage(page); + testLogger.info("Test setup completed", { + folderA: folderA.name, + folderB: folderB.name, + }); + }); + + test.afterEach(async () => { + // Delete seeded dashboards first: the folder delete is rejected while it + // still contains dashboards. + for (const dash of dashboardsA) { + await pm.apiCleanup.deleteDashboard(dash.dashboardId, folderA.folderId); + } + for (const dash of dashboardsB) { + await pm.apiCleanup.deleteDashboard(dash.dashboardId, folderB.folderId); + } + await pm.apiCleanup.deleteDashboardFolder(folderA.folderId); + await pm.apiCleanup.deleteDashboardFolder(folderB.folderId); + }); + + test( + "should reset the list to page 1 and strip the page query param when switching folders", + { tag: ["@folder-pagination-reset", "@all"] }, + async ({ page }) => { + testLogger.info("Opening folder A and navigating to page 2"); + await pm.dashboardFolder.openFolderByName(folderA.name); + await pm.dashboardList.expectPaginationInfoToMatch(/Showing 1 - 20 of 25/); + + await pm.dashboardList.clickNextPage(); + await pm.dashboardList.expectPaginationInfoToMatch(/Showing 21 - /); + await pm.dashboardList.expectUrlHasPageParam("2"); + + testLogger.info("Switching to folder B"); + await pm.dashboardFolder.openFolderByName(folderB.name); + await pm.dashboardList.expectPaginationInfoToMatch(/Showing 1 - 2 of 2/); + await pm.dashboardList.expectUrlHasNoPageParam(); + testLogger.info("Test completed"); + }, + ); + + test( + "should preserve the current page when returning from a dashboard in the same folder", + { tag: ["@folder-pagination-reset", "@all"] }, + async ({ page }) => { + testLogger.info("Opening folder A and navigating to page 2"); + await pm.dashboardFolder.openFolderByName(folderA.name); + await pm.dashboardList.expectPaginationInfoToMatch(/Showing 1 - 20 of 25/); + + await pm.dashboardList.clickNextPage(); + await pm.dashboardList.expectPaginationInfoToMatch(/Showing 21 - /); + await pm.dashboardList.expectUrlHasPageParam("2"); + + testLogger.info("Opening a dashboard from page 2"); + await pm.dashboardList.clickFirstDashboardNameCell(); + await page.waitForURL(/\/dashboards\/view/, { timeout: 30000 }); + + testLogger.info("Returning to the list"); + await pm.dashboardCreate.backToDashboardList(); + + await pm.dashboardList.expectPaginationInfoToMatch(/Showing 21 - /); + await pm.dashboardList.expectUrlHasPageParam("2"); + testLogger.info("Test completed"); + }, + ); + + test( + "should clamp a stale deep-linked page to page 1 on a single-page folder", + { tag: ["@folder-pagination-reset", "@all"] }, + async ({ page }) => { + testLogger.info("Deep-linking to folder B with a stale page=5"); + const orgId = process.env["ORGNAME"]; + await page.goto( + `${process.env["ZO_BASE_URL"]}/web/dashboards?org_identifier=${orgId}&folder=${folderB.folderId}&page=5`, + { waitUntil: "domcontentloaded" }, + ); + await waitForDashboardPage(page); + + await pm.dashboardList.expectPaginationInfoToMatch(/Showing 1 - 2 of 2/); + await pm.dashboardList.expectUrlNotHasPageParam("5"); + testLogger.info("Test completed"); + }, + ); + + test( + "should clamp to page 1 and render rows when switching to a shorter single-page folder", + { tag: ["@folder-pagination-reset", "@all"] }, + async ({ page }) => { + testLogger.info("Opening folder A and navigating to page 2"); + await pm.dashboardFolder.openFolderByName(folderA.name); + await pm.dashboardList.expectPaginationInfoToMatch(/Showing 1 - 20 of 25/); + + await pm.dashboardList.clickNextPage(); + await pm.dashboardList.expectPaginationInfoToMatch(/Showing 21 - /); + + testLogger.info("Switching to the shorter folder B"); + await pm.dashboardFolder.openFolderByName(folderB.name); + + await pm.dashboardList.expectPaginationInfoToMatch(/Showing 1 - 2 of 2/); + await pm.dashboardList.expectAtLeastOneRow(); + await pm.dashboardList.expectUrlHasNoPageParam(); + testLogger.info("Test completed"); + }, + ); +});