@rickcedwhat/playwright-smart-table 6.19.0 → 6.20.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.
package/dist/types.d.ts CHANGED
@@ -10,6 +10,15 @@ import type { SmartRowArray } from './utils/smartRowArray';
10
10
  * headerSelector: (root) => root.locator('[role="columnheader"]')
11
11
  */
12
12
  export type Selector = string | ((root: Locator | Page) => Locator) | ((root: Locator) => Locator);
13
+ /**
14
+ * Return type for `resolveRowIndex`. A plain number gives the logical index only;
15
+ * `{ index, selector }` additionally provides a CSS selector the library uses to
16
+ * build a self-healing row locator that survives virtual-scroll DOM recycling.
17
+ */
18
+ export type RowIndexResult = number | {
19
+ index: number;
20
+ selector: string;
21
+ };
13
22
  /**
14
23
  * Value used to filter rows.
15
24
  * - string/number/RegExp: filter by text content of the cell.
@@ -281,6 +290,12 @@ export type SmartRow<T = any> = Locator & {
281
290
  * );
282
291
  */
283
292
  smartFill: (data: Partial<T> | Record<string, any>, options?: FillOptions) => Promise<void>;
293
+ /**
294
+ * Get the resolved value of any column — real, override, or synthetic.
295
+ * @param column - Column name (case-sensitive)
296
+ * @returns The column value as a string
297
+ */
298
+ getValue(column: string): Promise<string>;
284
299
  /**
285
300
  * Returns whether the row exists in the DOM (i.e. is not a sentinel row).
286
301
  */
@@ -427,6 +442,9 @@ export interface ColumnOverrideReadContext {
427
442
  */
428
443
  getCell: (columnName: string) => Locator;
429
444
  }
445
+ export interface SyntheticColumnDef<T = any> {
446
+ compute: (row: SmartRow<T>) => Promise<string | number> | string | number;
447
+ }
430
448
  export interface ColumnOverride<TValue = any> {
431
449
  /**
432
450
  * How to extract the value from the cell.
@@ -512,6 +530,12 @@ export interface LoadingStrategy {
512
530
  * Defaults to 'read-as-is' when cellLoadingTimeout is set.
513
531
  */
514
532
  onCellLoadingTimeout?: 'skip' | 'read-as-is' | 'throw' | ((cell: import('@playwright/test').Locator, columnName: string, row: SmartRow) => Promise<string>);
533
+ /** Max ms to wait for sort stabilization when isTableLoading is set. @default 10000 */
534
+ sortStabilizationTimeout?: number;
535
+ /** Polling interval (ms) while waiting for sort stabilization. @default 100 */
536
+ sortStabilizationPollInterval?: number;
537
+ /** Fallback delay (ms) after sort when no isTableLoading is configured. @default 200 */
538
+ sortStabilizationFallbackDelay?: number;
515
539
  }
516
540
  /**
517
541
  * Organized container for all table interaction strategies.
@@ -559,14 +583,20 @@ export interface TableStrategies {
559
583
  *
560
584
  * Return `undefined` to fall back to DOM position.
561
585
  *
586
+ * When the index maps to a DOM attribute, return `{ index, selector }` instead of
587
+ * a plain number. The library uses the CSS selector to build a **self-healing row
588
+ * locator** that re-queries the DOM on every action — surviving virtual-scroll
589
+ * recycling for `getCell`, `smartFill`, and `toJSON` without manual re-pinning.
590
+ *
562
591
  * @example
563
- * // MUI DataGrid: read the global monotone counter from the attribute
592
+ * // MUI DataGrid: self-healing via data-rowindex attribute
564
593
  * resolveRowIndex: async (row) => {
565
594
  * const v = await row.getAttribute('data-rowindex').catch(() => null);
566
- * return v !== null && !isNaN(Number(v)) ? Number(v) : undefined;
595
+ * if (v === null || isNaN(Number(v))) return undefined;
596
+ * return { index: Number(v), selector: `[data-rowindex="${v}"]` };
567
597
  * }
568
598
  */
569
- resolveRowIndex?: (row: Locator) => Promise<number | undefined>;
599
+ resolveRowIndex?: (row: Locator) => Promise<RowIndexResult | undefined>;
570
600
  /**
571
601
  * Viewport oracle strategies for 2D virtualized tables (e.g. MUI DataGrid, AG Grid,
572
602
  * Braintrust-style grids where both rows and columns are virtualized simultaneously).
@@ -604,7 +634,7 @@ export interface TableConfig<T = any> {
604
634
  autoScroll?: boolean;
605
635
  /** Debug options for development and troubleshooting */
606
636
  debug?: DebugConfig;
607
- /** Reset hook */
637
+ /** Hook called after reset completes (after goToFirst, cache clear, and autoInit). */
608
638
  onReset?: (context: TableContext) => Promise<void>;
609
639
  /** All interaction strategies */
610
640
  strategies?: TableStrategies;
@@ -613,6 +643,13 @@ export interface TableConfig<T = any> {
613
643
  * Overrides both default extraction (toJSON) and filling (smartFill) logic.
614
644
  */
615
645
  columnOverrides?: Partial<Record<keyof T, ColumnOverride<T[keyof T]>>>;
646
+ /**
647
+ * Computed columns with no DOM presence. Each key becomes a virtual column name
648
+ * available in `toJSON()`, `getValue()`, and `findRow()`/`findRows()` filters.
649
+ * The `compute` function receives the full SmartRow and must only read real or
650
+ * override columns (no chaining between synthetics).
651
+ */
652
+ syntheticColumns?: Record<string, SyntheticColumnDef<T>>;
616
653
  /**
617
654
  * Locator for an empty-state element that replaces the table when there are no results.
618
655
  * If header resolution fails during init() and this locator is visible, init() succeeds
@@ -790,10 +827,14 @@ export interface TableResult<T = any> extends AsyncIterable<{
790
827
  */
791
828
  scrollToColumn: (columnName: string) => Promise<void>;
792
829
  /**
793
- * Counts the number of rows currently on the page.
794
- * Does not paginate.
830
+ * Counts the number of rows, optionally filtered.
831
+ * Without arguments, counts all rows. With filters, counts only matching rows.
832
+ * Paginates when pagination is configured.
795
833
  */
796
- countRows: () => Promise<number>;
834
+ countRows: (filters?: Record<string, FilterValue>, options?: {
835
+ exact?: boolean;
836
+ maxPages?: number;
837
+ }) => Promise<number>;
797
838
  /**
798
839
  * Iterates over rows and extracts the value of a single column.
799
840
  * More efficient than map + toJSON for single-column extraction.
@@ -808,7 +849,7 @@ export interface TableResult<T = any> extends AsyncIterable<{
808
849
  */
809
850
  getColumnValues(columnName: string, options?: RowIterationOptions): Promise<string[]>;
810
851
  /**
811
- * Resets the table state (clears cache, flags) and invokes the onReset strategy.
852
+ * Resets the table: calls goToFirst (if configured), clears cache, re-inits headers, then calls onReset.
812
853
  */
813
854
  reset: () => Promise<void>;
814
855
  /**
package/dist/useTable.js CHANGED
@@ -145,8 +145,14 @@ const useTable = (rootLocator, configOptions = {}) => {
145
145
  // Placeholder for the final table object
146
146
  let finalTable = null;
147
147
  // Helper factory
148
- const _makeSmart = (rowLocator, map, rowIndex, tablePageIndex, barrier) => {
149
- return (0, smartRow_1.default)(rowLocator, map, rowIndex, config, rootLocator, resolve, finalTable, tablePageIndex, barrier);
148
+ const _makeSmart = (rowLocator, map, rowIndex, tablePageIndex, barrier, rowSelector) => {
149
+ const effectiveLocator = rowSelector
150
+ ? rootLocator.locator(rowSelector)
151
+ : rowLocator;
152
+ const sr = (0, smartRow_1.default)(effectiveLocator, map, rowIndex, config, rootLocator, resolve, finalTable, tablePageIndex, barrier);
153
+ if (rowSelector)
154
+ sr._selfHealing = true;
155
+ return sr;
150
156
  };
151
157
  const tableState = { currentPageIndex: 0, empty: false };
152
158
  const rowFinder = new rowFinder_1.RowFinder(rootLocator, config, resolve, filterEngine, tableMapper, _makeSmart, tableState, (useBulk) => _advancePage(useBulk));
@@ -281,9 +287,10 @@ const useTable = (rootLocator, configOptions = {}) => {
281
287
  await headerCell.scrollIntoViewIfNeeded();
282
288
  },
283
289
  getHeaders: async () => {
290
+ var _a;
284
291
  log("getHeaders: fetching available columns");
285
292
  const map = await tableMapper.getMap();
286
- return Array.from(map.keys());
293
+ return [...map.keys(), ...Object.keys((_a = config.syntheticColumns) !== null && _a !== void 0 ? _a : {})];
287
294
  },
288
295
  getHeaderCell: async (columnName) => {
289
296
  log(`getHeaderCell: column="${columnName}"`);
@@ -293,27 +300,86 @@ const useTable = (rootLocator, configOptions = {}) => {
293
300
  throw _createColumnError(columnName, map, 'header cell');
294
301
  return resolve(config.headerSelector, rootLocator).nth(idx);
295
302
  },
296
- countRows: async () => {
303
+ countRows: async (filters, options) => {
304
+ var _a, _b, _c, _d;
297
305
  if (tableState.empty)
298
306
  return 0;
299
307
  await _autoInit();
308
+ const filtersRecord = (filters !== null && filters !== void 0 ? filters : {});
309
+ const hasFilters = Object.keys(filtersRecord).length > 0;
310
+ const effectiveMaxPages = (_a = options === null || options === void 0 ? void 0 : options.maxPages) !== null && _a !== void 0 ? _a : config.maxPages;
300
311
  const pag = config.strategies.pagination;
301
- const hasPagination = config.maxPages > 1 && !!((pag === null || pag === void 0 ? void 0 : pag.goNext) || (pag === null || pag === void 0 ? void 0 : pag.goNextBulk));
312
+ const hasPagination = effectiveMaxPages > 1 && !!((pag === null || pag === void 0 ? void 0 : pag.goNext) || (pag === null || pag === void 0 ? void 0 : pag.goNextBulk));
313
+ const domFilters = {};
314
+ const overrideFilters = {};
315
+ const syntheticFilters = {};
316
+ if (hasFilters) {
317
+ for (const [key, value] of Object.entries(filtersRecord)) {
318
+ if ((_b = config.syntheticColumns) === null || _b === void 0 ? void 0 : _b[key]) {
319
+ syntheticFilters[key] = value;
320
+ }
321
+ else if ((_d = (_c = config.columnOverrides) === null || _c === void 0 ? void 0 : _c[key]) === null || _d === void 0 ? void 0 : _d.read) {
322
+ overrideFilters[key] = value;
323
+ }
324
+ else {
325
+ domFilters[key] = value;
326
+ }
327
+ }
328
+ }
329
+ const hasPostFilters = Object.keys(overrideFilters).length > 0 || Object.keys(syntheticFilters).length > 0;
330
+ const resolveRows = () => {
331
+ var _a;
332
+ let rows = resolve(config.rowSelector, rootLocator);
333
+ if (Object.keys(domFilters).length > 0) {
334
+ const map = tableMapper.getMapSync();
335
+ if (!map)
336
+ throw new Error('Initialization Error: Table map not available. Call "await table.init()" first.');
337
+ rows = filterEngine.applyFilters(rows, domFilters, map, (_a = options === null || options === void 0 ? void 0 : options.exact) !== null && _a !== void 0 ? _a : false, rootLocator.page(), rootLocator);
338
+ }
339
+ return rows;
340
+ };
341
+ const countPostFilterMatches = async (candidates, indices) => {
342
+ var _a;
343
+ if (!hasPostFilters)
344
+ return indices.length;
345
+ const map = tableMapper.getMapSync();
346
+ if (!map)
347
+ throw new Error('Initialization Error: Table map not available. Call "await table.init()" first.');
348
+ const exact = (_a = options === null || options === void 0 ? void 0 : options.exact) !== null && _a !== void 0 ? _a : false;
349
+ const hasOverrides = Object.keys(overrideFilters).length > 0;
350
+ const hasSynthetics = Object.keys(syntheticFilters).length > 0;
351
+ let count = 0;
352
+ for (const idx of indices) {
353
+ const row = candidates[idx];
354
+ if (hasOverrides && !await rowFinder.matchesOverrideFilters(row, overrideFilters, map, exact))
355
+ continue;
356
+ if (hasSynthetics && !await rowFinder.matchesSyntheticFilters(row, syntheticFilters, map, exact))
357
+ continue;
358
+ count++;
359
+ }
360
+ return count;
361
+ };
302
362
  if (!hasPagination) {
303
- log("countRows: counting rows in current viewport (no pagination)");
304
- return resolve(config.rowSelector, rootLocator).count();
363
+ log(`countRows: counting rows in current viewport (no pagination)${hasFilters ? ` filters=${safeStringify(filtersRecord)}` : ''}`);
364
+ if (!hasPostFilters)
365
+ return resolveRows().count();
366
+ const all = await resolveRows().all();
367
+ return countPostFilterMatches(all, all.map((_, i) => i));
305
368
  }
306
- log(`countRows: paginating up to ${config.maxPages} page(s)`);
369
+ log(`countRows: paginating up to ${effectiveMaxPages} page(s)${hasFilters ? ` filters=${safeStringify(filtersRecord)}` : ''}`);
307
370
  const tracker = new elementTracker_1.ElementTracker('countRows');
308
371
  let total = 0;
309
372
  let pagesScanned = 1;
310
373
  let paginationError;
311
374
  try {
312
375
  while (true) {
313
- const newIndices = await tracker.getUnseenIndices(resolve(config.rowSelector, rootLocator));
314
- total += newIndices.length;
315
- log(`countRows: page ${pagesScanned} — ${newIndices.length} row(s) (running total: ${total})`);
316
- if (pagesScanned >= config.maxPages)
376
+ const rowLocators = resolveRows();
377
+ const newIndices = await tracker.getUnseenIndices(rowLocators);
378
+ const candidates = await rowLocators.all();
379
+ const matched = await countPostFilterMatches(candidates, newIndices);
380
+ total += matched;
381
+ log(`countRows: page ${pagesScanned} — ${matched} row(s) (running total: ${total})`);
382
+ if (pagesScanned >= effectiveMaxPages)
317
383
  break;
318
384
  if (!await _advancePage(false))
319
385
  break;
@@ -370,7 +436,6 @@ const useTable = (rootLocator, configOptions = {}) => {
370
436
  reset: async () => {
371
437
  var _a;
372
438
  log("Resetting table...");
373
- await config.onReset(createStrategyContext());
374
439
  if ((_a = config.strategies.pagination) === null || _a === void 0 ? void 0 : _a.goToFirst) {
375
440
  log("Auto-navigating to first page...");
376
441
  await config.strategies.pagination.goToFirst(createStrategyContext());
@@ -383,6 +448,7 @@ const useTable = (rootLocator, configOptions = {}) => {
383
448
  tableMapper.clear();
384
449
  log("Table reset complete. Calling autoInit to restore state.");
385
450
  await _autoInit();
451
+ await config.onReset(createStrategyContext());
386
452
  },
387
453
  revalidate: async () => {
388
454
  log("Revalidating table structure...");
@@ -392,6 +458,11 @@ const useTable = (rootLocator, configOptions = {}) => {
392
458
  },
393
459
  getRow: (filters, options = { exact: false }) => {
394
460
  log(`getRow: filters=${safeStringify(filters)} exact=${options.exact}`);
461
+ const syntheticKeys = Object.keys(filters).filter(k => { var _a; return (_a = config.syntheticColumns) === null || _a === void 0 ? void 0 : _a[k]; });
462
+ if (syntheticKeys.length > 0) {
463
+ throw new Error(`getRow() cannot filter by synthetic column(s): ${syntheticKeys.join(', ')}. ` +
464
+ `Use findRow() instead — synthetic columns require async evaluation.`);
465
+ }
395
466
  const map = tableMapper.getMapSync();
396
467
  if (!map)
397
468
  throw new Error('Initialization Error: You attempted to access a row before the table structure was mapped. Please call "await table.init()" once before using synchronous row access.');
@@ -420,12 +491,19 @@ const useTable = (rootLocator, configOptions = {}) => {
420
491
  throw new Error(`findRowByIndex(${index}) requires a strategies.resolveRowIndex to identify rows by their logical index. ` +
421
492
  `Configure one (e.g. read the grid's row-index attribute), or use getRowByIndex() (current render window) / findRow(filters).`);
422
493
  }
494
+ let foundSelector;
423
495
  // The currently-mounted row whose logical index === index, if any.
424
496
  const findMounted = async () => {
425
497
  const rows = await resolve(config.rowSelector, rootLocator).all();
426
498
  for (const r of rows) {
427
- if ((await resolveRI(r)) === index)
499
+ const result = await resolveRI(r);
500
+ if (result === undefined)
501
+ continue;
502
+ const normalized = (0, rowResolution_1.normalizeRowIndexResult)(result);
503
+ if (normalized.index === index) {
504
+ foundSelector = normalized.selector;
428
505
  return r;
506
+ }
429
507
  }
430
508
  return null;
431
509
  };
@@ -454,7 +532,7 @@ const useTable = (rootLocator, configOptions = {}) => {
454
532
  `Ensure a viewport.scrollToRow or pagination can bring it into view (searched up to maxPages=${(_b = options === null || options === void 0 ? void 0 : options.maxPages) !== null && _b !== void 0 ? _b : config.maxPages}).`);
455
533
  }
456
534
  await (0, debugUtils_1.debugDelay)(config, 'findRow');
457
- return _makeSmart(loc, map, index, tableState.currentPageIndex);
535
+ return _makeSmart(loc, map, index, tableState.currentPageIndex, undefined, foundSelector);
458
536
  },
459
537
  findRow: async (filters, options) => {
460
538
  log(`findRow: filters=${safeStringify(filters)} options=${safeStringify(options)}`);
@@ -474,7 +552,7 @@ const useTable = (rootLocator, configOptions = {}) => {
474
552
  },
475
553
  sorting: {
476
554
  apply: async (columnName, direction) => {
477
- var _a;
555
+ var _a, _b, _c, _d, _e;
478
556
  await _autoInit();
479
557
  if (!config.strategies.sorting)
480
558
  throw new Error('No sorting strategy has been configured.');
@@ -489,13 +567,16 @@ const useTable = (rootLocator, configOptions = {}) => {
489
567
  }
490
568
  await config.strategies.sorting.doSort({ columnName, direction, context });
491
569
  if ((_a = config.strategies.loading) === null || _a === void 0 ? void 0 : _a.isTableLoading) {
492
- const deadline = Date.now() + 10000;
570
+ const timeout = (_b = config.strategies.loading.sortStabilizationTimeout) !== null && _b !== void 0 ? _b : 10000;
571
+ const poll = (_c = config.strategies.loading.sortStabilizationPollInterval) !== null && _c !== void 0 ? _c : 100;
572
+ const deadline = Date.now() + timeout;
493
573
  while (Date.now() < deadline && await config.strategies.loading.isTableLoading(context)) {
494
- await rootLocator.page().waitForTimeout(100);
574
+ await rootLocator.page().waitForTimeout(poll);
495
575
  }
496
576
  }
497
577
  else {
498
- await rootLocator.page().waitForTimeout(200);
578
+ const fallback = (_e = (_d = config.strategies.loading) === null || _d === void 0 ? void 0 : _d.sortStabilizationFallbackDelay) !== null && _e !== void 0 ? _e : 200;
579
+ await rootLocator.page().waitForTimeout(fallback);
499
580
  }
500
581
  await (0, debugUtils_1.debugDelay)(config, 'default');
501
582
  const newState = await config.strategies.sorting.getSortState({ columnName, context });
@@ -535,8 +616,9 @@ const useTable = (rootLocator, configOptions = {}) => {
535
616
  const barrier = new navigationBarrier_1.NavigationBarrier(newIndices.length);
536
617
  for (const idx of newIndices) {
537
618
  // index = visit-order counter; rowIndex = logical/data index (B-hybrid, #362).
538
- const logical = (_b = yield __await((0, rowResolution_1.resolveLogicalRowIndex)(pageRows[idx], config, () => rowIndex))) !== null && _b !== void 0 ? _b : rowIndex;
539
- const sr = _makeSmart(pageRows[idx], map, logical, tableState.currentPageIndex, barrier);
619
+ const resolved = yield __await((0, rowResolution_1.resolveLogicalRowIndex)(pageRows[idx], config, () => rowIndex));
620
+ const logical = (_b = resolved === null || resolved === void 0 ? void 0 : resolved.index) !== null && _b !== void 0 ? _b : rowIndex;
621
+ const sr = _makeSmart(pageRows[idx], map, logical, tableState.currentPageIndex, barrier, resolved === null || resolved === void 0 ? void 0 : resolved.selector);
540
622
  // Iterator rows share one per-page DOM snapshot (positional locators); disable
541
623
  // toJSON's scroll-back recovery so reading one row can't invalidate the others (#366).
542
624
  sr._inBatch = true;
@@ -563,7 +645,7 @@ const useTable = (rootLocator, configOptions = {}) => {
563
645
  getRowLocators: () => resolve(config.rowSelector, rootLocator),
564
646
  getMap: () => tableMapper.getMapSync(),
565
647
  advancePage: _advancePage,
566
- makeSmartRow: (loc, map, idx, pageIdx, barrier) => _makeSmart(loc, map, idx, pageIdx, barrier),
648
+ makeSmartRow: (loc, map, idx, pageIdx, barrier, sel) => _makeSmart(loc, map, idx, pageIdx, barrier, sel),
567
649
  createSmartRowArray: smartRowArray_1.createSmartRowArray,
568
650
  config,
569
651
  getPage: () => rootLocator.page(),
@@ -578,7 +660,7 @@ const useTable = (rootLocator, configOptions = {}) => {
578
660
  getRowLocators: () => resolve(config.rowSelector, rootLocator),
579
661
  getMap: () => tableMapper.getMapSync(),
580
662
  advancePage: _advancePage,
581
- makeSmartRow: (loc, map, idx, pageIdx, barrier) => _makeSmart(loc, map, idx, pageIdx, barrier),
663
+ makeSmartRow: (loc, map, idx, pageIdx, barrier, sel) => _makeSmart(loc, map, idx, pageIdx, barrier, sel),
582
664
  createSmartRowArray: smartRowArray_1.createSmartRowArray,
583
665
  config,
584
666
  getPage: () => rootLocator.page(),
@@ -603,7 +685,7 @@ const useTable = (rootLocator, configOptions = {}) => {
603
685
  getRowLocators: () => resolve(config.rowSelector, rootLocator),
604
686
  getMap: () => tableMapper.getMapSync(),
605
687
  advancePage: _advancePage,
606
- makeSmartRow: (loc, map, idx, pageIdx, barrier) => _makeSmart(loc, map, idx, pageIdx, barrier),
688
+ makeSmartRow: (loc, map, idx, pageIdx, barrier, sel) => _makeSmart(loc, map, idx, pageIdx, barrier, sel),
607
689
  createSmartRowArray: smartRowArray_1.createSmartRowArray,
608
690
  config,
609
691
  getPage: () => rootLocator.page(),
@@ -56,5 +56,9 @@ function mergeTableConfig(base, overrides) {
56
56
  if (base.columnOverrides || overrides.columnOverrides) {
57
57
  result.columnOverrides = Object.assign(Object.assign({}, base.columnOverrides), overrides.columnOverrides);
58
58
  }
59
+ // Deep-merge syntheticColumns (override keys replace, preset keys retained)
60
+ if (base.syntheticColumns || overrides.syntheticColumns) {
61
+ result.syntheticColumns = Object.assign(Object.assign({}, base.syntheticColumns), overrides.syntheticColumns);
62
+ }
59
63
  return result;
60
64
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rickcedwhat/playwright-smart-table",
3
- "version": "6.19.0",
3
+ "version": "6.20.1",
4
4
  "description": "Smart, column-aware table interactions for Playwright",
5
5
  "author": "Cedrick Catalan",
6
6
  "license": "MIT",
@@ -110,6 +110,11 @@
110
110
  "peerDependencies": {
111
111
  "@playwright/test": "^1.40.0"
112
112
  },
113
+ "peerDependenciesMeta": {
114
+ "@playwright/test": {
115
+ "optional": true
116
+ }
117
+ },
113
118
  "devDependencies": {
114
119
  "@commitlint/cli": "^21.0.0",
115
120
  "@commitlint/config-conventional": "^21.0.0",