@cerios/playwright-table 2.0.1 → 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 +682 -621
- package/dist/index.d.mts +1913 -415
- package/dist/index.d.ts +1913 -415
- package/dist/index.js +5431 -1100
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +5419 -1071
- package/dist/index.mjs.map +1 -1
- package/package.json +32 -19
package/README.md
CHANGED
|
@@ -3,903 +3,964 @@
|
|
|
3
3
|
[](https://www.npmjs.com/package/@cerios/playwright-table)
|
|
4
4
|
[](https://www.npmjs.com/package/@cerios/playwright-table)
|
|
5
5
|
[](https://github.com/CeriosTesting/playwright-tables/blob/main/LICENSE)
|
|
6
|
-
[](https://playwright.dev)
|
|
7
7
|
|
|
8
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
##
|
|
29
|
+
## How it compares
|
|
26
30
|
|
|
27
|
-
|
|
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
|
-
|
|
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
|
-
|
|
33
|
-
npm i @cerios/playwright-table
|
|
34
|
-
```
|
|
43
|
+
---
|
|
35
44
|
|
|
36
|
-
|
|
45
|
+
## Installation
|
|
37
46
|
|
|
38
47
|
```bash
|
|
39
48
|
npm i -D @cerios/playwright-table
|
|
40
49
|
```
|
|
41
50
|
|
|
42
|
-
|
|
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
|
-
|
|
47
|
-
|
|
48
|
-
### Basic Usage
|
|
55
|
+
Import `test` and `expect` from this package instead of `@playwright/test`:
|
|
49
56
|
|
|
50
57
|
```ts
|
|
51
|
-
import {
|
|
58
|
+
import { test, expect, defineTable } from "@cerios/playwright-table";
|
|
52
59
|
|
|
53
|
-
|
|
54
|
-
|
|
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
|
-
//
|
|
57
|
-
|
|
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
|
-
//
|
|
62
|
-
|
|
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
|
-
//
|
|
66
|
-
await
|
|
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
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
-
|
|
74
|
-
-
|
|
75
|
-
-
|
|
76
|
-
-
|
|
77
|
-
|
|
78
|
-
|
|
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
|
-
##
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
172
|
+
The row, cell, `emptyRow` and `ignore` options take a CSS selector or a [descriptor](#selector-descriptors).
|
|
159
173
|
|
|
160
|
-
|
|
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
|
-
|
|
176
|
+
For popular data grids, [presets](#grid-presets) set these options for you.
|
|
163
177
|
|
|
164
|
-
|
|
178
|
+
### Selector descriptors
|
|
165
179
|
|
|
166
|
-
|
|
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
|
-
|
|
173
|
-
|
|
174
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
204
|
+
## Rows and cells
|
|
189
205
|
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
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
|
-
|
|
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
|
-
|
|
226
|
+
Snapshots and navigation are `async`:
|
|
197
227
|
|
|
198
228
|
```ts
|
|
199
|
-
//
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
###
|
|
249
|
+
### Column indexes and tables without headers
|
|
227
250
|
|
|
228
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
241
|
-
|
|
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
|
-
|
|
295
|
+
## Cell conditions
|
|
249
296
|
|
|
250
|
-
|
|
297
|
+
A condition in `row()` or a matcher is one of:
|
|
251
298
|
|
|
252
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
324
|
+
### Numbers
|
|
261
325
|
|
|
262
|
-
-
|
|
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
|
-
|
|
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
|
-
|
|
269
|
-
|
|
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
|
-
|
|
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
|
-
**
|
|
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
|
-
- `
|
|
289
|
-
-
|
|
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
|
-
|
|
345
|
+
`not`, `anyOf` and `allOf` take any matcher, including other combinators. `anyOf` and `allOf` need at least one matcher.
|
|
292
346
|
|
|
293
|
-
|
|
347
|
+
## Matchers
|
|
294
348
|
|
|
295
|
-
|
|
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
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
}
|
|
303
|
-
|
|
304
|
-
//
|
|
305
|
-
|
|
306
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
318
|
-
- `options?: TableOptions` - Table loading options
|
|
397
|
+
`toHaveRow`, `toHaveCells` and `toHaveEveryRow` need at least one condition: `{}` throws.
|
|
319
398
|
|
|
320
|
-
|
|
399
|
+
### What a failure shows
|
|
321
400
|
|
|
322
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
333
|
-
|
|
334
|
-
|
|
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
|
-
|
|
425
|
+
**`toBeExpanded`** fails on a row without `aria-expanded`, also with `.not`.
|
|
341
426
|
|
|
342
|
-
|
|
427
|
+
### Sorting
|
|
343
428
|
|
|
344
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
453
|
+
### Typed matcher arguments
|
|
353
454
|
|
|
354
|
-
|
|
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
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
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
|
-
|
|
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
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
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
|
-
|
|
487
|
+
// Or merge with another extended expect
|
|
488
|
+
export const expect = mergeExpects(tableExpect, myExpect);
|
|
489
|
+
```
|
|
408
490
|
|
|
409
|
-
|
|
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
|
-
|
|
493
|
+
## Reading data
|
|
412
494
|
|
|
413
|
-
|
|
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
|
-
|
|
497
|
+
```ts
|
|
498
|
+
const data = await users.toJson();
|
|
499
|
+
// [{ Name: "John", Status: "Active", Address: "Street 1\nAmsterdam" }, ...]
|
|
416
500
|
|
|
417
|
-
|
|
501
|
+
const raw = await users.toJson({ raw: true });
|
|
502
|
+
// [{ Name: "John", Status: " Active", Address: "Street 1\nAmsterdam " }, ...]
|
|
503
|
+
```
|
|
418
504
|
|
|
419
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
427
|
-
await
|
|
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
|
-
|
|
519
|
+
```ts
|
|
520
|
+
await expect(users).toHaveRow({ Status: { oneOf: ["Active", "Inactive"] } });
|
|
521
|
+
const data = await users.toJson();
|
|
522
|
+
```
|
|
440
523
|
|
|
441
|
-
|
|
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
|
-
|
|
526
|
+
### Some columns and typed values
|
|
444
527
|
|
|
445
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
454
|
-
- Error if table has rows but all cells are empty within timeout
|
|
545
|
+
### Waiting for a table to settle
|
|
455
546
|
|
|
456
|
-
|
|
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
|
-
|
|
460
|
-
await page.
|
|
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
|
-
|
|
463
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
485
|
-
|
|
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
|
-
|
|
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
|
-
|
|
595
|
+
Each action waits up to 5 seconds by default; pass `{ timeout }` to change it.
|
|
490
596
|
|
|
491
|
-
|
|
492
|
-
- Error if count is negative or not an integer
|
|
597
|
+
## Editable cells
|
|
493
598
|
|
|
494
|
-
|
|
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
|
-
|
|
498
|
-
await
|
|
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
|
-
|
|
501
|
-
await table.waitForExactRowCount(0);
|
|
608
|
+
## Placeholder rows
|
|
502
609
|
|
|
503
|
-
|
|
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
|
-
|
|
508
|
-
|
|
509
|
-
await
|
|
612
|
+
```ts
|
|
613
|
+
const orders = defineTable(page.locator("#orders"), { emptyRow: ".no-data" });
|
|
614
|
+
await expect(orders).toHaveRowCount(0);
|
|
510
615
|
```
|
|
511
616
|
|
|
512
|
-
|
|
617
|
+
The Angular Material preset sets it for the `mat-no-data-row`.
|
|
513
618
|
|
|
514
|
-
|
|
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
|
-
|
|
623
|
+
```ts
|
|
624
|
+
await expect(orders.footer.cell("Total")).toHaveText("€ 1,295.75");
|
|
625
|
+
```
|
|
522
626
|
|
|
523
|
-
|
|
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
|
-
|
|
629
|
+
## Data grids
|
|
526
630
|
|
|
527
|
-
|
|
631
|
+
### ARIA grids (AG Grid, MUI Data Grid)
|
|
528
632
|
|
|
529
|
-
|
|
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
|
-
|
|
533
|
-
|
|
534
|
-
|
|
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
|
-
|
|
537
|
-
|
|
538
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
-
|
|
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
|
-
###
|
|
657
|
+
### Grid presets
|
|
557
658
|
|
|
558
|
-
|
|
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
|
-
|
|
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
|
-
|
|
563
|
-
|
|
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
|
-
|
|
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
|
-
|
|
682
|
+
### Pages and virtual scrolling
|
|
568
683
|
|
|
569
|
-
|
|
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
|
-
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
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
|
-
//
|
|
581
|
-
|
|
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
|
-
//
|
|
586
|
-
|
|
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
|
-
|
|
594
|
-
|
|
595
|
-
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
763
|
+
**Body cells keep their line breaks**, as one `\n` between trimmed lines. Empty lines are dropped.
|
|
656
764
|
|
|
657
|
-
|
|
658
|
-
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
|
|
662
|
-
|
|
663
|
-
|
|
664
|
-
|
|
665
|
-
|
|
666
|
-
|
|
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> Amsterdam` | `"Street 1 Amsterdam"` | `"Street 1\nAmsterdam"` |
|
|
769
|
+
| `John⏎ Doe` (indentation in the HTML) | `"John Doe"` | `"John Doe"` |
|
|
770
|
+
| `Verzekerings­nummer`, zero-width spaces, bidi marks | `"Verzekeringsnummer"` | `"Verzekeringsnummer"` |
|
|
771
|
+
| `Name <span class="material-icons">arrow_upward</span>`, `` | `"Name"` | `"Name"` |
|
|
772
|
+
| `Name<span class="sr-only">, sortable</span>` | `"Name"` | `"Name"` |
|
|
773
|
+
| `Name` in a cell with `text-transform: uppercase` | `"NAME"` | `"NAME"` |
|
|
774
|
+
| `Café` (decomposed accent) | `"Café"` | `"Café"` |
|
|
775
|
+
| `<select>` with `High` selected | — | `"High"` |
|
|
670
776
|
|
|
671
|
-
|
|
777
|
+
In short:
|
|
672
778
|
|
|
673
|
-
|
|
674
|
-
|
|
675
|
-
|
|
676
|
-
|
|
677
|
-
|
|
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
|
-
|
|
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
|
-
|
|
690
|
-
|
|
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
|
-
|
|
694
|
-
|
|
695
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
810
|
+
Set `ignore` to change the list. It replaces the defaults, so spread them to add your own:
|
|
729
811
|
|
|
730
812
|
```ts
|
|
731
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
765
|
-
|
|
766
|
-
const data = await table.getJson();
|
|
835
|
+
```
|
|
836
|
+
Error: expect(locator).toHaveText(expected) failed
|
|
767
837
|
|
|
768
|
-
|
|
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
|
-
|
|
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
|
-
|
|
843
|
+
### Header validation
|
|
776
844
|
|
|
777
845
|
```ts
|
|
778
|
-
|
|
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
|
-
//
|
|
784
|
-
|
|
848
|
+
await users.validateHeaders(); // waits up to 5s, then throws:
|
|
849
|
+
// Missing headers: [Status]
|
|
850
|
+
// Available headers: [Name, State, Email]
|
|
785
851
|
|
|
786
|
-
|
|
787
|
-
|
|
852
|
+
await users.validateHeaders(["Name"], { timeout: 10_000 });
|
|
853
|
+
const keys = await users.headerKeys(); // ["Name", "State", "Email"]
|
|
854
|
+
```
|
|
788
855
|
|
|
789
|
-
|
|
790
|
-
await expect.poll(() => table.getJson()).toContainEqual({ Name: "John", Status: "Active" });
|
|
856
|
+
### Timeouts
|
|
791
857
|
|
|
792
|
-
|
|
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
|
-
|
|
796
|
-
|
|
797
|
-
|
|
798
|
-
|
|
799
|
-
|
|
800
|
-
|
|
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
|
-
|
|
868
|
+
## Debugging failures
|
|
806
869
|
|
|
807
|
-
|
|
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
|
-
|
|
872
|
+
To print a table yourself:
|
|
813
873
|
|
|
814
874
|
```ts
|
|
815
|
-
|
|
816
|
-
|
|
817
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
910
|
+
## TypeScript
|
|
851
911
|
|
|
852
|
-
|
|
912
|
+
The package is written in TypeScript and ships its types. The main exports:
|
|
853
913
|
|
|
854
914
|
```ts
|
|
855
915
|
import {
|
|
856
|
-
|
|
857
|
-
|
|
858
|
-
|
|
859
|
-
|
|
916
|
+
// Tables
|
|
917
|
+
defineTable,
|
|
918
|
+
TableLocator,
|
|
919
|
+
TableRow,
|
|
920
|
+
presets,
|
|
921
|
+
DEFAULT_IGNORE,
|
|
860
922
|
CellContentType,
|
|
861
|
-
|
|
862
|
-
|
|
863
|
-
|
|
864
|
-
|
|
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
|
-
|
|
950
|
+
## Browser support
|
|
873
951
|
|
|
874
|
-
|
|
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)
|