A high-performance Playwright library for testing HTML tables with comprehensive support for complex table structures including colspan, rowspan, multiple headers, and dynamic content. Features robust error handling, parallel data fetching, and intuitive
npm install @cerios/playwright-table



Testing HTML tables can be challenging due to their complex structures with colspan, rowspan, dynamic content, and nested elements. This package simplifies the process by providing a comprehensive API to parse, validate, and interact with HTML tables in your Playwright tests.
Key Features:
- ✅ Full support for colspan and rowspan attributes --- First, ensure you have Playwright installed: Then, install this package: `` Or as a dev dependency: ` > ⚠️ Upgrading from v1.x? See the Migration Guide for breaking changes and upgrade instructions. --- ` // Create a table instance // Get table data as JSON // Get specific cell locator // Wait for dynamic content By default, the following CSS selectors are used: - Header rows: thead > tr You can customize these selectors in the constructor (see Advanced Options). --- Retrieves all header rows with support for colspan and rowspan. Colspan cells get a suffix (__C1 Parameters: - options?: HeaderRowOptions Returns: Promise Example: HTML with complex headers ` const headers = await table.getHeaderRows({ Options: ` --- Retrieves the main header row. By default, uses the last header row. Customizable via constructor. Parameters: - options?: HeaderRowOptions Returns: Promise Throws: - Error if no header rows are available Example: ` const mainHeader = await table.getMainHeaderRow(); --- Retrieves all body rows with support for rowspan and colspan. Parameters: - options?: { cellContentType?: CellContentType } Returns: Promise Throws: Error if no body rows found Example: ` Cell Content Types: - CellContentType.InnerText --- Gets a Playwright Locator for a specific cell by row and column indices (0-based). Parameters: - rowNumber: number Returns: Locator Example: ` --- Finds a cell by matching row conditions and returns the locator for a specific column. Supports both object syntax (multiple conditions) and tuple syntax (single condition). Parameters: - conditions: Record Returns: Promise Throws: - Error if no matching row found Example: ` // Find cell with single condition (tuple syntax) // Find cell with single condition (object syntax also works) --- Gets all cell locators in a column by header name. Parameters: - headerName: string Returns: Promise Throws: Error if header not found Example: ` // Extract all values from "Name" column --- Gets all cell locators in a column by column index (0-based). Parameters: - headerIndex: number Returns: Promise Throws: Error if index out of bounds Example: ` // Verify all cells are visible --- Converts the entire table to a JSON array of objects. Headers become object keys, values are cell contents. Parameters: - options?: TableOptions Returns: Promise Throws: Error if no headers or body rows found Simple Example: ` const json = await table.getJson(); Complex Example with Options: ` --- Waits for the table body to be completely empty (no body rows). > 💡 Tip: Consider using expect.poll Parameters: - options?: PollingOptions Returns: Promise Throws: Error if table is not empty within timeout Example: ` // Wait for table to clear // Verify empty state --- Waits for the table body to have at least one body row with actual content. A row is considered valid if it has at least one cell containing text. > 💡 Tip: Consider using expect.poll Parameters: - options?: PollingOptions Returns: Promise Throws: - Error if table remains empty (no rows) within timeout Example: ` // Wait for table to populate with actual data // Now safe to process data - guaranteed to have content // Useful for dynamically loaded tables where structure appears before data --- Waits for the table to have exactly the specified number of body rows. > 💡 Tip: Consider using expect.poll Parameters: - count: number Returns: Promise Throws: - Error if row count doesn't match within timeout Example: ` // Verify table has exactly 0 rows (alternative to waitForEmpty) // Wait after pagination change // Verify filtered results Use Cases: - Verifying exact result counts after filtering --- Gets the count of header and body rows in the table. Lightweight method that doesn't fetch cell data, only counts rows. Parameters: None Returns: Promise<{ header: number; body: number }> Example: ` // Assert specific row count // Check if table is empty efficiently Use Cases: - Quick validation of table size without fetching data --- Gets all distinct (unique) values from a specific column. Returns a sorted array of unique string values, excluding empty values. Parameters: - headerName: string Returns: Promise Throws: Error if the specified header is not found in the table Example: ` // Get distinct countries for filtering // Verify expected values exist // Build dynamic filters from table data Use Cases: - Extracting filter options from table data --- Finds the index of the first body row matching the specified conditions. Returns -1 if no matching row is found. Parameters: - conditions: Record Returns: Promise Throws: Error if specified headers don't exist in the table Example: ` // Find row and interact with it // Check if specific data exists before taking action Use Cases: - Finding row position for interaction without fetching all data --- Customize selectors for non-standard table structures or div-based tables: ` ` Control how headers are processed: ` --- The library provides detailed error messages with context to help you debug issues: ` Error Context Includes: - Locator descriptions with full selector chains --- ` // ✅ Good - wait for content first // ✅ Good - wait for specific row count // ✅ Good - use expect.poll for flexible waiting While this library provides dedicated wait methods, Playwright's expect.poll ` // Wait for exact row count // Wait for table to be empty // Wait for at least N rows // Wait for specific data to appear // Wait for column to have specific value // Custom timeout and intervals Benefits of expect.poll - ✅ Native Playwright API with familiar assertion syntax ` // For all text including hidden elements ` ` --- Full TypeScript support with exported types: ` --- The library is optimized for performance: - ✅ Parallel data fetching - All cells in a row are fetched simultaneously --- Works with all Playwright-supported browsers: - ✅ Chromium --- Contributions are welcome! Please check out the GitHub repository. --- This project is licensed under the MIT License. See the LICENSE file for details. --- - 📖 Documentation --- Made with ❤️ by Cerios
- ✅ Handles multiple header rows with flexible main header selection
- ✅ Dynamic content loading with polling and stability checks
- ✅ Parallel data fetching for optimal performance
- ✅ Robust error handling with detailed context
- ✅ Support for both InnerText and TextContent extraction
- ✅ Works with standard elements and custom div-based tables
- ✅ Comprehensive waiting utilities for dynamic tablesInstallation
Playwright Documentationbash`
npm i @cerios/playwright-tablebash`
npm i -D @cerios/playwright-tableQuick Start
$3
ts
import { PlaywrightTable } from "@cerios/playwright-table";
const table = new PlaywrightTable(page.locator("table"));
const data = await table.getJson();
console.log(data);
// Output: [{"Name": "John", "Age": "30"}, {"Name": "Jane", "Age": "25"}]
const cell = table.getBodyCellLocator(0, 1); // Row 0, Column 1
await expect(cell).toHaveText("30");
await table.waitForExactRowCount(5, { timeout: 10000 });
`$3
th
- Header cells: tbody > tr
- Body rows: td
- Body cells: Core Methods
$3
, __C2, etc.). - Configure header parsing behavior - Array of header rows
Name
Details
Age
City
ts
// HTML with complex headers
Name
Details
Age
City
colspan: { enabled: true, suffix: true }
});
console.log(headers);
// Output: [
// ["Name", "Details", "Details__C1"],
// ["Name", "Age", "City"]
// ]
`ts`
{
cellContentType?: CellContentType.InnerText | CellContentType.TextContent, // Default: InnerText
emptyCellReplacement?: boolean, // Replace empty cells with "{{Empty}}" (default: false)
duplicateSuffix?: boolean, // Add suffix to duplicate headers (default: false)
colspan?: {
enabled?: boolean, // Enable colspan parsing (default: false)
suffix?: boolean // Add suffix to colspan cells (default: false)
}
}$3
- Configure header parsing - The main header row
- Error if setMainHeaderRow index is out of boundsts
// Use first row as main header
const table = new PlaywrightTable(page.locator("table"), {
header: { setMainHeaderRow: 0 },
});
console.log(mainHeader);
// Output: ["Name", "Age", "City"]
`$3
- Content extraction type - Array of body rowsts`
const rows = await table.getBodyRows({
cellContentType: CellContentType.TextContent, // Include hidden elements
});
console.log(rows);
// Output: [["John", "30", "NYC"], ["Jane", "25", "LA"]] - Rendered text (default, excludes hidden elements)CellContentType.TextContent
- - Raw text content (includes hidden elements)$3
- Row index (0-based)headerPosition: number
- - Column index (0-based) - Playwright locator for the cellts`
// Get cell at row 1, column 2
const cellLocator = table.getBodyCellLocator(1, 2);
await expect(cellLocator).toHaveText("Expected Value");
await cellLocator.click();$3
- Header-value pairs to match (object) or single [header, value] tupletargetHeader: string
- - Column name to retrieveoptions?: TableOptions
- - Table loading options - Cell locator
- Error if specified headers don't existts
// Find cell with multiple conditions (object syntax)
const emailCell = await table.getBodyCellLocatorByRowConditions({ "First name": "John", Status: "Active" }, "Email");
await expect(emailCell).toContainText("@example.com");
const actionCell = await table.getBodyCellLocatorByRowConditions(["Username", "john.doe"], "Actions");
await actionCell.locator("button.delete").click();
const statusCell = await table.getBodyCellLocatorByRowConditions({ Username: "john.doe" }, "Status");
`$3
- Column header nameoptions?: TableOptions
- - Table loading options - Array of cell locatorsts
// Get all cells in "Status" column
const statusCells = await table.getAllBodyCellLocatorsByHeaderName("Status");
for (const cell of statusCells) {
await expect(cell).toHaveText(/Active|Inactive/);
}
const names = await Promise.all(statusCells.map(cell => cell.textContent()));
console.log(names); // ["John", "Jane", "Bob"]
`$3
- Column index (0-based)options?: TableOptions
- - Table loading options - Array of cell locatorsts
// Get all cells in first column (index 0)
const firstColumnCells = await table.getAllBodyCellLocatorsByHeaderIndex(0);
const values = await Promise.all(firstColumnCells.map(c => c.textContent()));
console.log(values);
for (const cell of firstColumnCells) {
await expect(cell).toBeVisible();
}
`$3
- Configure parsing behavior - Array of row objects
Name Age
John 30
Jane 25
ts
// HTML:
Name Age
John 30
Jane 25
console.log(json);
// Output:
// [
// { "Name": "John", "Age": "30" },
// { "Name": "Jane", "Age": "25" }
// ]
`ts`
// HTML with colspan, rowspan, empty cells, and duplicates
const json = await table.getJson({
headerRowOptions: {
colspan: { enabled: true, suffix: true },
duplicateSuffix: true,
emptyCellReplacement: true,
},
});
// Output:
// [
// {
// "Awesome Rowspan": "Value1",
// "Name": "John",
// "Name__C1": "Doe",
// "{{Empty}}": "EmptyValue",
// "Duplicate": "First",
// "Duplicate__D1": "Second"
// }
// ]Waiting Methods
$3
for more flexibility: await expect.poll(async () => (await table.getBodyRows()).length).toBe(0);ts
// Click delete all button
await page.locator("button.delete-all").click();
await table.waitForEmpty({ timeout: 5000 });
const rows = await table.getBodyRows();
expect(rows).toHaveLength(0);
`$3
for more flexibility: await expect.poll(async () => (await table.getBodyRows()).length).toBeGreaterThan(0);
- Error if table has rows but all cells are empty within timeoutts
// Trigger data load
await page.locator("button.load-data").click();
await table.waitForNonEmpty({ timeout: 5000 });
const data = await table.getJson();
expect(data.length).toBeGreaterThan(0);
await searchInput.fill("search term");
await table.waitForNonEmpty(); // Waits for rows AND content, not just empty row elements
`$3
for more flexibility: await expect.poll(async () => (await table.getBodyRows()).length).toBe(10); - The exact number of rows expectedoptions?: PollingOptions
- - Polling options (timeout, interval)
- Error if count is negative or not an integerts
// Wait for table to have exactly 10 rows
await table.waitForExactRowCount(10, { timeout: 5000 });
await table.waitForExactRowCount(0);
await page.locator("button.page-2").click();
await table.waitForExactRowCount(25);
await page.locator("input.search").fill("admin");
await table.waitForExactRowCount(3); // Expect exactly 3 admin users
`
- Validating pagination behavior
- Testing data deletion/addition
- Ensuring precise table state for assertions$3
tsHeaders: ${counts.header}, Body: ${counts.body}
// Get current row counts
const counts = await table.getRowCount();
console.log();
const { body } = await table.getRowCount();
expect(body).toBe(10);
const rowCounts = await table.getRowCount();
if (rowCounts.body === 0) {
console.log("Table is empty");
}
`
- Performance-sensitive checks in loops
- Conditional logic based on row counts
- Monitoring table size during pagination$3
- The header name of the column to extract distinct values fromoptions?: TableOptions
- - Optional table loading options - Sorted array of distinct valuests
// Get all unique status values
const statuses = await table.getDistinctColumnValues("Status");
// Result: ["Active", "Inactive", "Pending"]
const countries = await table.getDistinctColumnValues("Country");
console.log(Available countries: ${countries.join(", ")});
const roles = await table.getDistinctColumnValues("Role");
expect(roles).toContain("Admin");
expect(roles).toHaveLength(3);
const categories = await table.getDistinctColumnValues("Category");
for (const category of categories) {
await page.locator(button[data-filter="${category}"]).click();`
// Verify filtering works
}
- Validating data variety and uniqueness
- Building dynamic test data sets
- Verifying dropdown/select options match table content$3
- Record of header names and expected cell values to matchoptions?: TableOptions
- - Optional table loading options - The 0-based index of the first matching row, or -1 if not foundtsFound at row ${index}
// Find index of row where Status is "Active"
const index = await table.findRowIndex({ Status: "Active" });
if (index >= 0) {
console.log();
const cell = table.getBodyCellLocator(index, 0);
await cell.click();
}
const rowIndex = await table.findRowIndex({ Username: "john.doe" });
if (rowIndex >= 0) {
const deleteButton = table.getBodyCellLocator(rowIndex, 3).locator("button.delete");
await deleteButton.click();
} else {
console.log("User not found");
}
const orderIndex = await table.findRowIndex({ "Order ID": "12345" });
if (orderIndex === -1) {
throw new Error("Order not found in table");
}
`
- Conditional logic based on row existence
- Getting row indices for further locator operations
- Checking data presence without waitingAdvanced Options
$3
ts`
const table = new PlaywrightTable(page.locator(".custom-table"), {
header: {
setMainHeaderRow: 0, // Use first header row as main (default: last row)
rowSelector: ".header-row", // Custom header row selector
columnSelector: ".header-cell", // Custom header cell selector
},
row: {
rowSelector: ".data-row", // Custom body row selector
columnSelector: ".data-cell", // Custom body cell selector
},
});$3
ts`
// Works with div-based table structures
const divTable = new PlaywrightTable(page.locator(".divTable"), {
header: {
rowSelector: ".divTableHeading > .divTableRow",
columnSelector: ".divTableHead",
},
row: {
rowSelector: ".divTableBody > .divTableRow",
columnSelector: ".divTableCell",
},
});$3
ts`
const headers = await table.getHeaderRows({
cellContentType: CellContentType.TextContent, // Include hidden text
emptyCellReplacement: true, // Replace "" with "{{Empty}}"
duplicateSuffix: true, // "Name", "Name__D1", "Name__D2"
colspan: {
enabled: true, // Process colspan attributes
suffix: true, // "Col", "Col__C1", "Col__C2"
},
});Error Handling
ts`
try {
await table.getBodyCellLocatorByRowConditions({ "Invalid Header": "value" }, "Target");
} catch (error) {
console.error(error.message);
// Error output:
// Header "Invalid Header" not found.
// Available headers: [First name, Last name, Date of birth]
// Header row locator: Locator@table >> thead >> tr
}
- Available headers when header not found
- Row/column indices when out of bounds
- Cell content types in use
- Validation failures with expected vs actual valuesBest Practices
$3
ts
// ❌ Bad - might fail if table still loading
const data = await table.getJson();
await table.waitForNonEmpty();
const data = await table.getJson();
await table.waitForExactRowCount(5);
const data = await table.getJson();
await expect.poll(async () => (await table.getBodyRows()).length).toBe(5);
const data = await table.getJson();
`$3
offers more flexibility when combined with retrieval methods:ts
import { expect } from "@playwright/test";
await expect.poll(async () => (await table.getBodyRows()).length).toBe(10);
await expect.poll(async () => (await table.getJson()).length).toBe(0);
await expect.poll(async () => (await table.getBodyRows()).length).toBeGreaterThanOrEqual(5);
await expect.poll(() => table.getJson()).toContainEqual({ Name: "John", Status: "Active" });
await expect.poll(() => table.getDistinctColumnValues("Status")).toContain("Completed");
await expect
.poll(async () => (await table.getBodyRows()).length, {
message: "Waiting for table to have 10 rows",
timeout: 10000,
intervals: [100, 250, 500, 1000],
})
.toBe(10);
`:
- ✅ Rich assertion library (toBe, toContain, toEqual, toMatch, etc.)getBodyRows
- ✅ Better error messages with actual vs expected values
- ✅ Works with any retrieval method (, getJson, getDistinctColumnValues, etc.)$3
ts
// For visible text only (respects CSS visibility)
const rows = await table.getBodyRows({
cellContentType: CellContentType.InnerText,
});
const rows = await table.getBodyRows({
cellContentType: CellContentType.TextContent,
});
`$3
ts`
// Enable all options for complex tables with colspan/rowspan
const json = await table.getJson({
headerRowOptions: {
colspan: { enabled: true, suffix: true },
duplicateSuffix: true,
emptyCellReplacement: true,
},
});$3
ts`
// Get locator first, then interact
const cell = await table.getBodyCellLocatorByRowConditions({ Name: "John Doe" }, "Actions");
await cell.locator("button.edit").click();
await cell.locator("button.delete").click();TypeScript Support
ts``
import {
PlaywrightTable,
HeaderRow,
BodyRow,
Cell,
CellContentType,
HeaderRowOptions,
TableOptions,
PollingOptions,
RowKind,
} from "@cerios/playwright-table";Performance
- ✅ Efficient DOM queries - Minimizes Playwright API calls
- ✅ Smart polling - Configurable intervals and timeouts
- ✅ Lazy evaluation - Only fetches data when neededBrowser Support
- ✅ Firefox
- ✅ WebKitContributing
License
Support
- 🐛 Report Issues
- 💬 Discussions