@cerios/playwright-table 2.0.2 → 2.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -3,903 +3,964 @@
3
3
  [![npm version](https://img.shields.io/npm/v/@cerios/playwright-table.svg?style=flat-square)](https://www.npmjs.com/package/@cerios/playwright-table)
4
4
  [![npm downloads](https://img.shields.io/npm/dm/@cerios/playwright-table.svg?style=flat-square)](https://www.npmjs.com/package/@cerios/playwright-table)
5
5
  [![license](https://img.shields.io/npm/l/@cerios/playwright-table.svg?style=flat-square)](https://github.com/CeriosTesting/playwright-tables/blob/main/LICENSE)
6
- [![Playwright](https://img.shields.io/badge/playwright-%3E%3D1.40.0-45ba4b.svg?style=flat-square&logo=playwright)](https://playwright.dev)
6
+ [![Playwright](https://img.shields.io/badge/playwright-%3E%3D1.45.0-45ba4b.svg?style=flat-square&logo=playwright)](https://playwright.dev)
7
7
 
8
- ## Introduction
8
+ > **Using `PlaywrightTable`?** The classic API (`getJson()`, `getBodyCellLocator()`, `waitFor*()`) is still supported, in maintenance mode: it gets fixes, and new features land in `TableLocator`. Its docs are in [docs/playwright-table.md](docs/playwright-table.md). Upgrading from v1.x? See the [Migration Guide](MIGRATIONv2.md).
9
9
 
10
- 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.
10
+ Find rows and cells by header name in your Playwright tests, and assert on whole tables. `TableLocator` gives you auto-waiting Playwright locators and retrying `expect` matchers for HTML tables and data grids, with header names checked at compile time.
11
11
 
12
- **Key Features:**
12
+ ```ts
13
+ await expect(users.row({ Name: "John" }).cell("Status")).toHaveText("Active");
14
+ await expect(users).toHaveRows([{ Name: "John" }, { Name: "Jane" }]);
15
+ ```
13
16
 
14
- - ✅ Full support for **colspan** and **rowspan** attributes
15
- - ✅ Handles **multiple header rows** with flexible main header selection
16
- - ✅ **Dynamic content loading** with polling and stability checks
17
- - ✅ **Parallel data fetching** for optimal performance
18
- - ✅ Robust **error handling** with detailed context
19
- - ✅ Support for both **InnerText** and **TextContent** extraction
20
- - ✅ Works with standard `<table>` elements and custom div-based tables
21
- - ✅ Comprehensive **waiting utilities** for dynamic tables
17
+ **Key features:**
22
18
 
23
- ---
19
+ - ✅ **Rows and cells by header name**, as Playwright locators that auto-wait and retry
20
+ - ✅ **Compile-time checked header names**, with autocomplete
21
+ - ✅ **Retrying table matchers**: `toHaveRow`, `toHaveRows`, `toContainRows`, `toHaveEveryRow`, `toHaveColumnValues`, `toBeSortedBy`, `toHaveTotalRowCount` and more
22
+ - ✅ **Rich cell conditions**: text, RegExp, `contains`, `oneOf`, numeric comparisons, `not`/`anyOf`/`allOf` and your own predicates
23
+ - ✅ **Complex tables**: `colspan`, `rowspan`, several header rows, row headers, nested tables and shadow DOM
24
+ - ✅ **Data grids**: presets for AG Grid, MUI X Data Grid, Angular Material and TanStack Virtual, with **pagination** and **virtual scrolling**
25
+ - ✅ **Row interactions**: fill inputs and in-place editors, select rows, expand tree grid rows
26
+ - ✅ **Text as the user sees it**: icon glyphs, screen reader only text and invisible characters are left out
27
+ - ✅ **Readable failures**: the table is shown in the error and attached to the test report
24
28
 
25
- ## Installation
29
+ ## How it compares
26
30
 
27
- First, ensure you have Playwright installed:
28
- [Playwright Documentation](https://github.com/microsoft/playwright/blob/main/README.md)
31
+ Playwright has no table API: finding "the cell under _Status_ in the row where _Name_ is John" means chaining `getByRole("row").filter(...)` and counting header positions yourself. The request for a built-in table selector ([microsoft/playwright#4646](https://github.com/microsoft/playwright/issues/4646)) was closed as not planned, so this package fills that gap.
29
32
 
30
- Then, install this package:
33
+ | | Playwright locators | Most table helpers | `TableLocator` |
34
+ | ----------------------------------------------------- | ------------------- | ------------------ | ------------------------ |
35
+ | Cell by row condition and header name | manual | ✔ | ✔, as a retrying locator |
36
+ | Conditions per cell (not row-wide `hasText`) | manual | some | ✔ |
37
+ | `colspan` / `rowspan`, multi-row headers | ✗ | rarely | ✔ |
38
+ | ARIA grids, pinned columns, `aria-rowindex` | `getByRole` only | some | ✔ |
39
+ | Retrying `expect` matchers for rows, columns, sorting | ✗ | ✗ | ✔ |
40
+ | Pagination and virtual scrolling | ✗ | some | ✔ |
41
+ | Typed header names, checked at compile time | ✗ | ✗ | ✔ |
31
42
 
32
- ```bash
33
- npm i @cerios/playwright-table
34
- ```
43
+ ---
35
44
 
36
- Or as a dev dependency:
45
+ ## Installation
37
46
 
38
47
  ```bash
39
48
  npm i -D @cerios/playwright-table
40
49
  ```
41
50
 
42
- > ⚠️ **Upgrading from v1.x?** See the [Migration Guide](./MIGRATIONv2.md) for breaking changes and upgrade instructions.
51
+ You need [`@playwright/test`](https://playwright.dev/docs/intro) 1.45 or later, and TypeScript 5.0 or later if you type-check your tests. With Playwright 1.53 and later, locators also read like the code that built them in error messages and traces.
43
52
 
44
- ---
53
+ ## Quick start
45
54
 
46
- ## Quick Start
47
-
48
- ### Basic Usage
55
+ Import `test` and `expect` from this package instead of `@playwright/test`:
49
56
 
50
57
  ```ts
51
- import { PlaywrightTable } from "@cerios/playwright-table";
58
+ import { test, expect, defineTable } from "@cerios/playwright-table";
52
59
 
53
- // Create a table instance
54
- const table = new PlaywrightTable(page.locator("table"));
60
+ test("users table", async ({ page }) => {
61
+ await page.goto("/users");
62
+ const users = defineTable(page.locator("#users"), { headers: ["Name", "Status", "Email"] });
55
63
 
56
- // Get table data as JSON
57
- const data = await table.getJson();
58
- console.log(data);
59
- // Output: [{"Name": "John", "Age": "30"}, {"Name": "Jane", "Age": "25"}]
64
+ // The cell under "Status" in the row where "Name" is John. Retries until it matches.
65
+ await expect(users.row({ Name: "John" }).cell("Status")).toHaveText("Active");
60
66
 
61
- // Get specific cell locator
62
- const cell = table.getBodyCellLocator(0, 1); // Row 0, Column 1
63
- await expect(cell).toHaveText("30");
67
+ // Cells are plain Playwright locators
68
+ await users.row({ Name: "Jane" }).cell("Email").getByRole("link").click();
64
69
 
65
- // Wait for dynamic content
66
- await table.waitForExactRowCount(5, { timeout: 10000 });
70
+ // Assert the whole table: exactly these rows, in order
71
+ await expect(users).toHaveRows([
72
+ { Name: "John", Status: "Active" },
73
+ { Name: "Jane", Status: /inactive/i },
74
+ ]);
75
+ });
67
76
  ```
68
77
 
69
- ### Default Selectors
70
-
71
- By default, the following CSS selectors are used:
72
-
73
- - **Header rows:** `thead > tr`
74
- - **Header cells:** `th`
75
- - **Body rows:** `tbody > tr`
76
- - **Body cells:** `td`
77
-
78
- You can customize these selectors in the constructor (see [Advanced Options](#advanced-options)).
78
+ The `test` of this package registers the selector engine that `TableLocator` uses. If you have your own `test`, see [Setup](#setup).
79
+
80
+ ## Contents
81
+
82
+ - [Setup](#setup)
83
+ - [Defining a table](#defining-a-table)
84
+ - [Selector descriptors](#selector-descriptors)
85
+ - [Rows and cells](#rows-and-cells)
86
+ - [Column indexes and tables without headers](#column-indexes-and-tables-without-headers)
87
+ - [Row headers and nested tables](#row-headers-and-nested-tables)
88
+ - [Cell conditions](#cell-conditions)
89
+ - [Matchers](#matchers)
90
+ - [Sorting](#sorting)
91
+ - [Typed matcher arguments](#typed-matcher-arguments)
92
+ - [Bring your own matchers](#bring-your-own-matchers)
93
+ - [Reading data](#reading-data)
94
+ - [Some columns and typed values](#some-columns-and-typed-values)
95
+ - [Waiting for a table to settle](#waiting-for-a-table-to-settle)
96
+ - [Row interactions](#row-interactions)
97
+ - [Editable cells](#editable-cells)
98
+ - [Placeholder rows](#placeholder-rows)
99
+ - [Footer rows](#footer-rows)
100
+ - [Data grids](#data-grids)
101
+ - [ARIA grids (AG Grid, MUI Data Grid)](#aria-grids-ag-grid-mui-data-grid)
102
+ - [Grid presets](#grid-presets)
103
+ - [Pages and virtual scrolling](#pages-and-virtual-scrolling)
104
+ - [Shadow DOM](#shadow-dom)
105
+ - [Reference](#reference)
106
+ - [How text is read](#how-text-is-read)
107
+ - [Header keys](#header-keys)
108
+ - [Header validation](#header-validation)
109
+ - [Timeouts](#timeouts)
110
+ - [Debugging failures](#debugging-failures)
111
+ - [Common pitfalls](#common-pitfalls)
112
+ - [`TableLocator` or `PlaywrightTable`?](#tablelocator-or-playwrighttable)
113
+ - [TypeScript](#typescript)
79
114
 
80
115
  ---
81
116
 
82
- ## Core Methods
83
-
84
- ### 1. `getHeaderRows(options?)`
85
-
86
- Retrieves all header rows with support for colspan and rowspan. Colspan cells get a suffix (`__C1`, `__C2`, etc.).
117
+ ## Setup
87
118
 
88
- **Parameters:**
119
+ `TableLocator` resolves rows and cells in the browser with a custom selector engine (`pw-table-v1`), on every retry. That is what makes its locators auto-wait, and stay correct when columns move.
89
120
 
90
- - `options?: HeaderRowOptions` - Configure header parsing behavior
91
-
92
- **Returns:** `Promise<HeaderRow[]>` - Array of header rows
93
-
94
- **Example:**
95
-
96
- HTML with complex headers
97
-
98
- <table>
99
- <thead>
100
- <tr>
101
- <th rowspan="2">Name</th>
102
- <th colspan="2">Details</th>
103
- </tr>
104
- <tr>
105
- <th>Age</th>
106
- <th>City</th>
107
- </tr>
108
- </thead>
109
- </table>
121
+ The `test` of this package registers the engine with a worker-scoped auto fixture. If you have your own fixtures, call `registerTableSelectors()` instead. It must run **before the page is created**, and it is safe to call more than once:
110
122
 
111
123
  ```ts
112
- // HTML with complex headers
113
- <table>
114
- <thead>
115
- <tr>
116
- <th rowspan="2">Name</th>
117
- <th colspan="2">Details</th>
118
- </tr>
119
- <tr>
120
- <th>Age</th>
121
- <th>City</th>
122
- </tr>
123
- </thead>
124
- </table>
125
-
126
- const headers = await table.getHeaderRows({
127
- colspan: { enabled: true, suffix: true }
124
+ import { registerTableSelectors } from "@cerios/playwright-table";
125
+ import { test as base } from "@playwright/test";
126
+
127
+ export const test = base.extend<{}, { tableSelectors: void }>({
128
+ tableSelectors: [
129
+ async ({ playwright }, use) => {
130
+ await registerTableSelectors(playwright.selectors);
131
+ await use();
132
+ },
133
+ { scope: "worker", auto: true },
134
+ ],
128
135
  });
129
- console.log(headers);
130
- // Output: [
131
- // ["Name", "Details", "Details__C1"],
132
- // ["Name", "Age", "City"]
133
- // ]
134
136
  ```
135
137
 
136
- **Options:**
138
+ Without an argument, `registerTableSelectors()` registers the engine with the `selectors` of `@playwright/test`, for example in a `test.beforeAll`.
139
+
140
+ The engine runs in an isolated world, so locators, matchers and predicates also work on pages whose CSP forbids `eval`.
141
+
142
+ ## Defining a table
137
143
 
138
144
  ```ts
139
- {
140
- cellContentType?: CellContentType.InnerText | CellContentType.TextContent, // Default: InnerText
141
- emptyCellReplacement?: boolean, // Replace empty cells with "{{Empty}}" (default: false)
142
- duplicateSuffix?: boolean, // Add suffix to duplicate headers (default: false)
143
- normalizeWhitespace?: boolean, // Collapse internal whitespace/newlines (default: false)
144
- stripIconGlyphs?: boolean, // Remove icon-font private-use glyphs from headers (default: false)
145
- colspan?: {
146
- enabled?: boolean, // Enable colspan parsing (default: false)
147
- suffix?: boolean // Add suffix to colspan cells (default: false)
148
- }
149
- }
145
+ const users = defineTable(page.locator("#users"), { headers: ["Name", "Status", "Email"] });
146
+ // or: new TableLocator(page.locator("#users"), { headers: ["Name", "Status", "Email"] })
150
147
  ```
151
148
 
152
- ---
149
+ The first argument is the table root: the `<table>`, or the element that holds the grid. All options are optional:
153
150
 
154
- ### 2. `getMainHeaderRow(options?)`
151
+ | Option | Default | Description |
152
+ | ------------------------- | ------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------- |
153
+ | `headers` | | The header names, for typed header keys and `validateHeaders()`. Without it, any string is accepted as a header. It doesn't restrict anything at runtime. |
154
+ | `header.rowSelector` | `thead>tr`, plus leading `<th>` rows in a table without thead | The header rows. In a table without a `<thead>`, the leading rows of `<th>` cells (without `<td>`) in the first `<tbody>` are header rows. |
155
+ | `header.columnSelector` | `th, td` | The header cells. |
156
+ | `header.setMainHeaderRow` | the last header row | The index of the header row whose names are the [header keys](#header-keys). |
157
+ | `row.rowSelector` | `tbody>tr`, without those header rows | The body rows. |
158
+ | `row.columnSelector` | `td, th` | The body cells, so row headers are columns too (see [Row headers and nested tables](#row-headers-and-nested-tables)). |
159
+ | `footer.rowSelector` | `tfoot>tr` | The [footer rows](#footer-rows). |
160
+ | `aria` | `false` | Use ARIA roles for the default selectors (see [ARIA grids](#aria-grids-ag-grid-mui-data-grid)). |
161
+ | `cellContentType` | `InnerText` | How header and body text is read: `CellContentType.InnerText` or `TextContent` (see [How text is read](#how-text-is-read)). |
162
+ | `ignore` | `DEFAULT_IGNORE` | Elements in cells whose text is left out (see [Ignored elements](#ignored-elements)). |
163
+ | `paginate` | | How to go to the next page (see [Pages and virtual scrolling](#pages-and-virtual-scrolling)). |
164
+ | `scroll` | | How to scroll a virtualized grid (see [Pages and virtual scrolling](#pages-and-virtual-scrolling)). |
165
+ | `loading` | | The loading indicator, such as a spinner. `waitForStable()` and the page and scroll navigation wait until it is hidden. |
166
+ | `emptyRow` | | Placeholder rows such as "No data", which are not body rows (see [Placeholder rows](#placeholder-rows)). |
167
+ | `readInputValues` | `false` | Read the value of text inputs in body cells as their text (see [Editable cells](#editable-cells)). |
168
+ | `select` | the checkbox in a selection column | The control that selects a row, from the row locator (see [Row interactions](#row-interactions)). |
155
169
 
156
- Retrieves the main header row. By default, uses the **last header row**. Customizable via constructor.
170
+ `loading`, `paginate.next`, `paginate.first` and `scroll.container` take a `Locator`, or a function that finds it from the table root (`root => root.getByRole("progressbar")`).
157
171
 
158
- **Parameters:**
172
+ The row, cell, `emptyRow` and `ignore` options take a CSS selector or a [descriptor](#selector-descriptors).
159
173
 
160
- - `options?: HeaderRowOptions` - Configure header parsing
174
+ To read only `thead` rows as header rows and `tbody` rows as body rows, set `header.rowSelector` to `"thead>tr"` and `row.rowSelector` to `"tbody>tr"`. When the two row selectors are the same, a warning is logged: header rows would also be read as body rows.
161
175
 
162
- **Returns:** `Promise<HeaderRow>` - The main header row
176
+ For popular data grids, [presets](#grid-presets) set these options for you.
163
177
 
164
- **Throws:**
178
+ ### Selector descriptors
165
179
 
166
- - Error if no header rows are available
167
- - Error if `setMainHeaderRow` index is out of bounds
168
-
169
- **Example:**
180
+ Instead of a CSS selector, the row, cell, `emptyRow` and `ignore` options take a descriptor, which selects elements by their role and text, like `getByRole()` and `filter({ hasText })`. Every field you give must match:
170
181
 
171
182
  ```ts
172
- // Use first row as main header
173
- const table = new PlaywrightTable(page.locator("table"), {
174
- header: { setMainHeaderRow: 0 },
183
+ const orders = defineTable(page.locator("#orders"), {
184
+ row: {
185
+ rowSelector: { css: "tbody>tr", role: "row", hasNotText: /^loading/i },
186
+ columnSelector: { role: ["cell", "rowheader"] },
187
+ },
188
+ emptyRow: { hasText: "No data" },
189
+ ignore: [...DEFAULT_IGNORE, { css: "span", hasText: /^sort/ }],
175
190
  });
176
-
177
- const mainHeader = await table.getMainHeaderRow();
178
- console.log(mainHeader);
179
- // Output: ["Name", "Age", "City"]
180
191
  ```
181
192
 
182
- ---
193
+ | Field | Matches |
194
+ | ------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
195
+ | `css` | A CSS selector. |
196
+ | `role` | A role, or any of a list of roles: the first role in the `role` attribute, or else the role of a table element (below). |
197
+ | `hasText` | Text in the element: a string matches case-insensitively, anywhere, with whitespace collapsed; a RegExp is tested on that text. Hidden text (`textContent`) counts. |
198
+ | `hasNotText` | Text that is not in the element. |
183
199
 
184
- ### 3. `getBodyRows(options?)`
200
+ Table elements without a `role` attribute have these roles: `<table>` is a `table`; `<thead>`, `<tbody>` and `<tfoot>` are a `rowgroup`; `<tr>` is a `row`; `<td>` is a `gridcell` in a table with `role="grid"` or `"treegrid"`, and a `cell` otherwise; `<th>` is a `rowheader` with `scope="row"` or in a row with a `<td>` (outside a `<thead>`), and a `columnheader` otherwise.
185
201
 
186
- Retrieves all body rows with support for rowspan and colspan.
202
+ These options are not `Locator`s: rows and cells are found in the browser on every retry of a locator or matcher, so they keep up with a table that changes, and a `Locator` can't be run there. The table root and the options that point at a control (`loading`, `paginate`, `scroll.container`, `select`) are `Locator`s, because they are used from your test.
187
203
 
188
- **Parameters:**
204
+ ## Rows and cells
189
205
 
190
- - `options?: TableOptions` - Full table options (`bodyRowOptions`, `headerRowOptions`) and legacy `cellContentType` shortcut
191
-
192
- **Returns:** `Promise<BodyRow[]>` - Array of body rows
206
+ ```ts
207
+ users.row({ Name: "John" }); // TableRow: body rows where every condition matches
208
+ users.rowAt(0); // TableRow: the n-th body row (0-based)
209
+ users.rows; // TableRow: all body rows
210
+ users.cell(anyRowLocator, "Email"); // Locator: the cell under a header for any row locator
211
+ users.column("Status"); // Locator: every body cell under a header
212
+ users.header("Name"); // Locator: the header cell
213
+ users.footer; // TableRow: the footer rows
214
+ users.root; // Locator: the table root
215
+
216
+ const row = users.row({ Name: "John" });
217
+ row.locator; // Locator for the row element(s)
218
+ row.cell("Email"); // Locator for the cell
219
+ row.filter({ hasText: "x" }); // TableRow narrowed with Locator.filter()
220
+ row.first(); // TableRow: the first of these rows (also last() and nth(i))
221
+ row.table; // the TableLocator of the row
222
+ ```
193
223
 
194
- **Throws:** Error if no body rows found
224
+ Everything above is built synchronously and resolved when you use it, like any Playwright locator. Header names are resolved to columns on every evaluation, so a locator stays correct when columns move.
195
225
 
196
- **Example:**
226
+ Snapshots and navigation are `async`:
197
227
 
198
228
  ```ts
199
- // Legacy shortcut
200
- const rows = await table.getBodyRows({
201
- cellContentType: CellContentType.TextContent, // Include hidden elements
202
- });
203
-
204
- // Full table options (useful for custom grids with noisy headers)
205
- const rowsWithOptions = await table.getBodyRows({
206
- bodyRowOptions: { cellContentType: CellContentType.InnerText },
207
- headerRowOptions: {
208
- normalizeWhitespace: true,
209
- stripIconGlyphs: true,
210
- },
211
- });
212
-
213
- console.log(rows);
214
- // Output: [["John", "30", "NYC"], ["Jane", "25", "LA"]]
229
+ await users.toJson(); // every body row as an object keyed by header
230
+ await users.columnValues("Status"); // the text of every body cell in a column (also by index)
231
+ await users.headerKeys(); // the header keys
232
+ await users.waitForStable(); // waits until the table stops changing
233
+ await users.findRow({ Name: "Joe" }); // TableRow: searches every page and scroll position
234
+ await users.totalRowCount(); // every body row, also on other pages
235
+ await users.format(); // the rows in view as a text table
215
236
  ```
216
237
 
217
- **Cell Content Types:**
218
-
219
- - `CellContentType.InnerText` - Rendered text (default, excludes hidden elements)
220
- - `CellContentType.TextContent` - Raw text content (includes hidden elements)
238
+ `row()` follows Playwright's strict mode: acting on it throws when several rows match. Use `first()`, `last()`, `nth(i)`, `rows.filter(...)`, `rowAt(n)` or `table.cell(anyRowLocator, header)` to compose. All of them keep typed cell access.
221
239
 
222
- Header sanitization options (`normalizeWhitespace`, `stripIconGlyphs`) can be used in all methods that accept `HeaderRowOptions` or `TableOptions`, such as `getHeaderRows`, `getMainHeaderRow`, `getJson`, `findRowIndex`, and `getBodyCellLocatorByRowConditions`.
240
+ ```ts
241
+ await users.row({ Status: "Active" }).first().cell("Edit").click();
242
+ await expect(users.rows.filter({ hasText: "Jane" }).cell("Status")).toHaveText("Active");
243
+ await expect(users.rows.last().cell("Name")).toHaveText("Joe");
244
+ await expect(users.column("Status")).toHaveText(["Active", "Inactive", "Active"]);
245
+ ```
223
246
 
224
- ---
247
+ A condition object needs at least one condition: `row({})` throws. Use `rows` for every row.
225
248
 
226
- ### 4. `getBodyCellLocator(rowNumber, headerPosition)`
249
+ ### Column indexes and tables without headers
227
250
 
228
- Gets a Playwright Locator for a specific cell by row and column indices (0-based).
251
+ `cell()`, `column()`, `header()` and `columnValues()` also take a 0-based column index. Indexes follow the visual grid, so a cell spanning columns or rows covers every index it spans, just like header keys do.
229
252
 
230
- **Parameters:**
253
+ ```ts
254
+ await expect(users.row({ Name: "John" }).cell(2)).toHaveText("john@example.com");
255
+ await expect(users.column(1)).toHaveText(["Active", "Inactive", "Active"]);
256
+ ```
231
257
 
232
- - `rowNumber: number` - Row index (0-based)
233
- - `headerPosition: number` - Column index (0-based)
258
+ Column indexes don't need a header row, so they also work on tables without one. Header names, `row()` conditions, `toJson()` and `toHaveRows` need a header row:
234
259
 
235
- **Returns:** `Locator` - Playwright locator for the cell
260
+ ```ts
261
+ const plain = defineTable(page.locator("#plain")); // <table> with only a <tbody>
262
+ await expect(plain.rowAt(1).cell(0)).toHaveText("Jane");
263
+ await expect(plain.rows.filter({ hasText: "Jane" }).cell(2)).toHaveText("jane@example.com");
264
+ await expect(plain).toHaveRowCount(2);
265
+ ```
236
266
 
237
- **Example:**
267
+ ### Row headers and nested tables
268
+
269
+ Accessible tables often start each body row with a row header (`<th scope="row">`), and put an empty `<td>` in the corner of the header row. `TableLocator` takes `th` and `td` cells in both header and body rows, so these columns don't shift:
270
+
271
+ ```html
272
+ <thead>
273
+ <tr>
274
+ <td></td>
275
+ <th>Q1</th>
276
+ <th>Q2</th>
277
+ </tr>
278
+ </thead>
279
+ <tbody>
280
+ <tr>
281
+ <th scope="row">North</th>
282
+ <td>100</td>
283
+ <td>200</td>
284
+ </tr>
285
+ </tbody>
286
+ ```
238
287
 
239
288
  ```ts
240
- // Get cell at row 1, column 2
241
- const cellLocator = table.getBodyCellLocator(1, 2);
242
- await expect(cellLocator).toHaveText("Expected Value");
243
- await cellLocator.click();
289
+ const sales = defineTable(page.locator("#sales"));
290
+ await expect(sales.row({ "{{Empty}}": "North" }).cell("Q2")).toHaveText("200");
244
291
  ```
245
292
 
246
- ---
293
+ Rows and cells inside a cell, such as a nested table, are content of that cell, not part of the table. `rows`, `rowAt()`, `toHaveRowCount` and the column indexes skip them. To work with the nested table, define it as a table of its own.
247
294
 
248
- ### 5. `getBodyCellLocatorByRowConditions(conditions, targetHeader, options?)`
295
+ ## Cell conditions
249
296
 
250
- 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).
297
+ A condition in `row()` or a matcher is one of:
251
298
 
252
- **Parameters:**
299
+ | Condition | Matches when the cell text... |
300
+ | ---------------------------------------------------------------- | ------------------------------------------- |
301
+ | `"John"` | equals the string |
302
+ | `/^jo/i` | matches the RegExp |
303
+ | `{ equals: "john", ignoreCase: true }` | equals the value (optionally ignoring case) |
304
+ | `{ contains: "oh", ignoreCase: true }` | contains the value |
305
+ | `{ startsWith: "Jo" }` | starts with the value |
306
+ | `{ endsWith: "@example.com" }` | ends with the value |
307
+ | `{ oneOf: ["Active", "Pending"] }` | equals one of the values |
308
+ | `{ empty: true }` | is empty |
309
+ | `{ predicate: text => Number(text) > 18 }` | makes the predicate return `true` |
310
+ | `{ not: "Deleted" }` | does not match the nested matcher |
311
+ | `{ anyOf: ["Active", /^pend/i] }` | matches at least one nested matcher |
312
+ | `{ allOf: [{ startsWith: "J" }, { not: { contains: "Doe" } }] }` | matches every nested matcher |
313
+ | `{ gt: 18 }`, `{ gte: 18 }`, `{ lt: 65 }`, `{ lte: 65 }` | holds a number that compares as asked |
314
+ | `{ between: [10, 20] }` | holds a number from 10 to 20 (inclusive) |
253
315
 
254
- - `conditions: Record<string, string> | [string, string]` - Header-value pairs to match (object) or single [header, value] tuple
255
- - `targetHeader: string` - Column name to retrieve
256
- - `options?: TableOptions` - Table loading options
316
+ `ignoreCase: true` works with `equals`, `contains`, `startsWith` and `endsWith`. Cell text is compared as described in [How text is read](#how-text-is-read): each line collapsed and trimmed, with one `\n` between lines.
257
317
 
258
- **Returns:** `Promise<Locator>` - Cell locator
318
+ ```ts
319
+ await expect(users.row({ Name: { startsWith: "Jo" }, Status: { not: "Deleted" } }).cell("Email")).toHaveText(
320
+ "john@example.com"
321
+ );
322
+ ```
259
323
 
260
- **Throws:**
324
+ ### Numbers
261
325
 
262
- - Error if no matching row found
263
- - Error if specified headers don't exist
326
+ The numeric conditions read the one number in the cell, so `"€ 1,200.50"` is 1200.5, `"-3 %"` is -3 and `"1 234"` is 1234. Digits may be grouped in threes by `,`, `'` or a (non-breaking) space. A cell with no number, or with more than one (`"12:30"`, `"5 of 10"`, a date), never matches.
264
327
 
265
- **Example:**
328
+ For tables with a decimal comma, add `decimal: ","`. Digits are then grouped by `.`, `'` or a space: `{ gt: 1000, decimal: "," }` matches `"€ 1.234,50"`.
266
329
 
267
330
  ```ts
268
- // Find cell with multiple conditions (object syntax)
269
- const emailCell = await table.getBodyCellLocatorByRowConditions({ "First name": "John", Status: "Active" }, "Email");
270
- await expect(emailCell).toContainText("@example.com");
271
-
272
- // Find cell with single condition (tuple syntax)
273
- const actionCell = await table.getBodyCellLocatorByRowConditions(["Username", "john.doe"], "Actions");
274
- await actionCell.locator("button.delete").click();
275
-
276
- // Find cell with single condition (object syntax also works)
277
- const statusCell = await table.getBodyCellLocatorByRowConditions({ Username: "john.doe" }, "Status");
331
+ await expect(orders.row({ Total: { gt: 1000 } }).cell("Customer")).toHaveText("John");
332
+ await expect(orders).toHaveRow({ Total: { between: [10, 20], decimal: "," }, Status: "Active" });
278
333
  ```
279
334
 
280
- ---
281
-
282
- ### 6. `getAllBodyCellLocatorsByHeaderName(headerName, options?)`
335
+ ### Predicates
283
336
 
284
- Gets all cell locators in a column by header name.
337
+ A `predicate` gets two arguments: the normalized `text` that the other conditions compare, and the `raw` text exactly as the browser reads it (`innerText`, or `textContent`), for example `{ predicate: (text, raw) => raw.includes(" ") }`.
285
338
 
286
- **Parameters:**
339
+ `predicate` runs **in the browser**, like a `page.evaluate` function. It is serialized with `toString()`, so it cannot use variables from your test.
287
340
 
288
- - `headerName: string` - Column header name
289
- - `options?: TableOptions` - Table loading options
341
+ - A predicate that throws (for example on a variable from your test), returns a promise or cannot be compiled fails the row, also inside `not`, `anyOf` or `allOf`. A broken predicate never turns into a match.
342
+ - When a matcher fails, it reports the predicate's error.
343
+ - Bound and built-in functions (`fn.bind(...)`, `Boolean`) have no source to send, so `row()` rejects them right away.
290
344
 
291
- **Returns:** `Promise<Locator[]>` - Array of cell locators
345
+ `not`, `anyOf` and `allOf` take any matcher, including other combinators. `anyOf` and `allOf` need at least one matcher.
292
346
 
293
- **Throws:** Error if header not found
347
+ ## Matchers
294
348
 
295
- **Example:**
349
+ The exported `expect` includes table and row matchers. They retry until the `expect` timeout (or the `timeout` option) and support `.not`:
296
350
 
297
351
  ```ts
298
- // Get all cells in "Status" column
299
- const statusCells = await table.getAllBodyCellLocatorsByHeaderName("Status");
300
- for (const cell of statusCells) {
301
- await expect(cell).toHaveText(/Active|Inactive/);
302
- }
303
-
304
- // Extract all values from "Name" column
305
- const names = await Promise.all(statusCells.map(cell => cell.textContent()));
306
- console.log(names); // ["John", "Jane", "Bob"]
352
+ await expect(users).toHaveRow({ Name: "John", Status: /active/i });
353
+ await expect(users).not.toHaveRow({ Name: "Ghost" });
354
+ await expect(users).toHaveRowCount(3);
355
+ await expect(users).toHaveHeaders(["Name", "Email"]);
356
+ await expect(users).toHaveHeaders(["Name", "Status", "Email"], { exact: true });
357
+
358
+ // Exactly these rows, in order. Each row only checks the headers it mentions; {} matches any row.
359
+ await expect(users).toHaveRows([
360
+ { Name: "John", Status: "Active" },
361
+ { Name: "Jane", Status: /inactive/i },
362
+ { Name: "Joe" },
363
+ ]);
364
+ await expect(users).toHaveRows([]); // the body is empty
365
+ // The same rows, in any order
366
+ await expect(users).toHaveRows([{ Name: "Joe" }, { Name: "John" }, { Name: "Jane" }], { anyOrder: true });
367
+
368
+ // These rows somewhere in the table, in any order; other rows are allowed
369
+ await expect(users).toContainRows([{ Name: "Joe", Status: "Active" }, { Name: "John" }]);
370
+
371
+ // Every body row matches, for example after filtering. A table without body rows fails.
372
+ await expect(users).toHaveEveryRow({ Status: "Active" });
373
+ await expect(orders).toHaveEveryRow({ Total: { gt: 0 } });
374
+
375
+ // One value per body row, in order (or with anyOrder). Values take any cell condition.
376
+ await expect(users).toHaveColumnValues("Status", ["Active", "Inactive", "Active"]);
377
+ await expect(orders).toHaveColumnValues("Total", [{ gt: 1000 }, /€ 80/, { lt: 20 }]);
378
+
379
+ // Every row, also on other pages or not rendered (see Pages and virtual scrolling)
380
+ await expect(grid).toHaveTotalRowCount(200);
381
+
382
+ // With a timeout
383
+ await expect(users).toHaveRow({ Name: "John" }, { timeout: 10_000 });
307
384
  ```
308
385
 
309
- ---
310
-
311
- ### 7. `getAllBodyCellLocatorsByHeaderIndex(headerIndex, options?)`
386
+ Row matchers take a row locator:
312
387
 
313
- Gets all cell locators in a column by column index (0-based).
388
+ ```ts
389
+ // The row locator must resolve to exactly one row, whose cells match every condition
390
+ await expect(users.row({ Name: "John" })).toHaveCells({ Status: "Active", Email: /@example\.com$/ });
391
+ await expect(users.rowAt(0)).not.toHaveCells({ Status: "Deleted" }); // the row exists, but doesn't match
314
392
 
315
- **Parameters:**
393
+ await expect(users.row({ Name: "John" })).toBeSelected(); // aria-selected="true", or its selection checkbox is checked
394
+ await expect(files.row({ Name: "src" })).not.toBeExpanded(); // tree grids: aria-expanded
395
+ ```
316
396
 
317
- - `headerIndex: number` - Column index (0-based)
318
- - `options?: TableOptions` - Table loading options
397
+ `toHaveRow`, `toHaveCells` and `toHaveEveryRow` need at least one condition: `{}` throws.
319
398
 
320
- **Returns:** `Promise<Locator[]>` - Array of cell locators
399
+ ### What a failure shows
321
400
 
322
- **Throws:** Error if index out of bounds
401
+ `toContainRows` and `toHaveRows` with `anyOrder` give each entry a different body row, and find the best fit, so overlapping entries such as `{ Status: "Active" }` and `{ Name: "John" }` both pass when John is active and another row is too. When they fail, the message shows the entries left without a row and the body rows, as a table of the columns the entries mention:
323
402
 
324
- **Example:**
403
+ ```
404
+ Expected: body rows matching, in any order (other rows allowed)
405
+ [0] { "Name": "Joe" }
406
+ [1] { "Name": "Ghost", "Status": "Active" }
407
+ Received: 3 body rows
408
+ No body row left for [1] { "Name": "Ghost", "Status": "Active" }
409
+ Body rows:
410
+ # | Name | Status
411
+ 0 | John | Active
412
+ 1 | Jane | Inactive
413
+ 2 | Joe | Active
414
+ ```
325
415
 
326
- ```ts
327
- // Get all cells in first column (index 0)
328
- const firstColumnCells = await table.getAllBodyCellLocatorsByHeaderIndex(0);
329
- const values = await Promise.all(firstColumnCells.map(c => c.textContent()));
330
- console.log(values);
416
+ The other matchers show what they found too:
331
417
 
332
- // Verify all cells are visible
333
- for (const cell of firstColumnCells) {
334
- await expect(cell).toBeVisible();
335
- }
336
- ```
418
+ - `toHaveEveryRow` lists the rows that don't match.
419
+ - `toHaveColumnValues` shows the column values and every row that doesn't match its value.
420
+ - `toHaveRows` shows the number of rows received and, for each row that doesn't match, the cells it actually has.
421
+ - `toHaveCells` shows the cells the row actually has.
337
422
 
338
- ---
423
+ **Unknown headers.** When a condition names an unknown header, the matcher fails with `Header "Stauts" not found.` and lists the available headers. A condition on an unknown header can never be checked, so it fails with `.not` too: `.not` first waits for the headers, then asserts that no row matches. A typo in a negated assertion never passes silently.
339
424
 
340
- ### 8. `getJson(options?)`
425
+ **`toBeExpanded`** fails on a row without `aria-expanded`, also with `.not`.
341
426
 
342
- Converts the entire table to a JSON array of objects. **Headers become object keys**, values are cell contents.
427
+ ### Sorting
343
428
 
344
- **Parameters:**
429
+ `toBeSortedBy` checks that the body rows are sorted by a column. It retries, so it waits for a sort that takes a server round trip. It checks the data itself, not the sort icon:
345
430
 
346
- - `options?: TableOptions` - Configure parsing behavior
431
+ ```ts
432
+ await users.header("Name").click();
433
+ await expect(users).toBeSortedBy("Name"); // ascending, as text
434
+ await expect(users).toBeSortedBy("Name", { order: "desc", ariaSort: true });
435
+ await expect(users).toBeSortedBy("Salary", { type: "number" }); // "€ 1,200" is 1200
436
+ await expect(users).toBeSortedBy("Salary", { type: "number", decimal: "," }); // "€ 1.200,50" is 1200.5
437
+ await expect(users).toBeSortedBy("Joined", { type: "date" });
438
+ await expect(users).toBeSortedBy("Priority", { compare: (a, b) => priorities.indexOf(a) - priorities.indexOf(b) });
439
+ await expect(users).toBeSortedBy(2); // by column index, also in a table without header rows
440
+ ```
347
441
 
348
- **Returns:** `Promise<any[]>` - Array of row objects
442
+ | Option | Default | Description |
443
+ | ---------- | -------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
444
+ | `order` | `"asc"` | `"asc"` or `"desc"`. Equal neighbours are fine in both. |
445
+ | `type` | `"text"` | `"text"` ignores case and accents, and compares digit runs as numbers (`"item 9"` before `"item 10"`). `"number"` reads the one number in the text, like the [numeric conditions](#numbers). `"date"` parses with `Date.parse`. |
446
+ | `decimal` | `"."` | The decimal separator for `type: "number"`: `"."` or `","`. |
447
+ | `compare` | | A custom comparison like `Array.sort` takes. It runs in Node, so it can use variables from your test. Overrides `type`. |
448
+ | `ariaSort` | `false` | Also require `aria-sort="ascending"` or `"descending"` on the header cell. |
449
+ | `timeout` | | Defaults to the `expect` timeout. |
349
450
 
350
- **Throws:** Error if no headers or body rows found
451
+ For other number formats, pass a `compare`. An empty body, an unknown column or a value that is not a number or date fails the assertion, also with `.not`, so a table that hasn't loaded yet doesn't count as sorted. The failure message lists the column values and the first pair that is out of order.
351
452
 
352
- **Simple Example:**
453
+ ### Typed matcher arguments
353
454
 
354
- <table>
355
- <thead><tr><th>Name</th><th>Age</th></tr></thead>
356
- <tbody>
357
- <tr><td>John</td><td>30</td></tr>
358
- <tr><td>Jane</td><td>25</td></tr>
359
- </tbody>
360
- </table>
455
+ With the exported `expect`, matcher arguments are typed by the table's headers, also after `.not` and with `expect.soft`. A misspelled header in `toHaveRow`, `toHaveRows`, `toContainRows`, `toHaveEveryRow`, `toHaveColumnValues`, `toBeSortedBy` or `toHaveCells` is a compile-time error:
361
456
 
362
457
  ```ts
363
- // HTML:
364
- <table>
365
- <thead><tr><th>Name</th><th>Age</th></tr></thead>
366
- <tbody>
367
- <tr><td>John</td><td>30</td></tr>
368
- <tr><td>Jane</td><td>25</td></tr>
369
- </tbody>
370
- </table>
371
-
372
- const json = await table.getJson();
373
- console.log(json);
374
- // Output:
375
- // [
376
- // { "Name": "John", "Age": "30" },
377
- // { "Name": "Jane", "Age": "25" }
378
- // ]
458
+ const users = defineTable(page.locator("#users"), { headers: ["Name", "Status"] });
459
+ await expect(users).toHaveRow({ Name: "John" });
460
+ // @ts-expect-error "Stauts" is not a header of this table
461
+ await expect(users).toHaveRow({ Stauts: "Active" });
379
462
  ```
380
463
 
381
- **Complex Example with Options:**
464
+ `toHaveHeaders` takes any string, so you can assert that a header the table doesn't declare is absent. Conditions are checked at runtime too, for JavaScript and for tables without `headers`.
465
+
466
+ ### Bring your own matchers
467
+
468
+ The matchers are exported as `tableMatchers`, so you can combine them with your own:
382
469
 
383
470
  ```ts
384
- // HTML with colspan, rowspan, empty cells, and duplicates
385
- const json = await table.getJson({
386
- headerRowOptions: {
387
- colspan: { enabled: true, suffix: true },
388
- duplicateSuffix: true,
389
- emptyCellReplacement: true,
471
+ import { TableLocator, tableMatchers } from "@cerios/playwright-table";
472
+ import { expect as base } from "@playwright/test";
473
+
474
+ // Spread them into your own extend
475
+ export const expect = base.extend({
476
+ ...tableMatchers,
477
+ async toHaveEmailFor(table: TableLocator, name: string) {
478
+ /* ... */
390
479
  },
391
480
  });
392
- // Output:
393
- // [
394
- // {
395
- // "Awesome Rowspan": "Value1",
396
- // "Name": "John",
397
- // "Name__C1": "Doe",
398
- // "{{Empty}}": "EmptyValue",
399
- // "Duplicate": "First",
400
- // "Duplicate__D1": "Second"
401
- // }
402
- // ]
403
481
  ```
404
482
 
405
- ---
483
+ ```ts
484
+ import { expect as tableExpect } from "@cerios/playwright-table";
485
+ import { mergeExpects } from "@playwright/test";
406
486
 
407
- ## Waiting Methods
487
+ // Or merge with another extended expect
488
+ export const expect = mergeExpects(tableExpect, myExpect);
489
+ ```
408
490
 
409
- ### 9. `waitForEmpty(options?)`
491
+ An `expect` you extend or merge yourself has Playwright's own matcher types, so the table matcher arguments are checked at runtime only.
410
492
 
411
- Waits for the table body to be completely empty (no body rows).
493
+ ## Reading data
412
494
 
413
- > 💡 **Tip:** Consider using `expect.poll` for more flexibility: `await expect.poll(async () => (await table.getBodyRows()).length).toBe(0);`
495
+ `toJson()` reads every body row as an object keyed by [header key](#header-keys). A cell spanning rows or columns is repeated in every position it covers, and cell text is read as in row conditions: line breaks stay as one `\n` (see [How text is read](#how-text-is-read)).
414
496
 
415
- **Parameters:**
497
+ ```ts
498
+ const data = await users.toJson();
499
+ // [{ Name: "John", Status: "Active", Address: "Street 1\nAmsterdam" }, ...]
416
500
 
417
- - `options?: PollingOptions`
501
+ const raw = await users.toJson({ raw: true });
502
+ // [{ Name: "John", Status: " Active", Address: "Street 1\nAmsterdam " }, ...]
503
+ ```
418
504
 
419
- **Returns:** `Promise<void>`
505
+ `raw: true` returns every body cell exactly as the browser reads it (`innerText`, or `textContent`): tabs, non-breaking spaces, icon glyphs and the text of ignored elements stay. Header keys are normalized either way.
420
506
 
421
- **Throws:** Error if table is not empty within timeout
507
+ > To check a table, prefer the [matchers](#matchers): they retry until the rows match. `toJson()` is for reading data you need in the test.
422
508
 
423
- **Example:**
509
+ By default `toJson()` is a snapshot: it doesn't wait for rows to load, and it throws if the table has no header row. Set `minRows` to wait first for a header row and at least that many body rows with content:
424
510
 
425
511
  ```ts
426
- // Click delete all button
427
- await page.locator("button.delete-all").click();
428
-
429
- // Wait for table to clear
430
- await table.waitForEmpty({ timeout: 5000 });
431
-
432
- // Verify empty state
433
- const rows = await table.getBodyRows();
434
- expect(rows).toHaveLength(0);
512
+ const data = await users.toJson({ minRows: 1 }); // waits up to 5s
513
+ const page1 = await users.toJson({ minRows: 25, timeout: 10_000 });
514
+ // On timeout: TableLocator.toJson() expected at least 25 body rows with content, found 3 (3 body rows in total).
435
515
  ```
436
516
 
437
- ---
517
+ A row has content when at least one of its cells has text, so empty skeleton rows don't count. A placeholder row with text, such as "Loading…", does count. For those tables, or tables that are still filling, assert on the content you expect first, then read:
438
518
 
439
- ### 10. `waitForNonEmpty(options?)`
519
+ ```ts
520
+ await expect(users).toHaveRow({ Status: { oneOf: ["Active", "Inactive"] } });
521
+ const data = await users.toJson();
522
+ ```
440
523
 
441
- 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.
524
+ To read every row of a paginated or virtualized table, use `toJson({ allRows: true })` (see [Pages and virtual scrolling](#pages-and-virtual-scrolling)).
442
525
 
443
- > 💡 **Tip:** Consider using `expect.poll` for more flexibility: `await expect.poll(async () => (await table.getBodyRows()).length).toBeGreaterThan(0);`
526
+ ### Some columns and typed values
444
527
 
445
- **Parameters:**
528
+ `columns` picks columns, in that order. `parse` turns the text of a column into a typed value: `"number"` (read like the [numeric conditions](#numbers), with `decimal` for a decimal comma), `"date"` (`Date.parse`), or your own function, which runs in Node. Empty cells become `null` for `"number"` and `"date"`. The result is typed from both options:
446
529
 
447
- - `options?: PollingOptions`
530
+ ```ts
531
+ const totals = await orders.toJson({
532
+ columns: ["Customer", "Total", "Placed"],
533
+ parse: { Total: "number", Placed: "date" },
534
+ decimal: ",",
535
+ });
536
+ // { Customer: string; Total: number | null; Placed: Date | null }[]
537
+ // [{ Customer: "John", Total: 1200.5, Placed: 2026-01-15T00:00:00.000Z }, ...]
448
538
 
449
- **Returns:** `Promise<void>`
539
+ const sum = totals.reduce((total, order) => total + (order.Total ?? 0), 0);
540
+ const statuses = new Set(await orders.columnValues("Status")); // distinct values
541
+ ```
450
542
 
451
- **Throws:**
543
+ A key that is not a header fails with the available headers. A cell that `"number"` or `"date"` cannot parse, or a function that throws, fails with the row, the column and the text, such as `row 2 "Total" ("n/a") is not a number`.
452
544
 
453
- - Error if table remains empty (no rows) within timeout
454
- - Error if table has rows but all cells are empty within timeout
545
+ ### Waiting for a table to settle
455
546
 
456
- **Example:**
547
+ After a search, filter, page change or sort, a table often refreshes in steps: a spinner, an empty body, then rows coming in. Reading it too early gives stale or partial data. `waitForStable()` waits until the header keys and every body cell text stay the same for `quiet` milliseconds (500 by default), and the `loading` indicator, if you pass one, is hidden:
457
548
 
458
549
  ```ts
459
- // Trigger data load
460
- await page.locator("button.load-data").click();
550
+ await page.getByLabel("Search").fill("Jo");
551
+ await orders.waitForStable({ loading: page.getByRole("progressbar") });
552
+ const rows = await orders.toJson();
553
+
554
+ // Or in one go: waits for minRows first (if set), then for the table to settle
555
+ const settled = await orders.toJson({ stable: { loading: page.locator(".spinner") } });
556
+ const loaded = await orders.toJson({ minRows: 1, stable: true });
557
+ // On timeout: TableLocator.waitForStable() timed out after 5000ms: the table kept changing (12 changes seen).
558
+ ```
461
559
 
462
- // Wait for table to populate with actual data
463
- await table.waitForNonEmpty({ timeout: 5000 });
560
+ | `waitForStable` option | Default | Description |
561
+ | ---------------------- | -------------------- | --------------------------------------------- |
562
+ | `quiet` | 500 | How long the table must stay the same, in ms. |
563
+ | `loading` | the `loading` option | A loading indicator that must be hidden. |
564
+ | `timeout` | 5000 | The time to wait, in ms. |
464
565
 
465
- // Now safe to process data - guaranteed to have content
466
- const data = await table.getJson();
467
- expect(data.length).toBeGreaterThan(0);
566
+ A table that hasn't started to refresh yet looks stable, so pass `loading` when the table shows a loading indicator, or assert on the content you expect first. Locators and matchers retry on their own and don't need `waitForStable()`.
468
567
 
469
- // Useful for dynamically loaded tables where structure appears before data
470
- await searchInput.fill("search term");
471
- await table.waitForNonEmpty(); // Waits for rows AND content, not just empty row elements
472
- ```
568
+ ## Row interactions
473
569
 
474
- ---
475
-
476
- ### 11. `waitForExactRowCount(count, options?)`
570
+ ```ts
571
+ const john = users.row({ Name: "John" });
477
572
 
478
- Waits for the table to have exactly the specified number of body rows.
573
+ // Fills the controls in the cells: a boolean checks a checkbox or switch, a string selects an option or fills an input
574
+ await john.fill({ Email: "john@example.org", Role: "Admin", Active: false });
479
575
 
480
- > 💡 **Tip:** Consider using `expect.poll` for more flexibility: `await expect.poll(async () => (await table.getBodyRows()).length).toBe(10);`
576
+ // A cell without a control is edited in place, as data grids do: double-click, fill the editor, press Enter
577
+ await grid.row({ Id: "2" }).fill({ Model: "New model" }); // `commit: "Tab"` or `false` to press another key or none
481
578
 
482
- **Parameters:**
579
+ await john.select(); // checks the selection checkbox, or clicks the row; waits until selected
580
+ await expect(john).toBeSelected();
581
+ await john.deselect();
582
+ await john.isSelected(); // true or false
483
583
 
484
- - `count: number` - The exact number of rows expected
485
- - `options?: PollingOptions` - Polling options (timeout, interval)
584
+ // Tree grids
585
+ await files.row({ Name: "src" }).expand();
586
+ await files.row({ Name: "src" }).collapse();
587
+ await files.row({ Name: "src" }).isExpanded(); // true, false, or null for a row that cannot expand
588
+ expect(await files.row({ Name: "index.ts" }).level()).toBe(2); // aria-level
589
+ ```
486
590
 
487
- **Returns:** `Promise<void>`
591
+ - **`fill(values, { commit, timeout })`** goes through the columns in order. When the row is found by a column you fill, fill that column last: after it changes, the row no longer matches. Editors that open outside the cell (popups) are not found; fill those with Playwright locators.
592
+ - **`select()` and `deselect()`** check or uncheck the row's selection checkbox, or else click the row, and wait until `isSelected()` agrees. The selection checkbox is the one in a column without header text (such as one with only a "select all" checkbox), or the control of the `select` table option (`select: row => row.getByRole("checkbox", { name: "Select row" })`). A checkbox in a column with header text, such as "Active", is data and is never ticked to select the row. A row with `aria-selected` is selected when it is `"true"`; otherwise, when its selection checkbox is checked.
593
+ - **`expand()` and `collapse()`** read `aria-expanded` on the row, or else on the first element in it that has one and is not a menu button (`aria-haspopup`), and throw when there is none. To toggle, they click the `toggle` option (`{ toggle: row => row.locator(".twisty") }`; an error if it doesn't show up), or else that element with `aria-expanded`, or else (when the row itself has `aria-expanded`) the button in its first cell, or else focus the row and press `ArrowRight` or `ArrowLeft`. Other buttons in the row, such as Delete, are never clicked. Then they wait for `aria-expanded` to change.
488
594
 
489
- **Throws:**
595
+ Each action waits up to 5 seconds by default; pass `{ timeout }` to change it.
490
596
 
491
- - Error if row count doesn't match within timeout
492
- - Error if count is negative or not an integer
597
+ ## Editable cells
493
598
 
494
- **Example:**
599
+ Cell text is the text the cell shows, like Playwright's `toHaveText`: the value of an `<input>` is not part of it. For an editable grid, or cells in edit mode, set `readInputValues: true`. The value of text areas and text-like inputs (`text`, `email`, `number`, `search`, `tel`, `url` and the date and time types) in body cells then counts as their text, in conditions, `toJson()`, `columnValues()` and the matchers:
495
600
 
496
601
  ```ts
497
- // Wait for table to have exactly 10 rows
498
- await table.waitForExactRowCount(10, { timeout: 5000 });
602
+ const people = defineTable(page.locator("#people"), { readInputValues: true });
603
+ await expect(people).toHaveRow({ Email: "john@example.org" }); // <td><input value="john@example.org"></td>
604
+ ```
605
+
606
+ Checkboxes, buttons, files and passwords stay out, a `<select>` reads as its selected option either way, and header cells never read their inputs (filter fields). Raw text (`raw: true`, and the `raw` of a predicate) is not affected.
499
607
 
500
- // Verify table has exactly 0 rows (alternative to waitForEmpty)
501
- await table.waitForExactRowCount(0);
608
+ ## Placeholder rows
502
609
 
503
- // Wait after pagination change
504
- await page.locator("button.page-2").click();
505
- await table.waitForExactRowCount(25);
610
+ A row such as "No data", shown when a table is empty, is a body row unless you say otherwise. Set `emptyRow` to a CSS selector or a [descriptor](#selector-descriptors) of such rows, such as `{ hasText: "No data" }`: `rows`, `row()`, `toJson()`, the matchers and the row counts leave them out, so an empty table has 0 body rows.
506
611
 
507
- // Verify filtered results
508
- await page.locator("input.search").fill("admin");
509
- await table.waitForExactRowCount(3); // Expect exactly 3 admin users
612
+ ```ts
613
+ const orders = defineTable(page.locator("#orders"), { emptyRow: ".no-data" });
614
+ await expect(orders).toHaveRowCount(0);
510
615
  ```
511
616
 
512
- **Use Cases:**
617
+ The Angular Material preset sets it for the `mat-no-data-row`.
513
618
 
514
- - Verifying exact result counts after filtering
515
- - Validating pagination behavior
516
- - Testing data deletion/addition
517
- - Ensuring precise table state for assertions
619
+ ## Footer rows
518
620
 
519
- ---
621
+ Footer rows, such as a totals row, are not body rows: `rows`, `row()`, `toJson()` and the matchers leave them out. `footer` gives them typed cells:
520
622
 
521
- ### 12. `getRowCount()`
623
+ ```ts
624
+ await expect(orders.footer.cell("Total")).toHaveText("€ 1,295.75");
625
+ ```
522
626
 
523
- Gets the count of header and body rows in the table. Lightweight method that doesn't fetch cell data, only counts rows.
627
+ Footer rows are `tfoot>tr` by default (none with `aria: true`); set `footer.rowSelector` for others. Their cells are found with `row.columnSelector`.
524
628
 
525
- **Parameters:** None
629
+ ## Data grids
526
630
 
527
- **Returns:** `Promise<{ header: number; body: number }>`
631
+ ### ARIA grids (AG Grid, MUI Data Grid)
528
632
 
529
- **Example:**
633
+ Data grids are often built from `div`s with ARIA roles instead of `<table>` elements. Set `aria: true` to use roles for the default selectors (your own selectors still win):
530
634
 
531
635
  ```ts
532
- // Get current row counts
533
- const counts = await table.getRowCount();
534
- console.log(`Headers: ${counts.header}, Body: ${counts.body}`);
636
+ const grid = defineTable(page.getByRole("grid"), { aria: true, headers: ["Id", "Name", "Status"] });
637
+ await expect(grid.row({ Name: "Jane" }).cell("Status")).toHaveText("Inactive");
638
+ ```
535
639
 
536
- // Assert specific row count
537
- const { body } = await table.getRowCount();
538
- expect(body).toBe(10);
640
+ | Part | Default with `aria: true` |
641
+ | ------------ | ------------------------------------------------------- |
642
+ | Header rows | `[role=row]` with a `[role=columnheader]` inside |
643
+ | Header cells | `[role=columnheader]` |
644
+ | Body rows | the other `[role=row]`s |
645
+ | Body cells | `[role=gridcell]`, `[role=cell]` and `[role=rowheader]` |
539
646
 
540
- // Check if table is empty efficiently
541
- const rowCounts = await table.getRowCount();
542
- if (rowCounts.body === 0) {
543
- console.log("Table is empty");
544
- }
545
- ```
647
+ These ARIA attributes are used whenever they are present, with or without `aria: true`:
546
648
 
547
- **Use Cases:**
649
+ - **`aria-colindex`** places a cell in its column, so columns stay aligned when the grid only renders the columns in view (column virtualization). A column that is not rendered has no cells, so its locators wait until you scroll it into view.
650
+ - **`aria-rowindex`** joins row elements with the same index into one row. Grids with pinned columns render each row as several elements (one per pinned area), and `row()`, `cell()` and `toJson()` see them as one row. A joined row's `locator` is its first element, so `row.locator.filter({ hasText })` only sees the text of that part; use `row()` conditions instead. When every row has an `aria-rowindex`, rows are ordered by it, because grids don't always keep the DOM in visual order.
651
+ - **`aria-colspan`** and **`aria-rowspan`** work like `colspan` and `rowspan`.
548
652
 
549
- - Quick validation of table size without fetching data
550
- - Performance-sensitive checks in loops
551
- - Conditional logic based on row counts
552
- - Monitoring table size during pagination
653
+ A row without header cells that shares its `aria-rowindex` with a header row (such as the header row of an empty pinned container) belongs to the header, not to the body.
553
654
 
554
- ---
655
+ Grids also virtualize rows: only the rows in view (plus a buffer) exist in the DOM. `toHaveRowCount`, `toHaveRows` and `toJson()` only see rendered rows. To reach the other rows, set the `scroll` option and use `findRow()`, `toJson({ allRows: true })` and `toHaveTotalRowCount` (see [Pages and virtual scrolling](#pages-and-virtual-scrolling)), and find rows by their content rather than by `rowAt()`.
555
656
 
556
- ### 13. `getDistinctColumnValues(headerName, options?)`
657
+ ### Grid presets
557
658
 
558
- Gets all distinct (unique) values from a specific column. Returns a sorted array of unique string values, excluding empty values.
659
+ `presets` holds ready-made options for popular data grids: the selectors, the loading indicator, the scroll container and the pagination controls. Spread a preset into your options to add typed headers or change a setting:
559
660
 
560
- **Parameters:**
661
+ ```ts
662
+ import { defineTable, presets } from "@cerios/playwright-table";
663
+
664
+ const cars = defineTable(page.locator("#myGrid"), { ...presets.agGrid(), headers: ["Make", "Model", "Price"] });
665
+ const users = defineTable(page.getByRole("grid"), presets.muiDataGrid());
666
+ const orders = defineTable(
667
+ page.locator("table[mat-table]"),
668
+ presets.angularMaterial({ paginator: page.locator("mat-paginator") })
669
+ );
670
+ const people = defineTable(page.locator("table"), presets.tanstackVirtual({ container: page.locator(".scroller") }));
671
+ ```
561
672
 
562
- - `headerName: string` - The header name of the column to extract distinct values from
563
- - `options?: TableOptions` - Optional table loading options
673
+ | Preset | Covers |
674
+ | --------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
675
+ | `agGrid()` | ARIA roles, header rows without the floating filter row, pinned top and bottom rows (such as totals) as footer rows, the body viewport, the paging panel ("First Page" and "Next Page"), the loading overlay, and hidden elements (`.ag-hidden`) left out of the text. Tested with AG Grid Community 36. |
676
+ | `muiDataGrid()` | ARIA roles, rows and cells without filler and skeleton cells, the virtual scroller, the "Go to next page" button, the loading indicator (MUI X v7/v8 markup). |
677
+ | `angularMaterial({ paginator? })` | `mat-table` rows and cells (MDC and legacy classes), footer rows, no "no data" row, and the sort header arrow left out of the text. With `paginator` (the `mat-paginator`, as a `Locator` or a function), its "First page" and "Next page" buttons. |
678
+ | `tanstackVirtual({ container })` | The scroll container (a `Locator`), and rows told apart by `data-index`. TanStack Table itself renders plain tables and needs no preset. |
564
679
 
565
- **Returns:** `Promise<string[]>` - Sorted array of distinct values
680
+ AG Grid also virtualizes columns: only the columns in view are rendered. Rows are read with every column the grid has rendered; to read the others, set `suppressColumnVirtualisation: true` in your test setup or make the grid wide enough.
566
681
 
567
- **Throws:** Error if the specified header is not found in the table
682
+ ### Pages and virtual scrolling
568
683
 
569
- **Example:**
684
+ Tell the table how to reach its other rows, and `findRow()`, `toJson({ allRows: true })`, `totalRowCount()` and `toHaveTotalRowCount` go through them:
570
685
 
571
686
  ```ts
572
- // Get all unique status values
573
- const statuses = await table.getDistinctColumnValues("Status");
574
- // Result: ["Active", "Inactive", "Pending"]
575
-
576
- // Get distinct countries for filtering
577
- const countries = await table.getDistinctColumnValues("Country");
578
- console.log(`Available countries: ${countries.join(", ")}`);
687
+ const orders = defineTable(page.locator("#orders"), {
688
+ // Paginated: the next page control (and optionally the first page control, to always start on page 1)
689
+ paginate: { next: page.getByRole("button", { name: "Next" }), first: page.getByRole("button", { name: "First" }) },
690
+ // Virtualized: the element that scrolls (a function gets the table root)
691
+ scroll: { container: root => root.locator(".viewport") },
692
+ // Wait for this indicator to be hidden after every page change or scroll
693
+ loading: root => root.getByRole("progressbar"),
694
+ });
579
695
 
580
- // Verify expected values exist
581
- const roles = await table.getDistinctColumnValues("Role");
582
- expect(roles).toContain("Admin");
583
- expect(roles).toHaveLength(3);
696
+ const row = await orders.findRow({ Id: "1042" }); // searches every page and scroll position
697
+ await row.cell("Status").click(); // the table is left showing the row
584
698
 
585
- // Build dynamic filters from table data
586
- const categories = await table.getDistinctColumnValues("Category");
587
- for (const category of categories) {
588
- await page.locator(`button[data-filter="${category}"]`).click();
589
- // Verify filtering works
590
- }
699
+ const all = await orders.toJson({ allRows: true }); // every row of every page, in order
700
+ await expect(orders).toHaveTotalRowCount(312);
591
701
  ```
592
702
 
593
- **Use Cases:**
594
-
595
- - Extracting filter options from table data
596
- - Validating data variety and uniqueness
597
- - Building dynamic test data sets
598
- - Verifying dropdown/select options match table content
703
+ - **`findRow(where)`** checks the rows in view first. Then it starts on the first page (with `paginate.first`) and the top, scrolls each page down step by step, and goes to the next page until a row matches. It returns `row(where)`, which now resolves to the row. Without `paginate` and `scroll`, it waits until a row matches, like a locator. On failure it says how many pages it searched.
704
+ - **`toJson({ allRows: true })`** reads the rows at every position. Rows seen at several scroll positions are read once: they are told apart by `aria-rowindex`, `row-index`, `data-rowindex`, `data-index` or `data-id` (or `scroll.rowKey`), or else by their text and how often that text came before in the view, so identical rows are all kept. When every row has a position key (all of these but `data-id`), rows are ordered by it; otherwise they keep the order they were shown in.
705
+ - **`totalRowCount()`** and **`toHaveTotalRowCount(n)`** use `aria-rowcount` when the grid declares it (minus the other rows it also counts, such as header, footer and pinned rows). Without it, they read every row like `toJson({ allRows: true })`, or count the body rows of a table without `paginate` and `scroll`.
599
706
 
600
- ---
707
+ `findRow()`, `totalRowCount()` and `toJson({ allRows: true })` take a `timeout` for the whole search, 30 seconds by default. They first wait until the table settles (and `loading` is hidden), so a table that is still loading isn't read as empty. `totalRowCount()` and `toJson({ allRows: true })` leave the table on its last page, scrolled down: without `paginate.first`, the next read starts there.
601
708
 
602
- ### 14. `findRowIndex(conditions, options?)`
709
+ | `paginate` option | Description |
710
+ | ----------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
711
+ | `next` | The control that goes to the next page. Required. |
712
+ | `first` | The control that goes to the first page. Navigation starts by clicking it, unless the table shows the first page: `first` is missing, hidden or disabled (like `isLastPage`), or else `isFirstPage` says so. |
713
+ | `isFirstPage` | `(first) => Promise<boolean>`, for a `first` that stays enabled on page 1. It replaces the disabled check: no click when it says true, and after a click, wait until it does (an error at the timeout). |
714
+ | `isLastPage` | `(next) => Promise<boolean>`. Defaults to: `next` is missing, hidden, `disabled`, `aria-disabled="true"` or in a `.disabled` element. |
715
+ | `maxPages` | Read only the first pages: `3` reads at most 3. Defaults to every page. |
603
716
 
604
- Finds the index of the first body row matching the specified conditions. Returns -1 if no matching row is found.
717
+ | `scroll` option | Description |
718
+ | --------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
719
+ | `container` | The element that scrolls. Defaults to the table root. A container that can never scroll (`overflow-y: visible` or `clip`) is an error when navigation starts, which names an element that does scroll when it finds one. So a `<table>` that scrolls in a wrapper `div` needs `container`. |
720
+ | `step` | Pixels per step. Defaults to 90% of the container height. |
721
+ | `rowKey` | The attribute that identifies a row, such as `"data-id"`. |
722
+ | `settle` | After each scroll (and two animation frames), how long the table must stay the same, in milliseconds. Defaults to 50. `0` skips it, but still waits for `loading`. |
723
+ | `maxSteps` | Scroll at most this many steps per page. Defaults to scrolling to the bottom. |
605
724
 
606
- **Parameters:**
725
+ After clicking `first`, the table must change. A `first` that stays enabled on page 1 doesn't change it, so without `isFirstPage` that wait ends after 2 seconds (0.5 seconds for a table with a `loading` indicator that doesn't show). After each page change, the table must change, have rows again and then settle; a `next` that doesn't change the table fails with a clear error. A `next` or `first` that matches twice (a pager above and below the table) uses the first one.
607
726
 
608
- - `conditions: Record<string, string>` - Record of header names and expected cell values to match
609
- - `options?: TableOptions` - Optional table loading options
727
+ `maxPages` and `maxSteps` limit what is read on purpose: with `maxPages: 3`, `toJson({ allRows: true })` returns the rows of the first 3 pages and `findRow()` searches them, without an error. `totalRowCount()` counts them too, unless the grid declares `aria-rowcount`: that is the total of the whole grid.
610
728
 
611
- **Returns:** `Promise<number>` - The 0-based index of the first matching row, or -1 if not found
729
+ The `timeout` stops a table that never ends, such as a `next` button that never gets disabled or a list that keeps growing. When it runs out before the end of the table (or the limits), the call fails and says how far it got, so you never get part of the rows as if they were all of them:
612
730
 
613
- **Throws:** Error if specified headers don't exist in the table
731
+ ```
732
+ TableLocator.toJson({ allRows: true }) timed out after 30000ms before reading every row; 2500 rows were read,
733
+ on 100 pages. Pass a larger timeout.
734
+ ```
614
735
 
615
- **Example:**
736
+ Reading a large virtualized grid takes one step per screen of rows, so for big grids prefer filtering the grid down, or asserting `toHaveTotalRowCount`, over reading every row.
616
737
 
617
- ```ts
618
- // Find index of row where Status is "Active"
619
- const index = await table.findRowIndex({ Status: "Active" });
620
- if (index >= 0) {
621
- console.log(`Found at row ${index}`);
622
- const cell = table.getBodyCellLocator(index, 0);
623
- await cell.click();
624
- }
738
+ ### Shadow DOM
625
739
 
626
- // Find row and interact with it
627
- const rowIndex = await table.findRowIndex({ Username: "john.doe" });
628
- if (rowIndex >= 0) {
629
- const deleteButton = table.getBodyCellLocator(rowIndex, 3).locator("button.delete");
630
- await deleteButton.click();
631
- } else {
632
- console.log("User not found");
633
- }
740
+ Like Playwright locators, `TableLocator` looks inside open shadow roots. Web component tables work with the custom element as the root, also when the table sits in a shadow root inside another shadow root:
634
741
 
635
- // Check if specific data exists before taking action
636
- const orderIndex = await table.findRowIndex({ "Order ID": "12345" });
637
- if (orderIndex === -1) {
638
- throw new Error("Order not found in table");
639
- }
742
+ ```ts
743
+ // <users-table id="users"> renders its <table> in an open shadow root
744
+ const users = defineTable(page.locator("#users"), { headers: ["Name", "Status"] });
745
+ await expect(users.row({ Name: "Jane" }).cell("Status")).toHaveText("Active");
640
746
  ```
641
747
 
642
- **Use Cases:**
748
+ - Rows and cells are found in the root's own shadow root and in every open shadow root inside it. A host's shadow content comes before its light DOM children.
749
+ - Like Playwright CSS, a selector matches within one shadow tree: `thead>tr` does not match a `tr` whose `thead` is in another tree.
750
+ - A table in the shadow root of a cell is content of that cell, just like a nested table.
751
+ - Closed shadow roots can't be reached.
752
+ - Cell text is read with `innerText` (or `textContent`), so text inside the shadow root of an element _in_ a cell depends on the browser.
753
+ - With `aria: true`, the `:has()` in the default row selectors does not look into a row's own shadow root. Set `header.rowSelector` and `row.rowSelector` for grids whose rows render their cells in a shadow root.
643
754
 
644
- - Finding row position for interaction without fetching all data
645
- - Conditional logic based on row existence
646
- - Getting row indices for further locator operations
647
- - Checking data presence without waiting
755
+ ## Reference
648
756
 
649
- ---
757
+ ### How text is read
650
758
 
651
- ## Advanced Options
759
+ `TableLocator` reads headers and cells the way a person reads them on screen, so what you see in the browser is what you write in your test.
652
760
 
653
- ### Custom Selectors
761
+ **Header text is always one line.** Line breaks (`<br>`, block elements, newlines in the HTML), tabs and non-breaking spaces become one space.
654
762
 
655
- Customize selectors for non-standard table structures or div-based tables:
763
+ **Body cells keep their line breaks**, as one `\n` between trimmed lines. Empty lines are dropped.
656
764
 
657
- ```ts
658
- const table = new PlaywrightTable(page.locator(".custom-table"), {
659
- header: {
660
- setMainHeaderRow: 0, // Use first header row as main (default: last row)
661
- rowSelector: ".header-row", // Custom header row selector
662
- columnSelector: ".header-cell", // Custom header cell selector
663
- },
664
- row: {
665
- rowSelector: ".data-row", // Custom body row selector
666
- columnSelector: ".data-cell", // Custom body cell selector
667
- },
668
- });
669
- ```
765
+ | In the page | Header key | Body cell text |
766
+ | ------------------------------------------------------------------- | ---------------------- | ----------------------- |
767
+ | `First<br>name`, `<div>First</div><div>name</div>` | `"First name"` | `"First\nname"` |
768
+ | `Street 1<br><br>&nbsp; Amsterdam` | `"Street 1 Amsterdam"` | `"Street 1\nAmsterdam"` |
769
+ | `John⏎ Doe` (indentation in the HTML) | `"John Doe"` | `"John Doe"` |
770
+ | `Verzekerings&shy;nummer`, zero-width spaces, bidi marks | `"Verzekeringsnummer"` | `"Verzekeringsnummer"` |
771
+ | `Name <span class="material-icons">arrow_upward</span>`, `&#xE5CF;` | `"Name"` | `"Name"` |
772
+ | `Name<span class="sr-only">, sortable</span>` | `"Name"` | `"Name"` |
773
+ | `Name` in a cell with `text-transform: uppercase` | `"NAME"` | `"NAME"` |
774
+ | `Cafe&#x301;` (decomposed accent) | `"Café"` | `"Café"` |
775
+ | `<select>` with `High` selected | — | `"High"` |
670
776
 
671
- ### Div-based Tables
777
+ In short:
672
778
 
673
- ```ts
674
- // Works with div-based table structures
675
- const divTable = new PlaywrightTable(page.locator(".divTable"), {
676
- header: {
677
- rowSelector: ".divTableHeading > .divTableRow",
678
- columnSelector: ".divTableHead",
679
- },
680
- row: {
681
- rowSelector: ".divTableBody > .divTableRow",
682
- columnSelector: ".divTableCell",
683
- },
684
- });
685
- ```
779
+ - **Invisible characters are removed**: soft hyphens, zero-width spaces, word joiners, byte order marks and bidi marks. Zero-width joiners stay, so emoji like 👨‍👩‍👧 are kept whole.
780
+ - **Icon glyphs are removed**: Unicode private use characters, where icon fonts such as Font Awesome put their icons. Real symbols such as `€`, `°C` and `✓` stay. A header that is only an icon becomes `{{Empty}}`.
781
+ - **Ignored elements are left out**: the text of every element inside a cell that matches one of the `ignore` selectors (see below).
782
+ - **Hidden elements are left out**: `display: none` and `visibility: hidden`, as `innerText` does. CSS `text-transform` is kept.
783
+ - **Accents are composed** (Unicode NFC), so `é` matches however the page encodes it.
686
784
 
687
- ### ARIA Treegrid Tables
785
+ With `cellContentType: CellContentType.TextContent`, all text in the DOM is read, hidden elements included, and `text-transform` is ignored. The other rules, `ignore` included, still apply.
688
786
 
689
- For `role="treegrid"` structures, make sure header and body row selectors are scoped to different containers.
690
- If both use the same selector (for example `div[role='row']`), header rows can be included in body rows.
787
+ The header names you pass are normalized the same way, so `"First\nname"`, `"First name"` and `"First name"` all find the header `First name`. That goes for `row()`, `cell()`, `column()`, `header()`, `validateHeaders()` and the matchers.
788
+
789
+ Values in conditions are normalized like body cells, so `"Street 1\r\nAmsterdam"` matches a cell that shows two lines. A string condition must include the line break. To match one line of a cell, use `contains` or a RegExp:
691
790
 
692
791
  ```ts
693
- const table = new PlaywrightTable(page.locator("[role='treegrid']"), {
694
- header: {
695
- setMainHeaderRow: 0,
696
- rowSelector: "[data-kind='header-group'] > div[role='row']",
697
- columnSelector: "div[role='columnheader']",
698
- },
699
- row: {
700
- rowSelector: "[data-kind='body-group'] > div[role='row']",
701
- columnSelector: "div[role='gridcell']",
702
- },
703
- });
792
+ await expect(users.row({ Address: "Street 1\nAmsterdam" }).cell("Name")).toHaveText("John");
793
+ await expect(users.row({ Address: { contains: "Amsterdam" } }).cell("Name")).toHaveText("John");
794
+ await expect(users.row({ Address: /^Amsterdam$/m }).cell("Name")).toHaveText("John");
704
795
  ```
705
796
 
706
- ### Header Row Options
797
+ For the text exactly as the browser reads it, use `toJson({ raw: true })`, the second argument of a [`predicate`](#predicates), or the cell locator itself (`await row.cell("Address").innerText()`).
707
798
 
708
- Control how headers are processed:
709
-
710
- ```ts
711
- const headers = await table.getHeaderRows({
712
- cellContentType: CellContentType.TextContent, // Include hidden text
713
- emptyCellReplacement: true, // Replace "" with "{{Empty}}"
714
- duplicateSuffix: true, // "Name", "Name__D1", "Name__D2"
715
- normalizeWhitespace: true, // "Account\nName" -> "Account Name"
716
- stripIconGlyphs: true, // Removes private-use icon glyph chars from header text
717
- colspan: {
718
- enabled: true, // Process colspan attributes
719
- suffix: true, // "Col", "Col__C1", "Col__C2"
720
- },
721
- });
722
- ```
799
+ #### Ignored elements
723
800
 
724
- ---
801
+ Some text in a cell isn't meant to be read: screen reader only text such as `, sorted ascending`, or icon fonts that draw a word such as `arrow_upward` as an icon. The browser has no way to tell these apart from other text, so `TableLocator` leaves out the elements that match a list of CSS selectors. By default, that list is `DEFAULT_IGNORE`:
725
802
 
726
- ## Error Handling
803
+ | Selector | What it covers |
804
+ | --------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------- |
805
+ | `.sr-only`, `.visually-hidden` | Screen reader only text (Tailwind, Bootstrap) |
806
+ | `.material-icons`, `.material-icons-outlined`, `.material-icons-round`, `.material-icons-sharp`, `.material-icons-two-tone` | Material Icons ligatures |
807
+ | `.material-symbols-outlined`, `.material-symbols-rounded`, `.material-symbols-sharp` | Material Symbols ligatures |
808
+ | `[aria-hidden=true]` | Decorative content hidden from assistive technology |
727
809
 
728
- The library provides **detailed error messages** with context to help you debug issues:
810
+ Set `ignore` to change the list. It replaces the defaults, so spread them to add your own:
729
811
 
730
812
  ```ts
731
- try {
732
- await table.getBodyCellLocatorByRowConditions({ "Invalid Header": "value" }, "Target");
733
- } catch (error) {
734
- console.error(error.message);
735
- // Error output:
736
- // Header "Invalid Header" not found.
737
- // Available headers: [First name, Last name, Date of birth]
738
- // Header row locator: Locator@table >> thead >> tr
739
- }
740
- ```
813
+ import { DEFAULT_IGNORE, defineTable } from "@cerios/playwright-table";
741
814
 
742
- **Error Context Includes:**
815
+ const users = defineTable(page.locator("#users"), { ignore: [...DEFAULT_IGNORE, ".sort-icon", ".badge-count"] });
816
+ const everything = defineTable(page.locator("#users"), { ignore: [] }); // read all text
817
+ ```
743
818
 
744
- - Locator descriptions with full selector chains
745
- - Available headers when header not found
746
- - Row/column indices when out of bounds
747
- - Cell content types in use
748
- - Validation failures with expected vs actual values
819
+ The selectors apply to elements inside header and body cells, not to the cells themselves. Raw values (`toJson({ raw: true })` and a predicate's `raw` argument) are never filtered.
749
820
 
750
- ---
821
+ ### Header keys
751
822
 
752
- ## Best Practices
823
+ Header keys are the names you use for columns, with the header text read as described in [How text is read](#how-text-is-read):
753
824
 
754
- ### 1. Wait for Dynamic Content
825
+ - Colspan continuations get `__C<n>`. For example, a `Name` header spanning two columns gives the keys `Name` and `Name__C1`.
826
+ - Duplicate names get `__D<n>`: the second `Name` becomes `Name__D1`, the third `Name__D2`.
827
+ - Empty headers become `{{Empty}}`.
828
+ - Headers carried down by `rowspan` keep their text.
829
+ - The main header row is the last header row, unless `header.setMainHeaderRow` is set.
755
830
 
756
- ```ts
757
- // ❌ Bad - might fail if table still loading
758
- const data = await table.getJson();
831
+ Header arguments accept ``H | `${H}__C${number}` | `${H}__D${number}` ``, so `users.row({ Name__C1: "Doe" })` type-checks.
759
832
 
760
- // ✅ Good - wait for content first
761
- await table.waitForNonEmpty();
762
- const data = await table.getJson();
833
+ If a header is unknown, the locator matches nothing and keeps waiting until the assertion times out. Locators read like the code that built them in error messages and traces (Playwright 1.53 and later):
763
834
 
764
- // ✅ Good - wait for specific row count
765
- await table.waitForExactRowCount(5);
766
- const data = await table.getJson();
835
+ ```
836
+ Error: expect(locator).toHaveText(expected) failed
767
837
 
768
- // ✅ Good - use expect.poll for flexible waiting
769
- await expect.poll(async () => (await table.getBodyRows()).length).toBe(5);
770
- const data = await table.getJson();
838
+ Locator: locator('#users').row({ "Name": "Jhon" }).cell("Status")
771
839
  ```
772
840
 
773
- ### 2. Use `expect.poll` for Flexible Waiting
841
+ For a clear failure up front that names the unknown header and lists the available ones, use `validateHeaders()` or the [matchers](#matchers).
774
842
 
775
- While this library provides dedicated wait methods, Playwright's `expect.poll` offers more flexibility when combined with retrieval methods:
843
+ ### Header validation
776
844
 
777
845
  ```ts
778
- import { expect } from "@playwright/test";
779
-
780
- // Wait for exact row count
781
- await expect.poll(async () => (await table.getBodyRows()).length).toBe(10);
846
+ const users = defineTable(page.locator("#users"), { headers: ["Name", "Status", "Email"] });
782
847
 
783
- // Wait for table to be empty
784
- await expect.poll(async () => (await table.getJson()).length).toBe(0);
848
+ await users.validateHeaders(); // waits up to 5s, then throws:
849
+ // Missing headers: [Status]
850
+ // Available headers: [Name, State, Email]
785
851
 
786
- // Wait for at least N rows
787
- await expect.poll(async () => (await table.getBodyRows()).length).toBeGreaterThanOrEqual(5);
852
+ await users.validateHeaders(["Name"], { timeout: 10_000 });
853
+ const keys = await users.headerKeys(); // ["Name", "State", "Email"]
854
+ ```
788
855
 
789
- // Wait for specific data to appear
790
- await expect.poll(() => table.getJson()).toContainEqual({ Name: "John", Status: "Active" });
856
+ ### Timeouts
791
857
 
792
- // Wait for column to have specific value
793
- await expect.poll(() => table.getDistinctColumnValues("Status")).toContain("Completed");
858
+ Locators follow Playwright's own timeouts. The rest have these defaults, and every one takes a `timeout` option:
794
859
 
795
- // Custom timeout and intervals
796
- await expect
797
- .poll(async () => (await table.getBodyRows()).length, {
798
- message: "Waiting for table to have 10 rows",
799
- timeout: 10000,
800
- intervals: [100, 250, 500, 1000],
801
- })
802
- .toBe(10);
803
- ```
860
+ | Call | Default timeout |
861
+ | ----------------------------------------------------------------------------------- | --------------------------------------------------------- |
862
+ | Matchers (`expect(table)...`, `expect(row)...`) | the `expect` timeout |
863
+ | `validateHeaders()`, `waitForStable()`, `toJson({ minRows })`, `toJson({ stable })` | 5 seconds (for `toJson`, for the whole read) |
864
+ | `row.fill()` (per action), `select()`, `deselect()`, `expand()`, `collapse()` | 5 seconds |
865
+ | `findRow()`, `totalRowCount()`, `toJson({ allRows: true })` | 30 seconds for the whole search |
866
+ | `toJson()`, `headerKeys()`, `columnValues()`, `format()` | Playwright's action timeout, for the table to be attached |
804
867
 
805
- **Benefits of `expect.poll`:**
868
+ ## Debugging failures
806
869
 
807
- - ✅ Native Playwright API with familiar assertion syntax
808
- - ✅ Rich assertion library (`toBe`, `toContain`, `toEqual`, `toMatch`, etc.)
809
- - ✅ Better error messages with actual vs expected values
810
- - ✅ Works with any retrieval method (`getBodyRows`, `getJson`, `getDistinctColumnValues`, etc.)
870
+ When a table matcher fails inside a test, the body rows are attached to the test report as a Markdown table (once per test and table, at most 1000 rows), so the HTML report and trace viewer show what the table contained. `toHaveCells` attaches the table of its row too; `toBeSelected` and `toBeExpanded` don't attach one.
811
871
 
812
- ### 3. Use Appropriate Content Type
872
+ To print a table yourself:
813
873
 
814
874
  ```ts
815
- // For visible text only (respects CSS visibility)
816
- const rows = await table.getBodyRows({
817
- cellContentType: CellContentType.InnerText,
818
- });
819
-
820
- // For all text including hidden elements
821
- const rows = await table.getBodyRows({
822
- cellContentType: CellContentType.TextContent,
823
- });
875
+ console.log(await users.format({ columns: ["Name", "Status"] }));
876
+ // # | Name | Status
877
+ // 0 | John | Active
878
+ // 1 | Jane | Inactive
824
879
  ```
825
880
 
826
- ### 4. Handle Complex Tables
881
+ `format()` shows the rows in view, at most `maxRows` (50 by default). Cells longer than 30 characters are cut off, and line breaks show as `↵`. Unknown `columns` are skipped.
827
882
 
828
- ```ts
829
- // Enable all options for complex tables with colspan/rowspan
830
- const json = await table.getJson({
831
- headerRowOptions: {
832
- colspan: { enabled: true, suffix: true },
833
- duplicateSuffix: true,
834
- emptyCellReplacement: true,
835
- },
836
- });
837
- ```
883
+ ## Common pitfalls
838
884
 
839
- ### 5. Leverage Cell Locators for Interactions
885
+ | Pitfall | Fix |
886
+ | --------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
887
+ | Hard-coded row or column numbers break when columns move or rows reorder. | Find rows by content with `row({...})` and cells by header name. Use `rowAt()` only when the position is what you test. |
888
+ | Reading the table, then asserting on the snapshot, is flaky. | Assert with retrying matchers (`toHaveRow`, `toHaveRows`, `toHaveCells`, `toBeSortedBy`, `expect(cell).toHaveText()`). Use `toJson()` only after one of them has passed. |
889
+ | Skeleton or "Loading…" rows make a table look loaded. | Assert on content you expect (`toHaveRow`), or use `toJson({ minRows })`. `toBeSortedBy` fails on an empty body. |
890
+ | Reading the table right after a search or filter returns stale or partial rows. | `await table.waitForStable({ loading })` first, or `toJson({ stable: { loading } })` (see [Waiting for a table to settle](#waiting-for-a-table-to-settle)). |
891
+ | A filter test only checks the first row, or passes when nothing is left. | `toHaveEveryRow({ Status: "Active" })` checks every row, and fails on an empty body. |
892
+ | Numbers with a decimal comma (`1.234,56`) don't compare. | Add `decimal: ","` to numeric conditions, `toJson({ parse })` and `toBeSortedBy`. |
893
+ | A sort test only checks the arrow icon or `aria-sort`. | `toBeSortedBy` checks the data; add `ariaSort: true` to check the header too. |
894
+ | Numbers or dates sort as text (`"10"` before `"9"`, `"€ 950"` after `"€ 1,200"`). | Pass `type: "number"` or `type: "date"` to `toBeSortedBy`, or a `compare` for other formats. |
895
+ | A row header (`<th scope="row">`) or a nested table shifts the columns. | `TableLocator` handles both by default (see [Row headers and nested tables](#row-headers-and-nested-tables)). |
896
+ | Data grids only render the rows and columns in view. | Use a [preset](#grid-presets) or the `scroll` option, then `findRow()`, `toJson({ allRows: true })` and `toHaveTotalRowCount` instead of counting rendered rows (see [Pages and virtual scrolling](#pages-and-virtual-scrolling)). |
897
+ | A row is on another page. | Set `paginate` and use `findRow()`; it goes through the pages until a row matches. |
898
+ | `expect(cell).toHaveText()` fails on a cell with an icon or screen reader text. | `toHaveText` uses Playwright's own text rules. Compare as the table reads text with `toHaveCells`, `toHaveRow` or a `row()` condition (see [How text is read](#how-text-is-read)). |
899
+ | `row()` throws a strict mode violation. | Several rows match. Add a condition, or pick one with `first()`, `last()` or `nth()`. |
840
900
 
841
- ```ts
842
- // Get locator first, then interact
843
- const cell = await table.getBodyCellLocatorByRowConditions({ Name: "John Doe" }, "Actions");
844
- await cell.locator("button.edit").click();
845
- await cell.locator("button.delete").click();
846
- ```
901
+ ## `TableLocator` or `PlaywrightTable`?
847
902
 
848
- ---
903
+ | Use | When |
904
+ | --------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
905
+ | **`TableLocator`** | New tests. Assertions and interactions on rows, cells and the whole table, with auto-waiting, retries, reorder-safe locators and typed headers. |
906
+ | **`PlaywrightTable`** | Existing tests that use it, header parsing you configure yourself (`HeaderRowOptions`), and all header rows at once (`getHeaderRows()`). See [its docs](docs/playwright-table.md). |
907
+
908
+ `PlaywrightTable` is in maintenance mode: it gets fixes, and new features land in `TableLocator`. Both can be used on the same table, but on some tables `toJson()` and `getJson()` give different results (see [the differences](docs/playwright-table.md#playwrighttable-and-tablelocator-differences)).
849
909
 
850
- ## TypeScript Support
910
+ ## TypeScript
851
911
 
852
- Full TypeScript support with exported types:
912
+ The package is written in TypeScript and ships its types. The main exports:
853
913
 
854
914
  ```ts
855
915
  import {
856
- PlaywrightTable,
857
- HeaderRow,
858
- BodyRow,
859
- Cell,
916
+ // Tables
917
+ defineTable,
918
+ TableLocator,
919
+ TableRow,
920
+ presets,
921
+ DEFAULT_IGNORE,
860
922
  CellContentType,
861
- HeaderRowOptions,
862
- TableOptions,
863
- PollingOptions,
864
- RowKind,
923
+ // Test, expect and the selector engine
924
+ test,
925
+ expect,
926
+ tableMatchers,
927
+ registerTableSelectors,
928
+ // Types
929
+ type TableLocatorOptions,
930
+ type TablePreset,
931
+ type HeaderKey,
932
+ type ColumnKey,
933
+ type RowWhere,
934
+ type CellMatcher,
935
+ type CellCondition,
936
+ type NumberCondition,
937
+ type PaginateOptions,
938
+ type ScrollOptions,
939
+ type StableOptions,
940
+ type TableJsonOptions,
941
+ type TableMatcherOptions,
942
+ type TableRowsOptions,
943
+ type TableSortOptions,
944
+ type TableExpect,
865
945
  } from "@cerios/playwright-table";
866
946
  ```
867
947
 
868
- ---
869
-
870
- ## Performance
948
+ For the `PlaywrightTable` exports, see [its docs](docs/playwright-table.md#typescript).
871
949
 
872
- The library is optimized for performance:
950
+ ## Browser support
873
951
 
874
- - ✅ **Parallel data fetching** - All cells in a row are fetched simultaneously
875
- - ✅ **Efficient DOM queries** - Minimizes Playwright API calls
876
- - ✅ **Smart polling** - Configurable intervals and timeouts
877
- - ✅ **Lazy evaluation** - Only fetches data when needed
878
-
879
- ---
880
-
881
- ## Browser Support
882
-
883
- Works with all Playwright-supported browsers:
884
-
885
- - ✅ Chromium
886
- - ✅ Firefox
887
- - ✅ WebKit
888
-
889
- ---
952
+ `TableLocator` is tested on Chromium, Firefox and WebKit, also on pages whose CSP forbids `eval`.
890
953
 
891
954
  ## Contributing
892
955
 
893
956
  Contributions are welcome! Please check out the [GitHub repository](https://github.com/CeriosTesting/playwright-tables).
894
957
 
895
- ---
958
+ Run `npm run build` before `npm test`: the `dist-cjs` and `dist-esm` test projects import the built bundles to verify that the selector engine survives bundling.
896
959
 
897
960
  ## License
898
961
 
899
962
  This project is licensed under the MIT License. See the [LICENSE](LICENSE) file for details.
900
963
 
901
- ---
902
-
903
964
  ## Support
904
965
 
905
966
  - 📖 [Documentation](https://github.com/CeriosTesting/playwright-tables)