08ea5fbe98
添加用户管理视图、API和状态管理文件
623 lines
20 KiB
TypeScript
623 lines
20 KiB
TypeScript
import { Page, Locator } from '@playwright/test';
|
|
import { testLogger } from '../shared/utils/test-logger';
|
|
|
|
export interface TableColumn {
|
|
name: string;
|
|
selector: string;
|
|
type: 'text' | 'number' | 'date' | 'boolean' | 'action';
|
|
sortable?: boolean;
|
|
filterable?: boolean;
|
|
}
|
|
|
|
export interface TableRow {
|
|
index: number;
|
|
data: Record<string, string>;
|
|
cells: Map<string, Locator>;
|
|
}
|
|
|
|
export interface TableOperation {
|
|
name: string;
|
|
selector: string;
|
|
type: 'edit' | 'delete' | 'view' | 'custom';
|
|
}
|
|
|
|
export class TableHelper {
|
|
private page: Page;
|
|
private tableSelector: string;
|
|
private columns: Map<string, TableColumn> = new Map();
|
|
private operations: Map<string, TableOperation> = new Map();
|
|
|
|
constructor(page: Page, tableSelector: string = 'table') {
|
|
this.page = page;
|
|
this.tableSelector = tableSelector;
|
|
testLogger.info(`TableHelper initialized for table: ${tableSelector}`);
|
|
}
|
|
|
|
setColumn(column: TableColumn): void {
|
|
this.columns.set(column.name, column);
|
|
testLogger.debug(`Column added: ${column.name}`);
|
|
}
|
|
|
|
setColumns(columns: TableColumn[]): void {
|
|
columns.forEach(column => this.setColumn(column));
|
|
testLogger.debug(`${columns.length} columns added`);
|
|
}
|
|
|
|
setOperation(operation: TableOperation): void {
|
|
this.operations.set(operation.name, operation);
|
|
testLogger.debug(`Operation added: ${operation.name}`);
|
|
}
|
|
|
|
setOperations(operations: TableOperation[]): void {
|
|
operations.forEach(operation => this.setOperation(operation));
|
|
testLogger.debug(`${operations.length} operations added`);
|
|
}
|
|
|
|
getColumn(name: string): TableColumn | undefined {
|
|
return this.columns.get(name);
|
|
}
|
|
|
|
getAllColumns(): TableColumn[] {
|
|
return Array.from(this.columns.values());
|
|
}
|
|
|
|
async getRowCount(): Promise<number> {
|
|
testLogger.info('Getting row count');
|
|
|
|
const rowsLocator = this.page.locator(`${this.tableSelector} tbody tr, ${this.tableSelector} .table-row`);
|
|
const count = await rowsLocator.count();
|
|
|
|
testLogger.debug(`Row count: ${count}`);
|
|
return count;
|
|
}
|
|
|
|
async getColumnCount(): Promise<number> {
|
|
testLogger.info('Getting column count');
|
|
|
|
const headersLocator = this.page.locator(`${this.tableSelector} thead th, ${this.tableSelector} .table-header th`);
|
|
const count = await headersLocator.count();
|
|
|
|
testLogger.debug(`Column count: ${count}`);
|
|
return count;
|
|
}
|
|
|
|
async getRow(index: number): Promise<TableRow> {
|
|
testLogger.info(`Getting row: ${index}`);
|
|
|
|
const rowLocator = this.page.locator(`${this.tableSelector} tbody tr, ${this.tableSelector} .table-row`).nth(index);
|
|
const cells: Map<string, Locator> = new Map();
|
|
const data: Record<string, string> = {};
|
|
|
|
const columnEntries = Array.from(this.columns.entries());
|
|
for (const [columnName, column] of columnEntries) {
|
|
const cellSelector = `td:nth-child(${this.getColumnIndex(columnName) + 1}), .table-cell:nth-child(${this.getColumnIndex(columnName) + 1})`;
|
|
const cellLocator = rowLocator.locator(cellSelector);
|
|
|
|
cells.set(columnName, cellLocator);
|
|
|
|
let cellValue: string;
|
|
|
|
switch (column.type) {
|
|
case 'text':
|
|
cellValue = await cellLocator.textContent() || '';
|
|
break;
|
|
case 'number':
|
|
cellValue = await cellLocator.textContent() || '0';
|
|
break;
|
|
case 'date':
|
|
cellValue = await cellLocator.textContent() || '';
|
|
break;
|
|
case 'boolean':
|
|
const checkboxLocator = cellLocator.locator('input[type="checkbox"]');
|
|
cellValue = String(await checkboxLocator.isChecked());
|
|
break;
|
|
case 'action':
|
|
cellValue = 'action';
|
|
break;
|
|
default:
|
|
cellValue = await cellLocator.textContent() || '';
|
|
}
|
|
|
|
data[columnName] = cellValue.trim();
|
|
}
|
|
|
|
const row: TableRow = {
|
|
index,
|
|
data,
|
|
cells
|
|
};
|
|
|
|
testLogger.debug(`Row retrieved: ${index}`);
|
|
return row;
|
|
}
|
|
|
|
async getAllRows(): Promise<TableRow[]> {
|
|
testLogger.info('Getting all rows');
|
|
|
|
const rowCount = await this.getRowCount();
|
|
const rows: TableRow[] = [];
|
|
|
|
for (let i = 0; i < rowCount; i++) {
|
|
rows.push(await this.getRow(i));
|
|
}
|
|
|
|
testLogger.info(`All rows retrieved: ${rows.length}`);
|
|
return rows;
|
|
}
|
|
|
|
async getTableData(): Promise<Record<string, string>[]> {
|
|
testLogger.info('Getting table data');
|
|
|
|
const rows = await this.getAllRows();
|
|
const data: Record<string, string>[] = rows.map(row => row.data);
|
|
|
|
testLogger.debug(`Table data retrieved: ${JSON.stringify(data)}`);
|
|
return data;
|
|
}
|
|
|
|
async findRowByColumn(columnName: string, value: string): Promise<TableRow | undefined> {
|
|
testLogger.info(`Finding row by column: ${columnName} = ${value}`);
|
|
|
|
const rows = await this.getAllRows();
|
|
const foundRow = rows.find(row => row.data[columnName] === value);
|
|
|
|
if (foundRow) {
|
|
testLogger.info(`Row found: ${foundRow.index}`);
|
|
} else {
|
|
testLogger.warn(`Row not found: ${columnName} = ${value}`);
|
|
}
|
|
|
|
return foundRow;
|
|
}
|
|
|
|
async findRowsByColumn(columnName: string, value: string): Promise<TableRow[]> {
|
|
testLogger.info(`Finding rows by column: ${columnName} = ${value}`);
|
|
|
|
const rows = await this.getAllRows();
|
|
const foundRows = rows.filter(row => row.data[columnName] === value);
|
|
|
|
testLogger.info(`Rows found: ${foundRows.length}`);
|
|
return foundRows;
|
|
}
|
|
|
|
async filterByColumn(columnName: string, value: string): Promise<void> {
|
|
testLogger.info(`Filtering by column: ${columnName} = ${value}`);
|
|
|
|
const column = this.columns.get(columnName);
|
|
|
|
if (!column) {
|
|
throw new Error(`Column not found: ${columnName}`);
|
|
}
|
|
|
|
if (!column.filterable) {
|
|
throw new Error(`Column is not filterable: ${columnName}`);
|
|
}
|
|
|
|
const filterSelector = `${this.tableSelector} thead th:nth-child(${this.getColumnIndex(columnName) + 1}) .filter-input, ${this.tableSelector} .table-header th:nth-child(${this.getColumnIndex(columnName) + 1}) .filter-input`;
|
|
const filterLocator = this.page.locator(filterSelector);
|
|
|
|
await filterLocator.waitFor({ state: 'visible' });
|
|
await filterLocator.fill(value);
|
|
|
|
await this.page.waitForTimeout(500);
|
|
|
|
testLogger.info(`Filter applied: ${columnName} = ${value}`);
|
|
}
|
|
|
|
async clearFilter(columnName: string): Promise<void> {
|
|
testLogger.info(`Clearing filter: ${columnName}`);
|
|
|
|
const column = this.columns.get(columnName);
|
|
|
|
if (!column) {
|
|
throw new Error(`Column not found: ${columnName}`);
|
|
}
|
|
|
|
if (!column.filterable) {
|
|
throw new Error(`Column is not filterable: ${columnName}`);
|
|
}
|
|
|
|
const filterSelector = `${this.tableSelector} thead th:nth-child(${this.getColumnIndex(columnName) + 1}) .filter-input, ${this.tableSelector} .table-header th:nth-child(${this.getColumnIndex(columnName) + 1}) .filter-input`;
|
|
const filterLocator = this.page.locator(filterSelector);
|
|
|
|
await filterLocator.clear();
|
|
|
|
await this.page.waitForTimeout(500);
|
|
|
|
testLogger.info(`Filter cleared: ${columnName}`);
|
|
}
|
|
|
|
async clearAllFilters(): Promise<void> {
|
|
testLogger.info('Clearing all filters');
|
|
|
|
const columnKeys = Array.from(this.columns.keys());
|
|
for (const columnName of columnKeys) {
|
|
const column = this.columns.get(columnName);
|
|
|
|
if (column?.filterable) {
|
|
try {
|
|
await this.clearFilter(columnName);
|
|
} catch (error) {
|
|
const errorObj = error instanceof Error ? error : new Error(String(error));
|
|
testLogger.warn(`Failed to clear filter: ${columnName}`, { error: errorObj.message });
|
|
}
|
|
}
|
|
}
|
|
|
|
testLogger.info('All filters cleared');
|
|
}
|
|
|
|
async sortByColumn(columnName: string, order: 'asc' | 'desc' = 'asc'): Promise<void> {
|
|
testLogger.info(`Sorting by column: ${columnName} (${order})`);
|
|
|
|
const column = this.columns.get(columnName);
|
|
|
|
if (!column) {
|
|
throw new Error(`Column not found: ${columnName}`);
|
|
}
|
|
|
|
if (!column.sortable) {
|
|
throw new Error(`Column is not sortable: ${columnName}`);
|
|
}
|
|
|
|
const sortSelector = `${this.tableSelector} thead th:nth-child(${this.getColumnIndex(columnName) + 1}) .sort-icon, ${this.tableSelector} .table-header th:nth-child(${this.getColumnIndex(columnName) + 1}) .sort-icon`;
|
|
const sortLocator = this.page.locator(sortSelector);
|
|
|
|
await sortLocator.waitFor({ state: 'visible' });
|
|
await sortLocator.click();
|
|
|
|
await this.page.waitForTimeout(500);
|
|
|
|
const currentOrder = await this.getSortOrder(columnName);
|
|
if (currentOrder !== order) {
|
|
await sortLocator.click();
|
|
await this.page.waitForTimeout(500);
|
|
}
|
|
|
|
testLogger.info(`Sorted by: ${columnName} (${order})`);
|
|
}
|
|
|
|
async getSortOrder(columnName: string): Promise<'asc' | 'desc' | null> {
|
|
const column = this.columns.get(columnName);
|
|
|
|
if (!column) {
|
|
throw new Error(`Column not found: ${columnName}`);
|
|
}
|
|
|
|
const sortSelector = `${this.tableSelector} thead th:nth-child(${this.getColumnIndex(columnName) + 1})`;
|
|
const sortLocator = this.page.locator(sortSelector);
|
|
|
|
const classList = await sortLocator.getAttribute('class') || '';
|
|
|
|
if (classList.includes('asc')) {
|
|
return 'asc';
|
|
} else if (classList.includes('desc')) {
|
|
return 'desc';
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
async clickRow(index: number): Promise<void> {
|
|
testLogger.info(`Clicking row: ${index}`);
|
|
|
|
const rowLocator = this.page.locator(`${this.tableSelector} tbody tr, ${this.tableSelector} .table-row`).nth(index);
|
|
|
|
await rowLocator.waitFor({ state: 'visible' });
|
|
await rowLocator.click();
|
|
|
|
testLogger.info(`Row clicked: ${index}`);
|
|
}
|
|
|
|
async clickRowByColumn(columnName: string, value: string): Promise<void> {
|
|
testLogger.info(`Clicking row by column: ${columnName} = ${value}`);
|
|
|
|
const row = await this.findRowByColumn(columnName, value);
|
|
|
|
if (!row) {
|
|
throw new Error(`Row not found: ${columnName} = ${value}`);
|
|
}
|
|
|
|
const rowLocator = this.page.locator(`${this.tableSelector} tbody tr, ${this.tableSelector} .table-row`).nth(row.index);
|
|
|
|
await rowLocator.click();
|
|
|
|
testLogger.info(`Row clicked: ${row.index}`);
|
|
}
|
|
|
|
async performOperation(rowIndex: number, operationName: string): Promise<void> {
|
|
testLogger.info(`Performing operation: ${operationName} on row: ${rowIndex}`);
|
|
|
|
const operation = this.operations.get(operationName);
|
|
|
|
if (!operation) {
|
|
throw new Error(`Operation not found: ${operationName}`);
|
|
}
|
|
|
|
const rowLocator = this.page.locator(`${this.tableSelector} tbody tr, ${this.tableSelector} .table-row`).nth(rowIndex);
|
|
const operationLocator = rowLocator.locator(operation.selector);
|
|
|
|
await operationLocator.waitFor({ state: 'visible' });
|
|
await operationLocator.click();
|
|
|
|
testLogger.info(`Operation performed: ${operationName} on row: ${rowIndex}`);
|
|
}
|
|
|
|
async performOperationByColumn(columnName: string, value: string, operationName: string): Promise<void> {
|
|
testLogger.info(`Performing operation: ${operationName} on row: ${columnName} = ${value}`);
|
|
|
|
const row = await this.findRowByColumn(columnName, value);
|
|
|
|
if (!row) {
|
|
throw new Error(`Row not found: ${columnName} = ${value}`);
|
|
}
|
|
|
|
await this.performOperation(row.index, operationName);
|
|
}
|
|
|
|
async editRow(rowIndex: number): Promise<void> {
|
|
testLogger.info(`Editing row: ${rowIndex}`);
|
|
|
|
const editOperation = this.operations.get('edit');
|
|
|
|
if (!editOperation) {
|
|
throw new Error('Edit operation not found');
|
|
}
|
|
|
|
await this.performOperation(rowIndex, 'edit');
|
|
|
|
testLogger.info(`Row edited: ${rowIndex}`);
|
|
}
|
|
|
|
async editRowByColumn(columnName: string, value: string): Promise<void> {
|
|
testLogger.info(`Editing row by column: ${columnName} = ${value}`);
|
|
|
|
const row = await this.findRowByColumn(columnName, value);
|
|
|
|
if (!row) {
|
|
throw new Error(`Row not found: ${columnName} = ${value}`);
|
|
}
|
|
|
|
await this.editRow(row.index);
|
|
}
|
|
|
|
async deleteRow(rowIndex: number): Promise<void> {
|
|
testLogger.info(`Deleting row: ${rowIndex}`);
|
|
|
|
const deleteOperation = this.operations.get('delete');
|
|
|
|
if (!deleteOperation) {
|
|
throw new Error('Delete operation not found');
|
|
}
|
|
|
|
await this.performOperation(rowIndex, 'delete');
|
|
|
|
testLogger.info(`Row deleted: ${rowIndex}`);
|
|
}
|
|
|
|
async deleteRowByColumn(columnName: string, value: string): Promise<void> {
|
|
testLogger.info(`Deleting row by column: ${columnName} = ${value}`);
|
|
|
|
const row = await this.findRowByColumn(columnName, value);
|
|
|
|
if (!row) {
|
|
throw new Error(`Row not found: ${columnName} = ${value}`);
|
|
}
|
|
|
|
await this.deleteRow(row.index);
|
|
}
|
|
|
|
async viewRow(rowIndex: number): Promise<void> {
|
|
testLogger.info(`Viewing row: ${rowIndex}`);
|
|
|
|
const viewOperation = this.operations.get('view');
|
|
|
|
if (!viewOperation) {
|
|
throw new Error('View operation not found');
|
|
}
|
|
|
|
await this.performOperation(rowIndex, 'view');
|
|
|
|
testLogger.info(`Row viewed: ${rowIndex}`);
|
|
}
|
|
|
|
async viewRowByColumn(columnName: string, value: string): Promise<void> {
|
|
testLogger.info(`Viewing row by column: ${columnName} = ${value}`);
|
|
|
|
const row = await this.findRowByColumn(columnName, value);
|
|
|
|
if (!row) {
|
|
throw new Error(`Row not found: ${columnName} = ${value}`);
|
|
}
|
|
|
|
await this.viewRow(row.index);
|
|
}
|
|
|
|
async selectRow(rowIndex: number): Promise<void> {
|
|
testLogger.info(`Selecting row: ${rowIndex}`);
|
|
|
|
const rowLocator = this.page.locator(`${this.tableSelector} tbody tr, ${this.tableSelector} .table-row`).nth(rowIndex);
|
|
const checkboxLocator = rowLocator.locator('input[type="checkbox"]');
|
|
|
|
await checkboxLocator.waitFor({ state: 'visible' });
|
|
await checkboxLocator.check();
|
|
|
|
testLogger.info(`Row selected: ${rowIndex}`);
|
|
}
|
|
|
|
async selectRowByColumn(columnName: string, value: string): Promise<void> {
|
|
testLogger.info(`Selecting row by column: ${columnName} = ${value}`);
|
|
|
|
const row = await this.findRowByColumn(columnName, value);
|
|
|
|
if (!row) {
|
|
throw new Error(`Row not found: ${columnName} = ${value}`);
|
|
}
|
|
|
|
await this.selectRow(row.index);
|
|
}
|
|
|
|
async selectAllRows(): Promise<void> {
|
|
testLogger.info('Selecting all rows');
|
|
|
|
const selectAllLocator = this.page.locator(`${this.tableSelector} thead input[type="checkbox"], ${this.tableSelector} .table-header input[type="checkbox"]`);
|
|
|
|
await selectAllLocator.waitFor({ state: 'visible' });
|
|
await selectAllLocator.check();
|
|
|
|
testLogger.info('All rows selected');
|
|
}
|
|
|
|
async deselectRow(rowIndex: number): Promise<void> {
|
|
testLogger.info(`Deselecting row: ${rowIndex}`);
|
|
|
|
const rowLocator = this.page.locator(`${this.tableSelector} tbody tr, ${this.tableSelector} .table-row`).nth(rowIndex);
|
|
const checkboxLocator = rowLocator.locator('input[type="checkbox"]');
|
|
|
|
await checkboxLocator.waitFor({ state: 'visible' });
|
|
await checkboxLocator.uncheck();
|
|
|
|
testLogger.info(`Row deselected: ${rowIndex}`);
|
|
}
|
|
|
|
async deselectAllRows(): Promise<void> {
|
|
testLogger.info('Deselecting all rows');
|
|
|
|
const selectAllLocator = this.page.locator(`${this.tableSelector} thead input[type="checkbox"], ${this.tableSelector} .table-header input[type="checkbox"]`);
|
|
|
|
await selectAllLocator.waitFor({ state: 'visible' });
|
|
await selectAllLocator.uncheck();
|
|
|
|
testLogger.info('All rows deselected');
|
|
}
|
|
|
|
async getSelectedRows(): Promise<TableRow[]> {
|
|
testLogger.info('Getting selected rows');
|
|
|
|
const rows = await this.getAllRows();
|
|
const selectedRows: TableRow[] = [];
|
|
|
|
for (const row of rows) {
|
|
const checkboxLocator = this.page.locator(`${this.tableSelector} tbody tr:nth-child(${row.index + 1}) input[type="checkbox"], ${this.tableSelector} .table-row:nth-child(${row.index + 1}) input[type="checkbox"]`);
|
|
|
|
if (await checkboxLocator.isChecked()) {
|
|
selectedRows.push(row);
|
|
}
|
|
}
|
|
|
|
testLogger.info(`Selected rows: ${selectedRows.length}`);
|
|
return selectedRows;
|
|
}
|
|
|
|
async goToPage(pageNumber: number): Promise<void> {
|
|
testLogger.info(`Going to page: ${pageNumber}`);
|
|
|
|
const pageSelector = `${this.tableSelector} .pagination .page-item[data-page="${pageNumber}"], ${this.tableSelector} .pagination button[data-page="${pageNumber}"]`;
|
|
const pageLocator = this.page.locator(pageSelector);
|
|
|
|
await pageLocator.waitFor({ state: 'visible' });
|
|
await pageLocator.click();
|
|
|
|
await this.page.waitForTimeout(500);
|
|
|
|
testLogger.info(`Page changed: ${pageNumber}`);
|
|
}
|
|
|
|
async nextPage(): Promise<void> {
|
|
testLogger.info('Going to next page');
|
|
|
|
const nextSelector = `${this.tableSelector} .pagination .next, ${this.tableSelector} .pagination button[aria-label="Next"]`;
|
|
const nextLocator = this.page.locator(nextSelector);
|
|
|
|
await nextLocator.waitFor({ state: 'visible' });
|
|
await nextLocator.click();
|
|
|
|
await this.page.waitForTimeout(500);
|
|
|
|
testLogger.info('Next page loaded');
|
|
}
|
|
|
|
async previousPage(): Promise<void> {
|
|
testLogger.info('Going to previous page');
|
|
|
|
const prevSelector = `${this.tableSelector} .pagination .prev, ${this.tableSelector} .pagination button[aria-label="Previous"]`;
|
|
const prevLocator = this.page.locator(prevSelector);
|
|
|
|
await prevLocator.waitFor({ state: 'visible' });
|
|
await prevLocator.click();
|
|
|
|
await this.page.waitForTimeout(500);
|
|
|
|
testLogger.info('Previous page loaded');
|
|
}
|
|
|
|
async getCurrentPage(): Promise<number> {
|
|
testLogger.info('Getting current page');
|
|
|
|
const activePageSelector = `${this.tableSelector} .pagination .page-item.active, ${this.tableSelector} .pagination button.active`;
|
|
const activePageLocator = this.page.locator(activePageSelector);
|
|
|
|
const pageNumber = await activePageLocator.getAttribute('data-page');
|
|
|
|
testLogger.debug(`Current page: ${pageNumber}`);
|
|
return parseInt(pageNumber || '1', 10);
|
|
}
|
|
|
|
async getTotalPages(): Promise<number> {
|
|
testLogger.info('Getting total pages');
|
|
|
|
const pagesSelector = `${this.tableSelector} .pagination .page-item, ${this.tableSelector} .pagination button`;
|
|
const pagesLocator = this.page.locator(pagesSelector);
|
|
|
|
const count = await pagesLocator.count();
|
|
|
|
testLogger.debug(`Total pages: ${count}`);
|
|
return count;
|
|
}
|
|
|
|
async waitForData(timeout: number = 5000): Promise<void> {
|
|
testLogger.info(`Waiting for table data (${timeout}ms)`);
|
|
|
|
const rowsLocator = this.page.locator(`${this.tableSelector} tbody tr, ${this.tableSelector} .table-row`).first();
|
|
|
|
await rowsLocator.waitFor({ state: 'visible', timeout });
|
|
|
|
testLogger.info('Table data loaded');
|
|
}
|
|
|
|
async isEmpty(): Promise<boolean> {
|
|
testLogger.info('Checking if table is empty');
|
|
|
|
const rowCount = await this.getRowCount();
|
|
const isEmpty = rowCount === 0;
|
|
|
|
testLogger.debug(`Table is empty: ${isEmpty}`);
|
|
return isEmpty;
|
|
}
|
|
|
|
async hasData(): Promise<boolean> {
|
|
const isEmpty = await this.isEmpty();
|
|
return !isEmpty;
|
|
}
|
|
|
|
async refresh(): Promise<void> {
|
|
testLogger.info('Refreshing table');
|
|
|
|
const refreshSelector = `${this.tableSelector} .refresh-button, ${this.tableSelector} button[aria-label="Refresh"]`;
|
|
const refreshLocator = this.page.locator(refreshSelector);
|
|
|
|
if (await refreshLocator.isVisible()) {
|
|
await refreshLocator.click();
|
|
await this.page.waitForTimeout(500);
|
|
testLogger.info('Table refreshed');
|
|
} else {
|
|
testLogger.warn('Refresh button not found');
|
|
}
|
|
}
|
|
|
|
private getColumnIndex(columnName: string): number {
|
|
const columns = Array.from(this.columns.keys());
|
|
return columns.indexOf(columnName);
|
|
}
|
|
}
|