diff --git a/playwright/UI/FilterTypePatch.spec.ts b/playwright/UI/FilterTypePatch.spec.ts new file mode 100644 index 000000000..0ba8740dc --- /dev/null +++ b/playwright/UI/FilterTypePatch.spec.ts @@ -0,0 +1,378 @@ +import { + test, + expect, + navigateToAdvisories, + navigateToPackages, + navigateToSystems, + closePopupsIfExist, + openConditionalFilter, + verifyFilterTypeExists, + applyFilterSubtype, + resetFilters, + waitForTableLoad, + getPublishDateCutoff, + parsePublishDateCell, + osBaseName, + getWorkspaceGroup, + selectFilterType, +} from 'test-utils'; + +/** One day in ms; used for date-only display tolerance. */ +const DAY_MS = 24 * 60 * 60 * 1000; + +/** + * Filter tests for Patch pages: Advisories, Packages, and Systems. + */ + +test.describe('Patch Filters', () => { + test('Filter types on Advisory page', async ({ page, request, systems }) => { + const system = await systems.add('filter-advisory-test', 'base', undefined, { + tags: { network_performance: 'latency' }, + }); + + // Fetch an advisory ID from the created system + const advisoriesResponse = await request + .get(`/api/patch/v3/systems/${system.id}/advisories?limit=1`) + .then((r) => r.json()); + const advisoryId = advisoriesResponse?.data?.[0]?.id ?? 'RHSA'; + + await navigateToAdvisories(page); + await closePopupsIfExist(page); + await openConditionalFilter(page); + + await test.step('Verify "Advisory" filter with search subtype', async () => { + await verifyFilterTypeExists(page, 'Advisory'); + await applyFilterSubtype(page, 'Advisory', { name: advisoryId, inputType: 'search' }); + + // Assert exactly one data row exists (header + 1 row) and that it contains the advisory ID + const rows = page.getByRole('row'); + await expect(rows).toHaveCount(2); + await expect(rows.filter({ hasText: advisoryId })).toHaveCount(1); + + await resetFilters(page); + }); + + await test.step('Verify "Type" filter with all subtypes', async () => { + await verifyFilterTypeExists(page, 'Type'); + + // Test each type filter and verify all results match + for (const typeValue of ['Security', 'Bugfix', 'Enhancement', 'Other']) { + await applyFilterSubtype(page, 'Type', { name: typeValue, inputType: 'checkbox' }); + + // Get all cells in the Type column using data-label attribute + const typeCells = page.locator('td[data-label="Type"]'); + const cellCount = await typeCells.count(); + + if (cellCount > 0) { + // Verify ALL Type column cells contain the filtered value + for (let i = 0; i < cellCount; i++) { + await expect(typeCells.nth(i)).toHaveText(typeValue); + } + } + + await resetFilters(page); + } + }); + + await test.step('Verify "Severity" filter with all subtypes', async () => { + await verifyFilterTypeExists(page, 'Severity'); + + // Test each severity filter and verify all results match + for (const severityValue of ['None', 'Low', 'Moderate', 'Important', 'Critical']) { + await applyFilterSubtype(page, 'Severity', { name: severityValue, inputType: 'checkbox' }); + + // Get all cells in the Severity column using data-label attribute + const severityCells = page.locator('td[data-label="Severity"]'); + const cellCount = await severityCells.count(); + + if (cellCount > 0) { + // Verify ALL Severity column cells contain the filtered value + for (let i = 0; i < cellCount; i++) { + await expect(severityCells.nth(i)).toHaveText(severityValue); + } + } + + await resetFilters(page); + } + }); + + await test.step('Verify "Publish date" filter with all subtypes', async () => { + await verifyFilterTypeExists(page, 'Publish date'); + + for (const dateValue of [ + 'Last 7 days', + 'Last 30 days', + 'Last 90 days', + 'Last year', + 'More than 1 year ago', + ]) { + await applyFilterSubtype(page, 'Publish date', { name: dateValue, inputType: 'option' }); + + await expect(page.getByRole('grid', { name: 'Patch table view' })).toBeVisible(); + + const { minDate, maxDate } = getPublishDateCutoff(dateValue); + const publishDateCells = page.locator('td[data-label="Publish date"]'); + const cellCount = await publishDateCells.count(); + + if (cellCount > 0) { + for (let i = 0; i < cellCount; i++) { + const text = await publishDateCells.nth(i).textContent(); + const cellDate = parsePublishDateCell(text ?? ''); + if (cellDate) { + if (minDate !== undefined) { + // Cell shows date only (no time); allow 1-day tolerance for timezone/display + const cutoffMs = minDate.getTime() - DAY_MS; + expect( + cellDate.getTime(), + `Publish date "${text}" should not be older than ${dateValue}`, + ).toBeGreaterThanOrEqual(cutoffMs); + } else if (maxDate !== undefined) { + // "More than 1 year ago" means date must be before the cutoff + expect( + cellDate.getTime(), + `Publish date "${text}" should be older than 1 year`, + ).toBeLessThanOrEqual(maxDate.getTime()); + } + } + } + } + + await resetFilters(page); + } + }); + + await test.step('Verify "Reboot" filter with all subtypes', async () => { + await verifyFilterTypeExists(page, 'Reboot'); + + // Test each reboot filter and verify all results match + for (const rebootValue of ['Required', 'Not required']) { + await applyFilterSubtype(page, 'Reboot', { name: rebootValue, inputType: 'checkbox' }); + + // Get all cells in the Reboot column using data-label attribute + const rebootCells = page.locator('td[data-label="Reboot"]'); + const cellCount = await rebootCells.count(); + + if (cellCount > 0) { + // Verify ALL Reboot column cells contain the filtered value + for (let i = 0; i < cellCount; i++) { + await expect(rebootCells.nth(i)).toHaveText(rebootValue); + } + } + + await resetFilters(page); + } + }); + }); + + test('Filter types on Packages page', async ({ page, systems }) => { + await systems.add('filter-packages-test', 'base'); + + await navigateToPackages(page); + await closePopupsIfExist(page); + await waitForTableLoad(page); + + // Use a package name from the table so the filter is guaranteed to match a visible row + const firstPackageCell = page.locator('td[data-label="Name"]').first(); + await firstPackageCell.waitFor({ state: 'visible' }); + const packageName = (await firstPackageCell.textContent())?.trim(); + expect(packageName, 'Packages table should have at least one row').toBeDefined(); + + await openConditionalFilter(page); + + await test.step('Verify "Package" filter exists', async () => { + await verifyFilterTypeExists(page, 'Package'); + }); + + await test.step('Verify "Package" filter with search displays expected package', async () => { + await applyFilterSubtype(page, 'Package', { name: packageName!, inputType: 'search' }); + + const rows = page.getByRole('row'); + await expect(rows).toHaveCount(2); + await expect(rows.filter({ hasText: packageName })).toHaveCount(1); + + await resetFilters(page); + }); + + await test.step('Verify "Patch status" filter with all subtypes', async () => { + await verifyFilterTypeExists(page, 'Patch status'); + + // "Systems up to date": Packages default is "Systems with patches available". Uncheck it + // first, then check "Systems up to date" so only eq:0 is sent (API drops filter if both are sent). + await resetFilters(page); + await openConditionalFilter(page); + await applyFilterSubtype( + page, + 'Patch status', + { name: 'Systems with patches available', inputType: 'checkbox' }, + { verifyChip: false }, + ); + await openConditionalFilter(page); + await applyFilterSubtype(page, 'Patch status', { + name: 'Systems up to date', + inputType: 'checkbox', + }); + + let applicableCells = page.locator('td[data-label="Applicable systems"]'); + let cellCount = await applicableCells.count(); + if (cellCount > 0) { + for (let i = 0; i < cellCount; i++) { + await expect(applicableCells.nth(i)).toHaveText('0'); + } + } + + // "Systems with patches available": reset restores default (gt:0); no need to click again. + await resetFilters(page); + + applicableCells = page.locator('td[data-label="Applicable systems"]'); + cellCount = await applicableCells.count(); + if (cellCount > 0) { + for (let i = 0; i < cellCount; i++) { + const text = (await applicableCells.nth(i).textContent())?.trim() ?? ''; + const num = parseInt(text, 10); + expect( + Number.isNaN(num) ? 0 : num, + 'Applicable systems should be > 0 for "Systems with patches available"', + ).toBeGreaterThan(0); + } + } + }); + + await expect(page.getByRole('button', { name: 'Conditional filter toggle' })).toBeVisible(); + }); + + test('Filter types on Systems page', async ({ page, systems }) => { + await systems.add('filter-systems-test', 'base'); + + await navigateToSystems(page); + await closePopupsIfExist(page); + await openConditionalFilter(page); + + await test.step('Verify "Operating system" filter exists', async () => { + await verifyFilterTypeExists(page, 'Operating system'); + }); + + await test.step('Verify "Workspace" filter exists', async () => { + await verifyFilterTypeExists(page, 'Workspace'); + }); + + await test.step('Verify "Tag" filter exists', async () => { + await verifyFilterTypeExists(page, 'Tag'); + }); + + await test.step('Verify "System" filter exists', async () => { + await verifyFilterTypeExists(page, 'System', true); + }); + + await test.step('Verify "Status" filter exists', async () => { + await verifyFilterTypeExists(page, 'Status', true); + }); + + await test.step('Verify "Patch status" filter exists', async () => { + await verifyFilterTypeExists(page, 'Patch status'); + }); + + await expect(page.getByRole('button', { name: 'Conditional filter toggle' })).toBeVisible(); + }); +}); + +/** + * Verify filter contents (OS, Workspace, Tags, System, Patch status). + * Note: Workspace and Tag options come from the Inventory service (not Patch). + * If the Workspace or Tag dropdown is empty, the Inventory API returned no groups/tags + * for the account (e.g. on stage, or no host groups/tags created in Inventory). + */ +test('Verify filter contents', async ({ page, systems }) => { + await systems.add('filter-contents-test', 'base', undefined, { + tags: { network_performance: 'latency' }, + }); + await navigateToSystems(page); + await closePopupsIfExist(page); + await openConditionalFilter(page); + await resetFilters(page); + + await test.step('Verify "Operating system" filter contents', async () => { + await openConditionalFilter(page); + await applyFilterSubtype(page, 'Operating system', { + name: osBaseName, + inputType: 'checkbox', + }); + await expect(page.locator('td[data-label="OS"]').first()).toContainText('RHEL'); + await resetFilters(page); + }); + + await test.step('Verify "Workspace" filter contents', async () => { + await openConditionalFilter(page); + await selectFilterType(page, 'Workspace'); + await page.getByRole('button', { name: 'Menu toggle' }).click(); + // Workspace options come from Inventory. Set it in insights/inventory/workspaces and in .env file. + // Otherwise only "Ungrouped Hosts" is available. + const workspaceGroup = getWorkspaceGroup(); + const groupOption = page + .getByRole('menuitem') + .filter({ hasText: workspaceGroup }) + .or(page.getByRole('option', { name: workspaceGroup })); + let useUngrouped = true; + try { + await expect(groupOption.first()).toBeVisible({ timeout: 3000 }); + useUngrouped = false; + } catch { + // Group option not in Inventory; use Ungrouped Hosts + } + if (useUngrouped) { + await page + .getByRole('menuitem') + .filter({ hasText: 'Ungrouped Hosts' }) + .or(page.getByRole('option', { name: 'Ungrouped Hosts' })) + .first() + .click(); + await waitForTableLoad(page); + await expect( + page.getByRole('grid', { name: 'Patch table view' }).locator('tbody [role="row"]').first(), + ).toBeVisible(); + } else { + await groupOption.first().click(); + await waitForTableLoad(page); + await expect(page.locator('td').filter({ hasText: workspaceGroup }).first()).toBeVisible(); + } + await resetFilters(page); + }); + + await test.step('Verify "Tag" filter contents', async () => { + await openConditionalFilter(page); + await applyFilterSubtype(page, 'Tag', { + name: 'network_performance: latency', + inputType: 'checkbox', + }); + const grid = page.getByRole('grid', { name: 'Patch table view' }); + await expect(grid.locator('tbody [role="row"]').first()).toBeVisible(); + await expect(page.locator('td[data-label="Name"]').first()).toContainText( + 'filter-contents-test', + ); + await resetFilters(page); + }); + + await test.step('Verify "System" filter contents', async () => { + await openConditionalFilter(page); + // System filter shows "Filter by name" input, not a checkbox menu + await applyFilterSubtype(page, 'System', { + name: 'filter-contents-test', + inputType: 'search', + }); + await expect(page.locator('td[data-label="Name"]').first()).toContainText( + 'filter-contents-test', + ); + await resetFilters(page); + }); + + await test.step('Verify "Patch status" filter contents', async () => { + await openConditionalFilter(page); + await applyFilterSubtype(page, 'Patch status', { + name: 'Systems with patches available', + inputType: 'checkbox', + }); + await expect(page.locator('td[data-label="Name"]').first()).toContainText( + 'filter-systems-test', + ); + await resetFilters(page); + }); +}); diff --git a/playwright/test-utils/fixtures/systems.ts b/playwright/test-utils/fixtures/systems.ts index e9fafdd04..e880bc50a 100644 --- a/playwright/test-utils/fixtures/systems.ts +++ b/playwright/test-utils/fixtures/systems.ts @@ -19,6 +19,7 @@ import { cleanupArchive, cleanupSystem, createSystem, + CreateSystemOptions, randomName, SystemResult, SystemType, @@ -41,15 +42,21 @@ export interface Systems { * @param prefix - Prefix for the system name (will be combined with a random suffix) * @param type - Type of system to create (defaults to 'base') * @param token - Token of user to create system for (defaults to 'ADMIN_TOKEN' env variable) + * @param options - Optional (e.g. predefined tags like network_performance=latency) * @returns Promise resolving to the created system's ID and name * * @example * ```typescript * const system = await systems.add('my-test', 'base', process.env.ADMIN_TOKEN); - * console.log(system.id, system.name); + * const systemWithTag = await systems.add('filter-advisory-test', 'base', undefined, { tags: { network_performance: 'latency' } }); * ``` */ - add: (prefix: string, type?: SystemType, token?: string) => Promise; + add: ( + prefix: string, + type?: SystemType, + token?: string, + options?: CreateSystemOptions, + ) => Promise; /** * Creates multiple test systems in parallel. * @@ -120,7 +127,12 @@ export const systemsTest = base.extend({ }; await use({ - add: async (prefix: string, type: SystemType = 'base', token?: string) => + add: async ( + prefix: string, + type: SystemType = 'base', + token?: string, + options?: CreateSystemOptions, + ) => await systemsTest.step( `Adding system`, async (): Promise => { @@ -130,6 +142,7 @@ export const systemsTest = base.extend({ `${prefix}-${randomName()}`, type, token ?? process.env.ADMIN_TOKEN!, + options, ); allSystems.push(response); return response; diff --git a/playwright/test-utils/helpers/filters.ts b/playwright/test-utils/helpers/filters.ts new file mode 100644 index 000000000..c2ac107cf --- /dev/null +++ b/playwright/test-utils/helpers/filters.ts @@ -0,0 +1,311 @@ +/** + * Filter interaction helpers for Playwright tests. + * + * This module provides utilities for: + * - Opening and interacting with conditional filter dropdowns + * - Applying different types of filters (checkbox, option, search) + * - Verifying filter types and subtypes exist + * - Verifying filter chips appear + * - Resetting filters + * - Publish date filter helpers (cutoff calculation, cell parsing) + */ + +import { Page } from '@playwright/test'; + +const DAY_MS = 24 * 60 * 60 * 1000; + +/** Returns { minDate } for "Last X" filters or { maxDate } for "More than 1 year ago". */ +export function getPublishDateCutoff(filterLabel: string): { + minDate?: Date; + maxDate?: Date; +} { + const now = new Date(); + if (filterLabel === 'More than 1 year ago') { + const max = new Date(now.getTime() - 365 * DAY_MS); + max.setUTCHours(23, 59, 59, 999); // End of day so date-only display matches + return { maxDate: max }; + } + const days = + filterLabel === 'Last 7 days' + ? 7 + : filterLabel === 'Last 30 days' + ? 30 + : filterLabel === 'Last 90 days' + ? 90 + : 365; + const min = new Date(now.getTime() - days * DAY_MS); + min.setUTCHours(0, 0, 0, 0); // Start of day - cells show date-only, avoid boundary failures + return { minDate: min }; +} + +/** Parse date from table cell (format "DD Mon YYYY" from processDate). */ +export function parsePublishDateCell(text: string): Date | null { + const trimmed = text.trim(); + if (trimmed === 'N/A' || !trimmed) { + return null; + } + const d = new Date(trimmed); + return Number.isNaN(d.getTime()) ? null : d; +} +import { expect, waitForTableLoad } from 'test-utils'; + +type FilterInputType = 'checkbox' | 'option' | 'search'; + +export interface FilterConfig { + name: string; // Button/dropdown name (e.g., 'Type', 'Severity') + type: FilterInputType; // How to interact with the filter + value: string; // Value to select/enter +} + +/** + * Filter subtype configuration for verifying and applying filter subtypes. + */ +export interface FilterSubtype { + name: string; // Display name of the subtype (e.g., 'Security', 'Bugfix') + inputType: FilterInputType; // How to interact with this subtype +} + +/** + * Opens the conditional filter dropdown. + * + * @param page - Playwright Page object + */ +export const openConditionalFilter = async (page: Page) => { + await page.getByRole('button', { name: 'Conditional filter toggle' }).click(); +}; + +/** + * Verifies that a filter type exists in the filter dropdown. + * Opens the conditional filter dropdown if it's not already open. + * + * @param page - Playwright Page object + * @param filterType - Name of the filter type (e.g., 'Type', 'Severity', 'Advisory') + * @param exact - If true, match the filter type name exactly (e.g. "System" not "Operating system") + */ +export const verifyFilterTypeExists = async (page: Page, filterType: string, exact?: boolean) => { + const menuitem = page.getByRole('menuitem', { name: filterType, exact: exact ?? false }); + + // Open the conditional filter dropdown if the menuitem isn't visible + if (!(await menuitem.isVisible())) { + await openConditionalFilter(page); + } + + await expect(menuitem).toBeVisible(); +}; + +/** + * Selects a filter type from the conditional filter dropdown. + * Opens the dropdown if it's not already open, then clicks the filter type. + * + * @param page - Playwright Page object + * @param filterType - Name of the filter type to select + */ +export const selectFilterType = async (page: Page, filterType: string) => { + const menuitem = page.getByRole('menuitem', { name: filterType, exact: true }); + + // Open the conditional filter dropdown if the menuitem isn't visible + if (!(await menuitem.isVisible())) { + await openConditionalFilter(page); + } + + await menuitem.click(); +}; + +/** + * Verify a filter chip is visible. + * + * @param page - Playwright Page object + * @param text - Text to find in the filter chip + */ +export const expectFilterChip = async (page: Page, text: string) => { + await expect(page.locator('.pf-v6-c-label__content').filter({ hasText: text })).toBeVisible(); +}; + +/** + * Verify a filter chip is hidden/removed. + * + * @param page - Playwright Page object + * @param text - Text that should not be in any filter chip + */ +export const expectFilterChipHidden = async (page: Page, text: string) => { + await expect(page.locator('.pf-v6-c-label__content').filter({ hasText: text })).toBeHidden(); +}; + +/** + * Applies a filter with its subtype value and optionally verifies the filter chip. + * Handles different input types: search, checkbox, and option/select. + * + * @param page - Playwright Page object + * @param filterType - Name of the filter type (e.g., 'Type', 'Severity') + * @param subtype - Subtype configuration with name and input type + * @param options - Optional settings + * @param options.verifyChip - Whether to verify the filter chip appears (default: true) + * @param options.chipText - Custom text to verify in the chip (defaults to subtype.name) + */ +export const applyFilterSubtype = async ( + page: Page, + filterType: string, + subtype: FilterSubtype, + options: { verifyChip?: boolean; chipText?: string } = {}, +) => { + const { verifyChip = true, chipText = subtype.name } = options; + + // Select the filter type first + await selectFilterType(page, filterType); + + // Some filter types use a subtype filter button with text "Filter by " (e.g. Operating system, Status). + // Others use "Menu toggle" (Workspace, Tags) or "Options menu" (Patch status). Open the list when needed. + const groupFilterBtn = page.getByRole('button', { name: 'Group filter' }).filter({ + hasText: new RegExp(`Filter by ${filterType.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}`, 'i'), + }); + const openedViaGroupFilter = await groupFilterBtn.isVisible(); + if (openedViaGroupFilter) { + await groupFilterBtn.click(); + // Clicking Group filter (e.g. "Filter by status") opens the Options menu (menuitems like Fresh, Stale) + } else if (subtype.inputType !== 'search') { + // Open the list: Workspace/Tags use "Menu toggle", others use "Options menu" + const menuToggle = page.getByRole('button', { name: 'Menu toggle' }); + const optionsMenu = page.getByRole('button', { name: 'Options menu' }); + if (await menuToggle.isVisible()) { + await menuToggle.click(); + } else { + await optionsMenu.click(); + } + } + + // Apply the subtype based on its input type + switch (subtype.inputType) { + case 'search': + await page.getByRole('textbox', { name: 'search-field' }).fill(subtype.name); + break; + + case 'checkbox': { + // Some filters use menuitems (e.g. Type, Severity); others use options (e.g. Patch status). Match either. + await page + .getByRole('menuitem') + .filter({ hasText: subtype.name }) + .or(page.getByRole('option', { name: subtype.name })) + .first() + .click(); + break; + } + + case 'option': { + await page.getByRole('option', { name: subtype.name }).click(); + break; + } + } + + await waitForTableLoad(page); + + // Verify the filter chip if requested + if (verifyChip) { + await expectFilterChip(page, chipText); + } +}; + +/** + * Verifies subtypes exist for a filter type. + * Opens the filter dropdown and checks that all specified subtypes are present. + * + * @param page - Playwright Page object + * @param filterType - Name of the filter type + * @param subtypes - Array of subtype names to verify + */ +export const verifyFilterSubtypesExist = async ( + page: Page, + filterType: string, + subtypes: string[], +) => { + // Select the filter type + await selectFilterType(page, filterType); + + // Some filter types show a "Group filter" button with text "Filter by " (e.g. Operating system, Status). + // Others use "Menu toggle" (Workspace, Tags) or "Options menu". Open the list when needed. + const groupFilterBtn = page.getByRole('button', { name: 'Group filter' }).filter({ + hasText: new RegExp(`Filter by ${filterType.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}`, 'i'), + }); + const openedViaGroupFilter = await groupFilterBtn.isVisible(); + let openedViaMenuToggle = false; + if (openedViaGroupFilter) { + await groupFilterBtn.click(); + } else { + const menuToggle = page.getByRole('button', { name: 'Menu toggle' }); + const optionsMenu = page.getByRole('button', { name: 'Options menu' }); + if (await menuToggle.isVisible()) { + await menuToggle.click(); + openedViaMenuToggle = true; + } else { + await optionsMenu.click(); + } + } + + // Verify each subtype exists + for (const subtype of subtypes) { + await expect( + page + .getByRole('menuitem', { name: subtype, exact: true }) + .or(page.getByRole('option', { name: subtype, exact: true })), + ).toBeVisible(); + } + + // Close the dropdown (same control we used to open it) + if (openedViaGroupFilter) { + await groupFilterBtn.click(); + } else if (openedViaMenuToggle) { + await page.getByRole('button', { name: 'Menu toggle' }).click(); + } else { + await page.getByRole('button', { name: 'Options menu' }).click(); + } +}; + +/** + * Apply a single filter to the page. + * + * @param page - Playwright Page object + * @param filter - Filter configuration + */ +export const applyFilter = async (page: Page, filter: FilterConfig) => { + if (filter.type === 'search') { + await page.getByRole('textbox', { name: 'search-field' }).fill(filter.value); + } else { + const dropdown = page.getByRole('button', { name: 'Options menu' }); + await dropdown.click(); + + if (filter.type === 'checkbox') { + await page.getByRole('menuitem', { name: filter.value, exact: true }).click(); + // Dropdown auto-closes after clicking menuitem + } else { + await page.getByRole('option', { name: filter.value, exact: true }).click(); + } + } + await waitForTableLoad(page); +}; + +/** + * Remove/uncheck a filter value. + * + * @param page - Playwright Page object + * @param filter - Filter configuration (only works for checkbox type) + */ +export const removeFilter = async (page: Page, filter: FilterConfig) => { + if (filter.type === 'checkbox') { + const dropdown = page.getByRole('button', { name: 'Options menu' }); + await dropdown.click(); + await page.getByRole('menuitem', { name: filter.value, exact: true }).click(); + // Dropdown auto-closes after clicking menuitem + await waitForTableLoad(page); + } +}; + +/** + * Reset/clear all filters. + * + * @param page - Playwright Page object + */ +export const resetFilters = async (page: Page) => { + // Close any open dropdowns first + await page.keyboard.press('Escape'); + await page.getByRole('button', { name: /Reset filters/i }).click(); + await waitForTableLoad(page); +}; diff --git a/playwright/test-utils/helpers/index.ts b/playwright/test-utils/helpers/index.ts index 8db3f7b8a..80d351531 100644 --- a/playwright/test-utils/helpers/index.ts +++ b/playwright/test-utils/helpers/index.ts @@ -3,3 +3,4 @@ export * from './auth'; export * from './navigation'; export * from './systems'; export * from './tables'; +export * from './filters'; diff --git a/playwright/test-utils/helpers/systems.ts b/playwright/test-utils/helpers/systems.ts index 5647cddc8..f873aec5b 100644 --- a/playwright/test-utils/helpers/systems.ts +++ b/playwright/test-utils/helpers/systems.ts @@ -50,6 +50,18 @@ const SystemTypeArchiveMap = new Map([ ['version-locked', ['rhel96_version_locked.tar.gz', false]], ]); +/** + * UI display name for the Operating system filter per system type. + */ +const SYSTEM_TYPE_OS_DISPLAY_NAME: Record = { + base: 'RHEL 9.6', + clean: 'RHEL 9.4', + 'version-locked': 'RHEL 9.6', +}; + +/** OS display name for the default (base) system type. Use in filtered table assertions. */ +export const osBaseName = SYSTEM_TYPE_OS_DISPLAY_NAME.base; + /** * Result of creating a test system. */ @@ -142,6 +154,41 @@ const updateHostname = (baseDir: string, systemName: string) => { fs.writeFileSync(hostnamePath, `${systemName}\n`); }; +/** + * Workspace group name written into the archive and used in Workspace filter tests. + * Reads from WORKSPACE_GROUP env to match a group created in the Inventory UI (insights/inventory/workspaces). + * Inventory UI only shows groups created there as menu options (not group names from system uploads). + */ +export const getWorkspaceGroup = () => process.env.WORKSPACE_GROUP; + +/** + * Escapes a string for use as a YAML double-quoted value (backslash and quote escaped). + */ +const yamlQuoted = (s: string) => `"${s.replace(/\\/g, '\\\\').replace(/"/g, '\\"')}"`; + +const setInsightsClientGroup = (baseDir: string, extraTags?: Record) => { + // Path must match insights-client payload: data/etc/insights-client/tags.yaml inside the archive. + const insightsClientDir = path.join(baseDir, 'data/etc/insights-client'); + const tagsPath = path.join(insightsClientDir, 'tags.yaml'); + if (!fs.existsSync(insightsClientDir)) { + fs.mkdirSync(insightsClientDir, { recursive: true }); + } + const groupName = getWorkspaceGroup(); + if (!groupName) { + throw new Error( + 'WORKSPACE_GROUP env is required. Add it to your .env (e.g. WORKSPACE_GROUP=TestSpace).', + ); + } + const lines = [`insights-client:`, ` group: ${yamlQuoted(groupName)}`]; + if (extraTags) { + for (const [key, value] of Object.entries(extraTags)) { + lines.push(` ${key}: ${yamlQuoted(value)}`); + } + } + const tagsYaml = lines.join('\n') + '\n'; + fs.writeFileSync(tagsPath, tagsYaml); +}; + /** * Compresses the modified archive into a new tar.gz file. * @@ -168,6 +215,12 @@ const compressArchive = (workingDir: string, systemName: string) => { } }; +/** Options when creating a test system (e.g. extra insights-client tags). */ +export type CreateSystemOptions = { + /** Predefined/custom tags for insights-client tags.yaml (e.g. { network_performance: 'latency' }). */ + tags?: Record; +}; + /** * Prepares a test archive by extracting a base archive, customizing it, and recompressing it. * @@ -175,13 +228,19 @@ const compressArchive = (workingDir: string, systemName: string) => { * 1. Creates a unique working directory in `../data/tmp/` * 2. Extracts the base archive for the specified system type * 3. Updates the machine ID, subscription manager ID, and hostname - * 4. Compresses the modified archive back into a tar.gz file + * 4. Optionally sets extra insights-client tags + * 5. Compresses the modified archive back into a tar.gz file * * @param systemName - The name for the test system (used for hostname and archive filename) * @param type - The type of system to create + * @param options - Optional extra tags for the system * @throws Error if the base archive doesn't exist or any preparation step fails */ -const prepareTestArchive = (systemName: string, type: SystemType) => { +const prepareTestArchive = ( + systemName: string, + type: SystemType, + options?: CreateSystemOptions, +) => { const archive = SystemTypeArchiveMap.get(type)?.[0] ?? ''; const archivePath = path.join(__dirname, '../data/', archive); if (!fs.existsSync(archivePath)) { @@ -199,6 +258,7 @@ const prepareTestArchive = (systemName: string, type: SystemType) => { updateMachineId(baseDir); updateSubscriptionManagerIdentity(baseDir); updateHostname(baseDir, systemName); + setInsightsClientGroup(baseDir, options?.tags); compressArchive(workingDir, systemName); }; @@ -362,8 +422,9 @@ export const createSystem = async ( systemName: string, systemType: SystemType, token: string, + options?: CreateSystemOptions, ): Promise => { - prepareTestArchive(systemName, systemType); + prepareTestArchive(systemName, systemType, options); await uploadArchive(request, path.join(__dirname, `../data/tmp/${systemName}.tar.gz`)); const id = await waitForSystemInInventory(request, systemName); await waitForSystemInPatch(request, systemName, systemType, id); diff --git a/playwright_example.env b/playwright_example.env index 8025e17e0..6a0cbcca0 100644 --- a/playwright_example.env +++ b/playwright_example.env @@ -16,6 +16,7 @@ CI="" # This is set to true for CI jobs (used by PW), if checking for PROD="" # Determines the environment used for the tests. INTEGRATION="" # When this is true, playwright test will run integration tests. DOCKER_SOCKET="" # Required for integration tests using containers. +WORKSPACE_GROUP="" # Required for Workspace filter tests (Also set in insights/inventory/workspaces) # -------------- OTHER -------------- TOKEN="" # This is handled programmatically.