@rickcedwhat/playwright-smart-table 1.0.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 +118 -0
- package/dist/strategies/index.d.ts +23 -0
- package/dist/strategies/index.js +104 -0
- package/dist/types.d.ts +20 -0
- package/dist/types.js +2 -0
- package/dist/useTable.d.ts +17 -0
- package/dist/useTable.js +226 -0
- package/package.json +23 -0
- package/playwright.config.ts +34 -0
- package/src/strategies/index.ts +106 -0
- package/src/types.ts +25 -0
- package/src/useTable.ts +237 -0
- package/tests/mui.spec.ts +53 -0
- package/tests/readme_verification.spec.ts +45 -0
- package/tests/strategies.spec.ts +69 -0
- package/tests/the-internet.spec.ts +37 -0
- package/tsconfig.json +14 -0
package/README.md
ADDED
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
Playwright Smart Table 🧠
|
|
2
|
+
A production-ready, type-safe table wrapper for Playwright.
|
|
3
|
+
It handles the hard stuff automatically:
|
|
4
|
+
Pagination (Next buttons, Load More, Infinite Scroll)
|
|
5
|
+
Complex Grids (MUI, AG-Grid, React Table)
|
|
6
|
+
Strict Mode (Throws errors if your filters match multiple rows)
|
|
7
|
+
🚀 Installation
|
|
8
|
+
npm install playwright-smart-table
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
(Note: Requires @playwright/test as a peer dependency)
|
|
12
|
+
🏁 Quick Start
|
|
13
|
+
Standard HTML Table (No config needed)
|
|
14
|
+
import { test, expect } from '@playwright/test';
|
|
15
|
+
import { useTable } from 'playwright-smart-table';
|
|
16
|
+
|
|
17
|
+
test('Verify User', async ({ page }) => {
|
|
18
|
+
await page.goto('/users');
|
|
19
|
+
|
|
20
|
+
// 1. Initialize (Defaults to <table>, <tr>, <td>)
|
|
21
|
+
const table = useTable(page.locator('#users-table'));
|
|
22
|
+
|
|
23
|
+
// 2. Find row across pages automatically!
|
|
24
|
+
// This will search Page 1, then Page 2, etc.
|
|
25
|
+
const row = await table.getByRow({ Name: 'Alice', Role: 'Admin' });
|
|
26
|
+
|
|
27
|
+
await expect(row).toBeVisible();
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
🧩 Pagination Strategies
|
|
32
|
+
This library doesn't guess how your table works. You tell it using a Strategy.
|
|
33
|
+
1. Standard "Next" Button
|
|
34
|
+
For tables like Datatables.net or simple paginated lists.
|
|
35
|
+
import { TableStrategies } from 'playwright-smart-table';
|
|
36
|
+
|
|
37
|
+
const table = useTable(locator, {
|
|
38
|
+
// Strategy: Find button -> Click -> Wait for first row to change
|
|
39
|
+
pagination: TableStrategies.clickNext('[aria-label="Next Page"]')
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
2. Infinite Scroll
|
|
44
|
+
For grids that load more data as you scroll down (AG-Grid, Virtual Lists, HTMX).
|
|
45
|
+
const table = useTable(locator, {
|
|
46
|
+
// Strategy: Aggressive scroll to bottom -> Wait for row count to increase
|
|
47
|
+
pagination: TableStrategies.infiniteScroll()
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
3. Load More Button
|
|
52
|
+
For lists with a "Load More Results" button at the bottom.
|
|
53
|
+
const table = useTable(locator, {
|
|
54
|
+
// Strategy: Click button -> Wait for row count to increase
|
|
55
|
+
pagination: TableStrategies.clickLoadMore('button.load-more')
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
⚙️ Advanced Config (MUI / Grid / Divs)
|
|
60
|
+
For complex div-based tables (like Material UI DataGrid), you can override the selectors.
|
|
61
|
+
const table = useTable(page.locator('.MuiDataGrid-root'), {
|
|
62
|
+
rowSelector: '.MuiDataGrid-row',
|
|
63
|
+
headerSelector: '.MuiDataGrid-columnHeader',
|
|
64
|
+
cellSelector: '.MuiDataGrid-cell',
|
|
65
|
+
pagination: TableStrategies.clickNext('[aria-label="Go to next page"]')
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
📖 API Reference
|
|
70
|
+
getByRow(filters, options?)
|
|
71
|
+
Finds a specific row matching the filters.
|
|
72
|
+
filters: { "Column Name": "Value", "Age": "25" }
|
|
73
|
+
options: { exact: true }
|
|
74
|
+
Returns: Playwright Locator for that row.
|
|
75
|
+
Throws error if multiple rows match.
|
|
76
|
+
await table.getByRow({ Email: "alice@example.com" });
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
getByCell(filters, targetColumn)
|
|
80
|
+
Finds a specific cell inside a filtered row. Useful for clicking action buttons.
|
|
81
|
+
Returns: Playwright Locator for the cell.
|
|
82
|
+
// Find the 'Edit' button for Alice
|
|
83
|
+
await table.getByCell({ Name: "Alice" }, "Actions").getByRole('button').click();
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
getRows()
|
|
87
|
+
Dumps all rows on the current page as an array of objects. Great for verifying sort order.
|
|
88
|
+
const rows = await table.getRows();
|
|
89
|
+
expect(rows[0].Name).toBe("Alice");
|
|
90
|
+
expect(rows[1].Name).toBe("Bob");
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
getRowAsJSON(filters)
|
|
94
|
+
Returns a single row's data as a JSON object.
|
|
95
|
+
const data = await table.getRowAsJSON({ ID: "123" });
|
|
96
|
+
console.log(data); // { ID: "123", Name: "Alice", Status: "Active" }
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
🛠️ Custom Strategies
|
|
100
|
+
Need to handle a custom pagination logic? Write your own strategy!
|
|
101
|
+
A strategy is just a function that returns Promise<boolean> (true if data loaded, false if done).
|
|
102
|
+
const myCustomStrategy = async ({ root, page, resolve }) => {
|
|
103
|
+
// 1. Find your specific button
|
|
104
|
+
const btn = resolve('.my-weird-button', root);
|
|
105
|
+
|
|
106
|
+
if (!await btn.isVisible()) return false; // Stop pagination
|
|
107
|
+
|
|
108
|
+
// 2. Click and Wait
|
|
109
|
+
await btn.click();
|
|
110
|
+
await page.waitForResponse(resp => resp.url().includes('/api/users'));
|
|
111
|
+
|
|
112
|
+
return true; // We loaded more!
|
|
113
|
+
};
|
|
114
|
+
|
|
115
|
+
// Usage
|
|
116
|
+
useTable(locator, { pagination: myCustomStrategy });
|
|
117
|
+
|
|
118
|
+
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import { PaginationStrategy, Selector } from '../types';
|
|
2
|
+
export declare const TableStrategies: {
|
|
3
|
+
/**
|
|
4
|
+
* Strategy: Clicks a "Next" button and waits for the first row of data to change.
|
|
5
|
+
* Best for: Standard pagination (Page 1 > Page 2 > Page 3)
|
|
6
|
+
* * @param nextButtonSelector - Selector for the next button (e.g. 'button.next' or getByRole('button', {name: 'Next'}))
|
|
7
|
+
* @param timeout - How long to wait for the table to refresh (default: 5000ms)
|
|
8
|
+
*/
|
|
9
|
+
clickNext: (nextButtonSelector: Selector, timeout?: number) => PaginationStrategy;
|
|
10
|
+
/**
|
|
11
|
+
* Strategy: Clicks a "Load More" button and waits for the row count to increase.
|
|
12
|
+
* Best for: Lists where "Load More" appends data to the bottom.
|
|
13
|
+
* * @param buttonSelector - Selector for the load more button
|
|
14
|
+
* @param timeout - Wait timeout (default: 5000ms)
|
|
15
|
+
*/
|
|
16
|
+
clickLoadMore: (buttonSelector: Selector, timeout?: number) => PaginationStrategy;
|
|
17
|
+
/**
|
|
18
|
+
* Strategy: Scrolls to the bottom of the table and waits for more rows to appear.
|
|
19
|
+
* Best for: Infinite Scroll grids (Ag-Grid, Virtual Lists)
|
|
20
|
+
* * @param timeout - Wait timeout (default: 5000ms)
|
|
21
|
+
*/
|
|
22
|
+
infiniteScroll: (timeout?: number) => PaginationStrategy;
|
|
23
|
+
};
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __awaiter = (this && this.__awaiter) || function (thisArg, _arguments, P, generator) {
|
|
3
|
+
function adopt(value) { return value instanceof P ? value : new P(function (resolve) { resolve(value); }); }
|
|
4
|
+
return new (P || (P = Promise))(function (resolve, reject) {
|
|
5
|
+
function fulfilled(value) { try { step(generator.next(value)); } catch (e) { reject(e); } }
|
|
6
|
+
function rejected(value) { try { step(generator["throw"](value)); } catch (e) { reject(e); } }
|
|
7
|
+
function step(result) { result.done ? resolve(result.value) : adopt(result.value).then(fulfilled, rejected); }
|
|
8
|
+
step((generator = generator.apply(thisArg, _arguments || [])).next());
|
|
9
|
+
});
|
|
10
|
+
};
|
|
11
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
12
|
+
exports.TableStrategies = void 0;
|
|
13
|
+
// src/strategies/index.ts
|
|
14
|
+
const test_1 = require("@playwright/test");
|
|
15
|
+
exports.TableStrategies = {
|
|
16
|
+
/**
|
|
17
|
+
* Strategy: Clicks a "Next" button and waits for the first row of data to change.
|
|
18
|
+
* Best for: Standard pagination (Page 1 > Page 2 > Page 3)
|
|
19
|
+
* * @param nextButtonSelector - Selector for the next button (e.g. 'button.next' or getByRole('button', {name: 'Next'}))
|
|
20
|
+
* @param timeout - How long to wait for the table to refresh (default: 5000ms)
|
|
21
|
+
*/
|
|
22
|
+
clickNext: (nextButtonSelector, timeout = 5000) => {
|
|
23
|
+
return (_a) => __awaiter(void 0, [_a], void 0, function* ({ root, config, resolve }) {
|
|
24
|
+
// 1. Find the button using the table's helper
|
|
25
|
+
const nextBtn = resolve(nextButtonSelector, root).first();
|
|
26
|
+
// If button isn't there or disabled, we are at the end
|
|
27
|
+
if (!(yield nextBtn.isVisible()) || !(yield nextBtn.isEnabled())) {
|
|
28
|
+
return false;
|
|
29
|
+
}
|
|
30
|
+
// 2. Snapshot the current state (text of the first row)
|
|
31
|
+
// We use the table's OWN row selector to ensure we are looking at real data
|
|
32
|
+
const firstRow = resolve(config.rowSelector, root).first();
|
|
33
|
+
const oldText = yield firstRow.innerText().catch(() => ""); // Handle empty tables gracefully
|
|
34
|
+
// 3. Click the button
|
|
35
|
+
yield nextBtn.click();
|
|
36
|
+
// 4. Smart Wait: Wait for the first row to have DIFFERENT text
|
|
37
|
+
try {
|
|
38
|
+
yield (0, test_1.expect)(firstRow).not.toHaveText(oldText, { timeout });
|
|
39
|
+
return true; // Success: Data changed
|
|
40
|
+
}
|
|
41
|
+
catch (e) {
|
|
42
|
+
return false; // Failed: Timed out (probably end of data or broken button)
|
|
43
|
+
}
|
|
44
|
+
});
|
|
45
|
+
},
|
|
46
|
+
/**
|
|
47
|
+
* Strategy: Clicks a "Load More" button and waits for the row count to increase.
|
|
48
|
+
* Best for: Lists where "Load More" appends data to the bottom.
|
|
49
|
+
* * @param buttonSelector - Selector for the load more button
|
|
50
|
+
* @param timeout - Wait timeout (default: 5000ms)
|
|
51
|
+
*/
|
|
52
|
+
clickLoadMore: (buttonSelector, timeout = 5000) => {
|
|
53
|
+
return (_a) => __awaiter(void 0, [_a], void 0, function* ({ root, config, resolve }) {
|
|
54
|
+
const loadMoreBtn = resolve(buttonSelector, root).first();
|
|
55
|
+
if (!(yield loadMoreBtn.isVisible()) || !(yield loadMoreBtn.isEnabled())) {
|
|
56
|
+
return false;
|
|
57
|
+
}
|
|
58
|
+
// 1. Snapshot: Count current rows
|
|
59
|
+
const rows = resolve(config.rowSelector, root);
|
|
60
|
+
const oldCount = yield rows.count();
|
|
61
|
+
// 2. Click
|
|
62
|
+
yield loadMoreBtn.click();
|
|
63
|
+
// 3. Smart Wait: Wait for row count to be greater than before
|
|
64
|
+
try {
|
|
65
|
+
yield (0, test_1.expect)(() => __awaiter(void 0, void 0, void 0, function* () {
|
|
66
|
+
const newCount = yield rows.count();
|
|
67
|
+
(0, test_1.expect)(newCount).toBeGreaterThan(oldCount);
|
|
68
|
+
})).toPass({ timeout });
|
|
69
|
+
return true;
|
|
70
|
+
}
|
|
71
|
+
catch (e) {
|
|
72
|
+
return false;
|
|
73
|
+
}
|
|
74
|
+
});
|
|
75
|
+
},
|
|
76
|
+
/**
|
|
77
|
+
* Strategy: Scrolls to the bottom of the table and waits for more rows to appear.
|
|
78
|
+
* Best for: Infinite Scroll grids (Ag-Grid, Virtual Lists)
|
|
79
|
+
* * @param timeout - Wait timeout (default: 5000ms)
|
|
80
|
+
*/
|
|
81
|
+
infiniteScroll: (timeout = 5000) => {
|
|
82
|
+
return (_a) => __awaiter(void 0, [_a], void 0, function* ({ root, config, resolve, page }) {
|
|
83
|
+
const rows = resolve(config.rowSelector, root);
|
|
84
|
+
const oldCount = yield rows.count();
|
|
85
|
+
if (oldCount === 0)
|
|
86
|
+
return false;
|
|
87
|
+
// 1. Scroll the very last row into view to trigger the fetch
|
|
88
|
+
yield rows.last().scrollIntoViewIfNeeded();
|
|
89
|
+
// Optional: Press "End" to force scroll on stubborn grids (like AG Grid)
|
|
90
|
+
yield page.keyboard.press('End');
|
|
91
|
+
// 2. Smart Wait: Wait for row count to increase
|
|
92
|
+
try {
|
|
93
|
+
yield (0, test_1.expect)(() => __awaiter(void 0, void 0, void 0, function* () {
|
|
94
|
+
const newCount = yield rows.count();
|
|
95
|
+
(0, test_1.expect)(newCount).toBeGreaterThan(oldCount);
|
|
96
|
+
})).toPass({ timeout });
|
|
97
|
+
return true;
|
|
98
|
+
}
|
|
99
|
+
catch (e) {
|
|
100
|
+
return false;
|
|
101
|
+
}
|
|
102
|
+
});
|
|
103
|
+
}
|
|
104
|
+
};
|
package/dist/types.d.ts
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import { Locator, Page } from '@playwright/test';
|
|
2
|
+
/**
|
|
3
|
+
* A selector can be a CSS string or a function.
|
|
4
|
+
* We allow 'parent' to be Locator OR Page to match your working logic.
|
|
5
|
+
*/
|
|
6
|
+
export type Selector = string | ((parent: Locator | Page) => Locator);
|
|
7
|
+
export interface TableConfig {
|
|
8
|
+
rowSelector?: Selector;
|
|
9
|
+
headerSelector?: Selector;
|
|
10
|
+
cellSelector?: Selector;
|
|
11
|
+
pagination?: PaginationStrategy;
|
|
12
|
+
maxPages?: number;
|
|
13
|
+
}
|
|
14
|
+
export interface TableContext {
|
|
15
|
+
root: Locator;
|
|
16
|
+
config: Required<TableConfig>;
|
|
17
|
+
page: Page;
|
|
18
|
+
resolve: (item: Selector, parent: Locator | Page) => Locator;
|
|
19
|
+
}
|
|
20
|
+
export type PaginationStrategy = (context: TableContext) => Promise<boolean>;
|
package/dist/types.js
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import { Locator } from '@playwright/test';
|
|
2
|
+
import { TableConfig } from './types';
|
|
3
|
+
export declare const useTable: (rootLocator: Locator, configOptions?: TableConfig) => {
|
|
4
|
+
getHeaders: () => Promise<string[]>;
|
|
5
|
+
getByRow: (filters: Record<string, string | RegExp | number>, options?: {
|
|
6
|
+
exact?: boolean;
|
|
7
|
+
maxPages?: number;
|
|
8
|
+
}) => Promise<Locator>;
|
|
9
|
+
getByCell: (rowFilters: Record<string, string | RegExp | number>, targetColumn: string) => Promise<Locator>;
|
|
10
|
+
getRows: () => Promise<Record<string, string>[]>;
|
|
11
|
+
getRowAsJSON: (filters: Record<string, string | RegExp | number>) => Promise<Record<string, string>>;
|
|
12
|
+
/**
|
|
13
|
+
* 🛠️ DEV TOOL: Prints a prompt to the console.
|
|
14
|
+
* Copy the output and paste it into Gemini/ChatGPT to generate your config.
|
|
15
|
+
*/
|
|
16
|
+
generateConfigPrompt: () => Promise<void>;
|
|
17
|
+
};
|
package/dist/useTable.js
ADDED
|
@@ -0,0 +1,226 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __awaiter = (this && this.__awaiter) || function (thisArg, _arguments, P, generator) {
|
|
3
|
+
function adopt(value) { return value instanceof P ? value : new P(function (resolve) { resolve(value); }); }
|
|
4
|
+
return new (P || (P = Promise))(function (resolve, reject) {
|
|
5
|
+
function fulfilled(value) { try { step(generator.next(value)); } catch (e) { reject(e); } }
|
|
6
|
+
function rejected(value) { try { step(generator["throw"](value)); } catch (e) { reject(e); } }
|
|
7
|
+
function step(result) { result.done ? resolve(result.value) : adopt(result.value).then(fulfilled, rejected); }
|
|
8
|
+
step((generator = generator.apply(thisArg, _arguments || [])).next());
|
|
9
|
+
});
|
|
10
|
+
};
|
|
11
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
12
|
+
exports.useTable = void 0;
|
|
13
|
+
const useTable = (rootLocator, configOptions = {}) => {
|
|
14
|
+
const config = Object.assign({ rowSelector: "tbody tr", headerSelector: "th", cellSelector: "td", pagination: undefined, maxPages: 1 }, configOptions);
|
|
15
|
+
// ✅ UPDATE: Accept Locator OR Page (to match your work logic)
|
|
16
|
+
const resolve = (item, parent) => {
|
|
17
|
+
if (typeof item === 'string')
|
|
18
|
+
return parent.locator(item);
|
|
19
|
+
if (typeof item === 'function')
|
|
20
|
+
return item(parent);
|
|
21
|
+
return item;
|
|
22
|
+
};
|
|
23
|
+
let _headerMap = null;
|
|
24
|
+
const _getMap = () => __awaiter(void 0, void 0, void 0, function* () {
|
|
25
|
+
if (_headerMap)
|
|
26
|
+
return _headerMap;
|
|
27
|
+
// Headers are still resolved relative to the table root (safer)
|
|
28
|
+
const headerLoc = resolve(config.headerSelector, rootLocator);
|
|
29
|
+
try {
|
|
30
|
+
yield headerLoc.first().waitFor({ state: 'visible', timeout: 3000 });
|
|
31
|
+
}
|
|
32
|
+
catch (e) { /* Ignore hydration */ }
|
|
33
|
+
const texts = yield headerLoc.allInnerTexts();
|
|
34
|
+
_headerMap = new Map(texts.map((t, i) => [t.trim() || `__col_${i}`, i]));
|
|
35
|
+
return _headerMap;
|
|
36
|
+
});
|
|
37
|
+
const _findRowLocator = (filters_1, ...args_1) => __awaiter(void 0, [filters_1, ...args_1], void 0, function* (filters, options = {}) {
|
|
38
|
+
var _a;
|
|
39
|
+
const map = yield _getMap();
|
|
40
|
+
const page = rootLocator.page();
|
|
41
|
+
const effectiveMaxPages = (_a = options.maxPages) !== null && _a !== void 0 ? _a : config.maxPages;
|
|
42
|
+
let currentPage = 1;
|
|
43
|
+
while (true) {
|
|
44
|
+
// 1. Row Locator uses ROOT (Matches your snippet)
|
|
45
|
+
let rowLocator = resolve(config.rowSelector, rootLocator);
|
|
46
|
+
for (const [colName, value] of Object.entries(filters)) {
|
|
47
|
+
const colIndex = map.get(colName);
|
|
48
|
+
if (colIndex === undefined)
|
|
49
|
+
throw new Error(`Column '${colName}' not found.`);
|
|
50
|
+
const exact = options.exact || false;
|
|
51
|
+
const filterVal = typeof value === 'number' ? String(value) : value;
|
|
52
|
+
// ✅ MATCHING YOUR WORK LOGIC EXACTLY
|
|
53
|
+
// 2. Cell Template uses PAGE (Matches your snippet)
|
|
54
|
+
const cellTemplate = resolve(config.cellSelector, page);
|
|
55
|
+
// 3. Filter using .nth(colIndex)
|
|
56
|
+
rowLocator = rowLocator.filter({
|
|
57
|
+
has: cellTemplate.nth(colIndex).getByText(filterVal, { exact }),
|
|
58
|
+
});
|
|
59
|
+
}
|
|
60
|
+
const count = yield rowLocator.count();
|
|
61
|
+
if (count > 1) {
|
|
62
|
+
throw new Error(`Strict Mode Violation: Found ${count} rows matching ${JSON.stringify(filters)}.`);
|
|
63
|
+
}
|
|
64
|
+
if (count === 1)
|
|
65
|
+
return rowLocator.first();
|
|
66
|
+
// --- PAGINATION LOGIC ---
|
|
67
|
+
if (config.pagination && currentPage < effectiveMaxPages) {
|
|
68
|
+
const context = {
|
|
69
|
+
root: rootLocator,
|
|
70
|
+
config: config,
|
|
71
|
+
page: page,
|
|
72
|
+
resolve: resolve
|
|
73
|
+
};
|
|
74
|
+
const didLoadMore = yield config.pagination(context);
|
|
75
|
+
if (didLoadMore) {
|
|
76
|
+
currentPage++;
|
|
77
|
+
continue;
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
return null;
|
|
81
|
+
}
|
|
82
|
+
});
|
|
83
|
+
return {
|
|
84
|
+
getHeaders: () => __awaiter(void 0, void 0, void 0, function* () { return Array.from((yield _getMap()).keys()); }),
|
|
85
|
+
getByRow: (filters_1, ...args_1) => __awaiter(void 0, [filters_1, ...args_1], void 0, function* (filters, options = {}) {
|
|
86
|
+
const row = yield _findRowLocator(filters, options);
|
|
87
|
+
if (!row)
|
|
88
|
+
return resolve(config.rowSelector, rootLocator).filter({ hasText: "NON_EXISTENT_ROW_SENTINEL_" + Date.now() });
|
|
89
|
+
return row;
|
|
90
|
+
}),
|
|
91
|
+
getByCell: (rowFilters, targetColumn) => __awaiter(void 0, void 0, void 0, function* () {
|
|
92
|
+
const row = yield _findRowLocator(rowFilters);
|
|
93
|
+
if (!row)
|
|
94
|
+
throw new Error(`Row not found: ${JSON.stringify(rowFilters)}`);
|
|
95
|
+
const map = yield _getMap();
|
|
96
|
+
const colIndex = map.get(targetColumn);
|
|
97
|
+
if (colIndex === undefined)
|
|
98
|
+
throw new Error(`Column '${targetColumn}' not found.`);
|
|
99
|
+
// Return the specific cell
|
|
100
|
+
// We scope this to the found ROW to ensure we get the right cell
|
|
101
|
+
if (typeof config.cellSelector === 'string') {
|
|
102
|
+
return row.locator(config.cellSelector).nth(colIndex);
|
|
103
|
+
}
|
|
104
|
+
else {
|
|
105
|
+
return resolve(config.cellSelector, row).nth(colIndex);
|
|
106
|
+
}
|
|
107
|
+
}),
|
|
108
|
+
getRows: () => __awaiter(void 0, void 0, void 0, function* () {
|
|
109
|
+
const map = yield _getMap();
|
|
110
|
+
const rowLocator = resolve(config.rowSelector, rootLocator);
|
|
111
|
+
const rowCount = yield rowLocator.count();
|
|
112
|
+
const results = [];
|
|
113
|
+
for (let i = 0; i < rowCount; i++) {
|
|
114
|
+
const row = rowLocator.nth(i);
|
|
115
|
+
let cells;
|
|
116
|
+
if (typeof config.cellSelector === 'string') {
|
|
117
|
+
cells = row.locator(config.cellSelector);
|
|
118
|
+
}
|
|
119
|
+
else {
|
|
120
|
+
cells = resolve(config.cellSelector, row);
|
|
121
|
+
}
|
|
122
|
+
const cellTexts = yield cells.allInnerTexts();
|
|
123
|
+
const rowData = {};
|
|
124
|
+
for (const [colName, colIdx] of map.entries()) {
|
|
125
|
+
rowData[colName] = (cellTexts[colIdx] || "").trim();
|
|
126
|
+
}
|
|
127
|
+
results.push(rowData);
|
|
128
|
+
}
|
|
129
|
+
return results;
|
|
130
|
+
}),
|
|
131
|
+
getRowAsJSON: (filters) => __awaiter(void 0, void 0, void 0, function* () {
|
|
132
|
+
const row = yield _findRowLocator(filters);
|
|
133
|
+
if (!row)
|
|
134
|
+
throw new Error(`Row not found: ${JSON.stringify(filters)}`);
|
|
135
|
+
let cells;
|
|
136
|
+
if (typeof config.cellSelector === 'string') {
|
|
137
|
+
cells = row.locator(config.cellSelector);
|
|
138
|
+
}
|
|
139
|
+
else {
|
|
140
|
+
cells = resolve(config.cellSelector, row);
|
|
141
|
+
}
|
|
142
|
+
const cellTexts = yield cells.allInnerTexts();
|
|
143
|
+
const map = yield _getMap();
|
|
144
|
+
const result = {};
|
|
145
|
+
for (const [colName, colIndex] of map.entries()) {
|
|
146
|
+
result[colName] = (cellTexts[colIndex] || "").trim();
|
|
147
|
+
}
|
|
148
|
+
return result;
|
|
149
|
+
}),
|
|
150
|
+
/**
|
|
151
|
+
* 🛠️ DEV TOOL: Prints a prompt to the console.
|
|
152
|
+
* Copy the output and paste it into Gemini/ChatGPT to generate your config.
|
|
153
|
+
*/
|
|
154
|
+
generateConfigPrompt: () => __awaiter(void 0, void 0, void 0, function* () {
|
|
155
|
+
const html = yield rootLocator.evaluate((el) => el.outerHTML);
|
|
156
|
+
const separator = "=".repeat(50);
|
|
157
|
+
const prompt = `
|
|
158
|
+
${separator}
|
|
159
|
+
🤖 COPY THE TEXT BELOW INTO GEMINI/ChatGPT 🤖
|
|
160
|
+
${separator}
|
|
161
|
+
|
|
162
|
+
I am using a Playwright helper factory called 'useTable'.
|
|
163
|
+
I need you to generate the configuration object based on the HTML structure below.
|
|
164
|
+
|
|
165
|
+
Here is the table HTML:
|
|
166
|
+
\`\`\`html
|
|
167
|
+
${html}
|
|
168
|
+
\`\`\`
|
|
169
|
+
|
|
170
|
+
Based on this HTML, generate the configuration object matching this signature:
|
|
171
|
+
const table = useTable(page.locator('...'), {
|
|
172
|
+
// Find the rows (exclude headers and empty spacer rows if possible)
|
|
173
|
+
rowSelector: "...", // OR (root) => root.locator(...)
|
|
174
|
+
|
|
175
|
+
// Find the column headers
|
|
176
|
+
headerSelector: "...", // OR (root) => root.locator(...)
|
|
177
|
+
|
|
178
|
+
// Find the cell (relative to a specific row)
|
|
179
|
+
cellSelector: "...", // OR (row) => row.locator(...)
|
|
180
|
+
|
|
181
|
+
// Find the "Next Page" button (if it exists in the HTML)
|
|
182
|
+
paginationNextSelector: (root) => root.locator(...)
|
|
183
|
+
});
|
|
184
|
+
|
|
185
|
+
**Requirements:**
|
|
186
|
+
1. Prefer \`getByRole\` or \`getByTestId\` over CSS classes where possible.
|
|
187
|
+
2. If the table uses \`div\` structures (like React Table), ensure the \`rowSelector\` does not accidentally select the header row.
|
|
188
|
+
3. If there are "padding" or "loading" rows, use \`.filter()\` to exclude them.
|
|
189
|
+
|
|
190
|
+
${separator}
|
|
191
|
+
`;
|
|
192
|
+
console.log(prompt);
|
|
193
|
+
})
|
|
194
|
+
};
|
|
195
|
+
/**
|
|
196
|
+
* 🛠️ DEV TOOL: Prints a prompt to help write a custom Pagination Strategy.
|
|
197
|
+
* It snapshots the HTML *surrounding* the table to find buttons/scroll containers.
|
|
198
|
+
*/
|
|
199
|
+
generateStrategyPrompt: () => __awaiter(void 0, void 0, void 0, function* () {
|
|
200
|
+
// 1. Get the parent container (often holds the pagination controls)
|
|
201
|
+
const container = rootLocator.locator('xpath=..');
|
|
202
|
+
const html = yield container.evaluate((el) => el.outerHTML);
|
|
203
|
+
const prompt = `
|
|
204
|
+
==================================================
|
|
205
|
+
🤖 COPY INTO GEMINI/ChatGPT TO WRITE A STRATEGY 🤖
|
|
206
|
+
==================================================
|
|
207
|
+
|
|
208
|
+
I am using 'playwright-smart-table'. I need a custom Pagination Strategy.
|
|
209
|
+
The table is inside this container HTML:
|
|
210
|
+
|
|
211
|
+
\`\`\`html
|
|
212
|
+
${html.substring(0, 5000)} ... (truncated)
|
|
213
|
+
\`\`\`
|
|
214
|
+
|
|
215
|
+
Write a strategy that implements this interface:
|
|
216
|
+
type PaginationStrategy = (context: TableContext) => Promise<boolean>;
|
|
217
|
+
|
|
218
|
+
Requirements:
|
|
219
|
+
1. Identify the "Next" button OR the scroll container.
|
|
220
|
+
2. Return 'true' if data loaded, 'false' if end of data.
|
|
221
|
+
3. Use context.resolve() to find elements.
|
|
222
|
+
`;
|
|
223
|
+
console.log(prompt);
|
|
224
|
+
});
|
|
225
|
+
};
|
|
226
|
+
exports.useTable = useTable;
|
package/package.json
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@rickcedwhat/playwright-smart-table",
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"description": "A smart table utility for Playwright with built-in pagination strategies.",
|
|
5
|
+
"main": "dist/index.js",
|
|
6
|
+
"types": "dist/index.d.ts",
|
|
7
|
+
"scripts": {
|
|
8
|
+
"build": "tsc",
|
|
9
|
+
"test": "npx playwright test"
|
|
10
|
+
},
|
|
11
|
+
"keywords": ["playwright", "testing", "table", "automation"],
|
|
12
|
+
"author": "",
|
|
13
|
+
"license": "ISC",
|
|
14
|
+
"peerDependencies": {
|
|
15
|
+
"@playwright/test": "^1.30.0"
|
|
16
|
+
},
|
|
17
|
+
"devDependencies": {
|
|
18
|
+
"@playwright/test": "^1.30.0",
|
|
19
|
+
"@types/node": "^20.0.0",
|
|
20
|
+
"ts-node": "^10.9.0",
|
|
21
|
+
"typescript": "^5.0.0"
|
|
22
|
+
}
|
|
23
|
+
}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
// playwright.config.ts
|
|
2
|
+
import { defineConfig } from '@playwright/test';
|
|
3
|
+
|
|
4
|
+
export default defineConfig({
|
|
5
|
+
// Look for test files in the "tests" directory, relative to this configuration file.
|
|
6
|
+
testDir: 'tests',
|
|
7
|
+
|
|
8
|
+
// Run all tests in parallel.
|
|
9
|
+
fullyParallel: true,
|
|
10
|
+
|
|
11
|
+
// Fail the build on CI if you accidentally left test.only in the source code.
|
|
12
|
+
forbidOnly: !!process.env.CI,
|
|
13
|
+
|
|
14
|
+
// Retry on CI only.
|
|
15
|
+
retries: process.env.CI ? 2 : 0,
|
|
16
|
+
|
|
17
|
+
// Opt out of parallel tests on CI.
|
|
18
|
+
workers: process.env.CI ? 1 : undefined,
|
|
19
|
+
|
|
20
|
+
// Reporter to use
|
|
21
|
+
reporter: 'html',
|
|
22
|
+
|
|
23
|
+
use: {
|
|
24
|
+
// Collect trace when retrying the failed test.
|
|
25
|
+
// Setting to 'on' forces it for every run (useful for debugging now).
|
|
26
|
+
trace: 'on',
|
|
27
|
+
|
|
28
|
+
// Record video for failures
|
|
29
|
+
video: 'retain-on-failure',
|
|
30
|
+
|
|
31
|
+
// Take screenshot on failure
|
|
32
|
+
screenshot: 'only-on-failure',
|
|
33
|
+
},
|
|
34
|
+
});
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
// src/strategies/index.ts
|
|
2
|
+
import { expect } from '@playwright/test';
|
|
3
|
+
import { PaginationStrategy, Selector, TableContext } from '../types';
|
|
4
|
+
|
|
5
|
+
export const TableStrategies = {
|
|
6
|
+
/**
|
|
7
|
+
* Strategy: Clicks a "Next" button and waits for the first row of data to change.
|
|
8
|
+
* Best for: Standard pagination (Page 1 > Page 2 > Page 3)
|
|
9
|
+
* * @param nextButtonSelector - Selector for the next button (e.g. 'button.next' or getByRole('button', {name: 'Next'}))
|
|
10
|
+
* @param timeout - How long to wait for the table to refresh (default: 5000ms)
|
|
11
|
+
*/
|
|
12
|
+
clickNext: (nextButtonSelector: Selector, timeout = 5000): PaginationStrategy => {
|
|
13
|
+
return async ({ root, config, resolve }: TableContext) => {
|
|
14
|
+
// 1. Find the button using the table's helper
|
|
15
|
+
const nextBtn = resolve(nextButtonSelector, root).first();
|
|
16
|
+
|
|
17
|
+
// If button isn't there or disabled, we are at the end
|
|
18
|
+
if (!await nextBtn.isVisible() || !await nextBtn.isEnabled()) {
|
|
19
|
+
return false;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
// 2. Snapshot the current state (text of the first row)
|
|
23
|
+
// We use the table's OWN row selector to ensure we are looking at real data
|
|
24
|
+
const firstRow = resolve(config.rowSelector, root).first();
|
|
25
|
+
const oldText = await firstRow.innerText().catch(() => ""); // Handle empty tables gracefully
|
|
26
|
+
|
|
27
|
+
// 3. Click the button
|
|
28
|
+
await nextBtn.click();
|
|
29
|
+
|
|
30
|
+
// 4. Smart Wait: Wait for the first row to have DIFFERENT text
|
|
31
|
+
try {
|
|
32
|
+
await expect(firstRow).not.toHaveText(oldText, { timeout });
|
|
33
|
+
return true; // Success: Data changed
|
|
34
|
+
} catch (e) {
|
|
35
|
+
return false; // Failed: Timed out (probably end of data or broken button)
|
|
36
|
+
}
|
|
37
|
+
};
|
|
38
|
+
},
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Strategy: Clicks a "Load More" button and waits for the row count to increase.
|
|
42
|
+
* Best for: Lists where "Load More" appends data to the bottom.
|
|
43
|
+
* * @param buttonSelector - Selector for the load more button
|
|
44
|
+
* @param timeout - Wait timeout (default: 5000ms)
|
|
45
|
+
*/
|
|
46
|
+
clickLoadMore: (buttonSelector: Selector, timeout = 5000): PaginationStrategy => {
|
|
47
|
+
return async ({ root, config, resolve }: TableContext) => {
|
|
48
|
+
const loadMoreBtn = resolve(buttonSelector, root).first();
|
|
49
|
+
|
|
50
|
+
if (!await loadMoreBtn.isVisible() || !await loadMoreBtn.isEnabled()) {
|
|
51
|
+
return false;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
// 1. Snapshot: Count current rows
|
|
55
|
+
const rows = resolve(config.rowSelector, root);
|
|
56
|
+
const oldCount = await rows.count();
|
|
57
|
+
|
|
58
|
+
// 2. Click
|
|
59
|
+
await loadMoreBtn.click();
|
|
60
|
+
|
|
61
|
+
// 3. Smart Wait: Wait for row count to be greater than before
|
|
62
|
+
try {
|
|
63
|
+
await expect(async () => {
|
|
64
|
+
const newCount = await rows.count();
|
|
65
|
+
expect(newCount).toBeGreaterThan(oldCount);
|
|
66
|
+
}).toPass({ timeout });
|
|
67
|
+
|
|
68
|
+
return true;
|
|
69
|
+
} catch (e) {
|
|
70
|
+
return false;
|
|
71
|
+
}
|
|
72
|
+
};
|
|
73
|
+
},
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Strategy: Scrolls to the bottom of the table and waits for more rows to appear.
|
|
77
|
+
* Best for: Infinite Scroll grids (Ag-Grid, Virtual Lists)
|
|
78
|
+
* * @param timeout - Wait timeout (default: 5000ms)
|
|
79
|
+
*/
|
|
80
|
+
infiniteScroll: (timeout = 5000): PaginationStrategy => {
|
|
81
|
+
return async ({ root, config, resolve, page }: TableContext) => {
|
|
82
|
+
const rows = resolve(config.rowSelector, root);
|
|
83
|
+
const oldCount = await rows.count();
|
|
84
|
+
|
|
85
|
+
if (oldCount === 0) return false;
|
|
86
|
+
|
|
87
|
+
// 1. Scroll the very last row into view to trigger the fetch
|
|
88
|
+
await rows.last().scrollIntoViewIfNeeded();
|
|
89
|
+
|
|
90
|
+
// Optional: Press "End" to force scroll on stubborn grids (like AG Grid)
|
|
91
|
+
await page.keyboard.press('End');
|
|
92
|
+
|
|
93
|
+
// 2. Smart Wait: Wait for row count to increase
|
|
94
|
+
try {
|
|
95
|
+
await expect(async () => {
|
|
96
|
+
const newCount = await rows.count();
|
|
97
|
+
expect(newCount).toBeGreaterThan(oldCount);
|
|
98
|
+
}).toPass({ timeout });
|
|
99
|
+
|
|
100
|
+
return true;
|
|
101
|
+
} catch (e) {
|
|
102
|
+
return false;
|
|
103
|
+
}
|
|
104
|
+
};
|
|
105
|
+
}
|
|
106
|
+
};
|
package/src/types.ts
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
// src/types.ts
|
|
2
|
+
import { Locator, Page } from '@playwright/test';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* A selector can be a CSS string or a function.
|
|
6
|
+
* We allow 'parent' to be Locator OR Page to match your working logic.
|
|
7
|
+
*/
|
|
8
|
+
export type Selector = string | ((parent: Locator | Page) => Locator);
|
|
9
|
+
|
|
10
|
+
export interface TableConfig {
|
|
11
|
+
rowSelector?: Selector;
|
|
12
|
+
headerSelector?: Selector;
|
|
13
|
+
cellSelector?: Selector;
|
|
14
|
+
pagination?: PaginationStrategy;
|
|
15
|
+
maxPages?: number;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export interface TableContext {
|
|
19
|
+
root: Locator;
|
|
20
|
+
config: Required<TableConfig>;
|
|
21
|
+
page: Page;
|
|
22
|
+
resolve: (item: Selector, parent: Locator | Page) => Locator;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export type PaginationStrategy = (context: TableContext) => Promise<boolean>;
|
package/src/useTable.ts
ADDED
|
@@ -0,0 +1,237 @@
|
|
|
1
|
+
// src/useTable.ts
|
|
2
|
+
import { Locator, Page, expect } from '@playwright/test';
|
|
3
|
+
import { TableConfig, TableContext, Selector } from './types';
|
|
4
|
+
|
|
5
|
+
export const useTable = (rootLocator: Locator, configOptions: TableConfig = {}) => {
|
|
6
|
+
const config: Required<TableConfig> = {
|
|
7
|
+
rowSelector: "tbody tr",
|
|
8
|
+
headerSelector: "th",
|
|
9
|
+
cellSelector: "td",
|
|
10
|
+
pagination: undefined as any,
|
|
11
|
+
maxPages: 1,
|
|
12
|
+
...configOptions,
|
|
13
|
+
};
|
|
14
|
+
|
|
15
|
+
// ✅ UPDATE: Accept Locator OR Page (to match your work logic)
|
|
16
|
+
const resolve = (item: Selector, parent: Locator | Page): Locator => {
|
|
17
|
+
if (typeof item === 'string') return parent.locator(item);
|
|
18
|
+
if (typeof item === 'function') return item(parent);
|
|
19
|
+
return item;
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
let _headerMap: Map<string, number> | null = null;
|
|
23
|
+
|
|
24
|
+
const _getMap = async () => {
|
|
25
|
+
if (_headerMap) return _headerMap;
|
|
26
|
+
// Headers are still resolved relative to the table root (safer)
|
|
27
|
+
const headerLoc = resolve(config.headerSelector, rootLocator);
|
|
28
|
+
try {
|
|
29
|
+
await headerLoc.first().waitFor({ state: 'visible', timeout: 3000 });
|
|
30
|
+
} catch (e) { /* Ignore hydration */ }
|
|
31
|
+
|
|
32
|
+
const texts = await headerLoc.allInnerTexts();
|
|
33
|
+
_headerMap = new Map(texts.map((t, i) => [t.trim() || `__col_${i}`, i]));
|
|
34
|
+
return _headerMap;
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
const _findRowLocator = async (filters: Record<string, string | RegExp | number>, options: { exact?: boolean, maxPages?: number } = {}) => {
|
|
38
|
+
const map = await _getMap();
|
|
39
|
+
const page = rootLocator.page();
|
|
40
|
+
const effectiveMaxPages = options.maxPages ?? config.maxPages;
|
|
41
|
+
let currentPage = 1;
|
|
42
|
+
|
|
43
|
+
while (true) {
|
|
44
|
+
// 1. Row Locator uses ROOT (Matches your snippet)
|
|
45
|
+
let rowLocator = resolve(config.rowSelector, rootLocator);
|
|
46
|
+
|
|
47
|
+
for (const [colName, value] of Object.entries(filters)) {
|
|
48
|
+
const colIndex = map.get(colName);
|
|
49
|
+
if (colIndex === undefined) throw new Error(`Column '${colName}' not found.`);
|
|
50
|
+
|
|
51
|
+
const exact = options.exact || false;
|
|
52
|
+
const filterVal = typeof value === 'number' ? String(value) : value;
|
|
53
|
+
|
|
54
|
+
// ✅ MATCHING YOUR WORK LOGIC EXACTLY
|
|
55
|
+
// 2. Cell Template uses PAGE (Matches your snippet)
|
|
56
|
+
const cellTemplate = resolve(config.cellSelector, page);
|
|
57
|
+
|
|
58
|
+
// 3. Filter using .nth(colIndex)
|
|
59
|
+
rowLocator = rowLocator.filter({
|
|
60
|
+
has: cellTemplate.nth(colIndex).getByText(filterVal, { exact }),
|
|
61
|
+
});
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
const count = await rowLocator.count();
|
|
65
|
+
if (count > 1) {
|
|
66
|
+
throw new Error(`Strict Mode Violation: Found ${count} rows matching ${JSON.stringify(filters)}.`);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
if (count === 1) return rowLocator.first();
|
|
70
|
+
|
|
71
|
+
// --- PAGINATION LOGIC ---
|
|
72
|
+
if (config.pagination && currentPage < effectiveMaxPages) {
|
|
73
|
+
const context: TableContext = {
|
|
74
|
+
root: rootLocator,
|
|
75
|
+
config: config,
|
|
76
|
+
page: page,
|
|
77
|
+
resolve: resolve
|
|
78
|
+
};
|
|
79
|
+
|
|
80
|
+
const didLoadMore = await config.pagination(context);
|
|
81
|
+
if (didLoadMore) {
|
|
82
|
+
currentPage++;
|
|
83
|
+
continue;
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
return null;
|
|
87
|
+
}
|
|
88
|
+
};
|
|
89
|
+
|
|
90
|
+
return {
|
|
91
|
+
getHeaders: async () => Array.from((await _getMap()).keys()),
|
|
92
|
+
|
|
93
|
+
getByRow: async (filters: Record<string, string | RegExp | number>, options: { exact?: boolean, maxPages?: number } = {}) => {
|
|
94
|
+
const row = await _findRowLocator(filters, options);
|
|
95
|
+
if (!row) return resolve(config.rowSelector, rootLocator).filter({ hasText: "NON_EXISTENT_ROW_SENTINEL_" + Date.now() });
|
|
96
|
+
return row;
|
|
97
|
+
},
|
|
98
|
+
|
|
99
|
+
getByCell: async (rowFilters: Record<string, string | RegExp | number>, targetColumn: string) => {
|
|
100
|
+
const row = await _findRowLocator(rowFilters);
|
|
101
|
+
if (!row) throw new Error(`Row not found: ${JSON.stringify(rowFilters)}`);
|
|
102
|
+
|
|
103
|
+
const map = await _getMap();
|
|
104
|
+
const colIndex = map.get(targetColumn);
|
|
105
|
+
if (colIndex === undefined) throw new Error(`Column '${targetColumn}' not found.`);
|
|
106
|
+
|
|
107
|
+
// Return the specific cell
|
|
108
|
+
// We scope this to the found ROW to ensure we get the right cell
|
|
109
|
+
if (typeof config.cellSelector === 'string') {
|
|
110
|
+
return row.locator(config.cellSelector).nth(colIndex);
|
|
111
|
+
} else {
|
|
112
|
+
return resolve(config.cellSelector, row).nth(colIndex);
|
|
113
|
+
}
|
|
114
|
+
},
|
|
115
|
+
|
|
116
|
+
getRows: async () => {
|
|
117
|
+
const map = await _getMap();
|
|
118
|
+
const rowLocator = resolve(config.rowSelector, rootLocator);
|
|
119
|
+
const rowCount = await rowLocator.count();
|
|
120
|
+
const results: Record<string, string>[] = [];
|
|
121
|
+
|
|
122
|
+
for (let i = 0; i < rowCount; i++) {
|
|
123
|
+
const row = rowLocator.nth(i);
|
|
124
|
+
let cells: Locator;
|
|
125
|
+
if (typeof config.cellSelector === 'string') {
|
|
126
|
+
cells = row.locator(config.cellSelector);
|
|
127
|
+
} else {
|
|
128
|
+
cells = resolve(config.cellSelector, row);
|
|
129
|
+
}
|
|
130
|
+
const cellTexts = await cells.allInnerTexts();
|
|
131
|
+
const rowData: Record<string, string> = {};
|
|
132
|
+
for (const [colName, colIdx] of map.entries()) {
|
|
133
|
+
rowData[colName] = (cellTexts[colIdx] || "").trim();
|
|
134
|
+
}
|
|
135
|
+
results.push(rowData);
|
|
136
|
+
}
|
|
137
|
+
return results;
|
|
138
|
+
},
|
|
139
|
+
|
|
140
|
+
getRowAsJSON: async (filters: Record<string, string | RegExp | number>) => {
|
|
141
|
+
const row = await _findRowLocator(filters);
|
|
142
|
+
if (!row) throw new Error(`Row not found: ${JSON.stringify(filters)}`);
|
|
143
|
+
|
|
144
|
+
let cells: Locator;
|
|
145
|
+
if (typeof config.cellSelector === 'string') {
|
|
146
|
+
cells = row.locator(config.cellSelector);
|
|
147
|
+
} else {
|
|
148
|
+
cells = resolve(config.cellSelector, row);
|
|
149
|
+
}
|
|
150
|
+
const cellTexts = await cells.allInnerTexts();
|
|
151
|
+
const map = await _getMap();
|
|
152
|
+
const result: Record<string, string> = {};
|
|
153
|
+
for (const [colName, colIndex] of map.entries()) {
|
|
154
|
+
result[colName] = (cellTexts[colIndex] || "").trim();
|
|
155
|
+
}
|
|
156
|
+
return result;
|
|
157
|
+
},
|
|
158
|
+
|
|
159
|
+
/**
|
|
160
|
+
* 🛠️ DEV TOOL: Prints a prompt to the console.
|
|
161
|
+
* Copy the output and paste it into Gemini/ChatGPT to generate your config.
|
|
162
|
+
*/
|
|
163
|
+
generateConfigPrompt: async () => {
|
|
164
|
+
const html = await rootLocator.evaluate((el) => el.outerHTML);
|
|
165
|
+
const separator = "=".repeat(50);
|
|
166
|
+
const prompt = `
|
|
167
|
+
${separator}
|
|
168
|
+
🤖 COPY THE TEXT BELOW INTO GEMINI/ChatGPT 🤖
|
|
169
|
+
${separator}
|
|
170
|
+
|
|
171
|
+
I am using a Playwright helper factory called 'useTable'.
|
|
172
|
+
I need you to generate the configuration object based on the HTML structure below.
|
|
173
|
+
|
|
174
|
+
Here is the table HTML:
|
|
175
|
+
\`\`\`html
|
|
176
|
+
${html}
|
|
177
|
+
\`\`\`
|
|
178
|
+
|
|
179
|
+
Based on this HTML, generate the configuration object matching this signature:
|
|
180
|
+
const table = useTable(page.locator('...'), {
|
|
181
|
+
// Find the rows (exclude headers and empty spacer rows if possible)
|
|
182
|
+
rowSelector: "...", // OR (root) => root.locator(...)
|
|
183
|
+
|
|
184
|
+
// Find the column headers
|
|
185
|
+
headerSelector: "...", // OR (root) => root.locator(...)
|
|
186
|
+
|
|
187
|
+
// Find the cell (relative to a specific row)
|
|
188
|
+
cellSelector: "...", // OR (row) => row.locator(...)
|
|
189
|
+
|
|
190
|
+
// Find the "Next Page" button (if it exists in the HTML)
|
|
191
|
+
paginationNextSelector: (root) => root.locator(...)
|
|
192
|
+
});
|
|
193
|
+
|
|
194
|
+
**Requirements:**
|
|
195
|
+
1. Prefer \`getByRole\` or \`getByTestId\` over CSS classes where possible.
|
|
196
|
+
2. If the table uses \`div\` structures (like React Table), ensure the \`rowSelector\` does not accidentally select the header row.
|
|
197
|
+
3. If there are "padding" or "loading" rows, use \`.filter()\` to exclude them.
|
|
198
|
+
|
|
199
|
+
${separator}
|
|
200
|
+
`;
|
|
201
|
+
console.log(prompt);
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
};
|
|
205
|
+
|
|
206
|
+
/**
|
|
207
|
+
* 🛠️ DEV TOOL: Prints a prompt to help write a custom Pagination Strategy.
|
|
208
|
+
* It snapshots the HTML *surrounding* the table to find buttons/scroll containers.
|
|
209
|
+
*/
|
|
210
|
+
generateStrategyPrompt: async () => {
|
|
211
|
+
// 1. Get the parent container (often holds the pagination controls)
|
|
212
|
+
const container = rootLocator.locator('xpath=..');
|
|
213
|
+
const html = await container.evaluate((el) => el.outerHTML);
|
|
214
|
+
|
|
215
|
+
const prompt = `
|
|
216
|
+
==================================================
|
|
217
|
+
🤖 COPY INTO GEMINI/ChatGPT TO WRITE A STRATEGY 🤖
|
|
218
|
+
==================================================
|
|
219
|
+
|
|
220
|
+
I am using 'playwright-smart-table'. I need a custom Pagination Strategy.
|
|
221
|
+
The table is inside this container HTML:
|
|
222
|
+
|
|
223
|
+
\`\`\`html
|
|
224
|
+
${html.substring(0, 5000)} ... (truncated)
|
|
225
|
+
\`\`\`
|
|
226
|
+
|
|
227
|
+
Write a strategy that implements this interface:
|
|
228
|
+
type PaginationStrategy = (context: TableContext) => Promise<boolean>;
|
|
229
|
+
|
|
230
|
+
Requirements:
|
|
231
|
+
1. Identify the "Next" button OR the scroll container.
|
|
232
|
+
2. Return 'true' if data loaded, 'false' if end of data.
|
|
233
|
+
3. Use context.resolve() to find elements.
|
|
234
|
+
`;
|
|
235
|
+
console.log(prompt);
|
|
236
|
+
}
|
|
237
|
+
};
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import { test, expect } from '@playwright/test';
|
|
2
|
+
import { useTable } from '../src/useTable';
|
|
3
|
+
import { TableStrategies } from '../src/strategies';
|
|
4
|
+
|
|
5
|
+
test('Material UI Data Grid Interaction', async ({ page }) => {
|
|
6
|
+
// 1. Navigate to the MUI DataGrid demo
|
|
7
|
+
await page.goto('https://mui.com/material-ui/react-table/');
|
|
8
|
+
|
|
9
|
+
// The demo grid is usually slightly down the page
|
|
10
|
+
const tableLocator = page.locator('.MuiDataGrid-root').first();
|
|
11
|
+
await tableLocator.scrollIntoViewIfNeeded();
|
|
12
|
+
|
|
13
|
+
// 2. Initialize Smart Table
|
|
14
|
+
const table = useTable(tableLocator, {
|
|
15
|
+
rowSelector: '.MuiDataGrid-row',
|
|
16
|
+
headerSelector: '.MuiDataGrid-columnHeader',
|
|
17
|
+
cellSelector: '.MuiDataGrid-cell', // MUI uses distinct divs for cells
|
|
18
|
+
pagination: TableStrategies.clickNext(
|
|
19
|
+
(root) => root.getByRole("button", { name: "Go to next page" })
|
|
20
|
+
),
|
|
21
|
+
maxPages: 5
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
// Debug: See what columns were detected
|
|
25
|
+
console.log('Headers Detected:', await table.getHeaders());
|
|
26
|
+
|
|
27
|
+
// 3. Find a row (Melisandre is usually in the standard demo dataset)
|
|
28
|
+
// Note: We use "Last name" because that matches the visible header text
|
|
29
|
+
const row = await table.getByRow({ "Last name": "Melisandre" });
|
|
30
|
+
await expect(row).toBeVisible();
|
|
31
|
+
console.log('✅ Found Melisandre');
|
|
32
|
+
|
|
33
|
+
// 4. Check specific cell data
|
|
34
|
+
// "Age" column for Melisandre should be "150"
|
|
35
|
+
const ageCell = await table.getByCell({ "Last name": "Melisandre" }, "Age");
|
|
36
|
+
await expect(ageCell).toHaveText("150");
|
|
37
|
+
console.log('✅ Verified Age is 150');
|
|
38
|
+
|
|
39
|
+
// 5. Dump Data
|
|
40
|
+
const userData = await table.getRowAsJSON({ "Last name": "Melisandre" });
|
|
41
|
+
console.log('User Data JSON:', userData);
|
|
42
|
+
|
|
43
|
+
// This works because we added getHeaders() back to the helper
|
|
44
|
+
const headers = await table.getHeaders();
|
|
45
|
+
console.log(headers);
|
|
46
|
+
|
|
47
|
+
// 4. Change: Interact with the Checkbox
|
|
48
|
+
// Logic: Find the cell in the first column (__col_0) for the row with Age: 150
|
|
49
|
+
// Then click the input/label inside that cell.
|
|
50
|
+
await (await table.getByCell({ Age: "150" }, "__col_0"))
|
|
51
|
+
.getByLabel("Select row")
|
|
52
|
+
.click();
|
|
53
|
+
});
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import { test, expect } from '@playwright/test';
|
|
2
|
+
import { useTable } from '../src/useTable';
|
|
3
|
+
|
|
4
|
+
test.describe('README.md Examples Verification', () => {
|
|
5
|
+
|
|
6
|
+
test('getRows() returns array of objects (Matches README)', async ({ page }) => {
|
|
7
|
+
// 1. Arrange: Go to a standard table with a "Name" column
|
|
8
|
+
await page.goto('https://datatables.net/examples/data_sources/dom');
|
|
9
|
+
const table = useTable(page.locator('#example'), {
|
|
10
|
+
headerSelector: 'thead th'
|
|
11
|
+
});
|
|
12
|
+
|
|
13
|
+
// 2. Act: Get all rows as JSON objects
|
|
14
|
+
const rows = await table.getRows();
|
|
15
|
+
|
|
16
|
+
// 3. Assert: Verify the structure matches the README example
|
|
17
|
+
console.log('First Row:', rows[0]);
|
|
18
|
+
|
|
19
|
+
// Datatables.net default sort order:
|
|
20
|
+
// Row 0: Airi Satou
|
|
21
|
+
// Row 1: Angelica Ramos
|
|
22
|
+
expect(rows[0]['Name']).toBe('Airi Satou');
|
|
23
|
+
expect(rows[1]['Name']).toBe('Angelica Ramos');
|
|
24
|
+
|
|
25
|
+
// Verify other columns to ensure mapping is correct
|
|
26
|
+
expect(rows[0]['Position']).toBe('Accountant');
|
|
27
|
+
expect(rows[0]['Office']).toBe('Tokyo');
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
test('getRowAsJSON() returns single object', async ({ page }) => {
|
|
31
|
+
await page.goto('https://datatables.net/examples/data_sources/dom');
|
|
32
|
+
const table = useTable(page.locator('#example'), {
|
|
33
|
+
headerSelector: 'thead th'
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
// Act
|
|
37
|
+
const data = await table.getRowAsJSON({ Name: 'Ashton Cox' });
|
|
38
|
+
|
|
39
|
+
// Assert
|
|
40
|
+
expect(data['Name']).toBe('Ashton Cox');
|
|
41
|
+
expect(data['Position']).toBe('Junior Technical Author');
|
|
42
|
+
expect(data['Salary']).toContain('$86,000');
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
});
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
// tests/strategies.spec.ts
|
|
2
|
+
import { test, expect } from '@playwright/test';
|
|
3
|
+
import { useTable } from '../src/useTable';
|
|
4
|
+
import { TableStrategies } from '../src/strategies';
|
|
5
|
+
|
|
6
|
+
test.describe('Real World Strategy Tests', () => {
|
|
7
|
+
|
|
8
|
+
test('Strategy: Click Next (Datatables.net)', async ({ page }) => {
|
|
9
|
+
await page.goto('https://datatables.net/examples/data_sources/dom');
|
|
10
|
+
|
|
11
|
+
const tableLoc = page.locator('#example');
|
|
12
|
+
|
|
13
|
+
const table = useTable(tableLoc, {
|
|
14
|
+
rowSelector: 'tbody tr',
|
|
15
|
+
headerSelector: 'thead th',
|
|
16
|
+
cellSelector: 'td',
|
|
17
|
+
// Strategy: Standard "Next" Button
|
|
18
|
+
pagination: TableStrategies.clickNext('#example_next'),
|
|
19
|
+
maxPages: 3
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
// Verify Page 1
|
|
23
|
+
await expect(await table.getByRow({ Name: "Airi Satou" })).toBeVisible();
|
|
24
|
+
|
|
25
|
+
// Verify Page 2 (Triggers Click Next)
|
|
26
|
+
// "Bradley Greer" is usually on Page 2
|
|
27
|
+
console.log("🔎 Searching for Bradley Greer (Page 2)...");
|
|
28
|
+
await expect(await table.getByRow({ Name: "Bradley Greer" })).toBeVisible();
|
|
29
|
+
console.log("✅ Found row on Page 2!");
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
test('Strategy: Infinite Scroll (HTMX Example)', async ({ page }) => {
|
|
33
|
+
await page.goto('https://htmx.org/examples/infinite-scroll/');
|
|
34
|
+
|
|
35
|
+
const tableLoc = page.locator('table');
|
|
36
|
+
|
|
37
|
+
const table = useTable(tableLoc, {
|
|
38
|
+
rowSelector: 'tbody tr',
|
|
39
|
+
headerSelector: 'thead th',
|
|
40
|
+
cellSelector: 'td',
|
|
41
|
+
// Strategy: Infinite Scroll (Scroll to bottom -> Wait for row count to increase)
|
|
42
|
+
pagination: TableStrategies.infiniteScroll(),
|
|
43
|
+
maxPages: 5
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
// 1. Get initial count
|
|
47
|
+
const initialRows = await table.getRows();
|
|
48
|
+
console.log(`Initial Row Count: ${initialRows.length}`);
|
|
49
|
+
|
|
50
|
+
// 2. Trigger Scroll by searching for a row that doesn't exist yet
|
|
51
|
+
// HTMX demo generates random IDs. We'll simulate a deep search by asking for
|
|
52
|
+
// the 40th row (since it loads ~20 at a time).
|
|
53
|
+
|
|
54
|
+
// HACK: Since IDs are random, we simply check that the library *attempts* to scroll.
|
|
55
|
+
// We expect it to eventually fail finding "NonExistent", but verify it tried 5 pages.
|
|
56
|
+
console.log("🔎 Triggering Scroll...");
|
|
57
|
+
const missing = await table.getByRow({ "ID": "NonExistentID" });
|
|
58
|
+
|
|
59
|
+
// It should have tried 5 times (scrolling each time)
|
|
60
|
+
await expect(missing).not.toBeVisible();
|
|
61
|
+
|
|
62
|
+
const finalRows = await table.getRows();
|
|
63
|
+
console.log(`Final Row Count: ${finalRows.length}`);
|
|
64
|
+
|
|
65
|
+
expect(finalRows.length).toBeGreaterThan(initialRows.length);
|
|
66
|
+
console.log("✅ Infinite Scroll successfully loaded more rows!");
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
});
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import { test, expect } from '@playwright/test';
|
|
2
|
+
import { useTable } from '../src/useTable';
|
|
3
|
+
|
|
4
|
+
test('The Internet Herokuapp - Standard Table', async ({ page }) => {
|
|
5
|
+
//--------------------------------
|
|
6
|
+
// Arrange:
|
|
7
|
+
//--------------------------------
|
|
8
|
+
await page.goto("https://the-internet.herokuapp.com/tables");
|
|
9
|
+
|
|
10
|
+
// 1. Setup the helper
|
|
11
|
+
// We select the second table (.nth(1)) just like your snippet
|
|
12
|
+
const tableLocator = page.getByRole("table").nth(1);
|
|
13
|
+
|
|
14
|
+
// No config needed! Defaults (tbody tr, th, td) work perfectly here.
|
|
15
|
+
const table = useTable(tableLocator);
|
|
16
|
+
|
|
17
|
+
//--------------------------------
|
|
18
|
+
// Act & Assert:
|
|
19
|
+
//--------------------------------
|
|
20
|
+
|
|
21
|
+
// OPTION A: Check a specific value (Cleaner)
|
|
22
|
+
// This verifies that the row with Last Name "Doe" has the Email "jdoe@hotmail.com"
|
|
23
|
+
await expect(
|
|
24
|
+
await table.getByCell({ "Last Name": "Doe" }, "Email")
|
|
25
|
+
).toHaveText("jdoe@hotmail.com");
|
|
26
|
+
|
|
27
|
+
// OPTION B: Interact with the whole row
|
|
28
|
+
// getByRow returns the standard Locator for that specific TR
|
|
29
|
+
const row = await table.getByRow({ "Last Name": "Doe" });
|
|
30
|
+
|
|
31
|
+
// Example: Verify visibility
|
|
32
|
+
await expect(row).toBeVisible();
|
|
33
|
+
|
|
34
|
+
// Example: Verify the 'edit' link is inside this specific row
|
|
35
|
+
// (We use toBeVisible instead of click just to keep the test safe/repeatable)
|
|
36
|
+
await expect(row.getByRole('link', { name: 'edit' })).toBeVisible();
|
|
37
|
+
});
|
package/tsconfig.json
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
{
|
|
2
|
+
"compilerOptions": {
|
|
3
|
+
"target": "es2016",
|
|
4
|
+
"module": "commonjs",
|
|
5
|
+
"declaration": true, /* Generates .d.ts files so users get Intellisense */
|
|
6
|
+
"outDir": "./dist", /* Where the compiled JS goes */
|
|
7
|
+
"strict": true, /* Enable strict type checking */
|
|
8
|
+
"esModuleInterop": true,
|
|
9
|
+
"skipLibCheck": true,
|
|
10
|
+
"forceConsistentCasingInFileNames": true
|
|
11
|
+
},
|
|
12
|
+
"include": ["src/**/*"],
|
|
13
|
+
"exclude": ["node_modules", "**/*.spec.ts"]
|
|
14
|
+
}
|