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 <akshay.patil@kiara.tech>
This commit is contained in:
Shrinath Rao
2026-09-24 05:17:03 +00:00
committed by GitHub
co-authored by Shrinath-O2 ktx-akshay
parent c1b9a1ca75
commit c90a6fa949
11 changed files with 969 additions and 3 deletions
+6 -2
View File
@@ -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": [
{
@@ -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();
@@ -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
@@ -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();
}
+326 -1
View File
@@ -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<Array<{alertId: string, name: string}>>}
*/
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>} 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<Array<{name: string}>>}
*/
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<Object>} { 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<Array<{name: string}>>}
*/
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<Object>} { 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>} 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<Array<{dashboardId: string, title: string}>>}
*/
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<Object>} { 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 };
}
@@ -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();
@@ -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=<pageValue>`.
async expectUrlHasPageParam(pageValue) {
await expect.poll(() => this.page.url(), { timeout: 15000 }).toContain(`page=${pageValue}`);
}
// Assert the current URL no longer carries `page=<pageValue>`.
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 });
}
}
@@ -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");
},
);
});
@@ -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");
},
);
});
@@ -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");
},
);
});
@@ -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");
},
);
});