@rickcedwhat/playwright-smart-table 6.12.0 → 6.14.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/dist/types.d.ts CHANGED
@@ -37,16 +37,45 @@ export type GetCellLocatorFn = (args: {
37
37
  config: FinalTableConfig<any>;
38
38
  }) => Locator;
39
39
  /**
40
- * Hook called before each cell value is read in toJSON (and columnOverrides.read).
41
- * Use this to scroll off-screen columns into view in horizontally virtualized tables,
42
- * wait for lazy-rendered content, or perform any pre-read setup.
40
+ * Hook called before each cell value is read in `toJSON` (and `columnOverrides.read`).
41
+ *
42
+ * Use this to scroll off-screen columns into view in **horizontally** virtualized tables,
43
+ * wait for lazy-rendered content (popovers, tooltips, async cell renderers), or perform
44
+ * any other pre-read setup that doesn't involve Y-axis scrolling.
45
+ *
46
+ * ---
47
+ *
48
+ * **⚠️ Y-scroll footgun — do NOT call `scrollIntoViewIfNeeded()` on row-virtualized grids.**
49
+ *
50
+ * `scrollIntoViewIfNeeded` adjusts *both* scroll axes. On grids that recycle DOM nodes
51
+ * based on the Y position (MUI DataGrid, AG Grid, react-window, etc.) calling it will
52
+ * shift the viewport vertically, unmounting the row you are currently reading and
53
+ * silently returning stale or empty cell values for the remainder of the row.
54
+ *
55
+ * Safe uses:
56
+ * - Column-only (X-axis) virtualization where rows are always in the DOM.
57
+ * - Calling `scrollIntoViewIfNeeded` on the **header cell** only, when that header is
58
+ * guaranteed not to trigger a Y-scroll (e.g. sticky header grids).
59
+ *
60
+ * For grids with **both** row and column virtualization, use the `viewport` strategy
61
+ * instead — it drives explicit `scrollToRow` / `scrollToColumn` calls and is aware of
62
+ * the virtualization lifecycle.
43
63
  *
44
64
  * @example
45
- * // Scroll the column header into view to trigger horizontal virtualization render
65
+ * // Safe: column-only horizontal virtualization (rows always in DOM)
46
66
  * strategies: {
47
67
  * beforeCellRead: async ({ columnName, getHeaderCell }) => {
48
68
  * const header = await getHeaderCell(columnName);
49
- * await header.scrollIntoViewIfNeeded();
69
+ * await header.scrollIntoViewIfNeeded(); // only scrolls X — rows stay mounted
70
+ * }
71
+ * }
72
+ *
73
+ * @example
74
+ * // Safe: wait for a lazy-rendered popover/tooltip before reading its text
75
+ * strategies: {
76
+ * beforeCellRead: async ({ cell }) => {
77
+ * await cell.hover();
78
+ * await cell.page().waitForSelector('.cell-tooltip', { state: 'visible' });
50
79
  * }
51
80
  * }
52
81
  */
@@ -401,6 +430,38 @@ export interface LoadingStrategy {
401
430
  isTableLoading?: (context: TableContext) => Promise<boolean>;
402
431
  isRowLoading?: (row: SmartRow) => Promise<boolean>;
403
432
  isHeaderLoading?: (context: TableContext) => Promise<boolean>;
433
+ /**
434
+ * How long (ms) to wait for a loading row to resolve before applying onRowLoadingTimeout.
435
+ * When not set, loading rows are immediately skipped (backward-compatible behavior).
436
+ */
437
+ rowLoadingTimeout?: number;
438
+ /**
439
+ * What to do when a row is still loading after rowLoadingTimeout ms.
440
+ * - 'skip': drop the row from results (matches legacy skip behavior)
441
+ * - 'read-as-is': include the row even though it's still loading (default)
442
+ * - 'throw': throw an error with row index and timeout info
443
+ * Defaults to 'read-as-is' when rowLoadingTimeout is set.
444
+ */
445
+ onRowLoadingTimeout?: 'skip' | 'read-as-is' | 'throw';
446
+ /**
447
+ * Predicate called before reading each cell. Return true if the cell is still loading.
448
+ * When cellLoadingTimeout is also set, the engine waits up to that many ms for the
449
+ * cell to resolve before applying onCellLoadingTimeout.
450
+ */
451
+ isCellLoading?: (cell: import('@playwright/test').Locator, columnName: string, row: SmartRow) => Promise<boolean>;
452
+ /**
453
+ * How long (ms) to wait for a loading cell to resolve before applying onCellLoadingTimeout.
454
+ * When not set and isCellLoading returns true, the cell is read as-is immediately.
455
+ */
456
+ cellLoadingTimeout?: number;
457
+ /**
458
+ * What to do when a cell is still loading after cellLoadingTimeout ms.
459
+ * - 'skip': use empty string for this cell value
460
+ * - 'read-as-is': read the cell content even though it's still loading (default)
461
+ * - 'throw': throw an error with column name, row index and timeout info
462
+ * Defaults to 'read-as-is' when cellLoadingTimeout is set.
463
+ */
464
+ onCellLoadingTimeout?: 'skip' | 'read-as-is' | 'throw';
404
465
  }
405
466
  /**
406
467
  * Organized container for all table interaction strategies.
@@ -516,6 +577,8 @@ export type RowIterationContext<T = any> = {
516
577
  rowIndex: number;
517
578
  /** 0-based iteration counter — the order this row was visited, not its DOM position or grid identity. */
518
579
  index: number;
580
+ /** 0-based page index — which page this row was collected from. */
581
+ pageIndex: number;
519
582
  stop: () => void;
520
583
  };
521
584
  /** Concurrency modes for row iteration. */
@@ -547,6 +610,7 @@ export interface TableResult<T = any> extends AsyncIterable<{
547
610
  row: SmartRow<T>;
548
611
  rowIndex: number;
549
612
  index: number;
613
+ pageIndex: number;
550
614
  }> {
551
615
  /**
552
616
  * Represents the current page index of the table's DOM.
@@ -559,7 +623,7 @@ export interface TableResult<T = any> extends AsyncIterable<{
559
623
  */
560
624
  init(options?: {
561
625
  timeout?: number;
562
- }): Promise<TableResult>;
626
+ }): Promise<TableResult<T>>;
563
627
  /**
564
628
  * SYNC: Checks if the table has been initialized.
565
629
  * @returns true if init() has been called and completed, false otherwise
@@ -575,13 +639,13 @@ export interface TableResult<T = any> extends AsyncIterable<{
575
639
  */
576
640
  getRow: (filters: Record<string, FilterValue>, options?: {
577
641
  exact?: boolean;
578
- }) => SmartRow;
642
+ }) => SmartRow<T>;
579
643
  /**
580
644
  * Gets a row by 0-based index on the current page.
581
645
  * Throws error if table is not initialized.
582
646
  * @param index 0-based row index
583
647
  */
584
- getRowByIndex: (index: number) => SmartRow;
648
+ getRowByIndex: (index: number) => SmartRow<T>;
585
649
  /**
586
650
  * ASYNC: Searches for a single row across pages using pagination.
587
651
  * Auto-initializes the table if not already initialized.
@@ -591,7 +655,7 @@ export interface TableResult<T = any> extends AsyncIterable<{
591
655
  findRow: (filters: Record<string, FilterValue>, options?: {
592
656
  exact?: boolean;
593
657
  maxPages?: number;
594
- }) => Promise<SmartRow>;
658
+ }) => Promise<SmartRow<T>>;
595
659
  /**
596
660
  * ASYNC: Searches for all matching rows across pages using pagination.
597
661
  * Auto-initializes the table if not already initialized.
package/dist/useTable.js CHANGED
@@ -196,6 +196,7 @@ const useTable = (rootLocator, configOptions = {}) => {
196
196
  if (pagesJumped > 0) {
197
197
  tableState.currentPageIndex += pagesJumped;
198
198
  log(`_advancePage: ${primitive} advanced ${pagesJumped} page(s) — now at page ${tableState.currentPageIndex}`);
199
+ await (0, debugUtils_1.debugDelay)(config, 'pagination');
199
200
  }
200
201
  else {
201
202
  log(`_advancePage: ${primitive} returned false — end of data`);
@@ -261,10 +262,35 @@ const useTable = (rootLocator, configOptions = {}) => {
261
262
  return resolve(config.headerSelector, rootLocator).nth(idx);
262
263
  },
263
264
  countRows: async () => {
264
- log("countRows: counting rows in current viewport");
265
265
  await _autoInit();
266
- const allRows = resolve(config.rowSelector, rootLocator);
267
- return allRows.count();
266
+ const pag = config.strategies.pagination;
267
+ const hasPagination = config.maxPages > 1 && !!((pag === null || pag === void 0 ? void 0 : pag.goNext) || (pag === null || pag === void 0 ? void 0 : pag.goNextBulk));
268
+ if (!hasPagination) {
269
+ log("countRows: counting rows in current viewport (no pagination)");
270
+ return resolve(config.rowSelector, rootLocator).count();
271
+ }
272
+ log(`countRows: paginating up to ${config.maxPages} page(s)`);
273
+ const tracker = new elementTracker_1.ElementTracker('countRows');
274
+ let total = 0;
275
+ let pagesScanned = 1;
276
+ try {
277
+ while (true) {
278
+ const newIndices = await tracker.getUnseenIndices(resolve(config.rowSelector, rootLocator));
279
+ total += newIndices.length;
280
+ log(`countRows: page ${pagesScanned} — ${newIndices.length} row(s) (running total: ${total})`);
281
+ if (pagesScanned >= config.maxPages)
282
+ break;
283
+ if (!await _advancePage(false))
284
+ break;
285
+ pagesScanned++;
286
+ }
287
+ }
288
+ finally {
289
+ await tracker.cleanup(rootLocator.page());
290
+ }
291
+ log(`countRows: ${total} total row(s) across ${pagesScanned} page(s) — resetting`);
292
+ await result.reset();
293
+ return total;
268
294
  },
269
295
  mapColumn: async (columnName, options = {}) => {
270
296
  log(`mapColumn: column="${columnName}" options=${safeStringify(options)}`);
@@ -318,8 +344,7 @@ const useTable = (rootLocator, configOptions = {}) => {
318
344
  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.');
319
345
  const allRows = resolve(config.rowSelector, rootLocator);
320
346
  const matchedRows = filterEngine.applyFilters(allRows, filters, map, options.exact || false, rootLocator.page(), rootLocator);
321
- const rowLocator = matchedRows.first();
322
- return _makeSmart(rowLocator, map, undefined); // sync path cannot compute real index
347
+ return _makeSmart(matchedRows, map, undefined); // sync path cannot compute real index
323
348
  },
324
349
  getRowByIndex: (index) => {
325
350
  log(`getRowByIndex: index=${index}`);
@@ -393,6 +418,7 @@ const useTable = (rootLocator, configOptions = {}) => {
393
418
  const effectiveMaxPages = config.maxPages;
394
419
  const tracker = new elementTracker_1.ElementTracker('iterator');
395
420
  const useBulk = false; // iterator has no options; default goNext
421
+ log(`iterator: starting (maxPages=${effectiveMaxPages})`);
396
422
  try {
397
423
  let rowIndex = 0;
398
424
  let pagesScanned = 1;
@@ -402,7 +428,7 @@ const useTable = (rootLocator, configOptions = {}) => {
402
428
  const pageRows = yield __await(rowLocators.all());
403
429
  const barrier = new navigationBarrier_1.NavigationBarrier(newIndices.length);
404
430
  for (const idx of newIndices) {
405
- yield yield __await({ row: _makeSmart(pageRows[idx], map, rowIndex, pagesScanned - 1, barrier), index: rowIndex, rowIndex });
431
+ yield yield __await({ row: _makeSmart(pageRows[idx], map, rowIndex, tableState.currentPageIndex, barrier), index: rowIndex, rowIndex, pageIndex: tableState.currentPageIndex });
406
432
  rowIndex++;
407
433
  }
408
434
  if (pagesScanned >= effectiveMaxPages)
@@ -429,6 +455,7 @@ const useTable = (rootLocator, configOptions = {}) => {
429
455
  createSmartRowArray: smartRowArray_1.createSmartRowArray,
430
456
  config,
431
457
  getPage: () => rootLocator.page(),
458
+ getCurrentPageIndex: () => tableState.currentPageIndex,
432
459
  }, callback, options);
433
460
  },
434
461
  map: async (callback, options = {}) => {
@@ -442,6 +469,7 @@ const useTable = (rootLocator, configOptions = {}) => {
442
469
  createSmartRowArray: smartRowArray_1.createSmartRowArray,
443
470
  config,
444
471
  getPage: () => rootLocator.page(),
472
+ getCurrentPageIndex: () => tableState.currentPageIndex,
445
473
  }, callback, options);
446
474
  },
447
475
  filter: async (predicate, options = {}) => {
@@ -455,6 +483,7 @@ const useTable = (rootLocator, configOptions = {}) => {
455
483
  createSmartRowArray: smartRowArray_1.createSmartRowArray,
456
484
  config,
457
485
  getPage: () => rootLocator.page(),
486
+ getCurrentPageIndex: () => tableState.currentPageIndex,
458
487
  }, predicate, options);
459
488
  },
460
489
  generateConfig: async () => {
@@ -7,6 +7,8 @@ export declare class NavigationBarrier {
7
7
  private finishedCount;
8
8
  private buckets;
9
9
  constructor(total: number);
10
+ /** True when more than one row shares this barrier (scrollToRow would evict peers). */
11
+ isParallel(): boolean;
10
12
  /**
11
13
  * Synchronize all active rows at a specific column index.
12
14
  * The last row to arrive will trigger the `moveAction`.
@@ -12,6 +12,10 @@ class NavigationBarrier {
12
12
  this.buckets = new Map();
13
13
  this.total = total;
14
14
  }
15
+ /** True when more than one row shares this barrier (scrollToRow would evict peers). */
16
+ isParallel() {
17
+ return this.total > 1;
18
+ }
15
19
  /**
16
20
  * Synchronize all active rows at a specific column index.
17
21
  * The last row to arrive will trigger the `moveAction`.
package/dist/utils.js CHANGED
@@ -6,6 +6,7 @@ exports.waitForCondition = void 0;
6
6
  * Replaces the dependency on 'expect(...).toPass()' to ensure compatibility
7
7
  * with environments where 'expect' is not globally available.
8
8
  */
9
+ // fallow-ignore-next-line unused-export
9
10
  const waitForCondition = async (predicate, timeout, page) => {
10
11
  const startTime = Date.now();
11
12
  while (Date.now() - startTime < timeout) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rickcedwhat/playwright-smart-table",
3
- "version": "6.12.0",
3
+ "version": "6.14.0",
4
4
  "description": "Smart, column-aware table interactions for Playwright",
5
5
  "author": "Cedrick Catalan",
6
6
  "license": "MIT",
@@ -28,6 +28,7 @@
28
28
  "docs:build:all": "vitepress build docs",
29
29
  "docs:verify-links": "node scripts/verify-vitepress-links.mjs",
30
30
  "embed-version": "node scripts/embed-version.mjs",
31
+ "generate-og": "node scripts/generate-og.mjs",
31
32
  "build": "pnpm run embed-version && pnpm run generate-types && pnpm run generate-config-types && pnpm run generate-docs && pnpm run generate-all-api-docs && pnpm run update-all-api-signatures && tsc",
32
33
  "prepublishOnly": "pnpm run build",
33
34
  "clean-port": "lsof -ti:3000 | xargs kill -9 || true",
@@ -83,6 +84,8 @@
83
84
  "@playwright/test": "^1.40.0"
84
85
  },
85
86
  "devDependencies": {
87
+ "@commitlint/cli": "^21.0.0",
88
+ "@commitlint/config-conventional": "^21.0.0",
86
89
  "@playwright/test": "^1.59.1",
87
90
  "@stryker-mutator/core": "^9.6.0",
88
91
  "@stryker-mutator/vitest-runner": "^9.6.0",
@@ -90,15 +93,11 @@
90
93
  "@vitest/coverage-v8": "^3.2.4",
91
94
  "@vitest/ui": "^3.2.4",
92
95
  "happy-dom": "^20.8.3",
93
- "@commitlint/cli": "^19.7.1",
94
- "@commitlint/config-conventional": "^19.7.1",
95
96
  "husky": "^9.1.7",
96
97
  "typescript": "^6.0.2",
97
98
  "vitepress": "^1.6.4",
99
+ "vitepress-plugin-tabs": "^0.9.0",
98
100
  "vitest": "^3.2.4"
99
101
  },
100
- "dependencies": {
101
- "@scarf/scarf": "^1.4.0"
102
- },
103
102
  "packageManager": "pnpm@10.33.2"
104
103
  }
@@ -1,14 +0,0 @@
1
- import { StrategyContext } from '../../types';
2
- /**
3
- * Primitive navigation functions for Glide Data Grid.
4
- * These define HOW to move, not WHEN to move.
5
- * The orchestration logic lives in _navigateToCell.
6
- */
7
- export declare const glideGoUp: (context: StrategyContext) => Promise<void>;
8
- export declare const glideGoDown: (context: StrategyContext) => Promise<void>;
9
- export declare const glideGoLeft: (context: StrategyContext) => Promise<void>;
10
- export declare const glideGoRight: (context: StrategyContext) => Promise<void>;
11
- /** Coarse `scrollLeft` toward `columnIndex`; `goLeft`/`goRight` correct the remainder. */
12
- export declare const glideSeekColumnIndex: (context: StrategyContext, columnIndex: number) => Promise<void>;
13
- /** `scrollLeft = 0` so low `aria-colindex` cells exist again (see smartRow for optional `Home`). */
14
- export declare const glideSnapFirstColumnIntoView: (context: StrategyContext) => Promise<void>;
@@ -1,53 +0,0 @@
1
- "use strict";
2
- Object.defineProperty(exports, "__esModule", { value: true });
3
- exports.glideSnapFirstColumnIntoView = exports.glideSeekColumnIndex = exports.glideGoRight = exports.glideGoLeft = exports.glideGoDown = exports.glideGoUp = void 0;
4
- /**
5
- * Primitive navigation functions for Glide Data Grid.
6
- * These define HOW to move, not WHEN to move.
7
- * The orchestration logic lives in _navigateToCell.
8
- */
9
- const glideGoUp = async (context) => {
10
- await context.page.keyboard.press('ArrowUp');
11
- };
12
- exports.glideGoUp = glideGoUp;
13
- const glideGoDown = async (context) => {
14
- await context.page.keyboard.press('ArrowDown');
15
- };
16
- exports.glideGoDown = glideGoDown;
17
- // Horizontal: use page `.dvn-scroller` (canvas root is not its ancestor). Virtualized rows expose
18
- // a small window of `td[aria-colindex]` that shifts with scrollLeft.
19
- const nudgePageScroller = async (page, delta) => {
20
- const scroller = page.locator('.dvn-scroller').first();
21
- await scroller.evaluate((el, d) => {
22
- el.scrollLeft += d;
23
- }, delta);
24
- };
25
- const glideGoLeft = async (context) => {
26
- await nudgePageScroller(context.page, -400);
27
- };
28
- exports.glideGoLeft = glideGoLeft;
29
- const glideGoRight = async (context) => {
30
- await nudgePageScroller(context.page, 400);
31
- };
32
- exports.glideGoRight = glideGoRight;
33
- /** Coarse `scrollLeft` toward `columnIndex`; `goLeft`/`goRight` correct the remainder. */
34
- const glideSeekColumnIndex = async (context, columnIndex) => {
35
- const { page } = context;
36
- const scroller = page.locator('.dvn-scroller').first();
37
- await scroller.evaluate((el, colIdx) => {
38
- const max = Math.max(0, el.scrollWidth - el.clientWidth);
39
- const ratio = Math.min(1, Math.max(0, (colIdx + 0.5) / 64));
40
- el.scrollLeft = Math.floor(ratio * max);
41
- }, columnIndex);
42
- await page.waitForTimeout(100);
43
- };
44
- exports.glideSeekColumnIndex = glideSeekColumnIndex;
45
- /** `scrollLeft = 0` so low `aria-colindex` cells exist again (see smartRow for optional `Home`). */
46
- const glideSnapFirstColumnIntoView = async (context) => {
47
- const { page } = context;
48
- await page.locator('.dvn-scroller').first().evaluate((el) => {
49
- el.scrollLeft = 0;
50
- });
51
- await page.waitForTimeout(150);
52
- };
53
- exports.glideSnapFirstColumnIntoView = glideSnapFirstColumnIntoView;