@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 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
+ };
@@ -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,2 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
@@ -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
+ };
@@ -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>;
@@ -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
+ }