Migrate Playwright suite to .NET UI tests and deprecate TS project

This commit is contained in:
Joseph Doherty
2026-02-06 18:44:40 -05:00
parent 4e56ea3435
commit 562f7e9e37
105 changed files with 1119 additions and 0 deletions
@@ -0,0 +1,88 @@
import { Page } from '@playwright/test';
// Test credentials - FakeAuthService accepts any credentials in development mode
const DEFAULT_USERNAME = 'testuser';
const DEFAULT_PASSWORD = 'testpass';
/**
* Login to the application using FakeAuthService credentials
* @param page - Playwright page object
* @param username - Optional username (defaults to testuser)
* @param password - Optional password (defaults to testpass)
*/
export async function login(page: Page, username?: string, password?: string): Promise<void> {
const user = username ?? DEFAULT_USERNAME;
const pass = password ?? DEFAULT_PASSWORD;
// Check if we're on the login page
const loginForm = page.locator('text=Authentication Required');
if (await loginForm.isVisible({ timeout: 5000 }).catch(() => false)) {
// Wait for form inputs to be ready
await page.locator('input[name="Username"]').waitFor({ state: 'visible', timeout: 10000 });
// Fill credentials
await page.locator('input[name="Username"]').fill(user);
await page.locator('input[name="Password"]').fill(pass);
// Click the submit button
await page.locator('button[type="submit"]:has-text("LOGIN")').click();
// Wait for login to process
await page.waitForTimeout(3000);
// After login, force navigation to refresh state
await page.goto('/search');
await page.waitForLoadState('networkidle', { timeout: 60000 });
// Verify login succeeded
if (await loginForm.isVisible({ timeout: 2000 }).catch(() => false)) {
throw new Error('Login failed - still on login page after attempt');
}
}
}
/**
* Logout from the application
* @param page - Playwright page object
*/
export async function logout(page: Page): Promise<void> {
// Click the logout button in the header
const logoutButton = page.locator('button:has-text("Logout")');
if (await logoutButton.isVisible({ timeout: 2000 }).catch(() => false)) {
await logoutButton.click();
await page.waitForLoadState('networkidle', { timeout: 30000 });
}
}
/**
* Check if the user is currently logged in
* @param page - Playwright page object
* @returns true if logged in, false otherwise
*/
export async function isLoggedIn(page: Page): Promise<boolean> {
const loginForm = page.locator('text=Authentication Required');
const logoutButton = page.locator('button:has-text("Logout")');
// If logout button is visible, user is logged in
if (await logoutButton.isVisible({ timeout: 2000 }).catch(() => false)) {
return true;
}
// If login form is visible, user is not logged in
if (await loginForm.isVisible({ timeout: 2000 }).catch(() => false)) {
return false;
}
// Default to not logged in
return false;
}
/**
* Ensure user is logged in, performing login if necessary
* @param page - Playwright page object
*/
export async function ensureLoggedIn(page: Page): Promise<void> {
if (!(await isLoggedIn(page))) {
await login(page);
}
}
@@ -0,0 +1,237 @@
import { Page } from '@playwright/test';
/**
* Configuration for autocomplete filter panels
*/
export interface AutocompleteConfig {
/** Panel header text to identify the panel */
panelHeader: string;
/** Minimum characters to trigger search */
minSearchLength: number;
/** Column to check for duplicates */
keyColumn: string;
}
/**
* Profit Center autocomplete configuration
*/
export const profitCenterConfig: AutocompleteConfig = {
panelHeader: 'Filter by Profit Center',
minSearchLength: 3,
keyColumn: 'Code',
};
/**
* Work Center autocomplete configuration
*/
export const workCenterConfig: AutocompleteConfig = {
panelHeader: 'Filter by Work Center',
minSearchLength: 3,
keyColumn: 'Work Center',
};
/**
* Operator autocomplete configuration
*/
export const operatorConfig: AutocompleteConfig = {
panelHeader: 'Filter by Operator',
minSearchLength: 3,
keyColumn: 'User ID',
};
/**
* Add an item via autocomplete search
* @param page - Playwright page object
* @param config - Autocomplete configuration
* @param searchText - Text to search for (must be >= minSearchLength)
*/
export async function addAutocompleteItem(
page: Page,
config: AutocompleteConfig,
searchText: string
): Promise<void> {
// Find the panel by its header
const panel = page.locator(`.rz-card:has-text("${config.panelHeader}")`);
// Find the autocomplete input within the panel
const autocomplete = panel.locator('.rz-autocomplete input');
// Clear and type the search text
await autocomplete.clear();
await autocomplete.fill(searchText);
// Wait for search results to appear
await page.waitForTimeout(500);
// Click the first matching result in the dropdown
const dropdown = page.locator('.rz-autocomplete-list');
if (await dropdown.isVisible({ timeout: 3000 }).catch(() => false)) {
await dropdown.locator('.rz-autocomplete-list-item').first().click();
await page.waitForTimeout(300);
}
// Click the Add button
await panel.locator('button:has-text("Add")').click();
await page.waitForTimeout(300);
}
/**
* Add multiple items via autocomplete
* @param page - Playwright page object
* @param config - Autocomplete configuration
* @param searchTexts - Array of search texts
*/
export async function addMultipleAutocompleteItems(
page: Page,
config: AutocompleteConfig,
searchTexts: string[]
): Promise<void> {
for (const text of searchTexts) {
await addAutocompleteItem(page, config, text);
}
}
/**
* Remove an item from the autocomplete list by clicking its delete button
* @param page - Playwright page object
* @param config - Autocomplete configuration
* @param rowIndex - The zero-based row index to remove
*/
export async function removeAutocompleteItem(
page: Page,
config: AutocompleteConfig,
rowIndex: number
): Promise<void> {
const panel = page.locator(`.rz-card:has-text("${config.panelHeader}")`);
const grid = panel.locator('.rz-data-grid');
const rows = grid.locator('tbody tr');
// Click the delete button in the specified row
await rows.nth(rowIndex).locator('button:has-text("Delete")').click();
await page.waitForTimeout(300);
}
/**
* Clear all items from the autocomplete panel
* @param page - Playwright page object
* @param config - Autocomplete configuration
*/
export async function clearAutocompleteItems(
page: Page,
config: AutocompleteConfig
): Promise<void> {
const panel = page.locator(`.rz-card:has-text("${config.panelHeader}")`);
const clearButton = panel.locator('button:has-text("Clear Data")');
if (await clearButton.isVisible({ timeout: 2000 }).catch(() => false)) {
await clearButton.click();
// Wait for and confirm the dialog
await page.waitForSelector('text=Confirm Clear', { timeout: 5000 });
await page.locator('button:has-text("OK")').click();
await page.waitForTimeout(500);
}
}
/**
* Get the count of items in the autocomplete panel
* @param page - Playwright page object
* @param config - Autocomplete configuration
* @returns Number of items in the grid
*/
export async function getAutocompleteItemCount(
page: Page,
config: AutocompleteConfig
): Promise<number> {
const panel = page.locator(`.rz-card:has-text("${config.panelHeader}")`);
const grid = panel.locator('.rz-data-grid');
// Check for "No records" message
const noRecords = grid.locator('text=No records to display');
if (await noRecords.isVisible({ timeout: 1000 }).catch(() => false)) {
return 0;
}
const rows = grid.locator('tbody tr');
return await rows.count();
}
/**
* Check if the autocomplete panel is visible
* @param page - Playwright page object
* @param config - Autocomplete configuration
* @returns true if panel is visible
*/
export async function isAutocompletePanelVisible(
page: Page,
config: AutocompleteConfig
): Promise<boolean> {
const panel = page.locator(`text=${config.panelHeader}`);
return await panel.isVisible({ timeout: 2000 }).catch(() => false);
}
/**
* Add a profit center
* @param page - Playwright page object
* @param code - Profit center code (e.g., "1AM")
*/
export async function addProfitCenter(page: Page, code: string): Promise<void> {
await addAutocompleteItem(page, profitCenterConfig, code);
}
/**
* Add multiple profit centers
* @param page - Playwright page object
* @param codes - Array of profit center codes
*/
export async function addProfitCenters(page: Page, codes: string[]): Promise<void> {
await addMultipleAutocompleteItems(page, profitCenterConfig, codes);
}
/**
* Add a work center
* @param page - Playwright page object
* @param code - Work center code
*/
export async function addWorkCenter(page: Page, code: string): Promise<void> {
await addAutocompleteItem(page, workCenterConfig, code);
}
/**
* Add multiple work centers
* @param page - Playwright page object
* @param codes - Array of work center codes
*/
export async function addWorkCenters(page: Page, codes: string[]): Promise<void> {
await addMultipleAutocompleteItems(page, workCenterConfig, codes);
}
/**
* Add an operator
* @param page - Playwright page object
* @param userId - Operator user ID (e.g., "ADAMSSN")
*/
export async function addOperator(page: Page, userId: string): Promise<void> {
await addAutocompleteItem(page, operatorConfig, userId);
}
/**
* Add multiple operators
* @param page - Playwright page object
* @param userIds - Array of operator user IDs
*/
export async function addOperators(page: Page, userIds: string[]): Promise<void> {
await addMultipleAutocompleteItems(page, operatorConfig, userIds);
}
/**
* Common test data for autocomplete panels
*/
export const TestAutocompleteData = {
profitCenters: ['1AM', '1BM', '1CM', '1PM', '2DM', '2SM', '3TM', '4IM', '5SM'],
workCenters: ['WC001', 'WC002', 'WC003'],
operators: [
'ADAMSSN', 'AGNEWA', 'AGNEWL', 'ALASMARB', 'ALEXIUCG',
'ALLENHY', 'ALLENNI', 'ALURUM', 'ALVESM1', 'APONTEVE',
],
} as const;
@@ -0,0 +1,177 @@
import { Page } from '@playwright/test';
/**
* Date picker panel identifiers
*/
export const DatePickerPanels = {
MIN_DATE: 'MinimumDt',
MAX_DATE: 'MaximumDt',
} as const;
/**
* Set the minimum date in the TimeSpan filter panel
* @param page - Playwright page object
* @param date - Date string in YYYY-MM-DD format or Date object
*/
export async function setMinDate(page: Page, date: string | Date): Promise<void> {
const dateStr = formatDateForInput(date);
const dateInput = page.locator('input[name="MinimumDt"]');
await dateInput.fill(dateStr);
await page.waitForTimeout(300);
}
/**
* Set the maximum date in the TimeSpan filter panel
* @param page - Playwright page object
* @param date - Date string in YYYY-MM-DD format or Date object
*/
export async function setMaxDate(page: Page, date: string | Date): Promise<void> {
const dateStr = formatDateForInput(date);
const dateInput = page.locator('input[name="MaximumDt"]');
await dateInput.fill(dateStr);
await page.waitForTimeout(300);
}
/**
* Set both minimum and maximum dates in the TimeSpan filter panel
* @param page - Playwright page object
* @param minDate - Minimum date string in YYYY-MM-DD format or Date object
* @param maxDate - Maximum date string in YYYY-MM-DD format or Date object
*/
export async function setDateRange(page: Page, minDate: string | Date, maxDate: string | Date): Promise<void> {
await setMinDate(page, minDate);
await setMaxDate(page, maxDate);
}
/**
* Get the current minimum date value
* @param page - Playwright page object
* @returns The date value as a string
*/
export async function getMinDate(page: Page): Promise<string> {
const dateInput = page.locator('input[name="MinimumDt"]');
return await dateInput.inputValue();
}
/**
* Get the current maximum date value
* @param page - Playwright page object
* @returns The date value as a string
*/
export async function getMaxDate(page: Page): Promise<string> {
const dateInput = page.locator('input[name="MaximumDt"]');
return await dateInput.inputValue();
}
/**
* Clear the minimum date field
* @param page - Playwright page object
*/
export async function clearMinDate(page: Page): Promise<void> {
const dateInput = page.locator('input[name="MinimumDt"]');
await dateInput.clear();
await page.waitForTimeout(300);
}
/**
* Clear the maximum date field
* @param page - Playwright page object
*/
export async function clearMaxDate(page: Page): Promise<void> {
const dateInput = page.locator('input[name="MaximumDt"]');
await dateInput.clear();
await page.waitForTimeout(300);
}
/**
* Clear both date fields
* @param page - Playwright page object
*/
export async function clearDateRange(page: Page): Promise<void> {
await clearMinDate(page);
await clearMaxDate(page);
}
/**
* Check if the TimeSpan filter panel is visible
* @param page - Playwright page object
* @returns true if visible
*/
export async function isTimeSpanPanelVisible(page: Page): Promise<boolean> {
const panel = page.locator('text=Filter by Time Span');
return await panel.isVisible({ timeout: 2000 }).catch(() => false);
}
/**
* Format a date for input into the date picker
* @param date - Date string or Date object
* @returns Date string in MM/DD/YYYY format (Radzen date picker format)
*/
function formatDateForInput(date: string | Date): string {
let d: Date;
if (typeof date === 'string') {
// Parse YYYY-MM-DD format
const parts = date.split('-');
if (parts.length === 3) {
d = new Date(parseInt(parts[0]), parseInt(parts[1]) - 1, parseInt(parts[2]));
} else {
d = new Date(date);
}
} else {
d = date;
}
// Format as MM/DD/YYYY for Radzen DatePicker
const month = String(d.getMonth() + 1).padStart(2, '0');
const day = String(d.getDate()).padStart(2, '0');
const year = d.getFullYear();
return `${month}/${day}/${year}`;
}
/**
* Common date ranges for testing
*/
export const TestDateRanges = {
/** Recent date range: 2020-01-01 to 2020-09-01 */
RECENT: {
min: '2020-01-01',
max: '2020-09-01',
},
/** Mid-range dates: 2018-01-01 to 2019-12-31 */
MID_RANGE: {
min: '2018-01-01',
max: '2019-12-31',
},
/** Historical date range: 2016-01-01 to 2017-12-31 */
HISTORICAL: {
min: '2016-01-01',
max: '2017-12-31',
},
/** Same day range: 2020-06-15 */
SAME_DAY: {
min: '2020-06-15',
max: '2020-06-15',
},
/** Start boundary: earliest date in database */
START_BOUNDARY: {
min: '1905-01-20',
max: '1905-12-31',
},
/** End boundary: latest date in database */
END_BOUNDARY: {
min: '2020-08-01',
max: '2020-09-01',
},
/** Invalid: min > max (for negative testing) */
INVALID_REVERSED: {
min: '2020-09-01',
max: '2020-01-01',
},
/** Future dates (for negative testing) */
FUTURE: {
min: '2025-01-01',
max: '2025-12-31',
},
} as const;
@@ -0,0 +1,237 @@
import { Page, Download } from '@playwright/test';
import path from 'path';
/**
* Configuration for file upload filter panels
*/
export interface FileUploadConfig {
/** Panel header text to identify the panel */
panelHeader: string;
/** Template filename (for download verification) */
templateFilename: string;
/** Expected columns in the template */
expectedColumns: string[];
}
/**
* Work Order upload configuration
*/
export const workOrderConfig: FileUploadConfig = {
panelHeader: 'Filter by Work Order',
templateFilename: 'WorkOrderTemplate.xlsx',
expectedColumns: ['Work Order Number'],
};
/**
* Component Lot upload configuration
*/
export const componentLotConfig: FileUploadConfig = {
panelHeader: 'Filter By Component Lot',
templateFilename: 'ComponentLotTemplate.xlsx',
expectedColumns: ['Lot Number', 'Item Number'],
};
/**
* Item Number upload configuration
*/
export const itemNumberConfig: FileUploadConfig = {
panelHeader: 'Filter by Item Number',
templateFilename: 'ItemNumberTemplate.xlsx',
expectedColumns: ['Item Number'],
};
/**
* Part Operation (Item/Operation/MIS) upload configuration
*/
export const partOperationConfig: FileUploadConfig = {
panelHeader: 'Filter By Item/Operation/MIS',
templateFilename: 'PartOperationTemplate.xlsx',
expectedColumns: ['Item Number', 'Operation Number', 'MIS Number', 'MIS Revision'],
};
/**
* Upload a file to a filter panel
* @param page - Playwright page object
* @param config - File upload configuration
* @param filePath - Path to the file to upload
*/
export async function uploadFile(
page: Page,
config: FileUploadConfig,
filePath: string
): Promise<void> {
// Find the panel by its header
const panel = page.locator(`.rz-card:has-text("${config.panelHeader}")`);
// Find the file input (RadzenUpload creates a hidden input)
const fileInput = panel.locator('input[type="file"]');
// Set the file
await fileInput.setInputFiles(filePath);
// Wait for upload to complete
await page.waitForTimeout(2000);
}
/**
* Upload a file using the global file input (fallback for panels without specific locators)
* @param page - Playwright page object
* @param filePath - Path to the file to upload
*/
export async function uploadFileGlobal(page: Page, filePath: string): Promise<void> {
const fileInput = page.locator('input[type="file"]');
await fileInput.setInputFiles(filePath);
await page.waitForTimeout(2000);
}
/**
* Download a template file from a filter panel
* @param page - Playwright page object
* @param config - File upload configuration
* @returns The Download object
*/
export async function downloadTemplate(
page: Page,
config: FileUploadConfig
): Promise<Download> {
const panel = page.locator(`.rz-card:has-text("${config.panelHeader}")`);
// Start waiting for download before clicking
const downloadPromise = page.waitForEvent('download');
// Click the download template button
await panel.locator('button:has-text("Download Template")').click();
// Wait for download to complete
const download = await downloadPromise;
return download;
}
/**
* Clear uploaded data from a filter panel
* @param page - Playwright page object
* @param config - File upload configuration
*/
export async function clearUploadedData(
page: Page,
config: FileUploadConfig
): Promise<void> {
const panel = page.locator(`.rz-card:has-text("${config.panelHeader}")`);
const clearButton = panel.locator('button:has-text("Clear Data")');
if (await clearButton.isVisible({ timeout: 2000 }).catch(() => false)) {
await clearButton.click();
// Wait for and confirm the dialog
await page.waitForSelector('text=Confirm Clear', { timeout: 5000 });
// Click the Ok button inside the dialog
await page.locator('.rz-dialog-wrapper button').getByText('Ok', { exact: true }).click();
// Wait for the grid to update - either "No records" appears or grid rows are removed
await page.waitForTimeout(1000);
// Wait for dialog to close
await page.waitForSelector('.rz-dialog-wrapper', { state: 'hidden', timeout: 5000 }).catch(() => {});
}
}
/**
* Get the count of uploaded items in a filter panel
* @param page - Playwright page object
* @param config - File upload configuration
* @returns Number of items in the grid
*/
export async function getUploadedItemCount(
page: Page,
config: FileUploadConfig
): Promise<number> {
const panel = page.locator(`.rz-card:has-text("${config.panelHeader}")`);
const grid = panel.locator('.rz-data-grid');
// Check for "No records" message
const noRecords = grid.locator('text=No records to display');
if (await noRecords.isVisible({ timeout: 1000 }).catch(() => false)) {
return 0;
}
const rows = grid.locator('tbody tr');
return await rows.count();
}
/**
* Check if the file upload panel is visible
* @param page - Playwright page object
* @param config - File upload configuration
* @returns true if panel is visible
*/
export async function isFileUploadPanelVisible(
page: Page,
config: FileUploadConfig
): Promise<boolean> {
const panel = page.locator(`text=${config.panelHeader}`);
return await panel.isVisible({ timeout: 2000 }).catch(() => false);
}
/**
* Verify the uploaded file shows items in the grid
* @param page - Playwright page object
* @param config - File upload configuration
* @returns true if grid has items
*/
export async function hasUploadedItems(
page: Page,
config: FileUploadConfig
): Promise<boolean> {
const count = await getUploadedItemCount(page, config);
return count > 0;
}
/**
* Get the test data directory path
*/
export function getTestDataDir(): string {
return path.join(__dirname, '..', 'test-data');
}
/**
* Get the path to a test data file
* @param filename - The filename in the test-data directory
*/
export function getTestDataPath(filename: string): string {
return path.join(getTestDataDir(), filename);
}
/**
* Standard test files available in test-data directory
*/
export const TestFiles = {
// Work Order files
SINGLE_WORKORDER: 'single_workorder.xlsx',
MULTIPLE_WORKORDERS: 'multiple_workorders.xlsx',
// Component Lot files
SINGLE_LOT: 'single_lot.xlsx',
MULTIPLE_LOTS: 'multiple_lots.xlsx',
// Item Number files
SINGLE_ITEM: 'single_item.xlsx',
MULTIPLE_ITEMS: 'multiple_items.xlsx',
// Part Operation files
SINGLE_OPERATION: 'single_operation.xlsx',
MULTIPLE_OPERATIONS: 'multiple_operations.xlsx',
// Invalid files (for negative testing)
INVALID_FORMAT: 'invalid_format.txt',
EMPTY_FILE: 'empty_file.xlsx',
INVALID_WORKORDERS: 'invalid_workorders.xlsx',
SPECIAL_CHARS_WORKORDERS: 'special_chars_workorders.xlsx',
} as const;
/**
* Get full path to a standard test file
* @param testFile - One of the TestFiles constants
*/
export function getTestFile(testFile: string): string {
return getTestDataPath(testFile);
}
@@ -0,0 +1,29 @@
/**
* Playwright Test Helpers for JDE Scoping Tool
*
* This module exports all helper functions for E2E testing.
*/
// Authentication helpers
export * from './auth.helper';
// Navigation helpers
export * from './navigation.helper';
// Search type helpers
export * from './search-type.helper';
// Date picker helpers
export * from './date-picker.helper';
// Autocomplete panel helpers
export * from './autocomplete.helper';
// File upload panel helpers
export * from './file-upload.helper';
// Radzen component helpers
export * from './radzen.helper';
// Form validation helpers
export * from './validation.helper';
@@ -0,0 +1,108 @@
import { Page } from '@playwright/test';
import { login } from './auth.helper';
/**
* Wait for Blazor WASM application to be fully loaded
* @param page - Playwright page object
* @param timeout - Maximum time to wait in milliseconds (default: 120000)
*/
export async function waitForBlazorReady(page: Page, timeout: number = 120000): Promise<void> {
// Wait for network to be idle (all WASM files loaded)
await page.waitForLoadState('networkidle', { timeout: 60000 });
// Wait for Radzen components or main content to be visible
await page.locator('.rz-dropdown').or(page.locator('text=Search Details')).or(page.locator('.rz-data-grid')).first().waitFor({
state: 'visible',
timeout: timeout
});
// Additional wait for Radzen components to fully initialize
await page.waitForTimeout(2000);
}
/**
* Navigate to the search page and wait for it to be ready
* @param page - Playwright page object
*/
export async function navigateToSearchPage(page: Page): Promise<void> {
await page.goto('/search');
await page.waitForLoadState('networkidle', { timeout: 60000 });
await login(page);
await waitForBlazorReady(page);
}
/**
* Navigate to a specific search by ID
* @param page - Playwright page object
* @param searchId - The ID of the search to view
*/
export async function navigateToSearch(page: Page, searchId: number): Promise<void> {
await page.goto(`/search/${searchId}`);
await page.waitForLoadState('networkidle', { timeout: 60000 });
await login(page);
await waitForBlazorReady(page);
}
/**
* Navigate to the searches dashboard (home page)
* @param page - Playwright page object
*/
export async function navigateToSearchesDashboard(page: Page): Promise<void> {
await page.goto('/searches');
await page.waitForLoadState('networkidle', { timeout: 60000 });
await login(page);
await waitForBlazorReady(page);
}
/**
* Navigate to the search queue page
* @param page - Playwright page object
*/
export async function navigateToSearchQueue(page: Page): Promise<void> {
await page.goto('/search/queue');
await page.waitForLoadState('networkidle', { timeout: 60000 });
await login(page);
await waitForBlazorReady(page);
}
/**
* Navigate to the refresh status page
* @param page - Playwright page object
*/
export async function navigateToRefreshStatus(page: Page): Promise<void> {
await page.goto('/refresh-status');
await page.waitForLoadState('networkidle', { timeout: 60000 });
await login(page);
await waitForBlazorReady(page);
}
/**
* Navigate to the data sync requests page
* @param page - Playwright page object
*/
export async function navigateToDataSync(page: Page): Promise<void> {
await page.goto('/data-sync/requests');
await page.waitForLoadState('networkidle', { timeout: 60000 });
await login(page);
await waitForBlazorReady(page);
}
/**
* Navigate to the login page
* @param page - Playwright page object
*/
export async function navigateToLoginPage(page: Page): Promise<void> {
await page.goto('/login');
await page.waitForLoadState('networkidle', { timeout: 60000 });
}
/**
* Click a navigation link in the header
* @param page - Playwright page object
* @param linkText - The text of the navigation link to click
*/
export async function clickNavLink(page: Page, linkText: string): Promise<void> {
await page.locator(`a:has-text("${linkText}")`).click();
await page.waitForLoadState('networkidle', { timeout: 30000 });
await waitForBlazorReady(page);
}
@@ -0,0 +1,259 @@
import { Page, expect, Locator } from '@playwright/test';
/**
* Notification types in Radzen
*/
export type NotificationType = 'success' | 'error' | 'warning' | 'info';
/**
* Badge style types matching Radzen badge styles
*/
export type BadgeStyle = 'danger' | 'success' | 'info' | 'warning' | 'primary' | 'secondary';
/**
* Wait for a notification of a specific type to appear
* @param page - Playwright page object
* @param type - The notification type to wait for
* @param timeout - Maximum time to wait (default: 5000ms)
*/
export async function waitForNotification(
page: Page,
type: NotificationType,
timeout: number = 5000
): Promise<void> {
const notificationClass = `.rz-notification-${type}`;
await page.locator(notificationClass).waitFor({ state: 'visible', timeout });
}
/**
* Wait for any notification to appear
* @param page - Playwright page object
* @param timeout - Maximum time to wait (default: 5000ms)
*/
export async function waitForAnyNotification(
page: Page,
timeout: number = 5000
): Promise<void> {
await page.locator('.rz-notification').waitFor({ state: 'visible', timeout });
}
/**
* Check if an error notification is visible
* @param page - Playwright page object
* @returns true if error notification is visible
*/
export async function hasErrorNotification(page: Page): Promise<boolean> {
return await page.locator('.rz-notification-error').isVisible({ timeout: 2000 }).catch(() => false);
}
/**
* Check if a success notification is visible
* @param page - Playwright page object
* @returns true if success notification is visible
*/
export async function hasSuccessNotification(page: Page): Promise<boolean> {
return await page.locator('.rz-notification-success').isVisible({ timeout: 2000 }).catch(() => false);
}
/**
* Assert that no error notification is visible
* @param page - Playwright page object
* @param timeout - Time to wait before asserting (default: 5000ms)
*/
export async function assertNoErrorNotification(page: Page, timeout: number = 5000): Promise<void> {
const errorNotification = page.locator('.rz-notification-error');
await expect(errorNotification).not.toBeVisible({ timeout });
}
/**
* Get the row count from a data grid
* @param page - Playwright page object
* @param gridLocator - Locator for the specific grid (or defaults to first grid)
* @returns Number of rows in the grid body
*/
export async function getDataGridRowCount(
page: Page,
gridLocator?: Locator
): Promise<number> {
const grid = gridLocator ?? page.locator('.rz-data-grid').first();
// Check for "No records" message
const noRecords = grid.locator('text=No records to display');
if (await noRecords.isVisible({ timeout: 1000 }).catch(() => false)) {
return 0;
}
const rows = grid.locator('tbody tr');
return await rows.count();
}
/**
* Double-click a row in a data grid
* @param page - Playwright page object
* @param rowIndex - Zero-based index of the row to double-click
* @param gridLocator - Locator for the specific grid (or defaults to first grid)
*/
export async function doubleClickDataGridRow(
page: Page,
rowIndex: number,
gridLocator?: Locator
): Promise<void> {
const grid = gridLocator ?? page.locator('.rz-data-grid').first();
const rows = grid.locator('tbody tr');
await rows.nth(rowIndex).dblclick();
await page.waitForTimeout(500);
}
/**
* Click a row in a data grid
* @param page - Playwright page object
* @param rowIndex - Zero-based index of the row to click
* @param gridLocator - Locator for the specific grid (or defaults to first grid)
*/
export async function clickDataGridRow(
page: Page,
rowIndex: number,
gridLocator?: Locator
): Promise<void> {
const grid = gridLocator ?? page.locator('.rz-data-grid').first();
const rows = grid.locator('tbody tr');
await rows.nth(rowIndex).click();
await page.waitForTimeout(300);
}
/**
* Get text content from a specific cell in a data grid
* @param page - Playwright page object
* @param rowIndex - Zero-based row index
* @param columnIndex - Zero-based column index
* @param gridLocator - Locator for the specific grid (or defaults to first grid)
* @returns Cell text content
*/
export async function getDataGridCellText(
page: Page,
rowIndex: number,
columnIndex: number,
gridLocator?: Locator
): Promise<string> {
const grid = gridLocator ?? page.locator('.rz-data-grid').first();
const cell = grid.locator(`tbody tr:nth-child(${rowIndex + 1}) td:nth-child(${columnIndex + 1})`);
return await cell.textContent() ?? '';
}
/**
* Check if a data grid shows "No records to display"
* @param page - Playwright page object
* @param gridLocator - Locator for the specific grid (or defaults to first grid)
* @returns true if no records message is visible
*/
export async function dataGridIsEmpty(
page: Page,
gridLocator?: Locator
): Promise<boolean> {
const grid = gridLocator ?? page.locator('.rz-data-grid').first();
const noRecords = grid.locator('text=No records to display');
return await noRecords.isVisible({ timeout: 2000 }).catch(() => false);
}
/**
* Wait for dialog to appear with specific title
* @param page - Playwright page object
* @param title - Dialog title text
* @param timeout - Maximum time to wait (default: 5000ms)
*/
export async function waitForDialog(
page: Page,
title: string,
timeout: number = 5000
): Promise<void> {
await page.locator(`.rz-dialog:has-text("${title}")`).waitFor({ state: 'visible', timeout });
}
/**
* Click OK button in a dialog
* @param page - Playwright page object
*/
export async function clickDialogOk(page: Page): Promise<void> {
await page.locator('.rz-dialog button:has-text("OK")').click();
await page.waitForTimeout(300);
}
/**
* Click Cancel button in a dialog
* @param page - Playwright page object
*/
export async function clickDialogCancel(page: Page): Promise<void> {
await page.locator('.rz-dialog button:has-text("Cancel")').click();
await page.waitForTimeout(300);
}
/**
* Confirm a dialog (waits for dialog and clicks OK)
* @param page - Playwright page object
* @param title - Dialog title to wait for
*/
export async function confirmDialog(page: Page, title: string): Promise<void> {
await waitForDialog(page, title);
await clickDialogOk(page);
}
/**
* Get the badge style from a badge element
* @param badge - Locator for the badge element
* @returns The badge style or null if not found
*/
export async function getBadgeStyle(badge: Locator): Promise<BadgeStyle | null> {
const classes = await badge.getAttribute('class') ?? '';
if (classes.includes('rz-badge-danger')) return 'danger';
if (classes.includes('rz-badge-success')) return 'success';
if (classes.includes('rz-badge-info')) return 'info';
if (classes.includes('rz-badge-warning')) return 'warning';
if (classes.includes('rz-badge-primary')) return 'primary';
if (classes.includes('rz-badge-secondary')) return 'secondary';
return null;
}
/**
* Check if a button is disabled
* @param page - Playwright page object
* @param buttonText - Text content of the button
* @returns true if button is disabled
*/
export async function isButtonDisabled(page: Page, buttonText: string): Promise<boolean> {
const button = page.locator(`button:has-text("${buttonText}")`);
return await button.isDisabled();
}
/**
* Click a button by its text
* @param page - Playwright page object
* @param buttonText - Text content of the button
*/
export async function clickButton(page: Page, buttonText: string): Promise<void> {
await page.locator(`button:has-text("${buttonText}")`).click();
await page.waitForTimeout(300);
}
/**
* Get the text content of a badge by its parent context
* @param page - Playwright page object
* @param contextText - Text near the badge to identify it
* @returns Badge text content
*/
export async function getBadgeText(page: Page, contextText: string): Promise<string> {
const badge = page.locator(`:has-text("${contextText}") .rz-badge`).first();
return await badge.textContent() ?? '';
}
/**
* Status badge style mapping (from Blazor code)
*/
export const StatusBadgeStyles: Record<string, BadgeStyle> = {
'Error': 'danger',
'Ended': 'success',
'Running': 'info',
'Queued': 'warning',
'New': 'secondary',
};
@@ -0,0 +1,128 @@
import { Page } from '@playwright/test';
/**
* Valid search type names as displayed in the dropdown
*/
export const SearchTypes = {
WORK_ORDER: 'Work Order',
COMPONENT_LOT: 'Component Lot',
TIMESPAN_PROFIT_CENTER: 'Time Span + Profit Center',
TIMESPAN_WORK_CENTER: 'Time Span + Work Center',
TIMESPAN_OPERATOR: 'Time Span + Operator',
TIMESPAN_PC_ITEM: 'Time Span + Profit Center + Item Number',
TIMESPAN_PC_PARTOP: 'Time Span + Profit Center + Item/Operation/MIS',
TIMESPAN_PC_WO_PARTOP: 'Time Span + Profit Center + Work Order + Item/Operation/MIS',
TIMESPAN_PC_EXTRACTMIS: 'Time Span + Profit Center + Extract MIS',
TIMESPAN_WC_ITEM: 'Time Span + Work Center + Item Number',
TIMESPAN_WC_EXTRACTMIS: 'Time Span + Work Center + Extract MIS',
TIMESPAN_WC_PARTOP: 'Time Span + Work Center + Item/Operation/MIS',
TIMESPAN_WC_WO_PARTOP: 'Time Span + Work Center + Work Order + Item/Operation/MIS',
TIMESPAN_ITEM: 'Time Span + Item Number',
TIMESPAN_WC_OPERATOR: 'Time Span + Work Center + Operator',
TIMESPAN_PC_OPERATOR: 'Time Span + Profit Center + Operator',
} as const;
export type SearchType = typeof SearchTypes[keyof typeof SearchTypes];
/**
* Search type to numeric code mapping
*/
export const SearchTypeCodes: Record<SearchType, number> = {
[SearchTypes.WORK_ORDER]: 10,
[SearchTypes.COMPONENT_LOT]: 20,
[SearchTypes.TIMESPAN_PROFIT_CENTER]: 30,
[SearchTypes.TIMESPAN_WORK_CENTER]: 40,
[SearchTypes.TIMESPAN_OPERATOR]: 50,
[SearchTypes.TIMESPAN_PC_ITEM]: 60,
[SearchTypes.TIMESPAN_PC_PARTOP]: 70,
[SearchTypes.TIMESPAN_PC_WO_PARTOP]: 80,
[SearchTypes.TIMESPAN_PC_EXTRACTMIS]: 90,
[SearchTypes.TIMESPAN_WC_ITEM]: 100,
[SearchTypes.TIMESPAN_WC_EXTRACTMIS]: 110,
[SearchTypes.TIMESPAN_WC_PARTOP]: 120,
[SearchTypes.TIMESPAN_WC_WO_PARTOP]: 130,
[SearchTypes.TIMESPAN_ITEM]: 140,
[SearchTypes.TIMESPAN_WC_OPERATOR]: 150,
[SearchTypes.TIMESPAN_PC_OPERATOR]: 160,
};
/**
* Select a search type from the dropdown
* @param page - Playwright page object
* @param searchType - The search type to select
*/
export async function selectSearchType(page: Page, searchType: SearchType): Promise<void> {
// Click the search type dropdown (first dropdown on the page)
await page.locator('.rz-dropdown').first().click();
// Wait for dropdown to open
await page.waitForTimeout(300);
// Select the option by exact text using aria-label for exact matching
await page.getByRole('option', { name: searchType, exact: true }).click();
// Wait for the filter panel to update
await page.waitForTimeout(500);
}
/**
* Get the currently selected search type
* @param page - Playwright page object
* @returns The currently selected search type text
*/
export async function getSelectedSearchType(page: Page): Promise<string> {
const dropdown = page.locator('.rz-dropdown').first();
return await dropdown.locator('.rz-dropdown-label').textContent() ?? '';
}
/**
* Check if the search type dropdown is disabled (for read-only searches)
* @param page - Playwright page object
* @returns true if dropdown is disabled
*/
export async function isSearchTypeDropdownDisabled(page: Page): Promise<boolean> {
const dropdown = page.locator('.rz-dropdown').first();
const isDisabled = await dropdown.getAttribute('aria-disabled');
return isDisabled === 'true';
}
/**
* Enter the search name
* @param page - Playwright page object
* @param name - The search name to enter
*/
export async function enterSearchName(page: Page, name: string): Promise<void> {
// The search name input has placeholder " " (single space)
await page.locator('input[placeholder=" "]').first().fill(name);
}
/**
* Get the current search name
* @param page - Playwright page object
* @returns The current search name value
*/
export async function getSearchName(page: Page): Promise<string> {
return await page.locator('input[placeholder=" "]').first().inputValue();
}
/**
* Click the Submit Search button
* @param page - Playwright page object
*/
export async function clickSubmitSearch(page: Page): Promise<void> {
// Button contains icon "send" and text "Submit" - use text content
await page.locator('button:has-text("Submit")').first().click();
await page.waitForTimeout(500);
}
/**
* Confirm the submit search dialog
* @param page - Playwright page object
*/
export async function confirmSubmitSearch(page: Page): Promise<void> {
// Wait for confirmation dialog
await page.waitForSelector('text=Confirm Submit', { timeout: 5000 });
// Click the Submit button inside the dialog (use exact match to avoid the main page Submit button)
await page.locator('.rz-dialog-wrapper button').getByText('Submit', { exact: true }).click();
await page.waitForTimeout(1000);
}
@@ -0,0 +1,187 @@
import { Page, expect, Locator } from '@playwright/test';
/**
* Check if a form field has a validation error
* @param page - Playwright page object
* @param fieldName - The name attribute of the field
* @returns true if field has validation error styling
*/
export async function fieldHasError(page: Page, fieldName: string): Promise<boolean> {
const field = page.locator(`[name="${fieldName}"]`);
const classes = await field.getAttribute('class') ?? '';
// Radzen validation error class
return classes.includes('rz-state-invalid') || classes.includes('invalid');
}
/**
* Check if the validation summary has any errors
* @param page - Playwright page object
* @returns true if validation errors are present
*/
export async function hasValidationErrors(page: Page): Promise<boolean> {
// Check for Radzen error notifications (Validation Error notifications)
const errorNotification = page.locator('.rz-notification-error:has-text("Validation Error")');
if (await errorNotification.isVisible({ timeout: 1000 }).catch(() => false)) {
return true;
}
// Check for Radzen validation summary
const validationSummary = page.locator('.rz-validation-summary');
if (await validationSummary.isVisible({ timeout: 1000 }).catch(() => false)) {
const errors = validationSummary.locator('.validation-message, .rz-message');
const count = await errors.count();
return count > 0;
}
// Check for standard validation messages
const validationMessages = page.locator('.validation-message');
return (await validationMessages.count()) > 0;
}
/**
* Get all validation error messages
* @param page - Playwright page object
* @returns Array of error message texts
*/
export async function getValidationErrors(page: Page): Promise<string[]> {
const errors: string[] = [];
// Get errors from notification toasts (Validation Error notifications)
const notificationErrors = page.locator('.rz-notification-error');
const notificationCount = await notificationErrors.count();
for (let i = 0; i < notificationCount; i++) {
const notification = notificationErrors.nth(i);
// Check if it's a validation error notification
const title = await notification.locator('div').first().textContent() ?? '';
if (title.includes('Validation Error')) {
// Get the message (second div in the notification)
const message = await notification.locator('div').nth(1).textContent();
if (message) errors.push(message.trim());
}
}
// Get errors from validation summary
const summaryErrors = page.locator('.rz-validation-summary .validation-message, .rz-validation-summary .rz-message');
const summaryCount = await summaryErrors.count();
for (let i = 0; i < summaryCount; i++) {
const text = await summaryErrors.nth(i).textContent();
if (text && !errors.includes(text.trim())) errors.push(text.trim());
}
// Get inline validation messages
const inlineErrors = page.locator('.field-validation-error, .validation-message');
const inlineCount = await inlineErrors.count();
for (let i = 0; i < inlineCount; i++) {
const text = await inlineErrors.nth(i).textContent();
if (text && !errors.includes(text.trim())) {
errors.push(text.trim());
}
}
return errors;
}
/**
* Assert that a specific validation error message is present
* @param page - Playwright page object
* @param expectedMessage - The expected error message (partial match)
*/
export async function assertValidationError(page: Page, expectedMessage: string): Promise<void> {
const errors = await getValidationErrors(page);
const found = errors.some(e => e.toLowerCase().includes(expectedMessage.toLowerCase()));
expect(found, `Expected validation error containing "${expectedMessage}" but found: ${errors.join(', ')}`).toBe(true);
}
/**
* Assert no validation errors are present
* @param page - Playwright page object
*/
export async function assertNoValidationErrors(page: Page): Promise<void> {
const hasErrors = await hasValidationErrors(page);
if (hasErrors) {
const errors = await getValidationErrors(page);
expect(hasErrors, `Expected no validation errors but found: ${errors.join(', ')}`).toBe(false);
}
}
/**
* Check if a specific form field is required and shows error when empty
* @param page - Playwright page object
* @param fieldLocator - Locator for the field
* @returns true if field shows required validation
*/
export async function isFieldRequired(page: Page, fieldLocator: Locator): Promise<boolean> {
// Check for required attribute
const required = await fieldLocator.getAttribute('required');
if (required !== null) return true;
// Check for aria-required attribute
const ariaRequired = await fieldLocator.getAttribute('aria-required');
return ariaRequired === 'true';
}
/**
* Check if a panel shows a validation message for missing required items
* @param page - Playwright page object
* @param panelHeader - The header text of the panel
* @returns true if panel has validation error
*/
export async function panelHasValidationError(page: Page, panelHeader: string): Promise<boolean> {
const panel = page.locator(`.rz-card:has-text("${panelHeader}")`);
const validationMessage = panel.locator('.validation-message, .rz-message-error, .text-danger');
return await validationMessage.isVisible({ timeout: 1000 }).catch(() => false);
}
/**
* Wait for validation to complete (after form submission attempt)
* @param page - Playwright page object
* @param timeout - Maximum time to wait (default: 2000ms)
*/
export async function waitForValidation(page: Page, timeout: number = 2000): Promise<void> {
await page.waitForTimeout(timeout);
}
/**
* Check if the Submit button is enabled (form is valid)
* @param page - Playwright page object
* @returns true if submit button is enabled
*/
export async function isSubmitEnabled(page: Page): Promise<boolean> {
const submitButton = page.locator('button:has-text("Submit Search")');
return !(await submitButton.isDisabled());
}
/**
* Attempt to submit and expect validation failure
* @param page - Playwright page object
* @param expectedError - Expected error message (partial match)
*/
export async function submitAndExpectError(page: Page, expectedError?: string): Promise<void> {
await page.locator('button:has-text("Submit Search")').click();
await waitForValidation(page);
expect(await hasValidationErrors(page), 'Expected validation errors after submit').toBe(true);
if (expectedError) {
await assertValidationError(page, expectedError);
}
}
/**
* Validation error message constants for common scenarios
*/
export const ValidationMessages = {
SEARCH_NAME_REQUIRED: 'Search name is required',
SEARCH_TYPE_REQUIRED: 'Search type is required',
MIN_DATE_REQUIRED: 'Minimum date is required',
MAX_DATE_REQUIRED: 'Maximum date is required',
INVALID_DATE_RANGE: 'Minimum date must be before or equal to maximum date',
WORK_ORDER_REQUIRED: 'At least one work order is required',
PROFIT_CENTER_REQUIRED: 'At least one profit center is required',
WORK_CENTER_REQUIRED: 'At least one work center is required',
OPERATOR_REQUIRED: 'At least one operator is required',
ITEM_NUMBER_REQUIRED: 'At least one item number is required',
COMPONENT_LOT_REQUIRED: 'At least one component lot is required',
PART_OPERATION_REQUIRED: 'At least one part operation is required',
} as const;