@rickcedwhat/playwright-smart-table
v2.3.1
Published
A smart table utility for Playwright with built-in pagination strategies that are fully extensible.
Downloads
2,525
Maintainers
Readme
Playwright Smart Table 🧠
A production-ready, type-safe table wrapper for Playwright that abstracts away the complexity of testing dynamic web tables. Handles pagination, infinite scroll, virtualization, and data grids (MUI, AG-Grid) so your tests remain clean and readable.
📦 Installation
npm install @rickcedwhat/playwright-smart-tableNote: Requires
@playwright/testas a peer dependency.
🎯 Getting Started
Step 1: Basic Table Interaction
For standard HTML tables (<table>, <tr>, <td>), the library works out of the box with sensible defaults:
// Example from: https://datatables.net/examples/data_sources/dom
const table = useTable(page.locator('#example'), {
headerSelector: 'thead th' // Override for this specific site
});
// Find the row with Name="Airi Satou", then get the Position cell
const row = await table.getByRow({ Name: 'Airi Satou' });
await expect(row.getCell('Position')).toHaveText('Accountant');What's happening here?
useTable()creates a smart table wrapper around your table locatorgetByRow()finds a specific row by column values- The returned
SmartRowknows its column structure, so.getCell('Position')works directly
Step 2: Understanding SmartRow
The SmartRow is the core power of this library. Unlike a standard Playwright Locator, it understands your table's column structure.
// Example from: https://datatables.net/examples/data_sources/dom
// Get SmartRow via getByRow
const row = await table.getByRow({ Name: 'Airi Satou' });
// Interact with cell using column name (resilient to column reordering)
await row.getCell('Position').click();
// Extract row data as JSON
const data = await row.toJSON();
console.log(data);
// { Name: "Airi Satou", Position: "Accountant", ... }Key Benefits:
- ✅ Column names instead of indices (survives column reordering)
- ✅ Extends Playwright's
LocatorAPI (all.click(),.isVisible(), etc. work) - ✅
.toJSON()for quick data extraction
🔧 Configuration & Advanced Scenarios
Working with Paginated Tables
For tables that span multiple pages, configure a pagination strategy:
// Example from: https://datatables.net/examples/data_sources/dom
const table = useTable(page.locator('#example'), {
rowSelector: 'tbody tr',
headerSelector: 'thead th',
cellSelector: 'td',
// Strategy: Tell it how to find the next page
pagination: TableStrategies.clickNext(() =>
page.getByRole('link', { name: 'Next' })
),
maxPages: 5 // Allow scanning up to 5 pages
});
// ✅ Verify Colleen is NOT visible initially
await expect(page.getByText("Colleen Hurst")).not.toBeVisible();
await expect(await table.getByRow({ Name: "Colleen Hurst" })).toBeVisible();
// NOTE: We're now on the page where Colleen Hurst exists (typically Page 2)Debug Mode
Enable debug logging to see exactly what the library is doing:
// Example from: https://datatables.net/examples/data_sources/dom
const table = useTable(page.locator('#example'), {
headerSelector: 'thead th',
debug: true // Enables verbose logging of internal operations
});
const row = await table.getByRow({ Name: 'Airi Satou' });
await expect(row).toBeVisible();This will log header mappings, row scans, and pagination triggers to help troubleshoot issues.
Resetting Table State
If your tests navigate deep into a paginated table, use .reset() to return to the first page:
// Example from: https://datatables.net/examples/data_sources/dom
// Navigate deep into the table by searching for a row on a later page
try {
await table.getByRow({ Name: 'Angelica Ramos' });
} catch (e) {}
// Reset internal state (and potentially UI) to Page 1
await table.reset();
// Now subsequent searches start from the beginning
const firstPageRow = await table.getByRow({ Name: 'Airi Satou' });
await expect(firstPageRow).toBeVisible();Column Scanning
Efficiently extract all values from a specific column:
// Example from: https://datatables.net/examples/data_sources/dom
// Quickly grab all text values from the "Office" column
const offices = await table.getColumnValues('Office');
expect(offices).toContain('Tokyo');
expect(offices.length).toBeGreaterThan(0);Filling Row Data
Use fill() to intelligently populate form fields in a table row. The method automatically detects input types (text inputs, selects, checkboxes, contenteditable divs) and fills them appropriately.
// Find a row and fill it with new data
const row = await table.getByRow({ ID: '1' });
await row.fill({
Name: 'John Updated',
Status: 'Inactive',
Active: false,
Notes: 'Updated notes here'
});
// Verify the values were filled correctly
await expect(row.getCell('Name').locator('input')).toHaveValue('John Updated');
await expect(row.getCell('Status').locator('select')).toHaveValue('Inactive');
await expect(row.getCell('Active').locator('input[type="checkbox"]')).not.toBeChecked();
await expect(row.getCell('Notes').locator('textarea')).toHaveValue('Updated notes here');Auto-detection supports:
- Text inputs (
input[type="text"],textarea) - Select dropdowns (
select) - Checkboxes/radios (
input[type="checkbox"],input[type="radio"],[role="checkbox"]) - Contenteditable divs (
[contenteditable="true"])
Custom input mappers:
For edge cases where auto-detection doesn't work (e.g., custom components, multiple inputs in a cell), use per-column mappers:
const row = await table.getByRow({ ID: '1' });
// Use custom input mappers for specific columns
await row.fill({
Name: 'John Updated',
Status: 'Inactive'
}, {
inputMappers: {
// Name column has multiple inputs - target the primary one
Name: (cell) => cell.locator('.primary-input'),
// Status uses standard select, but we could customize if needed
Status: (cell) => cell.locator('select')
}
});
// Verify the values
await expect(row.getCell('Name').locator('.primary-input')).toHaveValue('John Updated');
await expect(row.getCell('Status').locator('select')).toHaveValue('Inactive');Transforming Column Headers
Use headerTransformer to normalize or rename column headers. This is especially useful for tables with empty headers, inconsistent formatting, or when you want to use cleaner names in your tests.
Example 1: Renaming Empty Columns
Tables with empty header cells (like Material UI DataGrids) get auto-assigned names like __col_0, __col_1. Transform them to meaningful names:
// Example from: https://mui.com/material-ui/react-table/
const table = useTable(page.locator('.MuiDataGrid-root').first(), {
rowSelector: '.MuiDataGrid-row',
headerSelector: '.MuiDataGrid-columnHeader',
cellSelector: '.MuiDataGrid-cell',
pagination: TableStrategies.clickNext(
(root) => root.getByRole("button", { name: "Go to next page" })
),
maxPages: 5,
// Transform empty columns (detected as __col_0, __col_1, etc.) to meaningful names
headerTransformer: ({ text }) => {
// We know there is only one empty column which we will rename to "Actions" for easier reference
if (text.includes('__col_') || text.trim() === '') {
return 'Actions';
}
return text;
}
});
const headers = await table.getHeaders();
// Now we can reference the "Actions" column even if it has no header text
expect(headers).toContain('Actions');
// Use the renamed column
const row = await table.getByRow({ "Last name": "Melisandre" });
await row.getCell('Actions').getByLabel("Select row").click();Example 2: Normalizing Column Names
Clean up inconsistent column names (extra spaces, inconsistent casing):
// Example from: https://the-internet.herokuapp.com/tables
const table = useTable(page.locator('#table1'), {
// Normalize column names: remove extra spaces, handle inconsistent casing
headerTransformer: ({ text }) => {
return text.trim()
.replace(/\s+/g, ' ') // Normalize whitespace
.replace(/^\s*|\s*$/g, ''); // Remove leading/trailing spaces
}
});
// Now column names are consistent
const row = await table.getByRow({ "Last Name": "Doe" });
await expect(row.getCell("Email")).toHaveText("[email protected]");📖 API Reference
Table Methods
getByRow(filters, options?)
Purpose: Strict retrieval - finds exactly one row matching the filters.
Behavior:
- ✅ Returns
SmartRowif exactly one match - ❌ Throws error if multiple matches (ambiguous query)
- 👻 Returns sentinel locator if no match (allows
.not.toBeVisible()assertions) - 🔄 Auto-paginates if row isn't on current page (when
maxPages > 1and pagination strategy is configured)
Type Signature:
getByRow: <T extends { asJSON?: boolean }>(
filters: Record<string, string | RegExp | number>,
options?: { exact?: boolean, maxPages?: number } & T
) => Promise<T['asJSON'] extends true ? Record<string, string> : SmartRow>;// Example from: https://datatables.net/examples/data_sources/dom
// Find a row where Name is "Airi Satou" AND Office is "Tokyo"
const row = await table.getByRow({ Name: "Airi Satou", Office: "Tokyo" });
await expect(row).toBeVisible();
// Assert it does NOT exist
await expect(await table.getByRow({ Name: "Ghost User" })).not.toBeVisible();Get row data as JSON:
// Get row data directly as JSON object
const data = await table.getByRow({ Name: 'Airi Satou' }, { asJSON: true });
// Returns: { Name: "Airi Satou", Position: "Accountant", Office: "Tokyo", ... }
expect(data).toHaveProperty('Name', 'Airi Satou');
expect(data).toHaveProperty('Position');getAllRows(options?)
Purpose: Inclusive retrieval - gets all rows matching optional filters.
Best for: Checking existence, validating sort order, bulk data extraction.
Type Signature:
getAllRows: <T extends { asJSON?: boolean }>(
options?: { filter?: Record<string, any>, exact?: boolean } & T
) => Promise<T['asJSON'] extends true ? Record<string, string>[] : SmartRow[]>;// Example from: https://datatables.net/examples/data_sources/dom
// 1. Get ALL rows on the current page
const allRows = await table.getAllRows();
expect(allRows.length).toBeGreaterThan(0);
// 2. Get subset of rows (Filtering)
const tokyoUsers = await table.getAllRows({
filter: { Office: 'Tokyo' }
});
expect(tokyoUsers.length).toBeGreaterThan(0);
// 3. Dump data to JSON
const data = await table.getAllRows({ asJSON: true });
console.log(data); // [{ Name: "Airi Satou", ... }, ...]
expect(data.length).toBeGreaterThan(0);
expect(data[0]).toHaveProperty('Name');Filter rows with exact match:
// Get rows with exact match (default is fuzzy/contains match)
const exactMatches = await table.getAllRows({
filter: { Office: 'Tokyo' },
exact: true // Requires exact string match
});
expect(exactMatches.length).toBeGreaterThan(0);getColumnValues(column, options?)
Scans a specific column across all pages and returns values. Supports custom mappers for extracting non-text data.
Type Signature:
getColumnValues: <V = string>(
column: string,
options?: {
mapper?: (cell: Locator) => Promise<V> | V,
maxPages?: number
}
) => Promise<V[]>;Basic usage:
// Example from: https://datatables.net/examples/data_sources/dom
// Quickly grab all text values from the "Office" column
const offices = await table.getColumnValues('Office');
expect(offices).toContain('Tokyo');
expect(offices.length).toBeGreaterThan(0);With custom mapper:
// Extract numeric values from a column
const ages = await table.getColumnValues('Age', {
mapper: async (cell) => {
const text = await cell.innerText();
return parseInt(text, 10);
}
});
// Now ages is an array of numbers
expect(ages.every(age => typeof age === 'number')).toBe(true);
expect(ages.length).toBeGreaterThan(0);getHeaders()
Returns an array of all column names in the table.
Type Signature:
getHeaders: () => Promise<string[]>;getHeaderCell(columnName)
Returns a Playwright Locator for the specified header cell.
Type Signature:
getHeaderCell: (columnName: string) => Promise<Locator>;reset()
Resets table state (clears cache, pagination flags) and invokes the onReset strategy to return to the first page.
Type Signature:
reset: () => Promise<void>;🧩 Pagination Strategies
This library uses the Strategy Pattern for pagination. Use built-in strategies or write custom ones.
Built-in Strategies
TableStrategies.clickNext(selector)
Best for standard paginated tables (Datatables, lists). Clicks a button/link and waits for table content to change.
pagination: TableStrategies.clickNext((root) =>
root.page().getByRole('button', { name: 'Next' })
)TableStrategies.infiniteScroll()
Best for virtualized grids (AG-Grid, HTMX). Aggressively scrolls to trigger data loading.
pagination: TableStrategies.infiniteScroll()TableStrategies.clickLoadMore(selector)
Best for "Load More" buttons. Clicks and waits for row count to increase.
pagination: TableStrategies.clickLoadMore((root) =>
root.getByRole('button', { name: 'Load More' })
)Custom Strategies
A pagination strategy is a function that receives a TableContext and returns Promise<boolean> (true if more data loaded, false if no more pages):
export type PaginationStrategy = (context: TableContext) => Promise<boolean>;export interface TableContext {
root: Locator;
config: FinalTableConfig;
page: Page;
resolve: (selector: Selector, parent: Locator | Page) => Locator;
}🛠️ Developer Tools
generateConfigPrompt(options?)
Generates a prompt you can paste into ChatGPT/Gemini to automatically generate the TableConfig for your specific HTML.
await table.generateConfigPrompt({ output: 'console' });generateStrategyPrompt(options?)
Generates a prompt to help you write a custom pagination strategy.
await table.generateStrategyPrompt({ output: 'console' });Options:
export interface PromptOptions {
/**
* Output Strategy:
* - 'error': Throws an error with the prompt (useful for platforms that capture error output cleanly).
* - 'console': Standard console logs (Default).
*/
output?: 'console' | 'error';
includeTypes?: boolean;
}📚 Type Reference
Core Types
SmartRow
A SmartRow extends Playwright's Locator with table-aware methods.
export type SmartRow = Omit<Locator, 'fill'> & {
getCell(column: string): Locator;
toJSON(): Promise<Record<string, string>>;
/**
* Fills the row with data. Automatically detects input types (text input, select, checkbox, etc.).
*/
fill: (data: Record<string, any>, options?: FillOptions) => Promise<void>;
};Methods:
getCell(column: string): Returns aLocatorfor the specified cell in this rowtoJSON(): Extracts all cell data as a key-value objectfill(data, options?): Intelligently fills form fields in the row. Automatically detects input types or useinputMappersfor custom control
All standard Playwright Locator methods (.click(), .isVisible(), .textContent(), etc.) are also available.
TableConfig
Configuration options for useTable().
export interface TableConfig {
rowSelector?: Selector;
headerSelector?: Selector;
cellSelector?: Selector;
pagination?: PaginationStrategy;
sorting?: SortingStrategy;
maxPages?: number;
/**
* Hook to rename columns dynamically.
* * @param args.text - The default innerText of the header.
* @param args.index - The column index.
* @param args.locator - The specific header cell locator.
*/
headerTransformer?: (args: { text: string, index: number, locator: Locator }) => string | Promise<string>;
autoScroll?: boolean;
/**
* Enable debug mode to log internal state to console.
*/
debug?: boolean;
/**
* Strategy to reset the table to the first page.
* Called when table.reset() is invoked.
*/
onReset?: (context: TableContext) => Promise<void>;
}Property Descriptions:
rowSelector: CSS selector or function for table rows (default:"tbody tr")headerSelector: CSS selector or function for header cells (default:"th")cellSelector: CSS selector or function for data cells (default:"td")pagination: Strategy function for navigating pages (default: no pagination)maxPages: Maximum pages to scan when searching (default:1)headerTransformer: Function to transform/rename column headers dynamicallyautoScroll: Automatically scroll table into view (default:true)debug: Enable verbose logging (default:false)onReset: Strategy called whentable.reset()is invoked
Selector
Flexible selector type supporting strings, functions, or existing locators.
export type Selector = string | ((root: Locator | Page) => Locator);Examples:
// String selector
rowSelector: 'tbody tr'
// Function selector (useful for complex cases)
rowSelector: (root) => root.locator('[role="row"]')
// Can also accept a Locator directlyPaginationStrategy
Function signature for custom pagination logic.
export type PaginationStrategy = (context: TableContext) => Promise<boolean>;Returns true if more data was loaded, false if pagination should stop.
🚀 Tips & Best Practices
- Start Simple: Try the defaults first - they work for most standard HTML tables
- Use Debug Mode: When troubleshooting, enable
debug: trueto see what the library is doing - Leverage SmartRow: Use
.getCell()instead of manual column indices - your tests will be more maintainable - Type Safety: All methods are fully typed - use TypeScript for the best experience
- Pagination Strategies: Create reusable strategies for tables with similar pagination patterns
📝 License
ISC
