@rickcedwhat/playwright-smart-table 2.1.2 ā 2.2.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 +513 -71
- package/dist/strategies/index.js +0 -5
- package/dist/typeContext.d.ts +1 -1
- package/dist/typeContext.js +14 -1
- package/dist/types.d.ts +13 -1
- package/dist/useTable.js +140 -42
- package/package.json +2 -1
package/README.md
CHANGED
|
@@ -1,41 +1,79 @@
|
|
|
1
|
-
Playwright Smart Table š§
|
|
1
|
+
# Playwright Smart Table š§
|
|
2
2
|
|
|
3
|
-
A production-ready, type-safe table wrapper for Playwright.
|
|
3
|
+
A production-ready, type-safe table wrapper for Playwright that abstracts away the complexity of testing dynamic web tables. Handles pagination, infinite scroll, virtualization, and data grids (MUI, AG-Grid) so your tests remain clean and readable.
|
|
4
4
|
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
š¦ Installation
|
|
5
|
+
## š¦ Installation
|
|
8
6
|
|
|
7
|
+
```bash
|
|
9
8
|
npm install @rickcedwhat/playwright-smart-table
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
> **Note:** Requires `@playwright/test` as a peer dependency.
|
|
10
12
|
|
|
11
|
-
|
|
13
|
+
---
|
|
12
14
|
|
|
13
|
-
|
|
15
|
+
## šÆ Getting Started
|
|
14
16
|
|
|
15
|
-
|
|
17
|
+
### Step 1: Basic Table Interaction
|
|
16
18
|
|
|
17
|
-
For standard tables (
|
|
19
|
+
For standard HTML tables (`<table>`, `<tr>`, `<td>`), the library works out of the box with sensible defaults:
|
|
18
20
|
|
|
19
21
|
<!-- embed: quick-start -->
|
|
20
22
|
```typescript
|
|
23
|
+
// Example from: https://datatables.net/examples/data_sources/dom
|
|
21
24
|
const table = useTable(page.locator('#example'), {
|
|
22
25
|
headerSelector: 'thead th' // Override for this specific site
|
|
23
26
|
});
|
|
24
27
|
|
|
25
|
-
//
|
|
26
|
-
// If Airi is on Page 2, it handles pagination automatically.
|
|
28
|
+
// Find the row with Name="Airi Satou", then get the Position cell
|
|
27
29
|
const row = await table.getByRow({ Name: 'Airi Satou' });
|
|
28
30
|
|
|
29
31
|
await expect(row.getCell('Position')).toHaveText('Accountant');
|
|
30
32
|
```
|
|
31
33
|
<!-- /embed: quick-start -->
|
|
32
34
|
|
|
33
|
-
|
|
35
|
+
**What's happening here?**
|
|
36
|
+
- `useTable()` creates a smart table wrapper around your table locator
|
|
37
|
+
- `getByRow()` finds a specific row by column values
|
|
38
|
+
- The returned `SmartRow` knows its column structure, so `.getCell('Position')` works directly
|
|
39
|
+
|
|
40
|
+
### Step 2: Understanding SmartRow
|
|
41
|
+
|
|
42
|
+
The `SmartRow` is the core power of this library. Unlike a standard Playwright `Locator`, it understands your table's column structure.
|
|
43
|
+
|
|
44
|
+
<!-- embed: smart-row -->
|
|
45
|
+
```typescript
|
|
46
|
+
// Example from: https://datatables.net/examples/data_sources/dom
|
|
47
|
+
|
|
48
|
+
// Get SmartRow via getByRow
|
|
49
|
+
const row = await table.getByRow({ Name: 'Airi Satou' });
|
|
50
|
+
|
|
51
|
+
// Interact with cell using column name (resilient to column reordering)
|
|
52
|
+
await row.getCell('Position').click();
|
|
53
|
+
|
|
54
|
+
// Extract row data as JSON
|
|
55
|
+
const data = await row.toJSON();
|
|
56
|
+
console.log(data);
|
|
57
|
+
// { Name: "Airi Satou", Position: "Accountant", ... }
|
|
58
|
+
```
|
|
59
|
+
<!-- /embed: smart-row -->
|
|
60
|
+
|
|
61
|
+
**Key Benefits:**
|
|
62
|
+
- ā
Column names instead of indices (survives column reordering)
|
|
63
|
+
- ā
Extends Playwright's `Locator` API (all `.click()`, `.isVisible()`, etc. work)
|
|
64
|
+
- ā
`.toJSON()` for quick data extraction
|
|
65
|
+
|
|
66
|
+
---
|
|
67
|
+
|
|
68
|
+
## š§ Configuration & Advanced Scenarios
|
|
34
69
|
|
|
35
|
-
|
|
70
|
+
### Working with Paginated Tables
|
|
71
|
+
|
|
72
|
+
For tables that span multiple pages, configure a pagination strategy:
|
|
36
73
|
|
|
37
74
|
<!-- embed: pagination -->
|
|
38
75
|
```typescript
|
|
76
|
+
// Example from: https://datatables.net/examples/data_sources/dom
|
|
39
77
|
const table = useTable(page.locator('#example'), {
|
|
40
78
|
rowSelector: 'tbody tr',
|
|
41
79
|
headerSelector: 'thead th',
|
|
@@ -55,86 +93,205 @@ await expect(await table.getByRow({ Name: "Colleen Hurst" })).toBeVisible();
|
|
|
55
93
|
```
|
|
56
94
|
<!-- /embed: pagination -->
|
|
57
95
|
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
The core power of this library is the SmartRow.
|
|
61
|
-
|
|
62
|
-
Unlike a standard Playwright Locator, a SmartRow is aware of its context within the table's schema. It extends the standard Locator API, so you can chain standard Playwright methods (.click(), .isVisible()) directly off it.
|
|
96
|
+
### Debug Mode
|
|
63
97
|
|
|
64
|
-
|
|
65
|
-
```typescript
|
|
66
|
-
// 1. Get SmartRow via getByRow
|
|
67
|
-
const row = await table.getByRow({ Name: 'Airi Satou' });
|
|
68
|
-
|
|
69
|
-
// 2. Interact with cell (No more getByCell needed!)
|
|
70
|
-
// ā
Good: Resilient to column reordering
|
|
71
|
-
await row.getCell('Position').click();
|
|
72
|
-
|
|
73
|
-
// 3. Dump data from row
|
|
74
|
-
const data = await row.toJSON();
|
|
75
|
-
console.log(data);
|
|
76
|
-
// { Name: "Airi Satou", Position: "Accountant", ... }
|
|
77
|
-
```
|
|
78
|
-
<!-- /embed: smart-row -->
|
|
79
|
-
|
|
80
|
-
š Advanced Usage
|
|
81
|
-
|
|
82
|
-
š Debug Mode
|
|
83
|
-
|
|
84
|
-
Having trouble finding rows? Enable debug mode to see exactly what the library sees (headers mapped, rows scanned, pagination triggers).
|
|
98
|
+
Enable debug logging to see exactly what the library is doing:
|
|
85
99
|
|
|
86
100
|
<!-- embed: advanced-debug -->
|
|
87
101
|
```typescript
|
|
102
|
+
// Example from: https://datatables.net/examples/data_sources/dom
|
|
88
103
|
const table = useTable(page.locator('#example'), {
|
|
89
104
|
headerSelector: 'thead th',
|
|
90
|
-
debug: true
|
|
105
|
+
debug: true // Enables verbose logging of internal operations
|
|
91
106
|
});
|
|
107
|
+
|
|
108
|
+
const row = await table.getByRow({ Name: 'Airi Satou' });
|
|
109
|
+
await expect(row).toBeVisible();
|
|
92
110
|
```
|
|
93
111
|
<!-- /embed: advanced-debug -->
|
|
94
112
|
|
|
95
|
-
|
|
113
|
+
This will log header mappings, row scans, and pagination triggers to help troubleshoot issues.
|
|
114
|
+
|
|
115
|
+
### Resetting Table State
|
|
96
116
|
|
|
97
|
-
If your tests navigate deep into a table
|
|
117
|
+
If your tests navigate deep into a paginated table, use `.reset()` to return to the first page:
|
|
98
118
|
|
|
99
119
|
<!-- embed: advanced-reset -->
|
|
100
120
|
```typescript
|
|
101
|
-
//
|
|
102
|
-
//
|
|
121
|
+
// Example from: https://datatables.net/examples/data_sources/dom
|
|
122
|
+
// Navigate deep into the table by searching for a row on a later page
|
|
103
123
|
try {
|
|
104
124
|
await table.getByRow({ Name: 'Angelica Ramos' });
|
|
105
125
|
} catch (e) {}
|
|
106
126
|
|
|
107
127
|
// Reset internal state (and potentially UI) to Page 1
|
|
108
128
|
await table.reset();
|
|
129
|
+
|
|
130
|
+
// Now subsequent searches start from the beginning
|
|
131
|
+
const firstPageRow = await table.getByRow({ Name: 'Airi Satou' });
|
|
132
|
+
await expect(firstPageRow).toBeVisible();
|
|
109
133
|
```
|
|
110
134
|
<!-- /embed: advanced-reset -->
|
|
111
135
|
|
|
112
|
-
|
|
136
|
+
### Column Scanning
|
|
113
137
|
|
|
114
|
-
|
|
138
|
+
Efficiently extract all values from a specific column:
|
|
115
139
|
|
|
116
140
|
<!-- embed: advanced-column-scan -->
|
|
117
141
|
```typescript
|
|
142
|
+
// Example from: https://datatables.net/examples/data_sources/dom
|
|
118
143
|
// Quickly grab all text values from the "Office" column
|
|
119
144
|
const offices = await table.getColumnValues('Office');
|
|
120
145
|
expect(offices).toContain('Tokyo');
|
|
146
|
+
expect(offices.length).toBeGreaterThan(0);
|
|
121
147
|
```
|
|
122
148
|
<!-- /embed: advanced-column-scan -->
|
|
123
149
|
|
|
124
|
-
|
|
150
|
+
### Filling Row Data
|
|
151
|
+
|
|
152
|
+
Use `fill()` to intelligently populate form fields in a table row. The method automatically detects input types (text inputs, selects, checkboxes, contenteditable divs) and fills them appropriately.
|
|
125
153
|
|
|
126
|
-
|
|
154
|
+
<!-- embed: fill-basic -->
|
|
155
|
+
```typescript
|
|
156
|
+
// Find a row and fill it with new data
|
|
157
|
+
const row = await table.getByRow({ ID: '1' });
|
|
158
|
+
|
|
159
|
+
await row.fill({
|
|
160
|
+
Name: 'John Updated',
|
|
161
|
+
Status: 'Inactive',
|
|
162
|
+
Active: false,
|
|
163
|
+
Notes: 'Updated notes here'
|
|
164
|
+
});
|
|
127
165
|
|
|
128
|
-
|
|
166
|
+
// Verify the values were filled correctly
|
|
167
|
+
await expect(row.getCell('Name').locator('input')).toHaveValue('John Updated');
|
|
168
|
+
await expect(row.getCell('Status').locator('select')).toHaveValue('Inactive');
|
|
169
|
+
await expect(row.getCell('Active').locator('input[type="checkbox"]')).not.toBeChecked();
|
|
170
|
+
await expect(row.getCell('Notes').locator('textarea')).toHaveValue('Updated notes here');
|
|
171
|
+
```
|
|
172
|
+
<!-- /embed: fill-basic -->
|
|
129
173
|
|
|
130
|
-
|
|
174
|
+
**Auto-detection supports:**
|
|
175
|
+
- Text inputs (`input[type="text"]`, `textarea`)
|
|
176
|
+
- Select dropdowns (`select`)
|
|
177
|
+
- Checkboxes/radios (`input[type="checkbox"]`, `input[type="radio"]`, `[role="checkbox"]`)
|
|
178
|
+
- Contenteditable divs (`[contenteditable="true"]`)
|
|
131
179
|
|
|
132
|
-
|
|
180
|
+
**Custom input mappers:**
|
|
133
181
|
|
|
134
|
-
|
|
182
|
+
For edge cases where auto-detection doesn't work (e.g., custom components, multiple inputs in a cell), use per-column mappers:
|
|
183
|
+
|
|
184
|
+
<!-- embed: fill-custom-mappers -->
|
|
185
|
+
```typescript
|
|
186
|
+
const row = await table.getByRow({ ID: '1' });
|
|
187
|
+
|
|
188
|
+
// Use custom input mappers for specific columns
|
|
189
|
+
await row.fill({
|
|
190
|
+
Name: 'John Updated',
|
|
191
|
+
Status: 'Inactive'
|
|
192
|
+
}, {
|
|
193
|
+
inputMappers: {
|
|
194
|
+
// Name column has multiple inputs - target the primary one
|
|
195
|
+
Name: (cell) => cell.locator('.primary-input'),
|
|
196
|
+
// Status uses standard select, but we could customize if needed
|
|
197
|
+
Status: (cell) => cell.locator('select')
|
|
198
|
+
}
|
|
199
|
+
});
|
|
200
|
+
|
|
201
|
+
// Verify the values
|
|
202
|
+
await expect(row.getCell('Name').locator('.primary-input')).toHaveValue('John Updated');
|
|
203
|
+
await expect(row.getCell('Status').locator('select')).toHaveValue('Inactive');
|
|
204
|
+
```
|
|
205
|
+
<!-- /embed: fill-custom-mappers -->
|
|
206
|
+
|
|
207
|
+
### Transforming Column Headers
|
|
208
|
+
|
|
209
|
+
Use `headerTransformer` to normalize or rename column headers. This is especially useful for tables with empty headers, inconsistent formatting, or when you want to use cleaner names in your tests.
|
|
210
|
+
|
|
211
|
+
**Example 1: Renaming Empty Columns**
|
|
212
|
+
|
|
213
|
+
Tables with empty header cells (like Material UI DataGrids) get auto-assigned names like `__col_0`, `__col_1`. Transform them to meaningful names:
|
|
214
|
+
|
|
215
|
+
<!-- embed: header-transformer -->
|
|
216
|
+
```typescript
|
|
217
|
+
// Example from: https://mui.com/material-ui/react-table/
|
|
218
|
+
const table = useTable(page.locator('.MuiDataGrid-root').first(), {
|
|
219
|
+
rowSelector: '.MuiDataGrid-row',
|
|
220
|
+
headerSelector: '.MuiDataGrid-columnHeader',
|
|
221
|
+
cellSelector: '.MuiDataGrid-cell',
|
|
222
|
+
pagination: TableStrategies.clickNext(
|
|
223
|
+
(root) => root.getByRole("button", { name: "Go to next page" })
|
|
224
|
+
),
|
|
225
|
+
maxPages: 5,
|
|
226
|
+
// Transform empty columns (detected as __col_0, __col_1, etc.) to meaningful names
|
|
227
|
+
headerTransformer: ({ text }) => {
|
|
228
|
+
// We know there is only one empty column which we will rename to "Actions" for easier reference
|
|
229
|
+
if (text.includes('__col_') || text.trim() === '') {
|
|
230
|
+
return 'Actions';
|
|
231
|
+
}
|
|
232
|
+
return text;
|
|
233
|
+
}
|
|
234
|
+
});
|
|
235
|
+
|
|
236
|
+
const headers = await table.getHeaders();
|
|
237
|
+
// Now we can reference the "Actions" column even if it has no header text
|
|
238
|
+
expect(headers).toContain('Actions');
|
|
239
|
+
|
|
240
|
+
// Use the renamed column
|
|
241
|
+
const row = await table.getByRow({ "Last name": "Melisandre" });
|
|
242
|
+
await row.getCell('Actions').getByLabel("Select row").click();
|
|
243
|
+
```
|
|
244
|
+
<!-- /embed: header-transformer -->
|
|
245
|
+
|
|
246
|
+
**Example 2: Normalizing Column Names**
|
|
247
|
+
|
|
248
|
+
Clean up inconsistent column names (extra spaces, inconsistent casing):
|
|
249
|
+
|
|
250
|
+
<!-- embed: header-transformer-normalize -->
|
|
251
|
+
```typescript
|
|
252
|
+
// Example from: https://the-internet.herokuapp.com/tables
|
|
253
|
+
const table = useTable(page.locator('#table1'), {
|
|
254
|
+
// Normalize column names: remove extra spaces, handle inconsistent casing
|
|
255
|
+
headerTransformer: ({ text }) => {
|
|
256
|
+
return text.trim()
|
|
257
|
+
.replace(/\s+/g, ' ') // Normalize whitespace
|
|
258
|
+
.replace(/^\s*|\s*$/g, ''); // Remove leading/trailing spaces
|
|
259
|
+
}
|
|
260
|
+
});
|
|
261
|
+
|
|
262
|
+
// Now column names are consistent
|
|
263
|
+
const row = await table.getByRow({ "Last Name": "Doe" });
|
|
264
|
+
await expect(row.getCell("Email")).toHaveText("jdoe@hotmail.com");
|
|
265
|
+
```
|
|
266
|
+
<!-- /embed: header-transformer-normalize -->
|
|
267
|
+
|
|
268
|
+
---
|
|
269
|
+
|
|
270
|
+
## š API Reference
|
|
271
|
+
|
|
272
|
+
### Table Methods
|
|
273
|
+
|
|
274
|
+
#### <a name="getbyrow"></a>`getByRow(filters, options?)`
|
|
275
|
+
|
|
276
|
+
**Purpose:** Strict retrieval - finds exactly one row matching the filters.
|
|
277
|
+
|
|
278
|
+
**Behavior:**
|
|
279
|
+
- ā
Returns `SmartRow` if exactly one match
|
|
280
|
+
- ā Throws error if multiple matches (ambiguous query)
|
|
281
|
+
- š» Returns sentinel locator if no match (allows `.not.toBeVisible()` assertions)
|
|
282
|
+
- š Auto-paginates if row isn't on current page (when `maxPages > 1` and pagination strategy is configured)
|
|
283
|
+
|
|
284
|
+
**Type Signature:**
|
|
285
|
+
```typescript
|
|
286
|
+
getByRow: <T extends { asJSON?: boolean }>(
|
|
287
|
+
filters: Record<string, string | RegExp | number>,
|
|
288
|
+
options?: { exact?: boolean, maxPages?: number } & T
|
|
289
|
+
) => Promise<T['asJSON'] extends true ? Record<string, string> : SmartRow>;
|
|
290
|
+
```
|
|
135
291
|
|
|
136
292
|
<!-- embed: get-by-row -->
|
|
137
293
|
```typescript
|
|
294
|
+
// Example from: https://datatables.net/examples/data_sources/dom
|
|
138
295
|
// Find a row where Name is "Airi Satou" AND Office is "Tokyo"
|
|
139
296
|
const row = await table.getByRow({ Name: "Airi Satou", Office: "Tokyo" });
|
|
140
297
|
await expect(row).toBeVisible();
|
|
@@ -144,18 +301,37 @@ await expect(await table.getByRow({ Name: "Ghost User" })).not.toBeVisible();
|
|
|
144
301
|
```
|
|
145
302
|
<!-- /embed: get-by-row -->
|
|
146
303
|
|
|
147
|
-
|
|
304
|
+
Get row data as JSON:
|
|
305
|
+
<!-- embed: get-by-row-json -->
|
|
306
|
+
```typescript
|
|
307
|
+
// Get row data directly as JSON object
|
|
308
|
+
const data = await table.getByRow({ Name: 'Airi Satou' }, { asJSON: true });
|
|
309
|
+
// Returns: { Name: "Airi Satou", Position: "Accountant", Office: "Tokyo", ... }
|
|
310
|
+
|
|
311
|
+
expect(data).toHaveProperty('Name', 'Airi Satou');
|
|
312
|
+
expect(data).toHaveProperty('Position');
|
|
313
|
+
```
|
|
314
|
+
<!-- /embed: get-by-row-json -->
|
|
148
315
|
|
|
149
|
-
|
|
316
|
+
#### <a name="getallrows"></a>`getAllRows(options?)`
|
|
150
317
|
|
|
151
|
-
|
|
318
|
+
**Purpose:** Inclusive retrieval - gets all rows matching optional filters.
|
|
152
319
|
|
|
153
|
-
Best for
|
|
320
|
+
**Best for:** Checking existence, validating sort order, bulk data extraction.
|
|
321
|
+
|
|
322
|
+
**Type Signature:**
|
|
323
|
+
```typescript
|
|
324
|
+
getAllRows: <T extends { asJSON?: boolean }>(
|
|
325
|
+
options?: { filter?: Record<string, any>, exact?: boolean } & T
|
|
326
|
+
) => Promise<T['asJSON'] extends true ? Record<string, string>[] : SmartRow[]>;
|
|
327
|
+
```
|
|
154
328
|
|
|
155
329
|
<!-- embed: get-all-rows -->
|
|
156
330
|
```typescript
|
|
331
|
+
// Example from: https://datatables.net/examples/data_sources/dom
|
|
157
332
|
// 1. Get ALL rows on the current page
|
|
158
333
|
const allRows = await table.getAllRows();
|
|
334
|
+
expect(allRows.length).toBeGreaterThan(0);
|
|
159
335
|
|
|
160
336
|
// 2. Get subset of rows (Filtering)
|
|
161
337
|
const tokyoUsers = await table.getAllRows({
|
|
@@ -166,40 +342,306 @@ expect(tokyoUsers.length).toBeGreaterThan(0);
|
|
|
166
342
|
// 3. Dump data to JSON
|
|
167
343
|
const data = await table.getAllRows({ asJSON: true });
|
|
168
344
|
console.log(data); // [{ Name: "Airi Satou", ... }, ...]
|
|
345
|
+
expect(data.length).toBeGreaterThan(0);
|
|
346
|
+
expect(data[0]).toHaveProperty('Name');
|
|
169
347
|
```
|
|
170
348
|
<!-- /embed: get-all-rows -->
|
|
171
349
|
|
|
172
|
-
|
|
350
|
+
Filter rows with exact match:
|
|
351
|
+
<!-- embed: get-all-rows-exact -->
|
|
352
|
+
```typescript
|
|
353
|
+
// Get rows with exact match (default is fuzzy/contains match)
|
|
354
|
+
const exactMatches = await table.getAllRows({
|
|
355
|
+
filter: { Office: 'Tokyo' },
|
|
356
|
+
exact: true // Requires exact string match
|
|
357
|
+
});
|
|
173
358
|
|
|
174
|
-
|
|
359
|
+
expect(exactMatches.length).toBeGreaterThan(0);
|
|
360
|
+
```
|
|
361
|
+
<!-- /embed: get-all-rows-exact -->
|
|
175
362
|
|
|
176
|
-
|
|
363
|
+
#### <a name="getcolumnvalues"></a>`getColumnValues(column, options?)`
|
|
177
364
|
|
|
178
|
-
|
|
365
|
+
Scans a specific column across all pages and returns values. Supports custom mappers for extracting non-text data.
|
|
179
366
|
|
|
367
|
+
**Type Signature:**
|
|
368
|
+
```typescript
|
|
369
|
+
getColumnValues: <V = string>(
|
|
370
|
+
column: string,
|
|
371
|
+
options?: {
|
|
372
|
+
mapper?: (cell: Locator) => Promise<V> | V,
|
|
373
|
+
maxPages?: number
|
|
374
|
+
}
|
|
375
|
+
) => Promise<V[]>;
|
|
376
|
+
```
|
|
377
|
+
|
|
378
|
+
Basic usage:
|
|
379
|
+
<!-- embed: advanced-column-scan -->
|
|
380
|
+
```typescript
|
|
381
|
+
// Example from: https://datatables.net/examples/data_sources/dom
|
|
382
|
+
// Quickly grab all text values from the "Office" column
|
|
383
|
+
const offices = await table.getColumnValues('Office');
|
|
384
|
+
expect(offices).toContain('Tokyo');
|
|
385
|
+
expect(offices.length).toBeGreaterThan(0);
|
|
386
|
+
```
|
|
387
|
+
<!-- /embed: advanced-column-scan -->
|
|
388
|
+
|
|
389
|
+
With custom mapper:
|
|
390
|
+
<!-- embed: advanced-column-scan-mapper -->
|
|
391
|
+
```typescript
|
|
392
|
+
// Extract numeric values from a column
|
|
393
|
+
const ages = await table.getColumnValues('Age', {
|
|
394
|
+
mapper: async (cell) => {
|
|
395
|
+
const text = await cell.innerText();
|
|
396
|
+
return parseInt(text, 10);
|
|
397
|
+
}
|
|
398
|
+
});
|
|
399
|
+
|
|
400
|
+
// Now ages is an array of numbers
|
|
401
|
+
expect(ages.every(age => typeof age === 'number')).toBe(true);
|
|
402
|
+
expect(ages.length).toBeGreaterThan(0);
|
|
403
|
+
```
|
|
404
|
+
<!-- /embed: advanced-column-scan-mapper -->
|
|
405
|
+
|
|
406
|
+
#### <a name="getheaders"></a>`getHeaders()`
|
|
407
|
+
|
|
408
|
+
Returns an array of all column names in the table.
|
|
409
|
+
|
|
410
|
+
**Type Signature:**
|
|
411
|
+
```typescript
|
|
412
|
+
getHeaders: () => Promise<string[]>;
|
|
413
|
+
```
|
|
414
|
+
|
|
415
|
+
#### <a name="getheadercell"></a>`getHeaderCell(columnName)`
|
|
416
|
+
|
|
417
|
+
Returns a Playwright `Locator` for the specified header cell.
|
|
418
|
+
|
|
419
|
+
**Type Signature:**
|
|
420
|
+
```typescript
|
|
421
|
+
getHeaderCell: (columnName: string) => Promise<Locator>;
|
|
422
|
+
```
|
|
423
|
+
|
|
424
|
+
#### <a name="reset"></a>`reset()`
|
|
425
|
+
|
|
426
|
+
Resets table state (clears cache, pagination flags) and invokes the `onReset` strategy to return to the first page.
|
|
427
|
+
|
|
428
|
+
**Type Signature:**
|
|
429
|
+
```typescript
|
|
430
|
+
reset: () => Promise<void>;
|
|
431
|
+
```
|
|
432
|
+
|
|
433
|
+
---
|
|
434
|
+
|
|
435
|
+
## š§© Pagination Strategies
|
|
436
|
+
|
|
437
|
+
This library uses the **Strategy Pattern** for pagination. Use built-in strategies or write custom ones.
|
|
438
|
+
|
|
439
|
+
### Built-in Strategies
|
|
440
|
+
|
|
441
|
+
#### <a name="tablestrategiesclicknext"></a>`TableStrategies.clickNext(selector)`
|
|
442
|
+
|
|
443
|
+
Best for standard paginated tables (Datatables, lists). Clicks a button/link and waits for table content to change.
|
|
444
|
+
|
|
445
|
+
```typescript
|
|
180
446
|
pagination: TableStrategies.clickNext((root) =>
|
|
181
|
-
root.page().getByRole('button', { name: 'Next' })
|
|
447
|
+
root.page().getByRole('button', { name: 'Next' })
|
|
182
448
|
)
|
|
449
|
+
```
|
|
450
|
+
|
|
451
|
+
#### <a name="tablestrategiesinfinitescroll"></a>`TableStrategies.infiniteScroll()`
|
|
183
452
|
|
|
184
|
-
|
|
453
|
+
Best for virtualized grids (AG-Grid, HTMX). Aggressively scrolls to trigger data loading.
|
|
185
454
|
|
|
455
|
+
```typescript
|
|
186
456
|
pagination: TableStrategies.infiniteScroll()
|
|
457
|
+
```
|
|
458
|
+
|
|
459
|
+
#### <a name="tablestrategiesclickloadmore"></a>`TableStrategies.clickLoadMore(selector)`
|
|
187
460
|
|
|
188
|
-
|
|
461
|
+
Best for "Load More" buttons. Clicks and waits for row count to increase.
|
|
462
|
+
|
|
463
|
+
```typescript
|
|
464
|
+
pagination: TableStrategies.clickLoadMore((root) =>
|
|
465
|
+
root.getByRole('button', { name: 'Load More' })
|
|
466
|
+
)
|
|
467
|
+
```
|
|
468
|
+
|
|
469
|
+
### Custom Strategies
|
|
470
|
+
|
|
471
|
+
A pagination strategy is a function that receives a `TableContext` and returns `Promise<boolean>` (true if more data loaded, false if no more pages):
|
|
472
|
+
|
|
473
|
+
<!-- embed-type: PaginationStrategy -->
|
|
474
|
+
```typescript
|
|
475
|
+
export type PaginationStrategy = (context: TableContext) => Promise<boolean>;
|
|
476
|
+
```
|
|
477
|
+
<!-- /embed-type: PaginationStrategy -->
|
|
478
|
+
|
|
479
|
+
<!-- embed-type: TableContext -->
|
|
480
|
+
```typescript
|
|
481
|
+
export interface TableContext {
|
|
482
|
+
root: Locator;
|
|
483
|
+
config: Required<TableConfig>;
|
|
484
|
+
page: Page;
|
|
485
|
+
resolve: (selector: Selector, parent: Locator | Page) => Locator;
|
|
486
|
+
}
|
|
487
|
+
```
|
|
488
|
+
<!-- /embed-type: TableContext -->
|
|
189
489
|
|
|
190
|
-
|
|
490
|
+
---
|
|
191
491
|
|
|
192
|
-
|
|
492
|
+
## š ļø Developer Tools
|
|
193
493
|
|
|
194
|
-
generateConfigPrompt(options?)
|
|
494
|
+
### <a name="generateconfigprompt"></a>`generateConfigPrompt(options?)`
|
|
195
495
|
|
|
196
|
-
|
|
496
|
+
Generates a prompt you can paste into ChatGPT/Gemini to automatically generate the `TableConfig` for your specific HTML.
|
|
197
497
|
|
|
198
|
-
|
|
498
|
+
```typescript
|
|
199
499
|
await table.generateConfigPrompt({ output: 'console' });
|
|
500
|
+
```
|
|
501
|
+
|
|
502
|
+
### <a name="generatestrategyprompt"></a>`generateStrategyPrompt(options?)`
|
|
503
|
+
|
|
504
|
+
Generates a prompt to help you write a custom pagination strategy.
|
|
505
|
+
|
|
506
|
+
```typescript
|
|
507
|
+
await table.generateStrategyPrompt({ output: 'console' });
|
|
508
|
+
```
|
|
509
|
+
|
|
510
|
+
**Options:**
|
|
511
|
+
<!-- embed-type: PromptOptions -->
|
|
512
|
+
```typescript
|
|
513
|
+
export interface PromptOptions {
|
|
514
|
+
/**
|
|
515
|
+
* Output Strategy:
|
|
516
|
+
* - 'error': Throws an error with the prompt (Best for Cloud/QA Wolf to get clean text).
|
|
517
|
+
* - 'console': Standard console logs (Default).
|
|
518
|
+
*/
|
|
519
|
+
output?: 'console' | 'error';
|
|
520
|
+
includeTypes?: boolean;
|
|
521
|
+
}
|
|
522
|
+
```
|
|
523
|
+
<!-- /embed-type: PromptOptions -->
|
|
524
|
+
|
|
525
|
+
---
|
|
526
|
+
|
|
527
|
+
## š Type Reference
|
|
528
|
+
|
|
529
|
+
### Core Types
|
|
530
|
+
|
|
531
|
+
#### <a name="smartrow"></a>`SmartRow`
|
|
532
|
+
|
|
533
|
+
A `SmartRow` extends Playwright's `Locator` with table-aware methods.
|
|
534
|
+
|
|
535
|
+
<!-- embed-type: SmartRow -->
|
|
536
|
+
```typescript
|
|
537
|
+
export type SmartRow = Omit<Locator, 'fill'> & {
|
|
538
|
+
getCell(column: string): Locator;
|
|
539
|
+
toJSON(): Promise<Record<string, string>>;
|
|
540
|
+
/**
|
|
541
|
+
* Fills the row with data. Automatically detects input types (text input, select, checkbox, etc.).
|
|
542
|
+
*/
|
|
543
|
+
fill: (data: Record<string, any>, options?: FillOptions) => Promise<void>;
|
|
544
|
+
};
|
|
545
|
+
```
|
|
546
|
+
<!-- /embed-type: SmartRow -->
|
|
547
|
+
|
|
548
|
+
**Methods:**
|
|
549
|
+
- `getCell(column: string)`: Returns a `Locator` for the specified cell in this row
|
|
550
|
+
- `toJSON()`: Extracts all cell data as a key-value object
|
|
551
|
+
- `fill(data, options?)`: Intelligently fills form fields in the row. Automatically detects input types or use `inputMappers` for custom control
|
|
552
|
+
|
|
553
|
+
All standard Playwright `Locator` methods (`.click()`, `.isVisible()`, `.textContent()`, etc.) are also available.
|
|
554
|
+
|
|
555
|
+
#### <a name="tableconfig"></a>`TableConfig`
|
|
556
|
+
|
|
557
|
+
Configuration options for `useTable()`.
|
|
558
|
+
|
|
559
|
+
<!-- embed-type: TableConfig -->
|
|
560
|
+
```typescript
|
|
561
|
+
export interface TableConfig {
|
|
562
|
+
rowSelector?: Selector;
|
|
563
|
+
headerSelector?: Selector;
|
|
564
|
+
cellSelector?: Selector;
|
|
565
|
+
pagination?: PaginationStrategy;
|
|
566
|
+
maxPages?: number;
|
|
567
|
+
/**
|
|
568
|
+
* Hook to rename columns dynamically.
|
|
569
|
+
* * @param args.text - The default innerText of the header.
|
|
570
|
+
* @param args.index - The column index.
|
|
571
|
+
* @param args.locator - The specific header cell locator.
|
|
572
|
+
*/
|
|
573
|
+
headerTransformer?: (args: { text: string, index: number, locator: Locator }) => string | Promise<string>;
|
|
574
|
+
autoScroll?: boolean;
|
|
575
|
+
/**
|
|
576
|
+
* Enable debug mode to log internal state to console.
|
|
577
|
+
*/
|
|
578
|
+
debug?: boolean;
|
|
579
|
+
/**
|
|
580
|
+
* Strategy to reset the table to the first page.
|
|
581
|
+
* Called when table.reset() is invoked.
|
|
582
|
+
*/
|
|
583
|
+
onReset?: (context: TableContext) => Promise<void>;
|
|
584
|
+
}
|
|
585
|
+
```
|
|
586
|
+
<!-- /embed-type: TableConfig -->
|
|
587
|
+
|
|
588
|
+
**Property Descriptions:**
|
|
589
|
+
|
|
590
|
+
- `rowSelector`: CSS selector or function for table rows (default: `"tbody tr"`)
|
|
591
|
+
- `headerSelector`: CSS selector or function for header cells (default: `"th"`)
|
|
592
|
+
- `cellSelector`: CSS selector or function for data cells (default: `"td"`)
|
|
593
|
+
- `pagination`: Strategy function for navigating pages (default: no pagination)
|
|
594
|
+
- `maxPages`: Maximum pages to scan when searching (default: `1`)
|
|
595
|
+
- `headerTransformer`: Function to transform/rename column headers dynamically
|
|
596
|
+
- `autoScroll`: Automatically scroll table into view (default: `true`)
|
|
597
|
+
- `debug`: Enable verbose logging (default: `false`)
|
|
598
|
+
- `onReset`: Strategy called when `table.reset()` is invoked
|
|
599
|
+
|
|
600
|
+
#### <a name="selector"></a>`Selector`
|
|
601
|
+
|
|
602
|
+
Flexible selector type supporting strings, functions, or existing locators.
|
|
603
|
+
|
|
604
|
+
<!-- embed-type: Selector -->
|
|
605
|
+
```typescript
|
|
606
|
+
export type Selector = string | ((root: Locator | Page) => Locator);
|
|
607
|
+
```
|
|
608
|
+
<!-- /embed-type: Selector -->
|
|
609
|
+
|
|
610
|
+
**Examples:**
|
|
611
|
+
```typescript
|
|
612
|
+
// String selector
|
|
613
|
+
rowSelector: 'tbody tr'
|
|
614
|
+
|
|
615
|
+
// Function selector (useful for complex cases)
|
|
616
|
+
rowSelector: (root) => root.locator('[role="row"]')
|
|
617
|
+
|
|
618
|
+
// Can also accept a Locator directly
|
|
619
|
+
```
|
|
620
|
+
|
|
621
|
+
#### <a name="paginationstrategy"></a>`PaginationStrategy`
|
|
622
|
+
|
|
623
|
+
Function signature for custom pagination logic.
|
|
624
|
+
|
|
625
|
+
<!-- embed-type: PaginationStrategy -->
|
|
626
|
+
```typescript
|
|
627
|
+
export type PaginationStrategy = (context: TableContext) => Promise<boolean>;
|
|
628
|
+
```
|
|
629
|
+
<!-- /embed-type: PaginationStrategy -->
|
|
630
|
+
|
|
631
|
+
Returns `true` if more data was loaded, `false` if pagination should stop.
|
|
632
|
+
|
|
633
|
+
---
|
|
634
|
+
|
|
635
|
+
## š Tips & Best Practices
|
|
636
|
+
|
|
637
|
+
1. **Start Simple**: Try the defaults first - they work for most standard HTML tables
|
|
638
|
+
2. **Use Debug Mode**: When troubleshooting, enable `debug: true` to see what the library is doing
|
|
639
|
+
3. **Leverage SmartRow**: Use `.getCell()` instead of manual column indices - your tests will be more maintainable
|
|
640
|
+
4. **Type Safety**: All methods are fully typed - use TypeScript for the best experience
|
|
641
|
+
5. **Pagination Strategies**: Create reusable strategies for tables with similar pagination patterns
|
|
200
642
|
|
|
201
|
-
|
|
643
|
+
---
|
|
202
644
|
|
|
203
|
-
|
|
645
|
+
## š License
|
|
204
646
|
|
|
205
|
-
|
|
647
|
+
ISC
|
package/dist/strategies/index.js
CHANGED
|
@@ -102,11 +102,6 @@ exports.TableStrategies = {
|
|
|
102
102
|
return false;
|
|
103
103
|
// 1. Trigger Scroll
|
|
104
104
|
yield rows.last().scrollIntoViewIfNeeded();
|
|
105
|
-
// Optional: Keyboard press for robust grid handling
|
|
106
|
-
try {
|
|
107
|
-
yield page.keyboard.press('End');
|
|
108
|
-
}
|
|
109
|
-
catch (e) { }
|
|
110
105
|
// 2. Smart Wait (Polling)
|
|
111
106
|
return yield waitForCondition(() => __awaiter(void 0, void 0, void 0, function* () {
|
|
112
107
|
const newCount = yield rows.count();
|
package/dist/typeContext.d.ts
CHANGED
|
@@ -3,4 +3,4 @@
|
|
|
3
3
|
* This file is generated by scripts/embed-types.js
|
|
4
4
|
* It contains the raw text of types.ts to provide context for LLM prompts.
|
|
5
5
|
*/
|
|
6
|
-
export declare const TYPE_CONTEXT = "\nexport type Selector = string | ((root: Locator | Page) => Locator);\n\nexport type SmartRow = Locator & {\n getCell(column: string): Locator;\n toJSON(): Promise<Record<string, string>>;\n};\n\nexport interface TableContext {\n root: Locator;\n config: Required<TableConfig>;\n page: Page;\n resolve: (selector: Selector, parent: Locator | Page) => Locator;\n}\n\nexport type PaginationStrategy = (context: TableContext) => Promise<boolean>;\n\nexport interface PromptOptions {\n /**\n * Output Strategy:\n * - 'error': Throws an error with the prompt (Best for Cloud/QA Wolf to get clean text).\n * - 'console': Standard console logs (Default).\n */\n output?: 'console' | 'error';\n includeTypes?: boolean;\n}\n\nexport interface TableConfig {\n rowSelector?: Selector;\n headerSelector?: Selector;\n cellSelector?: Selector;\n pagination?: PaginationStrategy;\n maxPages?: number;\n /**\n * Hook to rename columns dynamically.\n * * @param args.text - The default innerText of the header.\n * @param args.index - The column index.\n * @param args.locator - The specific header cell locator.\n */\n headerTransformer?: (args: { text: string, index: number, locator: Locator }) => string | Promise<string>;\n autoScroll?: boolean;\n /**\n * Enable debug mode to log internal state to console.\n */\n debug?: boolean;\n /**\n * Strategy to reset the table to the first page.\n * Called when table.reset() is invoked.\n */\n onReset?: (context: TableContext) => Promise<void>;\n}\n\nexport interface TableResult {\n getHeaders: () => Promise<string[]>;\n getHeaderCell: (columnName: string) => Promise<Locator>;\n\n getByRow: <T extends { asJSON?: boolean }>(\n filters: Record<string, string | RegExp | number>, \n options?: { exact?: boolean, maxPages?: number } & T\n ) => Promise<T['asJSON'] extends true ? Record<string, string> : SmartRow>;\n\n getAllRows: <T extends { asJSON?: boolean }>(\n options?: { filter?: Record<string, any>, exact?: boolean } & T\n ) => Promise<T['asJSON'] extends true ? Record<string, string>[] : SmartRow[]>;\n\n generateConfigPrompt: (options?: PromptOptions) => Promise<void>;\n generateStrategyPrompt: (options?: PromptOptions) => Promise<void>;\n\n /**\n * Resets the table state (clears cache, flags) and invokes the onReset strategy.\n */\n reset: () => Promise<void>;\n\n /**\n * Scans a specific column across all pages and returns the values.\n */\n getColumnValues: <V = string>(column: string, options?: { mapper?: (cell: Locator) => Promise<V> | V, maxPages?: number }) => Promise<V[]>;\n}\n";
|
|
6
|
+
export declare const TYPE_CONTEXT = "\nexport type Selector = string | ((root: Locator | Page) => Locator);\n\nexport type SmartRow = Omit<Locator, 'fill'> & {\n getCell(column: string): Locator;\n toJSON(): Promise<Record<string, string>>;\n /**\n * Fills the row with data. Automatically detects input types (text input, select, checkbox, etc.).\n */\n fill: (data: Record<string, any>, options?: FillOptions) => Promise<void>;\n};\n\nexport interface TableContext {\n root: Locator;\n config: Required<TableConfig>;\n page: Page;\n resolve: (selector: Selector, parent: Locator | Page) => Locator;\n}\n\nexport type PaginationStrategy = (context: TableContext) => Promise<boolean>;\n\nexport interface PromptOptions {\n /**\n * Output Strategy:\n * - 'error': Throws an error with the prompt (Best for Cloud/QA Wolf to get clean text).\n * - 'console': Standard console logs (Default).\n */\n output?: 'console' | 'error';\n includeTypes?: boolean;\n}\n\nexport interface TableConfig {\n rowSelector?: Selector;\n headerSelector?: Selector;\n cellSelector?: Selector;\n pagination?: PaginationStrategy;\n maxPages?: number;\n /**\n * Hook to rename columns dynamically.\n * * @param args.text - The default innerText of the header.\n * @param args.index - The column index.\n * @param args.locator - The specific header cell locator.\n */\n headerTransformer?: (args: { text: string, index: number, locator: Locator }) => string | Promise<string>;\n autoScroll?: boolean;\n /**\n * Enable debug mode to log internal state to console.\n */\n debug?: boolean;\n /**\n * Strategy to reset the table to the first page.\n * Called when table.reset() is invoked.\n */\n onReset?: (context: TableContext) => Promise<void>;\n}\n\nexport interface FillOptions {\n /**\n * Custom input mappers for specific columns.\n * Maps column names to functions that return the input locator for that cell.\n * Columns not specified here will use auto-detection.\n */\n inputMappers?: Record<string, (cell: Locator) => Locator>;\n}\n\nexport interface TableResult {\n getHeaders: () => Promise<string[]>;\n getHeaderCell: (columnName: string) => Promise<Locator>;\n\n getByRow: <T extends { asJSON?: boolean }>(\n filters: Record<string, string | RegExp | number>, \n options?: { exact?: boolean, maxPages?: number } & T\n ) => Promise<T['asJSON'] extends true ? Record<string, string> : SmartRow>;\n\n getAllRows: <T extends { asJSON?: boolean }>(\n options?: { filter?: Record<string, any>, exact?: boolean } & T\n ) => Promise<T['asJSON'] extends true ? Record<string, string>[] : SmartRow[]>;\n\n generateConfigPrompt: (options?: PromptOptions) => Promise<void>;\n generateStrategyPrompt: (options?: PromptOptions) => Promise<void>;\n\n /**\n * Resets the table state (clears cache, flags) and invokes the onReset strategy.\n */\n reset: () => Promise<void>;\n\n /**\n * Scans a specific column across all pages and returns the values.\n */\n getColumnValues: <V = string>(column: string, options?: { mapper?: (cell: Locator) => Promise<V> | V, maxPages?: number }) => Promise<V[]>;\n}\n";
|
package/dist/typeContext.js
CHANGED
|
@@ -9,9 +9,13 @@ exports.TYPE_CONTEXT = void 0;
|
|
|
9
9
|
exports.TYPE_CONTEXT = `
|
|
10
10
|
export type Selector = string | ((root: Locator | Page) => Locator);
|
|
11
11
|
|
|
12
|
-
export type SmartRow = Locator & {
|
|
12
|
+
export type SmartRow = Omit<Locator, 'fill'> & {
|
|
13
13
|
getCell(column: string): Locator;
|
|
14
14
|
toJSON(): Promise<Record<string, string>>;
|
|
15
|
+
/**
|
|
16
|
+
* Fills the row with data. Automatically detects input types (text input, select, checkbox, etc.).
|
|
17
|
+
*/
|
|
18
|
+
fill: (data: Record<string, any>, options?: FillOptions) => Promise<void>;
|
|
15
19
|
};
|
|
16
20
|
|
|
17
21
|
export interface TableContext {
|
|
@@ -58,6 +62,15 @@ export interface TableConfig {
|
|
|
58
62
|
onReset?: (context: TableContext) => Promise<void>;
|
|
59
63
|
}
|
|
60
64
|
|
|
65
|
+
export interface FillOptions {
|
|
66
|
+
/**
|
|
67
|
+
* Custom input mappers for specific columns.
|
|
68
|
+
* Maps column names to functions that return the input locator for that cell.
|
|
69
|
+
* Columns not specified here will use auto-detection.
|
|
70
|
+
*/
|
|
71
|
+
inputMappers?: Record<string, (cell: Locator) => Locator>;
|
|
72
|
+
}
|
|
73
|
+
|
|
61
74
|
export interface TableResult {
|
|
62
75
|
getHeaders: () => Promise<string[]>;
|
|
63
76
|
getHeaderCell: (columnName: string) => Promise<Locator>;
|
package/dist/types.d.ts
CHANGED
|
@@ -1,8 +1,12 @@
|
|
|
1
1
|
import type { Locator, Page } from '@playwright/test';
|
|
2
2
|
export type Selector = string | ((root: Locator | Page) => Locator);
|
|
3
|
-
export type SmartRow = Locator & {
|
|
3
|
+
export type SmartRow = Omit<Locator, 'fill'> & {
|
|
4
4
|
getCell(column: string): Locator;
|
|
5
5
|
toJSON(): Promise<Record<string, string>>;
|
|
6
|
+
/**
|
|
7
|
+
* Fills the row with data. Automatically detects input types (text input, select, checkbox, etc.).
|
|
8
|
+
*/
|
|
9
|
+
fill: (data: Record<string, any>, options?: FillOptions) => Promise<void>;
|
|
6
10
|
};
|
|
7
11
|
export interface TableContext {
|
|
8
12
|
root: Locator;
|
|
@@ -48,6 +52,14 @@ export interface TableConfig {
|
|
|
48
52
|
*/
|
|
49
53
|
onReset?: (context: TableContext) => Promise<void>;
|
|
50
54
|
}
|
|
55
|
+
export interface FillOptions {
|
|
56
|
+
/**
|
|
57
|
+
* Custom input mappers for specific columns.
|
|
58
|
+
* Maps column names to functions that return the input locator for that cell.
|
|
59
|
+
* Columns not specified here will use auto-detection.
|
|
60
|
+
*/
|
|
61
|
+
inputMappers?: Record<string, (cell: Locator) => Locator>;
|
|
62
|
+
}
|
|
51
63
|
export interface TableResult {
|
|
52
64
|
getHeaders: () => Promise<string[]>;
|
|
53
65
|
getHeaderCell: (columnName: string) => Promise<Locator>;
|
package/dist/useTable.js
CHANGED
|
@@ -27,6 +27,30 @@ const useTable = (rootLocator, configOptions = {}) => {
|
|
|
27
27
|
if (config.debug)
|
|
28
28
|
console.log(`š [SmartTable Debug] ${msg}`);
|
|
29
29
|
};
|
|
30
|
+
const _suggestColumnName = (colName, availableColumns) => {
|
|
31
|
+
// Simple fuzzy matching - find columns with similar names
|
|
32
|
+
const lowerCol = colName.toLowerCase();
|
|
33
|
+
const suggestions = availableColumns.filter(col => col.toLowerCase().includes(lowerCol) ||
|
|
34
|
+
lowerCol.includes(col.toLowerCase()) ||
|
|
35
|
+
col.toLowerCase().replace(/\s+/g, '') === lowerCol.replace(/\s+/g, ''));
|
|
36
|
+
if (suggestions.length > 0 && suggestions[0] !== colName) {
|
|
37
|
+
return `. Did you mean "${suggestions[0]}"?`;
|
|
38
|
+
}
|
|
39
|
+
// Show similar column names (first 3)
|
|
40
|
+
if (availableColumns.length > 0 && availableColumns.length <= 10) {
|
|
41
|
+
return `. Available columns: ${availableColumns.map(c => `"${c}"`).join(', ')}`;
|
|
42
|
+
}
|
|
43
|
+
else if (availableColumns.length > 0) {
|
|
44
|
+
return `. Available columns (first 5): ${availableColumns.slice(0, 5).map(c => `"${c}"`).join(', ')}, ...`;
|
|
45
|
+
}
|
|
46
|
+
return '.';
|
|
47
|
+
};
|
|
48
|
+
const _createColumnError = (colName, map, context) => {
|
|
49
|
+
const availableColumns = Array.from(map.keys());
|
|
50
|
+
const suggestion = _suggestColumnName(colName, availableColumns);
|
|
51
|
+
const contextMsg = context ? ` (${context})` : '';
|
|
52
|
+
return new Error(`Column "${colName}" not found${contextMsg}${suggestion}`);
|
|
53
|
+
};
|
|
30
54
|
const _getMap = () => __awaiter(void 0, void 0, void 0, function* () {
|
|
31
55
|
if (_headerMap)
|
|
32
56
|
return _headerMap;
|
|
@@ -65,8 +89,11 @@ const useTable = (rootLocator, configOptions = {}) => {
|
|
|
65
89
|
const smart = rowLocator;
|
|
66
90
|
smart.getCell = (colName) => {
|
|
67
91
|
const idx = map.get(colName);
|
|
68
|
-
if (idx === undefined)
|
|
69
|
-
|
|
92
|
+
if (idx === undefined) {
|
|
93
|
+
const availableColumns = Array.from(map.keys());
|
|
94
|
+
const suggestion = _suggestColumnName(colName, availableColumns);
|
|
95
|
+
throw new Error(`Column "${colName}" not found${suggestion}`);
|
|
96
|
+
}
|
|
70
97
|
if (typeof config.cellSelector === 'string') {
|
|
71
98
|
return rowLocator.locator(config.cellSelector).nth(idx);
|
|
72
99
|
}
|
|
@@ -85,6 +112,92 @@ const useTable = (rootLocator, configOptions = {}) => {
|
|
|
85
112
|
}
|
|
86
113
|
return result;
|
|
87
114
|
});
|
|
115
|
+
smart.fill = (data, fillOptions) => __awaiter(void 0, void 0, void 0, function* () {
|
|
116
|
+
var _a;
|
|
117
|
+
logDebug(`Filling row with data: ${JSON.stringify(data)}`);
|
|
118
|
+
// Fill each column
|
|
119
|
+
for (const [colName, value] of Object.entries(data)) {
|
|
120
|
+
const colIdx = map.get(colName);
|
|
121
|
+
if (colIdx === undefined) {
|
|
122
|
+
throw _createColumnError(colName, map, 'in fill data');
|
|
123
|
+
}
|
|
124
|
+
const cell = smart.getCell(colName);
|
|
125
|
+
// Use custom input mapper for this column if provided, otherwise auto-detect
|
|
126
|
+
let inputLocator;
|
|
127
|
+
if ((_a = fillOptions === null || fillOptions === void 0 ? void 0 : fillOptions.inputMappers) === null || _a === void 0 ? void 0 : _a[colName]) {
|
|
128
|
+
inputLocator = fillOptions.inputMappers[colName](cell);
|
|
129
|
+
}
|
|
130
|
+
else {
|
|
131
|
+
// Auto-detect input type
|
|
132
|
+
// Try different input types in order of commonality
|
|
133
|
+
// Check for text input
|
|
134
|
+
const textInput = cell.locator('input[type="text"], input:not([type]), textarea').first();
|
|
135
|
+
const textInputCount = yield textInput.count().catch(() => 0);
|
|
136
|
+
// Check for select
|
|
137
|
+
const select = cell.locator('select').first();
|
|
138
|
+
const selectCount = yield select.count().catch(() => 0);
|
|
139
|
+
// Check for checkbox/radio
|
|
140
|
+
const checkbox = cell.locator('input[type="checkbox"], input[type="radio"], [role="checkbox"]').first();
|
|
141
|
+
const checkboxCount = yield checkbox.count().catch(() => 0);
|
|
142
|
+
// Check for contenteditable or div-based inputs
|
|
143
|
+
const contentEditable = cell.locator('[contenteditable="true"]').first();
|
|
144
|
+
const contentEditableCount = yield contentEditable.count().catch(() => 0);
|
|
145
|
+
// Determine which input to use (prioritize by commonality)
|
|
146
|
+
if (textInputCount > 0 && selectCount === 0 && checkboxCount === 0) {
|
|
147
|
+
inputLocator = textInput;
|
|
148
|
+
}
|
|
149
|
+
else if (selectCount > 0) {
|
|
150
|
+
inputLocator = select;
|
|
151
|
+
}
|
|
152
|
+
else if (checkboxCount > 0) {
|
|
153
|
+
inputLocator = checkbox;
|
|
154
|
+
}
|
|
155
|
+
else if (contentEditableCount > 0) {
|
|
156
|
+
inputLocator = contentEditable;
|
|
157
|
+
}
|
|
158
|
+
else if (textInputCount > 0) {
|
|
159
|
+
// Fallback to text input even if others exist
|
|
160
|
+
inputLocator = textInput;
|
|
161
|
+
}
|
|
162
|
+
else {
|
|
163
|
+
// No input found - try to click the cell itself (might trigger an editor)
|
|
164
|
+
inputLocator = cell;
|
|
165
|
+
}
|
|
166
|
+
// Warn if multiple inputs found (ambiguous)
|
|
167
|
+
const totalInputs = textInputCount + selectCount + checkboxCount + contentEditableCount;
|
|
168
|
+
if (totalInputs > 1 && config.debug) {
|
|
169
|
+
logDebug(`ā ļø Multiple inputs found in cell "${colName}" (${totalInputs} total). Using first match. Consider using inputMapper option for explicit control.`);
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
// Fill based on value type and input type
|
|
173
|
+
const inputTag = yield inputLocator.evaluate((el) => el.tagName.toLowerCase()).catch(() => 'unknown');
|
|
174
|
+
const inputType = yield inputLocator.getAttribute('type').catch(() => null);
|
|
175
|
+
const isContentEditable = yield inputLocator.getAttribute('contenteditable').catch(() => null);
|
|
176
|
+
logDebug(`Filling "${colName}" with value "${value}" (input: ${inputTag}, type: ${inputType})`);
|
|
177
|
+
if (inputType === 'checkbox' || inputType === 'radio') {
|
|
178
|
+
// Boolean value for checkbox/radio
|
|
179
|
+
const shouldBeChecked = Boolean(value);
|
|
180
|
+
const isChecked = yield inputLocator.isChecked().catch(() => false);
|
|
181
|
+
if (isChecked !== shouldBeChecked) {
|
|
182
|
+
yield inputLocator.click();
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
else if (inputTag === 'select') {
|
|
186
|
+
// Select dropdown
|
|
187
|
+
yield inputLocator.selectOption(String(value));
|
|
188
|
+
}
|
|
189
|
+
else if (isContentEditable === 'true') {
|
|
190
|
+
// Contenteditable div
|
|
191
|
+
yield inputLocator.click();
|
|
192
|
+
yield inputLocator.fill(String(value));
|
|
193
|
+
}
|
|
194
|
+
else {
|
|
195
|
+
// Text input, textarea, or generic
|
|
196
|
+
yield inputLocator.fill(String(value));
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
logDebug('Fill operation completed');
|
|
200
|
+
});
|
|
88
201
|
return smart;
|
|
89
202
|
};
|
|
90
203
|
const _applyFilters = (baseRows, filters, map, exact) => {
|
|
@@ -92,8 +205,9 @@ const useTable = (rootLocator, configOptions = {}) => {
|
|
|
92
205
|
const page = rootLocator.page();
|
|
93
206
|
for (const [colName, value] of Object.entries(filters)) {
|
|
94
207
|
const colIndex = map.get(colName);
|
|
95
|
-
if (colIndex === undefined)
|
|
96
|
-
throw
|
|
208
|
+
if (colIndex === undefined) {
|
|
209
|
+
throw _createColumnError(colName, map, 'in filter');
|
|
210
|
+
}
|
|
97
211
|
const filterVal = typeof value === 'number' ? String(value) : value;
|
|
98
212
|
const cellTemplate = resolve(config.cellSelector, page);
|
|
99
213
|
filtered = filtered.filter({
|
|
@@ -113,8 +227,26 @@ const useTable = (rootLocator, configOptions = {}) => {
|
|
|
113
227
|
const matchedRows = _applyFilters(allRows, filters, map, options.exact || false);
|
|
114
228
|
const count = yield matchedRows.count();
|
|
115
229
|
logDebug(`Page ${currentPage}: Found ${count} matches.`);
|
|
116
|
-
if (count > 1)
|
|
117
|
-
|
|
230
|
+
if (count > 1) {
|
|
231
|
+
// Try to get sample row data to help user identify the issue
|
|
232
|
+
const sampleData = [];
|
|
233
|
+
try {
|
|
234
|
+
const firstFewRows = yield matchedRows.all();
|
|
235
|
+
const sampleCount = Math.min(firstFewRows.length, 3);
|
|
236
|
+
for (let i = 0; i < sampleCount; i++) {
|
|
237
|
+
const rowData = yield _makeSmart(firstFewRows[i], map).toJSON();
|
|
238
|
+
sampleData.push(JSON.stringify(rowData));
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
catch (e) {
|
|
242
|
+
// If we can't extract sample data, that's okay - continue without it
|
|
243
|
+
}
|
|
244
|
+
const sampleMsg = sampleData.length > 0
|
|
245
|
+
? `\nSample matching rows:\n${sampleData.map((d, i) => ` ${i + 1}. ${d}`).join('\n')}`
|
|
246
|
+
: '';
|
|
247
|
+
throw new Error(`Strict Mode Violation: Found ${count} rows matching ${JSON.stringify(filters)} on page ${currentPage}. ` +
|
|
248
|
+
`Expected exactly one match. Try adding more filters to make your query unique.${sampleMsg}`);
|
|
249
|
+
}
|
|
118
250
|
if (count === 1)
|
|
119
251
|
return matchedRows.first();
|
|
120
252
|
if (currentPage < effectiveMaxPages) {
|
|
@@ -187,7 +319,7 @@ const useTable = (rootLocator, configOptions = {}) => {
|
|
|
187
319
|
const map = yield _getMap();
|
|
188
320
|
const idx = map.get(columnName);
|
|
189
321
|
if (idx === undefined)
|
|
190
|
-
throw
|
|
322
|
+
throw _createColumnError(columnName, map, 'header cell');
|
|
191
323
|
return resolve(config.headerSelector, rootLocator).nth(idx);
|
|
192
324
|
}),
|
|
193
325
|
reset: () => __awaiter(void 0, void 0, void 0, function* () {
|
|
@@ -208,7 +340,7 @@ const useTable = (rootLocator, configOptions = {}) => {
|
|
|
208
340
|
const map = yield _getMap();
|
|
209
341
|
const colIdx = map.get(column);
|
|
210
342
|
if (colIdx === undefined)
|
|
211
|
-
throw
|
|
343
|
+
throw _createColumnError(column, map);
|
|
212
344
|
const mapper = (_a = options === null || options === void 0 ? void 0 : options.mapper) !== null && _a !== void 0 ? _a : ((c) => c.innerText());
|
|
213
345
|
const effectiveMaxPages = (_b = options === null || options === void 0 ? void 0 : options.maxPages) !== null && _b !== void 0 ? _b : config.maxPages;
|
|
214
346
|
let currentPage = 1;
|
|
@@ -272,40 +404,6 @@ const useTable = (rootLocator, configOptions = {}) => {
|
|
|
272
404
|
const content = `\n==================================================\nš¤ COPY INTO GEMINI/ChatGPT TO WRITE A STRATEGY š¤\n==================================================\nI need a custom Pagination Strategy for 'playwright-smart-table'.\nContainer HTML:\n\`\`\`html\n${html.substring(0, 10000)} ...\n\`\`\`\n`;
|
|
273
405
|
yield _handlePrompt('Smart Table Strategy', content, options);
|
|
274
406
|
}),
|
|
275
|
-
/* * š§ ROADMAP (v2.2) š§
|
|
276
|
-
* The following features are planned. Implementations are tentative.
|
|
277
|
-
* DO NOT DELETE THIS SECTION UNTIL IMPLEMENTED OR REMOVED.
|
|
278
|
-
* THIS IS BEING USED TO TRACK FUTURE DEVELOPMENT.
|
|
279
|
-
*/
|
|
280
|
-
// __roadmap__fill: async (data: Record<string, any>) => {
|
|
281
|
-
// /* // * Goal: Fill a row with data intelligently.
|
|
282
|
-
// * Priority: Medium
|
|
283
|
-
// * Challenge: Handling different input types (select, checkbox, custom divs) blindly.
|
|
284
|
-
// */
|
|
285
|
-
// // const row = ... get row context ...
|
|
286
|
-
// // for (const [col, val] of Object.entries(data)) {
|
|
287
|
-
// // const cell = row.getCell(col);
|
|
288
|
-
// // const input = cell.locator('input, select, [role="checkbox"]');
|
|
289
|
-
// // if (await input.count() > 1) console.warn("Ambiguous input");
|
|
290
|
-
// // // Heuristics go here...
|
|
291
|
-
// // }
|
|
292
|
-
// // Note: Maybe we could pass the locator in the options for more control.
|
|
293
|
-
// },
|
|
294
|
-
// __roadmap__auditPages: async (options: { maxPages: number, audit: (rows: SmartRow[], page: number) => Promise<void> }) => {
|
|
295
|
-
// /*
|
|
296
|
-
// * Goal: Walk through pages and run a verification function on every page.
|
|
297
|
-
// * Priority: Low (Specific use case)
|
|
298
|
-
// * Logic:
|
|
299
|
-
// * let page = 1;
|
|
300
|
-
// * while (page <= options.maxPages) {
|
|
301
|
-
// * const rows = await getAllRows();
|
|
302
|
-
// * await options.audit(rows, page);
|
|
303
|
-
// * if (!await pagination(ctx)) break;
|
|
304
|
-
// * page++;
|
|
305
|
-
// * }
|
|
306
|
-
// */
|
|
307
|
-
// // Note: Maybe make is possible to skip several pages at once if the pagination strategy supports it.
|
|
308
|
-
// }
|
|
309
407
|
};
|
|
310
408
|
};
|
|
311
409
|
exports.useTable = useTable;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@rickcedwhat/playwright-smart-table",
|
|
3
|
-
"version": "2.
|
|
3
|
+
"version": "2.2.0",
|
|
4
4
|
"description": "A smart table utility for Playwright with built-in pagination strategies that are fully extensible.",
|
|
5
5
|
"repository": {
|
|
6
6
|
"type": "git",
|
|
@@ -17,6 +17,7 @@
|
|
|
17
17
|
"build": "npm run generate-types && npm run generate-docs && tsc",
|
|
18
18
|
"prepublishOnly": "npm run build",
|
|
19
19
|
"test": "npx playwright test",
|
|
20
|
+
"test:compatibility": "npx playwright test compatibility",
|
|
20
21
|
"prepare": "husky install"
|
|
21
22
|
},
|
|
22
23
|
"keywords": [
|