@rickcedwhat/playwright-smart-table 6.19.0 → 6.20.1-next.19d3cd3

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/smartRow.js CHANGED
@@ -1,5 +1,6 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
+ const rowResolution_1 = require("./engine/rowResolution");
3
4
  const fill_1 = require("./strategies/fill");
4
5
  const stringUtils_1 = require("./utils/stringUtils");
5
6
  const debugUtils_1 = require("./utils/debugUtils");
@@ -146,38 +147,6 @@ const _navigateToCell = async (params) => {
146
147
  if (typeof rowIndex !== 'number') {
147
148
  throw new Error('Row index is required for navigation');
148
149
  }
149
- const navigateOnce = async () => {
150
- // Get current position again to be sure
151
- let currRow = 0;
152
- let currCol = 0;
153
- if (config.strategies.getActiveCell) {
154
- const ac = await config.strategies.getActiveCell({ config, root: rootLocator, page, resolve });
155
- if (ac) {
156
- currRow = ac.rowIndex;
157
- currCol = ac.columnIndex;
158
- }
159
- }
160
- const rDiff = rowIndex - currRow;
161
- const cDiff = index - currCol;
162
- // Move one step vertically
163
- if (rDiff > 0 && nav.goDown) {
164
- (0, debugUtils_1.logDebug)(config, 'verbose', '_navigateToCell: moving down');
165
- await nav.goDown(context);
166
- }
167
- else if (rDiff < 0 && nav.goUp) {
168
- (0, debugUtils_1.logDebug)(config, 'verbose', '_navigateToCell: moving up');
169
- await nav.goUp(context);
170
- }
171
- // Move one step horizontally
172
- if (cDiff > 0 && nav.goRight) {
173
- (0, debugUtils_1.logDebug)(config, 'verbose', '_navigateToCell: moving right');
174
- await nav.goRight(context);
175
- }
176
- else if (cDiff < 0 && nav.goLeft) {
177
- (0, debugUtils_1.logDebug)(config, 'verbose', '_navigateToCell: moving left');
178
- await nav.goLeft(context);
179
- }
180
- };
181
150
  if (await targetReached()) {
182
151
  // Already there. If we have a barrier, check-in to stay in lock-step.
183
152
  if (barrier)
@@ -311,7 +280,7 @@ const _navigateToCell = async (params) => {
311
280
  else {
312
281
  navResult = await navigateUntilReached();
313
282
  }
314
- // navigateUntilReached may return a Locator it found mid-navigation; duck-type check via .count() (public API) rather than the internal _isLocator flag
283
+ // navigateUntilReached may return a Locator it found mid-navigation
315
284
  if (navResult && typeof navResult.count === 'function') {
316
285
  const loc = navResult;
317
286
  if (await loc.count() > 0)
@@ -353,9 +322,13 @@ const createSmartRow = (rowLocator, map, rowIndex, config, rootLocator, resolve,
353
322
  smart._barrier = barrier;
354
323
  // Attach Methods
355
324
  smart.getCell = (colName) => {
325
+ var _a, _b;
326
+ if ((_a = config.syntheticColumns) === null || _a === void 0 ? void 0 : _a[colName]) {
327
+ throw new Error(`Column "${colName}" is synthetic (no DOM cell) — use getValue("${colName}") or toJSON() instead`);
328
+ }
356
329
  const idx = map.get(colName);
357
330
  if (idx === undefined) {
358
- throw new Error((0, stringUtils_1.buildColumnNotFoundError)(colName, Array.from(map.keys())));
331
+ throw new Error((0, stringUtils_1.buildColumnNotFoundError)(colName, [...map.keys(), ...Object.keys((_b = config.syntheticColumns) !== null && _b !== void 0 ? _b : {})]));
359
332
  }
360
333
  let baseLocator;
361
334
  if (config.strategies.getCellLocator) {
@@ -413,8 +386,45 @@ const createSmartRow = (rowLocator, map, rowIndex, config, rootLocator, resolve,
413
386
  smart.wasFound = () => {
414
387
  return !smart[sentinel_1.SENTINEL_ROW];
415
388
  };
389
+ smart.getValue = async (colName) => {
390
+ var _a, _b, _c;
391
+ const syntheticDef = (_a = config.syntheticColumns) === null || _a === void 0 ? void 0 : _a[colName];
392
+ if (syntheticDef) {
393
+ const guardedRow = Object.create(smart);
394
+ guardedRow.getValue = async (innerCol) => {
395
+ var _a;
396
+ if ((_a = config.syntheticColumns) === null || _a === void 0 ? void 0 : _a[innerCol]) {
397
+ throw new Error(`[SmartTable] Synthetic column "${innerCol}" cannot be read from inside another synthetic column's compute(). ` +
398
+ `Synthetic columns may only reference real or override columns.`);
399
+ }
400
+ return smart.getValue(innerCol);
401
+ };
402
+ return String(await syntheticDef.compute(guardedRow));
403
+ }
404
+ const idx = map.get(colName);
405
+ if (idx === undefined) {
406
+ throw new Error((0, stringUtils_1.buildColumnNotFoundError)(colName, [...map.keys(), ...Object.keys((_b = config.syntheticColumns) !== null && _b !== void 0 ? _b : {})]));
407
+ }
408
+ const columnOverride = (_c = config.columnOverrides) === null || _c === void 0 ? void 0 : _c[colName];
409
+ const cell = config.strategies.getCellLocator
410
+ ? config.strategies.getCellLocator({
411
+ row: rowLocator, root: rootLocator, columnName: colName,
412
+ columnIndex: idx, rowIndex, page: rootLocator.page(), config,
413
+ })
414
+ : resolve(config.cellSelector, rowLocator).nth(idx);
415
+ if (columnOverride === null || columnOverride === void 0 ? void 0 : columnOverride.read) {
416
+ const getCell = (name) => {
417
+ const ci = map.get(name);
418
+ if (ci === undefined)
419
+ throw new Error(`Column "${name}" not found`);
420
+ return resolve(config.cellSelector, rowLocator).nth(ci);
421
+ };
422
+ return String(await columnOverride.read(cell, { row: smart, columnName: colName, columnIndex: idx, getCell }));
423
+ }
424
+ return (await cell.innerText()).trim();
425
+ };
416
426
  smart.toJSON = async (options) => {
417
- var _a, _b, _c, _d, _e, _f;
427
+ var _a, _b, _c, _d, _e, _f, _g, _h, _j;
418
428
  // Atomic mode: snapshot the row in a single evaluate, then apply column overrides
419
429
  // against a frozen reconstruction. Zero inter-column stagger — even in-place React
420
430
  // re-renders can't affect it because overrides read from a detached clone.
@@ -424,10 +434,84 @@ const createSmartRow = (rowLocator, map, rowIndex, config, rootLocator, resolve,
424
434
  throw new Error('[SmartTable] toJSON({ atomic: true }) requires cellSelector to be a CSS string, not a function.');
425
435
  }
426
436
  const page = rootLocator.page();
437
+ // Re-pin: the row locator is a positional nth-child from the iteration loop.
438
+ // On recycling virtualizers, elements can shift between SmartRow creation and
439
+ // this clone — the nth element may now show a different row's content.
440
+ // Verify via resolveRowIndex and rescan if drifted.
441
+ let cloneTarget = rowLocator;
442
+ const resolveRI = config.strategies.resolveRowIndex;
443
+ const isSelfHealing = smart._selfHealing === true;
444
+ const rescanForRow = resolveRI && typeof rowIndex === 'number' && !isSelfHealing
445
+ ? async () => {
446
+ const rows = await resolve(config.rowSelector, rootLocator).all();
447
+ for (const r of rows) {
448
+ try {
449
+ const rResult = await resolveRI(r);
450
+ if (rResult !== undefined && (0, rowResolution_1.normalizeRowIndexResult)(rResult).index === rowIndex)
451
+ return r;
452
+ }
453
+ catch ( /* element may have detached during scan */_a) { /* element may have detached during scan */ }
454
+ }
455
+ return null;
456
+ }
457
+ : null;
458
+ if (rescanForRow) {
459
+ let drifted = false;
460
+ try {
461
+ const curResult = await resolveRI(rowLocator);
462
+ const curIndex = curResult !== undefined
463
+ ? (0, rowResolution_1.normalizeRowIndexResult)(curResult).index
464
+ : undefined;
465
+ drifted = curIndex !== rowIndex;
466
+ }
467
+ catch (_k) {
468
+ drifted = true;
469
+ }
470
+ if (drifted) {
471
+ const inBatch = smart._inBatch === true || !!smart._barrier;
472
+ let recovered = await rescanForRow();
473
+ if (!recovered && !inBatch && ((_a = config.strategies.viewport) === null || _a === void 0 ? void 0 : _a.scrollToRow)) {
474
+ await config.strategies.viewport.scrollToRow({ root: rootLocator, config, page, resolve }, rowIndex);
475
+ const deadline = Date.now() + 500;
476
+ while (!recovered && Date.now() < deadline) {
477
+ recovered = await rescanForRow();
478
+ if (!recovered)
479
+ await page.waitForTimeout(50);
480
+ }
481
+ }
482
+ if (!recovered) {
483
+ throw new Error(`[SmartTable] toJSON({ atomic: true }): row ${rowIndex} recycled out of the DOM and could not be recovered. ` +
484
+ (inBatch
485
+ ? `(During a map/forEach batch, scroll-back recovery is disabled to avoid disrupting sibling rows.)`
486
+ : `Ensure a viewport.scrollToRow strategy can restore the row.`));
487
+ }
488
+ cloneTarget = recovered;
489
+ }
490
+ }
491
+ // Wait for async content renders to settle before cloning.
492
+ if (config.strategies.contentReady) {
493
+ await config.strategies.contentReady(cloneTarget, page);
494
+ // Revalidate: the element may have recycled during the await.
495
+ if (rescanForRow) {
496
+ let postIdx;
497
+ try {
498
+ const r = await resolveRI(cloneTarget);
499
+ postIdx = r !== undefined ? (0, rowResolution_1.normalizeRowIndexResult)(r).index : undefined;
500
+ }
501
+ catch (_l) {
502
+ postIdx = undefined;
503
+ }
504
+ if (postIdx !== rowIndex) {
505
+ const reFound = await rescanForRow();
506
+ if (reFound)
507
+ cloneTarget = reFound;
508
+ }
509
+ }
510
+ }
427
511
  // Step 1: clone row in browser memory (NOT in the DOM), extract text + row HTML.
428
512
  // Uses textContent (not innerText) because the clone is detached — innerText
429
513
  // falls back to textContent on detached nodes per HTML spec, so be explicit.
430
- const cloneHandle = await rowLocator.evaluateHandle(el => el.cloneNode(true));
514
+ const cloneHandle = await cloneTarget.evaluateHandle(el => el.cloneNode(true));
431
515
  let snapshot;
432
516
  try {
433
517
  snapshot = await cloneHandle.evaluate((row, sel) => {
@@ -481,7 +565,7 @@ const createSmartRow = (rowLocator, map, rowIndex, config, rootLocator, resolve,
481
565
  await materializeRow();
482
566
  const result = {};
483
567
  for (const [col, idx] of columnsToProcess) {
484
- const columnOverride = (_a = config.columnOverrides) === null || _a === void 0 ? void 0 : _a[col];
568
+ const columnOverride = (_b = config.columnOverrides) === null || _b === void 0 ? void 0 : _b[col];
485
569
  const mapper = columnOverride === null || columnOverride === void 0 ? void 0 : columnOverride.read;
486
570
  if (mapper) {
487
571
  const cell = page.locator(`#${snapId}`).locator(cellSel).nth(idx);
@@ -498,6 +582,17 @@ const createSmartRow = (rowLocator, map, rowIndex, config, rootLocator, resolve,
498
582
  result[col] = snapshot.cells[idx].text;
499
583
  }
500
584
  }
585
+ for (const [name, def] of Object.entries((_c = config.syntheticColumns) !== null && _c !== void 0 ? _c : {})) {
586
+ if ((options === null || options === void 0 ? void 0 : options.columns) && !options.columns.includes(name))
587
+ continue;
588
+ const snapshotRow = Object.create(smart);
589
+ snapshotRow.getValue = async (col) => {
590
+ if (col in result)
591
+ return String(result[col]);
592
+ return smart.getValue(col);
593
+ };
594
+ result[name] = String(await def.compute(snapshotRow));
595
+ }
501
596
  return result;
502
597
  }
503
598
  finally {
@@ -516,43 +611,39 @@ const createSmartRow = (rowLocator, map, rowIndex, config, rootLocator, resolve,
516
611
  throw new Error(`Column "${colName}" not found`);
517
612
  return resolve(config.headerSelector, rootLocator).nth(idx);
518
613
  };
519
- // #366: on virtualized tables the row's DOM node can recycle between the per-column
520
- // awaits below, which would make one toJSON() mix fields from different logical rows.
521
- // When a resolveRowIndex strategy is configured and this row has a known logical index,
522
- // re-pin the row before each column read: if the current locator has drifted to a
523
- // different logical row, re-locate the correct one (rescan, then scrollToRow) — or throw,
524
- // rather than silently returning a mixed-row object. No-op without resolveRowIndex/rowIndex.
614
+ // #366 / #392: on virtualized tables the row's DOM node can recycle between the
615
+ // per-column awaits below. When a self-healing locator is in use (the row locator was
616
+ // rebuilt with a CSS selector, e.g. `[data-rowindex="47"]`), the locator automatically
617
+ // re-queries the DOM — no rescan needed. For plain-number strategies (JS-only, no
618
+ // selector), fall back to the original rescan-then-scrollToRow approach.
525
619
  const resolveRI = config.strategies.resolveRowIndex;
526
620
  const canPin = !!resolveRI && typeof rowIndex === 'number';
621
+ const isSelfHealing = smart._selfHealing === true;
527
622
  const stableCtx = { root: rootLocator, config, page, resolve };
528
- // scrollToRow recovery is only safe for standalone reads (findRow/findRowByIndex/getRow*).
529
- // Inside a map/forEach/filter or async-iterator batch, the other rows are positional
530
- // locators from one DOM snapshot (and, in synchronized mode, coordinated by a barrier),
531
- // so an independent scroll here would fight peers / invalidate their locators. In a batch
532
- // we recover by rescanning the currently-mounted rows only, and throw if that fails.
533
623
  const inBatch = smart._inBatch === true || !!smart._barrier;
534
- // The last locator we confirmed points at the expected logical row. After a recovery the
535
- // node the original `rowLocator` referenced stays drifted, so re-checking it every column
536
- // would re-run the full rescan needlessly; validate the last-known-good locator first and
537
- // only fall back to a rescan when it too has drifted.
538
624
  let lastGood = rowLocator;
539
625
  const resolveStableRow = async () => {
540
626
  var _a;
627
+ // Self-healing locator: always re-queries the DOM by attribute — no rescan needed.
628
+ if (isSelfHealing)
629
+ return rowLocator;
541
630
  if (!canPin)
542
631
  return rowLocator;
543
- if ((await resolveRI(lastGood)) === rowIndex)
632
+ const result = await resolveRI(lastGood);
633
+ const resolvedIndex = result !== undefined ? (0, rowResolution_1.normalizeRowIndexResult)(result).index : undefined;
634
+ if (resolvedIndex === rowIndex)
544
635
  return lastGood; // still correct
545
636
  // Drifted (recycled). Re-locate the row carrying the expected logical index.
546
637
  const rescan = async () => {
547
638
  const rows = await resolve(config.rowSelector, rootLocator).all();
548
639
  for (const r of rows) {
549
- if ((await resolveRI(r)) === rowIndex)
640
+ const rResult = await resolveRI(r);
641
+ if (rResult !== undefined && (0, rowResolution_1.normalizeRowIndexResult)(rResult).index === rowIndex)
550
642
  return r;
551
643
  }
552
644
  return null;
553
645
  };
554
646
  let recovered = await rescan();
555
- // Scroll the row back only when it's safe to move the viewport (not mid-batch).
556
647
  if (!recovered && !inBatch && ((_a = config.strategies.viewport) === null || _a === void 0 ? void 0 : _a.scrollToRow)) {
557
648
  await config.strategies.viewport.scrollToRow(stableCtx, rowIndex);
558
649
  recovered = await rescan();
@@ -590,7 +681,7 @@ const createSmartRow = (rowLocator, map, rowIndex, config, rootLocator, resolve,
590
681
  // Re-pin to the correct logical row before reading this column (#366).
591
682
  const stableRow = await resolveStableRow();
592
683
  // Check if we have a column override for this column
593
- const columnOverride = (_b = config.columnOverrides) === null || _b === void 0 ? void 0 : _b[col];
684
+ const columnOverride = (_d = config.columnOverrides) === null || _d === void 0 ? void 0 : _d[col];
594
685
  const mapper = columnOverride === null || columnOverride === void 0 ? void 0 : columnOverride.read;
595
686
  // --- Navigation Logic Start ---
596
687
  const cell = config.strategies.getCellLocator
@@ -637,13 +728,13 @@ const createSmartRow = (rowLocator, map, rowIndex, config, rootLocator, resolve,
637
728
  });
638
729
  }
639
730
  // Cell loading wait (if configured)
640
- const isCellLoading = (_c = config.strategies.loading) === null || _c === void 0 ? void 0 : _c.isCellLoading;
641
- const rawCellTimeout = (_d = config.strategies.loading) === null || _d === void 0 ? void 0 : _d.cellLoadingTimeout;
731
+ const isCellLoading = (_e = config.strategies.loading) === null || _e === void 0 ? void 0 : _e.isCellLoading;
732
+ const rawCellTimeout = (_f = config.strategies.loading) === null || _f === void 0 ? void 0 : _f.cellLoadingTimeout;
642
733
  // undefined = unset (read as-is immediately); 0 = no wait (immediate check); >0 = wait up to N ms
643
734
  const cellLoadingTimeout = rawCellTimeout !== undefined && Number.isFinite(rawCellTimeout) && rawCellTimeout >= 0
644
735
  ? rawCellTimeout
645
736
  : undefined;
646
- const onCellLoadingTimeout = (_f = (_e = config.strategies.loading) === null || _e === void 0 ? void 0 : _e.onCellLoadingTimeout) !== null && _f !== void 0 ? _f : 'read-as-is';
737
+ const onCellLoadingTimeout = (_h = (_g = config.strategies.loading) === null || _g === void 0 ? void 0 : _g.onCellLoadingTimeout) !== null && _h !== void 0 ? _h : 'read-as-is';
647
738
  if (isCellLoading && await isCellLoading(targetCell, col, smart)) {
648
739
  if (cellLoadingTimeout !== undefined) {
649
740
  (0, debugUtils_1.logDebug)(config, 'verbose', `toJSON: cell "${col}" — waiting up to ${cellLoadingTimeout}ms`);
@@ -682,17 +773,31 @@ const createSmartRow = (rowLocator, map, rowIndex, config, rootLocator, resolve,
682
773
  result[col] = (text || '').trim();
683
774
  }
684
775
  }
776
+ for (const [name, def] of Object.entries((_j = config.syntheticColumns) !== null && _j !== void 0 ? _j : {})) {
777
+ if ((options === null || options === void 0 ? void 0 : options.columns) && !options.columns.includes(name))
778
+ continue;
779
+ const snapshotRow = Object.create(smart);
780
+ snapshotRow.getValue = async (col) => {
781
+ if (col in result)
782
+ return String(result[col]);
783
+ return smart.getValue(col);
784
+ };
785
+ result[name] = String(await def.compute(snapshotRow));
786
+ }
685
787
  return result;
686
788
  };
687
789
  smart.smartFill = async (data, fillOptions) => {
688
- var _a;
790
+ var _a, _b, _c;
689
791
  (0, debugUtils_1.logDebug)(config, 'info', 'Filling row', data);
690
792
  for (const [colName, value] of Object.entries(data)) {
691
793
  if (value === undefined)
692
794
  continue;
795
+ if ((_a = config.syntheticColumns) === null || _a === void 0 ? void 0 : _a[colName]) {
796
+ throw new Error(`Cannot fill synthetic column "${colName}" — it has no DOM cell`);
797
+ }
693
798
  const colIdx = map.get(colName);
694
799
  if (colIdx === undefined) {
695
- throw new Error((0, stringUtils_1.buildColumnNotFoundError)(colName, Array.from(map.keys())));
800
+ throw new Error((0, stringUtils_1.buildColumnNotFoundError)(colName, [...map.keys(), ...Object.keys((_b = config.syntheticColumns) !== null && _b !== void 0 ? _b : {})]));
696
801
  }
697
802
  await _navigateToCell({
698
803
  config,
@@ -706,7 +811,7 @@ const createSmartRow = (rowLocator, map, rowIndex, config, rootLocator, resolve,
706
811
  rowIndex,
707
812
  barrier: smart._barrier
708
813
  });
709
- const columnOverride = (_a = config.columnOverrides) === null || _a === void 0 ? void 0 : _a[colName];
814
+ const columnOverride = (_c = config.columnOverrides) === null || _c === void 0 ? void 0 : _c[colName];
710
815
  if (columnOverride === null || columnOverride === void 0 ? void 0 : columnOverride.write) {
711
816
  const cellLocator = smart.getCell(colName);
712
817
  let currentValue;
@@ -0,0 +1,13 @@
1
+ import type { ContentReadyStrategy } from '../types';
2
+ export type { ContentReadyStrategy };
3
+ export declare const ContentReadyStrategies: {
4
+ /**
5
+ * Polls the row's text content until two consecutive reads match.
6
+ * Handles recycling virtualizers where position updates synchronously
7
+ * but React renders cell content asynchronously.
8
+ */
9
+ textStable: (options?: {
10
+ timeout?: number;
11
+ interval?: number;
12
+ }) => ContentReadyStrategy;
13
+ };
@@ -0,0 +1,29 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.ContentReadyStrategies = void 0;
4
+ exports.ContentReadyStrategies = {
5
+ /**
6
+ * Polls the row's text content until two consecutive reads match.
7
+ * Handles recycling virtualizers where position updates synchronously
8
+ * but React renders cell content asynchronously.
9
+ */
10
+ textStable: (options = {}) => {
11
+ var _a, _b;
12
+ const timeout = (_a = options.timeout) !== null && _a !== void 0 ? _a : 500;
13
+ const interval = (_b = options.interval) !== null && _b !== void 0 ? _b : 50;
14
+ return async (row, page) => {
15
+ const deadline = Date.now() + timeout;
16
+ const remaining = () => Math.max(0, deadline - Date.now());
17
+ let prev = await row.innerText({ timeout: remaining() || timeout });
18
+ while (remaining() > 0) {
19
+ await page.waitForTimeout(Math.min(interval, remaining()));
20
+ if (remaining() <= 0)
21
+ break;
22
+ const cur = await row.innerText({ timeout: remaining() });
23
+ if (cur === prev)
24
+ return;
25
+ prev = cur;
26
+ }
27
+ };
28
+ },
29
+ };
@@ -73,7 +73,6 @@ exports.FillStrategies = {
73
73
  const inputTag = await inputLocator.evaluate((el) => el.tagName.toLowerCase()).catch(() => 'unknown');
74
74
  const inputType = await inputLocator.getAttribute('type').catch(() => null);
75
75
  const isContentEditable = await inputLocator.getAttribute('contenteditable').catch(() => null);
76
- // console.log(`[SmartTable] Filling "${columnName}" with value "${value}" (input: ${inputTag}, type: ${inputType})`);
77
76
  if (inputType === 'checkbox' || inputType === 'radio') {
78
77
  // Boolean value for checkbox/radio
79
78
  const shouldBeChecked = Boolean(value);
@@ -1,6 +1,7 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.HeaderStrategies = void 0;
4
+ const debugUtils_1 = require("../utils/debugUtils");
4
5
  exports.HeaderStrategies = {
5
6
  /**
6
7
  * Default strategy: Returns only the headers currently visible in the DOM.
@@ -70,10 +71,12 @@ exports.HeaderStrategies = {
70
71
  }
71
72
  }
72
73
  else {
73
- console.warn("HeaderStrategies.horizontalScroll: Could not find scroller. Returning visible headers.");
74
+ (0, debugUtils_1.logDebug)(config, 'info', "HeaderStrategies.horizontalScroll: Could not find scroller. Returning visible headers.");
75
+ }
76
+ if (isScrollerFound) {
77
+ await scrollerHandle.evaluate(el => el.scrollLeft = 0);
78
+ await page.waitForTimeout(200);
74
79
  }
75
- await scrollerHandle.evaluate(el => el.scrollLeft = 0);
76
- await page.waitForTimeout(200);
77
80
  return Array.from(collectedHeaders);
78
81
  };
79
82
  }
@@ -9,6 +9,7 @@ export * from './loading';
9
9
  export * from './stabilization';
10
10
  export * from './filter';
11
11
  export * from './viewport';
12
+ export * from './contentReady';
12
13
  export declare const Strategies: {
13
14
  Pagination: {
14
15
  click: (selectors: {
@@ -100,4 +101,10 @@ export declare const Strategies: {
100
101
  Viewport: {
101
102
  dataAttribute: (options?: import("./viewport").DataAttributeViewportOptions) => import("../types").ViewportStrategy;
102
103
  };
104
+ ContentReady: {
105
+ textStable: (options?: {
106
+ timeout?: number;
107
+ interval?: number;
108
+ }) => import("./contentReady").ContentReadyStrategy;
109
+ };
103
110
  };
@@ -26,6 +26,7 @@ const loading_1 = require("./loading");
26
26
  const stabilization_1 = require("./stabilization");
27
27
  const filter_1 = require("./filter");
28
28
  const viewport_1 = require("./viewport");
29
+ const contentReady_1 = require("./contentReady");
29
30
  __exportStar(require("./pagination"), exports);
30
31
  __exportStar(require("./sorting"), exports);
31
32
  __exportStar(require("./columns"), exports);
@@ -37,6 +38,7 @@ __exportStar(require("./loading"), exports);
37
38
  __exportStar(require("./stabilization"), exports);
38
39
  __exportStar(require("./filter"), exports);
39
40
  __exportStar(require("./viewport"), exports);
41
+ __exportStar(require("./contentReady"), exports);
40
42
  exports.Strategies = {
41
43
  Pagination: pagination_1.PaginationStrategies,
42
44
  Sorting: sorting_1.SortingStrategies,
@@ -49,4 +51,5 @@ exports.Strategies = {
49
51
  Stabilization: stabilization_1.StabilizationStrategies,
50
52
  Filter: filter_1.FilterStrategies,
51
53
  Viewport: viewport_1.ViewportStrategies,
54
+ ContentReady: contentReady_1.ContentReadyStrategies,
52
55
  };
@@ -83,6 +83,7 @@ exports.PaginationStrategies = {
83
83
  const scrollTarget = options.scrollTarget
84
84
  ? resolve(options.scrollTarget, root)
85
85
  : root;
86
+ const beforeScrollTop = await scrollTarget.evaluate((el) => el.scrollTop);
86
87
  const doScroll = async () => {
87
88
  const box = await scrollTarget.boundingBox();
88
89
  const scrollValue = amount * directionMultiplier;
@@ -98,8 +99,16 @@ exports.PaginationStrategies = {
98
99
  await page.mouse.wheel(0, scrollValue);
99
100
  }
100
101
  };
101
- // Stabilization: Wait
102
- return await stabilization(context, doScroll);
102
+ // Stabilization: Wait for content to settle
103
+ const stabilizationResult = await stabilization(context, doScroll);
104
+ // EOF detection: use scroll position, not stabilization result.
105
+ // Stabilization may time out for recycling virtualizers when the scroll
106
+ // amount is small enough to stay within the overscan window (no new rows
107
+ // rendered), but the scroll position still changed — we haven't reached
108
+ // the end of the data.
109
+ const afterScrollTop = await scrollTarget.evaluate((el) => el.scrollTop);
110
+ const scrollMoved = Math.abs(afterScrollTop - beforeScrollTop) >= 1;
111
+ return scrollMoved || stabilizationResult;
103
112
  };
104
113
  };
105
114
  const createGoToFirst = () => {
@@ -1,6 +1,7 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.ViewportStrategies = void 0;
4
+ const debugUtils_1 = require("../utils/debugUtils");
4
5
  /**
5
6
  * Viewport strategies for tables where row and cell elements carry a DOM attribute
6
7
  * equal to their index (e.g. Braintrust logs table, TanStack-style grids).
@@ -11,6 +12,10 @@ exports.ViewportStrategies = void 0;
11
12
  * - scrollToColumn — aligns header's left edge to container left, waits for cell mount
12
13
  * - scrollToRow — scrolls row into view, waits for row mount
13
14
  *
15
+ * **Important:** `rowAttribute` must be present on the elements matched by `rowSelector`,
16
+ * not on child cells. If row elements lack the attribute, `getVisibleRowRange` returns
17
+ * `{first:0, last:0}` and logs a warning at the `error` log level.
18
+ *
14
19
  * @example
15
20
  * // TanStack / Braintrust (data-index, 0-based — default)
16
21
  * viewport: Strategies.Viewport.dataAttribute()
@@ -34,17 +39,22 @@ const dataAttribute = (options) => {
34
39
  getVisibleColumnRange: async ({ root, config }) => {
35
40
  const rowSel = config.rowSelector;
36
41
  const cellSel = typeof config.cellSelector === 'string' ? config.cellSelector : `[${colAttr}]`;
37
- return root.evaluate((el, { rowSel, cellSel, colAttr, colOffset }) => {
42
+ const result = await root.evaluate((el, { rowSel, cellSel, colAttr, colOffset }) => {
38
43
  const firstRow = el.querySelector(rowSel);
39
44
  if (!firstRow)
40
- return { first: 0, last: 0 };
41
- const indices = Array.from(firstRow.querySelectorAll(cellSel))
45
+ return { first: 0, last: 0, _cellCount: 0, _validCount: 0 };
46
+ const cells = Array.from(firstRow.querySelectorAll(cellSel));
47
+ const indices = cells
42
48
  .map(c => Number(c.getAttribute(colAttr)) - colOffset)
43
49
  .filter(n => !isNaN(n));
44
50
  if (!indices.length)
45
- return { first: 0, last: 0 };
46
- return { first: Math.min(...indices), last: Math.max(...indices) };
51
+ return { first: 0, last: 0, _cellCount: cells.length, _validCount: 0 };
52
+ return { first: Math.min(...indices), last: Math.max(...indices), _cellCount: cells.length, _validCount: indices.length };
47
53
  }, { rowSel, cellSel, colAttr, colOffset });
54
+ if (result._cellCount > 0 && result._validCount === 0) {
55
+ (0, debugUtils_1.logDebug)(config, 'error', `dataAttribute viewport: ${result._cellCount} cell(s) found but none have attribute "${colAttr}". Check that columnAttribute targets cell elements matched by cellSelector.`);
56
+ }
57
+ return { first: result.first, last: result.last };
48
58
  },
49
59
  getVisibleRowIndices: async ({ root, config }) => {
50
60
  const rowSel = config.rowSelector;
@@ -72,33 +82,30 @@ const dataAttribute = (options) => {
72
82
  },
73
83
  getVisibleRowRange: async ({ root, config }) => {
74
84
  const rowSel = config.rowSelector;
75
- return root.evaluate((el, { rowSel, rowAttr, rowOffset, containerSel }) => {
85
+ const result = await root.evaluate((el, { rowSel, rowAttr, rowOffset, containerSel }) => {
76
86
  const rows = Array.from(el.querySelectorAll(rowSel));
77
- // #353: report only rows actually within the scroll container's vertical
78
- // bounds, not every mounted row. Previously overscan rows (kept mounted by
79
- // the virtual scroller above/below the fold) were counted as "visible".
80
- // Inclusive: a row with ANY vertical overlap counts as visible; only rows
81
- // entirely above or below the container are dropped — so we never drop a
82
- // partially-visible real row.
83
87
  const container = el.closest(containerSel);
84
88
  const containerRect = container ? container.getBoundingClientRect() : null;
85
- const indices = rows
89
+ const visibleRows = rows
86
90
  .filter(r => {
87
- // Container not resolvable — can't measure, so keep all rows (safe
88
- // fallback, preserves pre-#353 behavior rather than risk dropping real rows).
89
91
  if (!containerRect)
90
92
  return true;
91
93
  const rect = r.getBoundingClientRect();
92
94
  if (rect.height === 0)
93
- return false; // unrendered / detached
95
+ return false;
94
96
  return rect.bottom > containerRect.top && rect.top < containerRect.bottom;
95
- })
97
+ });
98
+ const indices = visibleRows
96
99
  .map(r => Number(r.getAttribute(rowAttr)) - rowOffset)
97
100
  .filter(n => !isNaN(n));
98
101
  if (!indices.length)
99
- return { first: 0, last: 0 };
100
- return { first: Math.min(...indices), last: Math.max(...indices) };
102
+ return { first: 0, last: 0, _rowCount: visibleRows.length, _validCount: 0 };
103
+ return { first: Math.min(...indices), last: Math.max(...indices), _rowCount: visibleRows.length, _validCount: indices.length };
101
104
  }, { rowSel, rowAttr, rowOffset, containerSel });
105
+ if (result._rowCount > 0 && result._validCount === 0) {
106
+ (0, debugUtils_1.logDebug)(config, 'error', `dataAttribute viewport: ${result._rowCount} row(s) found but none have attribute "${rowAttr}". Check that rowAttribute targets row elements matched by rowSelector, not child cells. getVisibleRowRange returning {first:0, last:0}.`);
107
+ }
108
+ return { first: result.first, last: result.last };
102
109
  },
103
110
  scrollToColumn: async ({ root, config }, colIndex) => {
104
111
  const headerSel = typeof config.headerSelector === 'string' ? config.headerSelector : null;