@rickcedwhat/playwright-smart-table 6.16.0 → 6.17.1

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.
@@ -1,7 +1,14 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.muiDataGrid = exports.muiTable = void 0;
4
+ exports.createMuiTable = createMuiTable;
5
+ exports.createMuiDataGrid = createMuiDataGrid;
4
6
  const debugUtils_1 = require("../utils/debugUtils");
7
+ const DEFAULT_LABELS = {
8
+ nextPage: 'Go to next page',
9
+ previousPage: 'Go to previous page',
10
+ firstPage: 'Go to first page',
11
+ };
5
12
  /**
6
13
  * Safely wait for MUI pagination to stabilize.
7
14
  * Polls for displayed row text changes and waits for loading overlays to disappear.
@@ -45,120 +52,136 @@ async function getPaginationRoot(context) {
45
52
  return context.root.locator('xpath=following::div[contains(@class, "MuiTablePagination-root")][1]').first();
46
53
  }
47
54
  /**
48
- * Preset configuration for the standard Material UI `<Table />` component.
55
+ * Creates a preset configuration for the standard Material UI `<Table />` component.
49
56
  *
50
- * Handles:
51
- * - Selectors for `.MuiTableRow-root`, `.MuiTableCell-head`, etc.
52
- * - Pagination via the standard `.MuiTablePagination-actions` footer.
53
- * - Sorting via the `.MuiTableSortLabel-root` header classes.
54
- * - Virtualized DOM deduplication (falls back to cell text if unvirtualized).
57
+ * @param opts.buttonLabels - Override aria-labels for pagination buttons (use for non-English locales).
58
+ *
59
+ * @example
60
+ * // French locale
61
+ * const table = useTable(loc, createMuiTable({ buttonLabels: { nextPage: 'Page suivante', previousPage: 'Page précédente' } }));
55
62
  */
56
- exports.muiTable = {
57
- rowSelector: 'tbody tr, tbody .MuiTableRow-root',
58
- cellSelector: 'td, th, .MuiTableCell-body',
59
- headerSelector: 'thead th, .MuiTableCell-head',
60
- headerTransformer: ({ text }) => text.replace(/\bsorted\s+(?:ascending|descending)\b/i, '').trim(),
61
- strategies: {
62
- pagination: {
63
- goNext: async (context) => {
64
- const root = await getPaginationRoot(context);
65
- const nextBtn = root.locator('button[aria-label="Go to next page"]');
66
- if (await nextBtn.count() === 0 || await nextBtn.isDisabled())
67
- return false;
68
- const displayedRows = root.locator('.MuiTablePagination-displayedRows');
69
- const oldText = await displayedRows.innerText().catch(() => '');
70
- // Force click ignores cookie banners obscuring the footer
71
- await nextBtn.click({ force: true });
72
- await waitForMuiPaginationStabilization(context, displayedRows, oldText);
73
- return true;
74
- },
75
- goPrevious: async (context) => {
76
- const root = await getPaginationRoot(context);
77
- const prevBtn = root.locator('button[aria-label="Go to previous page"]');
78
- if (await prevBtn.count() === 0 || await prevBtn.isDisabled())
79
- return false;
80
- const displayedRows = root.locator('.MuiTablePagination-displayedRows');
81
- const oldText = await displayedRows.innerText().catch(() => '');
82
- await prevBtn.click({ force: true });
83
- await waitForMuiPaginationStabilization(context, displayedRows, oldText);
84
- return true;
85
- },
86
- goToFirst: async (context) => {
87
- const root = await getPaginationRoot(context);
88
- const prevBtn = root.locator('button[aria-label="Go to previous page"]');
89
- const displayedRows = root.locator('.MuiTablePagination-displayedRows');
90
- let clicked = false;
91
- let noProgressCount = 0;
92
- const NO_PROGRESS_LIMIT = 3;
93
- while (await prevBtn.count() > 0 && !(await prevBtn.isDisabled())) {
63
+ function createMuiTable(opts) {
64
+ const labels = Object.assign(Object.assign({}, DEFAULT_LABELS), opts === null || opts === void 0 ? void 0 : opts.buttonLabels);
65
+ return {
66
+ rowSelector: 'tbody tr, tbody .MuiTableRow-root',
67
+ cellSelector: 'td, th, .MuiTableCell-body',
68
+ headerSelector: 'thead th, .MuiTableCell-head',
69
+ headerTransformer: ({ text }) => text.replace(/\bsorted\s+(?:ascending|descending)\b/i, '').trim(),
70
+ strategies: {
71
+ pagination: {
72
+ goNext: async (context) => {
73
+ const root = await getPaginationRoot(context);
74
+ const nextBtn = root.getByRole('button', { name: labels.nextPage, exact: true });
75
+ if (await nextBtn.count() === 0 || await nextBtn.isDisabled())
76
+ return false;
77
+ const displayedRows = root.locator('.MuiTablePagination-displayedRows');
94
78
  const oldText = await displayedRows.innerText().catch(() => '');
95
- await prevBtn.click();
79
+ // Force click ignores cookie banners obscuring the footer
80
+ await nextBtn.click({ force: true });
96
81
  await waitForMuiPaginationStabilization(context, displayedRows, oldText);
97
- clicked = true;
98
- const newText = await displayedRows.innerText().catch(() => '');
99
- if (newText === oldText) {
100
- noProgressCount++;
101
- if (noProgressCount >= NO_PROGRESS_LIMIT) {
102
- (0, debugUtils_1.logDebug)(context.config, 'error', `goToFirst: no pagination progress after ${NO_PROGRESS_LIMIT} consecutive clicks — aborting to avoid infinite loop`);
103
- return false;
104
- }
82
+ return true;
83
+ },
84
+ goPrevious: async (context) => {
85
+ const root = await getPaginationRoot(context);
86
+ const prevBtn = root.getByRole('button', { name: labels.previousPage, exact: true });
87
+ if (await prevBtn.count() === 0 || await prevBtn.isDisabled())
88
+ return false;
89
+ const displayedRows = root.locator('.MuiTablePagination-displayedRows');
90
+ const oldText = await displayedRows.innerText().catch(() => '');
91
+ await prevBtn.click({ force: true });
92
+ await waitForMuiPaginationStabilization(context, displayedRows, oldText);
93
+ return true;
94
+ },
95
+ goToFirst: async (context) => {
96
+ const root = await getPaginationRoot(context);
97
+ const displayedRows = root.locator('.MuiTablePagination-displayedRows');
98
+ // MUI TablePagination optionally renders a "first page" button (showFirstButton prop)
99
+ const firstBtn = root.getByRole('button', { name: labels.firstPage, exact: true });
100
+ if (await firstBtn.count() > 0 && !(await firstBtn.isDisabled())) {
101
+ const oldText = await displayedRows.innerText().catch(() => '');
102
+ await firstBtn.click({ force: true });
103
+ await waitForMuiPaginationStabilization(context, displayedRows, oldText);
104
+ return true;
105
105
  }
106
- else {
107
- noProgressCount = 0;
106
+ // Fall back to stepping backward one page at a time
107
+ const prevBtn = root.getByRole('button', { name: labels.previousPage, exact: true });
108
+ let clicked = false;
109
+ let noProgressCount = 0;
110
+ const NO_PROGRESS_LIMIT = 3;
111
+ while (await prevBtn.count() > 0 && !(await prevBtn.isDisabled())) {
112
+ const oldText = await displayedRows.innerText().catch(() => '');
113
+ await prevBtn.click({ force: true });
114
+ await waitForMuiPaginationStabilization(context, displayedRows, oldText);
115
+ clicked = true;
116
+ const newText = await displayedRows.innerText().catch(() => '');
117
+ if (newText === oldText) {
118
+ noProgressCount++;
119
+ if (noProgressCount >= NO_PROGRESS_LIMIT) {
120
+ (0, debugUtils_1.logDebug)(context.config, 'error', `goToFirst: no pagination progress after ${NO_PROGRESS_LIMIT} consecutive clicks — aborting to avoid infinite loop`);
121
+ return false;
122
+ }
123
+ }
124
+ else {
125
+ noProgressCount = 0;
126
+ }
108
127
  }
128
+ return clicked;
109
129
  }
110
- return clicked;
111
- }
112
- },
113
- sorting: {
114
- getSortState: async ({ columnName, context }) => {
115
- if (!context.getHeaderCell)
116
- return 'none';
117
- const header = await context.getHeaderCell(columnName);
118
- const sortLabel = header.locator('.MuiTableSortLabel-root').first();
119
- if (!(await sortLabel.isVisible().catch(() => false)))
120
- return 'none';
121
- const className = await sortLabel.getAttribute('class') || '';
122
- if (className.includes('directionAsc'))
123
- return 'asc';
124
- if (className.includes('directionDesc'))
125
- return 'desc';
126
- // Often, if the label is active but lacks explicit direction classes, MUI defaults to ascending
127
- if (className.includes('active'))
128
- return 'asc';
129
- return 'none';
130
130
  },
131
- doSort: async ({ columnName, direction, context }) => {
132
- var _a, _b;
133
- if (!context.getHeaderCell)
134
- return;
135
- const header = await context.getHeaderCell(columnName);
136
- const sortLabel = header.locator('.MuiTableSortLabel-root').first();
137
- const target = await sortLabel.isVisible().catch(() => false) ? sortLabel : header;
138
- let current = await ((_a = context.config.strategies.sorting) === null || _a === void 0 ? void 0 : _a.getSortState({ columnName, context })) || 'none';
139
- let attempts = 0;
140
- // Click until the state matches the target direction, max 3 times (none -> asc -> desc)
141
- while (current !== direction && attempts < 3) {
142
- await target.click();
143
- await context.page.waitForTimeout(100);
144
- current = await ((_b = context.config.strategies.sorting) === null || _b === void 0 ? void 0 : _b.getSortState({ columnName, context })) || 'none';
145
- attempts++;
131
+ sorting: {
132
+ getSortState: async ({ columnName, context }) => {
133
+ if (!context.getHeaderCell)
134
+ return 'none';
135
+ const header = await context.getHeaderCell(columnName);
136
+ const sortLabel = header.locator('.MuiTableSortLabel-root').first();
137
+ if (!(await sortLabel.isVisible().catch(() => false)))
138
+ return 'none';
139
+ const className = await sortLabel.getAttribute('class') || '';
140
+ if (className.includes('directionAsc'))
141
+ return 'asc';
142
+ if (className.includes('directionDesc'))
143
+ return 'desc';
144
+ // Often, if the label is active but lacks explicit direction classes, MUI defaults to ascending
145
+ if (className.includes('active'))
146
+ return 'asc';
147
+ return 'none';
148
+ },
149
+ doSort: async ({ columnName, direction, context }) => {
150
+ var _a, _b;
151
+ if (!context.getHeaderCell)
152
+ return;
153
+ const header = await context.getHeaderCell(columnName);
154
+ const sortLabel = header.locator('.MuiTableSortLabel-root').first();
155
+ const target = await sortLabel.isVisible().catch(() => false) ? sortLabel : header;
156
+ let current = await ((_a = context.config.strategies.sorting) === null || _a === void 0 ? void 0 : _a.getSortState({ columnName, context })) || 'none';
157
+ let attempts = 0;
158
+ // Click until the state matches the target direction, max 3 times (none -> asc -> desc)
159
+ while (current !== direction && attempts < 3) {
160
+ await target.click();
161
+ await context.page.waitForTimeout(100);
162
+ current = await ((_b = context.config.strategies.sorting) === null || _b === void 0 ? void 0 : _b.getSortState({ columnName, context })) || 'none';
163
+ attempts++;
164
+ }
146
165
  }
166
+ },
167
+ dedupe: async (row) => {
168
+ // MUI virtualized tables attach data-index to rows
169
+ const dataIndex = await row.getAttribute('data-index');
170
+ if (dataIndex !== null)
171
+ return dataIndex;
172
+ // Unvirtualized tables don't recycle nodes, but if they do duplicate, fallback to first cell's text
173
+ const text = await row.locator('td, th').first().innerText().catch(() => '');
174
+ return text || String(Math.random());
147
175
  }
148
- },
149
- dedupe: async (row) => {
150
- // MUI virtualized tables attach data-index to rows
151
- const dataIndex = await row.getAttribute('data-index');
152
- if (dataIndex !== null)
153
- return dataIndex;
154
- // Unvirtualized tables don't recycle nodes, but if they do duplicate, fallback to first cell's text
155
- const text = await row.locator('td, th').first().innerText().catch(() => '');
156
- return text || String(Math.random());
157
176
  }
158
- }
159
- };
177
+ };
178
+ }
179
+ /** Default preset for MUI `<Table />`. For non-English locales use `createMuiTable({ buttonLabels })`. */
180
+ exports.muiTable = createMuiTable();
160
181
  /**
161
- * Preset configuration for the MUI DataGrid component (@mui/x-data-grid).
182
+ * Creates a preset configuration for the MUI DataGrid component (@mui/x-data-grid).
183
+ *
184
+ * @param opts.buttonLabels - Override aria-labels for pagination buttons (use for non-English locales).
162
185
  *
163
186
  * Handles:
164
187
  * - Selectors for `.MuiDataGrid-row`, `.MuiDataGrid-cell`, and `.MuiDataGrid-columnHeader`.
@@ -167,184 +190,331 @@ exports.muiTable = {
167
190
  * - Vertical Virtualization deduplication using `data-rowindex`.
168
191
  * - Horizontal Virtualization support via `aria-colindex`.
169
192
  */
170
- exports.muiDataGrid = {
171
- rowSelector: '.MuiDataGrid-row',
172
- cellSelector: '.MuiDataGrid-cell',
173
- headerSelector: '.MuiDataGrid-columnHeader',
174
- strategies: {
175
- pagination: {
176
- goNext: async (context) => {
177
- const footer = context.root.locator('.MuiDataGrid-footerContainer');
178
- const nextBtn = footer.locator('button[aria-label="Go to next page"]');
179
- try {
180
- await nextBtn.waitFor({ state: 'visible', timeout: 2000 });
181
- }
182
- catch (e) {
183
- return false;
184
- }
185
- if (await nextBtn.isDisabled())
186
- return false;
187
- const displayedRows = footer.locator('.MuiTablePagination-displayedRows');
188
- const oldText = await displayedRows.innerText().catch(() => '');
189
- await nextBtn.scrollIntoViewIfNeeded().catch(() => { });
190
- await nextBtn.click({ force: true });
191
- await waitForMuiPaginationStabilization(context, displayedRows, oldText);
192
- return true;
193
- },
194
- goPrevious: async (context) => {
195
- const footer = context.root.locator('.MuiDataGrid-footerContainer');
196
- const prevBtn = footer.locator('button[aria-label="Go to previous page"]');
197
- try {
198
- await prevBtn.waitFor({ state: 'visible', timeout: 2000 });
199
- }
200
- catch (e) {
201
- return false;
202
- }
203
- if (await prevBtn.isDisabled())
204
- return false;
205
- const displayedRows = footer.locator('.MuiTablePagination-displayedRows');
206
- const oldText = await displayedRows.innerText().catch(() => '');
207
- await prevBtn.scrollIntoViewIfNeeded().catch(() => { });
208
- await prevBtn.click({ force: true });
209
- await waitForMuiPaginationStabilization(context, displayedRows, oldText);
210
- return true;
211
- }
212
- },
213
- sorting: {
214
- getSortState: async ({ columnName, context }) => {
215
- if (!context.getHeaderCell)
216
- return 'none';
217
- const header = await context.getHeaderCell(columnName);
218
- const sortAttr = await header.getAttribute('aria-sort');
219
- if (sortAttr === 'ascending')
220
- return 'asc';
221
- if (sortAttr === 'descending')
222
- return 'desc';
223
- return 'none';
193
+ function createMuiDataGrid(opts) {
194
+ const labels = Object.assign(Object.assign({}, DEFAULT_LABELS), opts === null || opts === void 0 ? void 0 : opts.buttonLabels);
195
+ return {
196
+ rowSelector: '.MuiDataGrid-row',
197
+ cellSelector: '.MuiDataGrid-cell',
198
+ headerSelector: '.MuiDataGrid-columnHeader',
199
+ concurrency: 'synchronized',
200
+ strategies: {
201
+ resolveRowIndex: async (row) => {
202
+ const v = await row.getAttribute('data-rowindex').catch(() => null);
203
+ return v !== null && !isNaN(Number(v)) ? Number(v) : undefined;
224
204
  },
225
- doSort: async ({ columnName, direction, context }) => {
226
- var _a, _b;
227
- if (!context.getHeaderCell)
228
- return;
229
- const header = await context.getHeaderCell(columnName);
230
- let current = await ((_a = context.config.strategies.sorting) === null || _a === void 0 ? void 0 : _a.getSortState({ columnName, context })) || 'none';
231
- let attempts = 0;
232
- while (current !== direction && attempts < 3) {
233
- const clickTarget = header.locator('.MuiDataGrid-columnHeaderTitleContainer').first();
234
- if (await clickTarget.isVisible()) {
235
- await clickTarget.click({ force: true });
205
+ pagination: {
206
+ goNext: async (context) => {
207
+ const footer = context.root.locator('.MuiDataGrid-footerContainer');
208
+ const nextBtn = footer.getByRole('button', { name: labels.nextPage, exact: true });
209
+ try {
210
+ await nextBtn.waitFor({ state: 'visible', timeout: 2000 });
211
+ }
212
+ catch (e) {
213
+ return false;
236
214
  }
237
- else {
238
- await header.click({ force: true });
215
+ if (await nextBtn.isDisabled())
216
+ return false;
217
+ const displayedRows = footer.locator('.MuiTablePagination-displayedRows');
218
+ const oldText = await displayedRows.innerText().catch(() => '');
219
+ await nextBtn.scrollIntoViewIfNeeded().catch(() => { });
220
+ await nextBtn.click({ force: true });
221
+ await waitForMuiPaginationStabilization(context, displayedRows, oldText);
222
+ return true;
223
+ },
224
+ goPrevious: async (context) => {
225
+ const footer = context.root.locator('.MuiDataGrid-footerContainer');
226
+ const prevBtn = footer.getByRole('button', { name: labels.previousPage, exact: true });
227
+ try {
228
+ await prevBtn.waitFor({ state: 'visible', timeout: 2000 });
239
229
  }
240
- // Wait for stabilization (using DataGrid specific overlay check inside helper)
241
- const displayedRows = context.root.locator('.MuiTablePagination-displayedRows');
242
- const text = await displayedRows.innerText().catch(() => '');
243
- await waitForMuiPaginationStabilization(context, displayedRows, text);
244
- current = await ((_b = context.config.strategies.sorting) === null || _b === void 0 ? void 0 : _b.getSortState({ columnName, context })) || 'none';
245
- attempts++;
230
+ catch (e) {
231
+ return false;
232
+ }
233
+ if (await prevBtn.isDisabled())
234
+ return false;
235
+ const displayedRows = footer.locator('.MuiTablePagination-displayedRows');
236
+ const oldText = await displayedRows.innerText().catch(() => '');
237
+ await prevBtn.scrollIntoViewIfNeeded().catch(() => { });
238
+ await prevBtn.click({ force: true });
239
+ await waitForMuiPaginationStabilization(context, displayedRows, oldText);
240
+ return true;
241
+ },
242
+ goToFirst: async (context) => {
243
+ const footer = context.root.locator('.MuiDataGrid-footerContainer');
244
+ const displayedRows = footer.locator('.MuiTablePagination-displayedRows');
245
+ const firstBtn = footer.getByRole('button', { name: labels.firstPage, exact: true });
246
+ try {
247
+ await firstBtn.waitFor({ state: 'visible', timeout: 1000 });
248
+ if (!(await firstBtn.isDisabled())) {
249
+ const oldText = await displayedRows.innerText().catch(() => '');
250
+ await firstBtn.scrollIntoViewIfNeeded().catch(() => { });
251
+ await firstBtn.click({ force: true });
252
+ await waitForMuiPaginationStabilization(context, displayedRows, oldText);
253
+ return true;
254
+ }
255
+ }
256
+ catch (_a) {
257
+ // first-page button not present; fall back to stepping backward
258
+ }
259
+ const prevBtn = footer.getByRole('button', { name: labels.previousPage, exact: true });
260
+ let clicked = false;
261
+ let noProgressCount = 0;
262
+ const NO_PROGRESS_LIMIT = 3;
263
+ while (true) {
264
+ try {
265
+ await prevBtn.waitFor({ state: 'visible', timeout: 2000 });
266
+ }
267
+ catch (_b) {
268
+ break;
269
+ }
270
+ if (await prevBtn.isDisabled())
271
+ break;
272
+ const oldText = await displayedRows.innerText().catch(() => '');
273
+ await prevBtn.scrollIntoViewIfNeeded().catch(() => { });
274
+ await prevBtn.click({ force: true });
275
+ await waitForMuiPaginationStabilization(context, displayedRows, oldText);
276
+ clicked = true;
277
+ const newText = await displayedRows.innerText().catch(() => '');
278
+ if (newText === oldText) {
279
+ noProgressCount++;
280
+ if (noProgressCount >= NO_PROGRESS_LIMIT) {
281
+ (0, debugUtils_1.logDebug)(context.config, 'error', `goToFirst: no pagination progress after ${NO_PROGRESS_LIMIT} consecutive clicks — aborting to avoid infinite loop`);
282
+ return false;
283
+ }
284
+ }
285
+ else {
286
+ noProgressCount = 0;
287
+ }
288
+ }
289
+ return clicked;
246
290
  }
247
- }
248
- },
249
- dedupe: async (row) => {
250
- var _a;
251
- // DataGrid uses data-rowindex for its own virtualization management
252
- return (_a = (await row.getAttribute('data-rowindex').catch(() => null))) !== null && _a !== void 0 ? _a : '';
253
- },
254
- getCellLocator: ({ row, columnIndex }) => {
255
- // Horizontal virtualization uses aria-colindex (1-indexed)
256
- return row.locator(`[aria-colindex="${columnIndex + 1}"]`);
257
- },
258
- // MUI DataGrid virtualizes rows vertically (always) and columns horizontally (when
259
- // columnBuffer is configured). The virtualScroller is a descendant of root, so
260
- // scroll methods use querySelector rather than closest().
261
- viewport: {
262
- getVisibleRowRange: async ({ root, config }) => {
263
- const rowSel = config.rowSelector;
264
- return root.evaluate((el, rowSel) => {
265
- const indices = Array.from(el.querySelectorAll(rowSel))
266
- .map(r => Number(r.getAttribute('data-rowindex')))
267
- .filter(n => !isNaN(n));
268
- if (!indices.length)
269
- return { first: 0, last: 0 };
270
- return { first: Math.min(...indices), last: Math.max(...indices) };
271
- }, rowSel);
272
291
  },
273
- getVisibleColumnRange: async ({ root, config }) => {
274
- const rowSel = config.rowSelector;
275
- const cellSel = typeof config.cellSelector === 'string' ? config.cellSelector : '[aria-colindex]';
276
- return root.evaluate((el, { rowSel, cellSel }) => {
277
- const firstRow = el.querySelector(rowSel);
278
- if (!firstRow)
279
- return { first: 0, last: 0 };
280
- const indices = Array.from(firstRow.querySelectorAll(cellSel))
281
- .map(c => Number(c.getAttribute('aria-colindex')) - 1)
282
- .filter(n => !isNaN(n));
283
- if (!indices.length)
284
- return { first: 0, last: 0 };
285
- return { first: Math.min(...indices), last: Math.max(...indices) };
286
- }, { rowSel, cellSel });
287
- },
288
- scrollToRow: async ({ root, config }, rowIndex) => {
289
- const rowSel = config.rowSelector;
290
- await root.evaluate((el, { rowSel, idx }) => {
291
- const scroller = el.querySelector('.MuiDataGrid-virtualScroller');
292
- if (!scroller)
293
- return;
294
- const row = el.querySelector(`${rowSel}[data-rowindex="${idx}"]`);
295
- if (row) {
296
- row.scrollIntoView({ block: 'nearest', inline: 'nearest' });
292
+ sorting: {
293
+ getSortState: async ({ columnName, context }) => {
294
+ if (!context.getHeaderCell)
295
+ return 'none';
296
+ const header = await context.getHeaderCell(columnName);
297
+ const sortAttr = await header.getAttribute('aria-sort');
298
+ if (sortAttr === 'ascending')
299
+ return 'asc';
300
+ if (sortAttr === 'descending')
301
+ return 'desc';
302
+ return 'none';
303
+ },
304
+ doSort: async ({ columnName, direction, context }) => {
305
+ var _a, _b;
306
+ if (!context.getHeaderCell)
297
307
  return;
308
+ const header = await context.getHeaderCell(columnName);
309
+ let current = await ((_a = context.config.strategies.sorting) === null || _a === void 0 ? void 0 : _a.getSortState({ columnName, context })) || 'none';
310
+ let attempts = 0;
311
+ while (current !== direction && attempts < 3) {
312
+ const clickTarget = header.locator('.MuiDataGrid-columnHeaderTitleContainer').first();
313
+ if (await clickTarget.isVisible()) {
314
+ await clickTarget.click({ force: true });
315
+ }
316
+ else {
317
+ await header.click({ force: true });
318
+ }
319
+ // Wait for stabilization (using DataGrid specific overlay check inside helper)
320
+ const displayedRows = context.root.locator('.MuiTablePagination-displayedRows');
321
+ const text = await displayedRows.innerText().catch(() => '');
322
+ await waitForMuiPaginationStabilization(context, displayedRows, text);
323
+ current = await ((_b = context.config.strategies.sorting) === null || _b === void 0 ? void 0 : _b.getSortState({ columnName, context })) || 'none';
324
+ attempts++;
298
325
  }
299
- const visibleRows = Array.from(el.querySelectorAll(rowSel));
300
- const heights = visibleRows
301
- .map(visibleRow => visibleRow.getBoundingClientRect().height)
302
- .filter(height => height > 0);
303
- const estimatedHeight = heights.length
304
- ? heights.reduce((sum, height) => sum + height, 0) / heights.length
305
- : 52;
306
- scroller.scrollTop = Math.max(0, idx * estimatedHeight - 20);
307
- }, { rowSel, idx: rowIndex });
308
- await root.locator(`${rowSel}[data-rowindex="${rowIndex}"]`)
309
- .waitFor({ state: 'attached', timeout: 3000 });
326
+ }
310
327
  },
311
- scrollToColumn: async ({ root, config }, colIndex) => {
312
- const headerSel = typeof config.headerSelector === 'string' ? config.headerSelector : null;
313
- await root.evaluate((el, { headerSel, idx }) => {
314
- // virtualScroller is a descendant — querySelector, not closest
315
- const scroller = el.querySelector('.MuiDataGrid-virtualScroller');
316
- if (!scroller || !headerSel)
317
- return;
318
- const headers = Array.from(el.querySelectorAll(headerSel));
319
- const target = headers[idx];
320
- if (!target) {
321
- const widths = headers
322
- .map(header => header.getBoundingClientRect().width)
323
- .filter(width => width > 0);
324
- const estimatedWidth = widths.length
325
- ? widths.reduce((sum, width) => sum + width, 0) / widths.length
326
- : 100;
327
- scroller.scrollLeft = Math.max(0, idx * estimatedWidth - 20);
328
- return;
329
- }
330
- const cRect = scroller.getBoundingClientRect();
331
- const tRect = target.getBoundingClientRect();
332
- if (tRect.left < cRect.left) {
333
- scroller.scrollLeft -= (cRect.left - tRect.left) + 20;
334
- }
335
- else if (tRect.right > cRect.right) {
336
- scroller.scrollLeft += (tRect.right - cRect.right) + 20;
328
+ dedupe: async (row) => {
329
+ var _a;
330
+ // DataGrid uses data-rowindex for its own virtualization management
331
+ return (_a = (await row.getAttribute('data-rowindex').catch(() => null))) !== null && _a !== void 0 ? _a : '';
332
+ },
333
+ getCellLocator: ({ row, root, columnIndex, rowIndex }) => {
334
+ // Use data-rowindex for a stable row reference: nth-based locators break after
335
+ // vertical eviction because the DOM order shifts when rows are removed/added.
336
+ const stableRow = typeof rowIndex === 'number'
337
+ ? root.locator(`[data-rowindex="${rowIndex}"]`)
338
+ : row;
339
+ return stableRow.locator(`[aria-colindex="${columnIndex + 1}"]`);
340
+ },
341
+ // MUI DataGrid virtualizes rows vertically (always) and columns horizontally (when
342
+ // columnBuffer is configured). The virtualScroller is a descendant of root, so
343
+ // scroll methods use querySelector rather than closest().
344
+ viewport: {
345
+ getVisibleRowRange: async ({ root, config }) => {
346
+ const rowSel = config.rowSelector;
347
+ return root.evaluate((el, rowSel) => {
348
+ const indices = Array.from(el.querySelectorAll(rowSel))
349
+ .map(r => Number(r.getAttribute('data-rowindex')))
350
+ .filter(n => !isNaN(n));
351
+ if (!indices.length)
352
+ return { first: 0, last: 0 };
353
+ return { first: Math.min(...indices), last: Math.max(...indices) };
354
+ }, rowSel);
355
+ },
356
+ getVisibleColumnRange: async ({ root, config }) => {
357
+ const rowSel = config.rowSelector;
358
+ const cellSel = typeof config.cellSelector === 'string' ? config.cellSelector : '[aria-colindex]';
359
+ return root.evaluate((el, { rowSel, cellSel }) => {
360
+ const scroller = el.querySelector('.MuiDataGrid-virtualScroller');
361
+ const allRows = Array.from(el.querySelectorAll(rowSel));
362
+ if (!allRows.length)
363
+ return { first: 0, last: 0 };
364
+ // Use a row that's fully within the scroller's viewport bounds.
365
+ // The first DOM row is often an overscan row with extra columns rendered
366
+ // outside the clip boundary, causing the range to appear wider than it is.
367
+ let targetRow = allRows[0];
368
+ if (scroller) {
369
+ const scrollerRect = scroller.getBoundingClientRect();
370
+ const inView = allRows.find(r => {
371
+ const rect = r.getBoundingClientRect();
372
+ return rect.height > 0 &&
373
+ rect.top >= scrollerRect.top &&
374
+ rect.bottom <= scrollerRect.bottom;
375
+ });
376
+ if (inView)
377
+ targetRow = inView;
378
+ }
379
+ const scrollerRect = scroller === null || scroller === void 0 ? void 0 : scroller.getBoundingClientRect();
380
+ const indices = Array.from(targetRow.querySelectorAll(cellSel))
381
+ .map(c => {
382
+ if (scrollerRect) {
383
+ const rect = c.getBoundingClientRect();
384
+ const visible = rect.width > 0 &&
385
+ rect.right > scrollerRect.left &&
386
+ rect.left < scrollerRect.right;
387
+ if (!visible)
388
+ return NaN;
389
+ }
390
+ return Number(c.getAttribute('aria-colindex')) - 1;
391
+ })
392
+ .filter(n => !isNaN(n) && n >= 0);
393
+ if (!indices.length)
394
+ return { first: 0, last: 0 };
395
+ return { first: Math.min(...indices), last: Math.max(...indices) };
396
+ }, { rowSel, cellSel });
397
+ },
398
+ scrollToRow: async ({ root, config }, rowIndex) => {
399
+ const rowSel = config.rowSelector;
400
+ await root.evaluate((el, { rowSel, idx }) => {
401
+ const scroller = el.querySelector('.MuiDataGrid-virtualScroller');
402
+ if (!scroller)
403
+ return;
404
+ const existingRow = el.querySelector(`${rowSel}[data-rowindex="${idx}"]`);
405
+ if (existingRow) {
406
+ // Row is in DOM — adjust scrollTop only, never scrollLeft.
407
+ const scrollerRect = scroller.getBoundingClientRect();
408
+ const rowRect = existingRow.getBoundingClientRect();
409
+ if (rowRect.top < scrollerRect.top) {
410
+ scroller.scrollTop -= (scrollerRect.top - rowRect.top) + 4;
411
+ }
412
+ else if (rowRect.bottom > scrollerRect.bottom) {
413
+ scroller.scrollTop += (rowRect.bottom - scrollerRect.bottom) + 4;
414
+ }
415
+ return;
416
+ }
417
+ // Row not in DOM — interpolate from visible rows' style.top positions.
418
+ // This is far more accurate than estimating from row heights because MUI
419
+ // DataGrid uses absolute positioning (style.top) for each virtual row.
420
+ const visibleRows = Array.from(el.querySelectorAll(rowSel));
421
+ const rowsWithPos = visibleRows
422
+ .map(r => {
423
+ const ridx = Number(r.getAttribute('data-rowindex'));
424
+ const top = parseFloat(r.style.top);
425
+ return { ridx, top };
426
+ })
427
+ .filter(r => !isNaN(r.ridx) && !isNaN(r.top) && r.top >= 0)
428
+ .sort((a, b) => a.ridx - b.ridx);
429
+ if (rowsWithPos.length >= 2) {
430
+ const first = rowsWithPos[0];
431
+ const last = rowsWithPos[rowsWithPos.length - 1];
432
+ const rowHeight = (last.top - first.top) / (last.ridx - first.ridx);
433
+ const targetTop = first.top + (idx - first.ridx) * rowHeight;
434
+ scroller.scrollTop = Math.max(0, targetTop - 20);
435
+ return;
436
+ }
437
+ // Fallback: use bounding-rect heights.
438
+ const heights = visibleRows
439
+ .map(r => r.getBoundingClientRect().height)
440
+ .filter(h => h > 0);
441
+ const estimatedHeight = heights.length
442
+ ? heights.reduce((s, h) => s + h, 0) / heights.length
443
+ : 52;
444
+ scroller.scrollTop = Math.max(0, idx * estimatedHeight - 20);
445
+ }, { rowSel, idx: rowIndex });
446
+ // Swallow timeout: the row may still be readable if it re-enters the
447
+ // virtual viewport. targetReached() in the caller is the authoritative check.
448
+ await root.locator(`${rowSel}[data-rowindex="${rowIndex}"]`)
449
+ .waitFor({ state: 'attached', timeout: 3000 })
450
+ .catch(() => { });
451
+ },
452
+ scrollToColumn: async ({ root, config, page }, colIndex) => {
453
+ const headerSel = typeof config.headerSelector === 'string' ? config.headerSelector : null;
454
+ await root.evaluate((el, { headerSel, idx }) => {
455
+ const scroller = el.querySelector('.MuiDataGrid-virtualScroller');
456
+ if (!scroller || !headerSel)
457
+ return;
458
+ const headers = Array.from(el.querySelectorAll(headerSel));
459
+ const targetAriaIdx = idx + 1; // aria-colindex is 1-based
460
+ // Find header by aria-colindex, NOT by DOM position. With column
461
+ // virtualization, DOM order does not match colIndex — only headers in
462
+ // the current virtual window are rendered, so headers[idx] would be wrong.
463
+ const target = headers.find(h => Number(h.getAttribute('aria-colindex')) === targetAriaIdx);
464
+ if (!target) {
465
+ // Header not in DOM (column virtualized away). Interpolate scrollLeft
466
+ // from the positions of currently visible headers.
467
+ const visibleHeaders = headers
468
+ .map(h => ({ colIdx: Number(h.getAttribute('aria-colindex')), rect: h.getBoundingClientRect() }))
469
+ .filter(h => !isNaN(h.colIdx) && h.rect.width > 0)
470
+ .sort((a, b) => a.colIdx - b.colIdx);
471
+ if (visibleHeaders.length >= 2) {
472
+ const first = visibleHeaders[0];
473
+ const last = visibleHeaders[visibleHeaders.length - 1];
474
+ const pxPerCol = (last.rect.left - first.rect.left) / (last.colIdx - first.colIdx);
475
+ const sRect = scroller.getBoundingClientRect();
476
+ const firstAbsLeft = first.rect.left - sRect.left + scroller.scrollLeft;
477
+ scroller.scrollLeft = Math.max(0, firstAbsLeft + (targetAriaIdx - first.colIdx) * pxPerCol - 20);
478
+ }
479
+ else if (visibleHeaders.length === 1) {
480
+ const h = visibleHeaders[0];
481
+ scroller.scrollLeft = Math.max(0, scroller.scrollLeft + (targetAriaIdx - h.colIdx) * h.rect.width - 20);
482
+ }
483
+ return;
484
+ }
485
+ const cRect = scroller.getBoundingClientRect();
486
+ const tRect = target.getBoundingClientRect();
487
+ if (tRect.left < cRect.left) {
488
+ scroller.scrollLeft -= (cRect.left - tRect.left) + 20;
489
+ }
490
+ else if (tRect.right > cRect.right) {
491
+ scroller.scrollLeft += (tRect.right - cRect.right) + 20;
492
+ }
493
+ }, { headerSel, idx: colIndex });
494
+ // Wait for any cell with this colIndex to appear. With column virtualization
495
+ // (32 cols), MUI DataGrid updates the DOM on requestAnimationFrame after a
496
+ // scrollLeft change — so we may need to wait a tick. If it doesn't appear,
497
+ // fall back to a brief pause; targetReached() is the authoritative gate.
498
+ // Scope to body cells (.MuiDataGrid-cell) — [aria-colindex] also matches
499
+ // column headers which appear before body cells after a scroll.
500
+ const cellSel = typeof config.cellSelector === 'string' ? config.cellSelector : '.MuiDataGrid-cell';
501
+ const appeared = await root.locator(`${cellSel}[aria-colindex="${colIndex + 1}"]`)
502
+ .first()
503
+ .waitFor({ state: 'attached', timeout: 2000 })
504
+ .then(() => true)
505
+ .catch(() => false);
506
+ if (!appeared) {
507
+ await page.waitForTimeout(300);
337
508
  }
338
- }, { headerSel, idx: colIndex });
339
- await root.locator(`${config.rowSelector} [aria-colindex="${colIndex + 1}"]`)
340
- .first()
341
- .waitFor({ state: 'attached', timeout: 3000 });
509
+ },
342
510
  },
343
- },
344
- loading: {
345
- isTableLoading: async (context) => {
346
- return await context.root.locator('.MuiDataGrid-overlay').isVisible().catch(() => false);
511
+ loading: {
512
+ isTableLoading: async (context) => {
513
+ return await context.root.locator('.MuiDataGrid-overlay').isVisible().catch(() => false);
514
+ }
347
515
  }
348
516
  }
349
- }
350
- };
517
+ };
518
+ }
519
+ /** Default preset for MUI DataGrid. For non-English locales use `createMuiDataGrid({ buttonLabels })`. */
520
+ exports.muiDataGrid = createMuiDataGrid();