@rickcedwhat/playwright-smart-table 6.25.0-next.0d74239 → 6.25.0-next.1e71a54

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.
@@ -3,7 +3,7 @@ import { FinalTableConfig, Selector, SmartRow, FilterValue } from '../types';
3
3
  import { FilterEngine } from '../filterEngine';
4
4
  import { TableMapper } from './tableMapper';
5
5
  import { SmartRowArray } from '../utils/smartRowArray';
6
- import { NavigationBarrier } from '../utils/navigationBarrier';
6
+ import type { NavigationBarrier } from '../utils/navigationBarrier';
7
7
  export declare class RowFinder<T = any> {
8
8
  private rootLocator;
9
9
  private config;
@@ -5,7 +5,6 @@ const debugUtils_1 = require("../utils/debugUtils");
5
5
  const smartRowArray_1 = require("../utils/smartRowArray");
6
6
  const elementTracker_1 = require("../utils/elementTracker");
7
7
  const sentinel_1 = require("../utils/sentinel");
8
- const navigationBarrier_1 = require("../utils/navigationBarrier");
9
8
  const rowResolution_1 = require("./rowResolution");
10
9
  const resolveCellLocator_1 = require("../utils/resolveCellLocator");
11
10
  const scanPages_1 = require("./scanPages");
@@ -172,26 +171,23 @@ class RowFinder {
172
171
  const newIndices = await tracker.getUnseenIndices(rowLocators);
173
172
  const currentRows = await rowLocators.all();
174
173
  let added = 0;
175
- const useBarrier = this.config.concurrency === 'synchronized' && newIndices.length > 1;
176
- const barrier = useBarrier ? new navigationBarrier_1.NavigationBarrier(newIndices.length) : undefined;
177
174
  for (const idx of newIndices) {
178
175
  const resolved = await (0, rowResolution_1.resolveLogicalRowIndex)(currentRows[idx], this.config, () => allRows.length);
179
176
  const rowLocator = this.nameFilteredRow(currentRows[idx], resolved, filtersRecord);
180
- const smartRow = this.makeSmartRow(rowLocator, map, resolved === null || resolved === void 0 ? void 0 : resolved.index, this.tableState.currentPageIndex, barrier, resolved === null || resolved === void 0 ? void 0 : resolved.selector);
177
+ // These rows outlive the scan and may be consumed individually. Only
178
+ // iteration engines that own every callback can attach a batch barrier.
179
+ const smartRow = this.makeSmartRow(rowLocator, map, resolved === null || resolved === void 0 ? void 0 : resolved.index, this.tableState.currentPageIndex, undefined, resolved === null || resolved === void 0 ? void 0 : resolved.selector);
181
180
  const loadingOutcome = await (0, rowResolution_1.resolveRowLoading)(smartRow, this.config.strategies.loading, 'skip', (msg) => (0, debugUtils_1.logDebug)(this.config, 'verbose', `findRows: ${msg}`));
182
181
  if (loadingOutcome !== 'process') {
183
- barrier === null || barrier === void 0 ? void 0 : barrier.markFinished();
184
182
  if (loadingOutcome === 'throw') {
185
183
  throw new Error(`[SmartTable] Row ${allRows.length} did not finish loading within ${(_a = this.config.strategies.loading) === null || _a === void 0 ? void 0 : _a.rowLoadingTimeout}ms`);
186
184
  }
187
185
  continue;
188
186
  }
189
187
  if (hasOverrideFilters && !await this.matchesOverrideFilters(currentRows[idx], overrideFilters, map, (_c = options === null || options === void 0 ? void 0 : options.exact) !== null && _c !== void 0 ? _c : false)) {
190
- barrier === null || barrier === void 0 ? void 0 : barrier.markFinished();
191
188
  continue;
192
189
  }
193
190
  if (hasSyntheticFilters && !await this.matchesSyntheticFilters(currentRows[idx], syntheticFilters, map, (_d = options === null || options === void 0 ? void 0 : options.exact) !== null && _d !== void 0 ? _d : false)) {
194
- barrier === null || barrier === void 0 ? void 0 : barrier.markFinished();
195
191
  continue;
196
192
  }
197
193
  allRows.push(smartRow);
@@ -29,7 +29,7 @@ export type RowLoadingOutcome = 'process' | 'skip' | 'throw';
29
29
  * 'skip'`), while `map`/`forEach`/`filter` process it as-is (`'process'`) — and map, in that
30
30
  * case, never even probes `isRowLoading`. Both are preserved exactly.
31
31
  *
32
- * The helper never throws and never touches the navigation barrier; it returns a decision so
33
- * each caller keeps its own barrier bookkeeping and error message.
32
+ * Timeout returns a decision so each caller keeps its own barrier bookkeeping and error
33
+ * message. Predicate errors propagate unchanged.
34
34
  */
35
35
  export declare function resolveRowLoading(row: SmartRow, loading: LoadingStrategy | undefined, noTimeoutAction: 'skip' | 'process', log?: (msg: string) => void): Promise<RowLoadingOutcome>;
@@ -3,6 +3,7 @@ Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.normalizeRowIndexResult = normalizeRowIndexResult;
4
4
  exports.resolveLogicalRowIndex = resolveLogicalRowIndex;
5
5
  exports.resolveRowLoading = resolveRowLoading;
6
+ const deadline_1 = require("../utils/deadline");
6
7
  /** Extract the numeric index from a {@link RowIndexResult}. */
7
8
  function normalizeRowIndexResult(result) {
8
9
  return typeof result === 'number' ? { index: result } : result;
@@ -36,8 +37,8 @@ async function resolveLogicalRowIndex(row, config, fallback) {
36
37
  * 'skip'`), while `map`/`forEach`/`filter` process it as-is (`'process'`) — and map, in that
37
38
  * case, never even probes `isRowLoading`. Both are preserved exactly.
38
39
  *
39
- * The helper never throws and never touches the navigation barrier; it returns a decision so
40
- * each caller keeps its own barrier bookkeeping and error message.
40
+ * Timeout returns a decision so each caller keeps its own barrier bookkeeping and error
41
+ * message. Predicate errors propagate unchanged.
41
42
  */
42
43
  async function resolveRowLoading(row, loading, noTimeoutAction, log) {
43
44
  var _a;
@@ -53,18 +54,21 @@ async function resolveRowLoading(row, loading, noTimeoutAction, log) {
53
54
  // map's fast path: with no timeout and a process-default, it never probes isRowLoading.
54
55
  if (timeout === undefined && noTimeoutAction === 'process')
55
56
  return 'process';
56
- if (!(await isRowLoading(row)))
57
+ const deadline = Date.now() + (timeout !== null && timeout !== void 0 ? timeout : Infinity);
58
+ const check = () => (0, deadline_1.withinDeadline)(() => isRowLoading(row), deadline, true);
59
+ if (!(await check()))
57
60
  return 'process';
58
61
  // Row is loading with no timeout configured → legacy skip (findRows).
59
62
  if (timeout === undefined)
60
63
  return 'skip';
61
64
  log === null || log === void 0 ? void 0 : log(`row ${row.rowIndex} — waiting up to ${timeout}ms for row to load`);
62
- const deadline = Date.now() + timeout;
63
- let resolved = !(await isRowLoading(row)); // immediate check (handles timeout=0)
65
+ let resolved = (timeout === 0 || Date.now() < deadline) && !(await check());
64
66
  while (!resolved && Date.now() < deadline) {
65
67
  // Page accessed lazily (only while actually polling), matching the pre-refactor code.
66
- await row.page().waitForTimeout(100);
67
- resolved = !(await isRowLoading(row));
68
+ await row.page().waitForTimeout(Math.min(100, deadline - Date.now()));
69
+ if (Date.now() >= deadline)
70
+ break;
71
+ resolved = !(await check());
68
72
  }
69
73
  if (resolved)
70
74
  return 'process';
@@ -6,6 +6,7 @@ exports.readRowLive = readRowLive;
6
6
  const rowResolution_1 = require("./rowResolution");
7
7
  const cellNavigation_1 = require("./cellNavigation");
8
8
  const debugUtils_1 = require("../utils/debugUtils");
9
+ const deadline_1 = require("../utils/deadline");
9
10
  /** `getHeaderCell` for strategy hooks: the table's when available, else an index lookup. */
10
11
  function headerCellGetter(env) {
11
12
  const { table } = env;
@@ -35,6 +36,15 @@ async function computeSyntheticColumns(env, result, columns) {
35
36
  }
36
37
  }
37
38
  const isBatchRow = (row) => row._inBatch === true || !!row._barrier;
39
+ /** Capture a mounted position's identity once, before readiness or navigation can recycle it. */
40
+ async function resolveSnapshotIdentity(env) {
41
+ if (!env.renderWindowPosition || !env.config.strategies.resolveRowIndex)
42
+ return env;
43
+ const identity = await (0, rowResolution_1.resolveLogicalRowIndex)(env.rowLocator, env.config, () => undefined);
44
+ // Keep the public SmartRow's mounted position unchanged; all snapshot recovery and
45
+ // navigation use this read's logical identity. An unknown identity is not position 0.
46
+ return Object.assign(Object.assign({}, env), { rowIndex: identity === null || identity === void 0 ? void 0 : identity.index, renderWindowPosition: false });
47
+ }
38
48
  // ─── Atomic snapshot ─────────────────────────────────────────────────────────
39
49
  /** Mounted row whose logical index (per resolveRowIndex) is `rowIndex`; tolerates rows detaching mid-scan. */
40
50
  async function rescanForRow(env, rowIndex) {
@@ -107,9 +117,7 @@ async function resolveCloneTarget(env) {
107
117
  await config.strategies.contentReady(cloneTarget, env.rootLocator.page());
108
118
  // Revalidate: the element may have recycled during the await.
109
119
  if (canRepin && await logicalIndexOf(env, cloneTarget) !== rowIndex) {
110
- const reFound = await rescanForRow(env, rowIndex);
111
- if (reFound)
112
- cloneTarget = reFound;
120
+ cloneTarget = await recoverDriftedRow(env, rowIndex);
113
121
  }
114
122
  }
115
123
  return cloneTarget;
@@ -148,6 +156,7 @@ async function readRowAtomic(env, columns) {
148
156
  if (typeof cellSel !== 'string') {
149
157
  throw new Error('[SmartTable] toJSON({ atomic: true }) requires cellSelector to be a CSS string, not a function.');
150
158
  }
159
+ env = await resolveSnapshotIdentity(env);
151
160
  const page = rootLocator.page();
152
161
  const snapshot = await cloneRow(await resolveCloneTarget(env), cellSel);
153
162
  // Determine which columns have overrides (need lazy materialization)
@@ -275,7 +284,9 @@ function cellLoadingTimeoutOf(config) {
275
284
  async function pollUntil(page, deadline, intervalMs, check, initial) {
276
285
  let done = initial;
277
286
  while (!done && Date.now() < deadline) {
278
- await page.waitForTimeout(intervalMs);
287
+ await page.waitForTimeout(Math.min(intervalMs, deadline - Date.now()));
288
+ if (Date.now() >= deadline)
289
+ break;
279
290
  done = await check();
280
291
  }
281
292
  return done;
@@ -285,11 +296,13 @@ async function pollUntil(page, deadline, intervalMs, check, initial) {
285
296
  * indicator mounts. A single false-negative then reads "" and skips
286
297
  * onCellLoadingTimeout. Only grace when the cell still looks empty.
287
298
  */
288
- async function graceForLoadingIndicator(page, targetCell, isLoading, timeout) {
289
- const peek = await targetCell.evaluate((el) => (el.textContent || '').trim()).catch(() => '');
299
+ async function graceForLoadingIndicator(page, targetCell, isLoading, deadline) {
300
+ const peek = await (0, deadline_1.withinDeadline)(() => targetCell.evaluate((el) => (el.textContent || '').trim()).catch(() => ''), deadline, null);
301
+ if (peek === null)
302
+ return true; // Deadline expired: apply the cell loading timeout policy.
290
303
  if (peek !== '')
291
304
  return false;
292
- return pollUntil(page, Date.now() + Math.min(250, timeout), 25, isLoading, false);
305
+ return pollUntil(page, Math.min(Date.now() + 250, deadline), 25, isLoading, false);
293
306
  }
294
307
  /** What to do when a cell is still loading after `cellLoadingTimeout`. */
295
308
  async function onStillLoading(env, targetCell, col, timeout) {
@@ -320,18 +333,19 @@ async function waitForCellLoaded(env, targetCell, col) {
320
333
  if (!isCellLoading)
321
334
  return { read: true };
322
335
  const page = env.rootLocator.page();
323
- const isLoading = () => isCellLoading(targetCell, col, smart);
324
336
  const timeout = cellLoadingTimeoutOf(config);
337
+ const deadline = Date.now() + (timeout !== null && timeout !== void 0 ? timeout : Infinity);
338
+ const isLoading = () => (0, deadline_1.withinDeadline)(() => isCellLoading(targetCell, col, smart), deadline, true);
325
339
  let loading = await isLoading();
326
340
  if (!loading && timeout !== undefined && timeout > 0) {
327
- loading = await graceForLoadingIndicator(page, targetCell, isLoading, timeout);
341
+ loading = await graceForLoadingIndicator(page, targetCell, isLoading, deadline);
328
342
  }
329
343
  if (!loading || timeout === undefined)
330
344
  return { read: true };
331
345
  (0, debugUtils_1.logDebug)(config, 'verbose', `toJSON: cell "${col}" — waiting up to ${timeout}ms`);
332
- const deadline = Date.now() + timeout;
333
346
  const isLoaded = async () => !(await isLoading());
334
- const cellResolved = await pollUntil(page, deadline, 100, isLoaded, await isLoaded()); // immediate check (handles timeout=0)
347
+ const initial = (timeout === 0 || Date.now() < deadline) && await isLoaded();
348
+ const cellResolved = await pollUntil(page, deadline, 100, isLoaded, initial);
335
349
  return cellResolved ? { read: true } : onStillLoading(env, targetCell, col, timeout);
336
350
  }
337
351
  /**
@@ -340,6 +354,7 @@ async function waitForCellLoaded(env, targetCell, col) {
340
354
  */
341
355
  async function readRowLive(env, columns) {
342
356
  var _a, _b, _c;
357
+ env = await resolveSnapshotIdentity(env);
343
358
  const { config, map, rootLocator, rowIndex } = env;
344
359
  const result = {};
345
360
  const page = rootLocator.page();
@@ -3,6 +3,7 @@ Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.TableMapper = void 0;
4
4
  const headers_1 = require("../strategies/headers");
5
5
  const debugUtils_1 = require("../utils/debugUtils");
6
+ const deadline_1 = require("../utils/deadline");
6
7
  class TableMapper {
7
8
  constructor(rootLocator, config, resolve) {
8
9
  this._headerMap = null;
@@ -21,9 +22,11 @@ class TableMapper {
21
22
  this.log('Mapping headers...');
22
23
  const headerTimeout = timeout !== null && timeout !== void 0 ? timeout : 3000;
23
24
  const startTime = Date.now();
25
+ const deadline = startTime + headerTimeout;
26
+ const remaining = () => Math.max(0, deadline - Date.now());
24
27
  if (this.config.autoScroll) {
25
28
  try {
26
- await this.rootLocator.scrollIntoViewIfNeeded({ timeout: 1000 });
29
+ await (0, deadline_1.withinDeadline)(() => this.rootLocator.scrollIntoViewIfNeeded({ timeout: Math.max(1, Math.min(1000, remaining())) }), deadline, undefined);
27
30
  }
28
31
  catch (e) { }
29
32
  }
@@ -39,22 +42,29 @@ class TableMapper {
39
42
  while (Date.now() - startTime < headerTimeout) {
40
43
  // 1. Wait for visibility
41
44
  try {
42
- await headerLoc.first().waitFor({ state: 'visible', timeout: 200 });
45
+ await (0, deadline_1.withinDeadline)(() => headerLoc.first().waitFor({ state: 'visible', timeout: Math.max(1, Math.min(200, remaining())) }), deadline, undefined);
43
46
  }
44
47
  catch (e) {
45
48
  // Continue to check existing/loading state even if not strictly "visible" yet
46
49
  }
50
+ if (remaining() <= 0)
51
+ break;
47
52
  // 2. Check Smart Loading State
48
53
  if ((_a = this.config.strategies.loading) === null || _a === void 0 ? void 0 : _a.isHeaderLoading) {
49
- const isStable = !(await this.config.strategies.loading.isHeaderLoading(context));
54
+ const isStable = !(await (0, deadline_1.withinDeadline)(() => this.config.strategies.loading.isHeaderLoading(context), deadline, true));
50
55
  if (!isStable) {
51
56
  this.log('Headers are loading/unstable... waiting');
52
- await new Promise(r => setTimeout(r, 100));
57
+ if (remaining() > 0)
58
+ await new Promise(r => setTimeout(r, Math.min(100, remaining())));
53
59
  continue;
54
60
  }
55
61
  }
56
62
  // 3. Attempt Scan
63
+ if (remaining() <= 0)
64
+ break;
57
65
  try {
66
+ // Preserve long-running discovery strategies (e.g. horizontal header scans).
67
+ // The deadline bounds readiness and retries, not a strategy's own operation.
58
68
  const rawHeaders = await strategy(context);
59
69
  const entries = await this.processHeaders(rawHeaders);
60
70
  // Success — validate before caching so a collision doesn't leave stale state
@@ -72,7 +82,8 @@ class TableMapper {
72
82
  catch (e) {
73
83
  lastError = e;
74
84
  this.log(`Header mapping failed. Attempt ${Math.floor((Date.now() - startTime) / 100)}: ${e.message}`);
75
- await new Promise(r => setTimeout(r, 100));
85
+ if (remaining() > 0)
86
+ await new Promise(r => setTimeout(r, Math.min(100, remaining())));
76
87
  }
77
88
  }
78
89
  throw lastError || new Error(`Timed out waiting for headers after ${headerTimeout}ms`);
@@ -3,4 +3,4 @@
3
3
  * Generated by scripts/embed-version.mjs from package.json
4
4
  */
5
5
  /** Semver of this package. Use in logs or diagnostics to confirm the installed build. */
6
- export declare const PLAYWRIGHT_SMART_TABLE_VERSION: "6.25.0-next.0d74239";
6
+ export declare const PLAYWRIGHT_SMART_TABLE_VERSION: "6.25.0-next.1e71a54";
@@ -6,4 +6,4 @@ exports.PLAYWRIGHT_SMART_TABLE_VERSION = void 0;
6
6
  * Generated by scripts/embed-version.mjs from package.json
7
7
  */
8
8
  /** Semver of this package. Use in logs or diagnostics to confirm the installed build. */
9
- exports.PLAYWRIGHT_SMART_TABLE_VERSION = "6.25.0-next.0d74239";
9
+ exports.PLAYWRIGHT_SMART_TABLE_VERSION = "6.25.0-next.1e71a54";
package/dist/types.d.ts CHANGED
@@ -526,6 +526,7 @@ export interface LoadingStrategy {
526
526
  * How long (ms) to wait for a loading row to resolve before applying onRowLoadingTimeout.
527
527
  * Honored by findRows AND by map/forEach/filter, where the wait runs before the dedupe
528
528
  * strategy so content-based dedupe keys see the row's final loaded state.
529
+ * Includes time spent awaiting the loading predicate, even if it never settles.
529
530
  * When not set (backward-compatible behavior): findRows skips loading rows immediately;
530
531
  * map/forEach/filter process them as-is.
531
532
  */
@@ -546,6 +547,7 @@ export interface LoadingStrategy {
546
547
  isCellLoading?: (cell: import('@playwright/test').Locator, columnName: string, row: SmartRow) => Promise<boolean>;
547
548
  /**
548
549
  * How long (ms) to wait for a loading cell to resolve before applying onCellLoadingTimeout.
550
+ * Includes predicate evaluation and any initial rendering grace period.
549
551
  * When not set and isCellLoading returns true, the cell is read as-is immediately.
550
552
  */
551
553
  cellLoadingTimeout?: number;
@@ -910,6 +912,8 @@ export interface TableResult<T = any> extends AsyncIterable<{
910
912
  /**
911
913
  * ASYNC: Searches for all matching rows across pages using pagination.
912
914
  * Auto-initializes the table if not already initialized.
915
+ * Returned rows can be read independently, including with `concurrency: 'synchronized'`.
916
+ * Use synchronized `map()` for coordinated column scrolling across virtualized rows.
913
917
  * @param filters - The filter criteria to match (omit or pass {} for all rows)
914
918
  * @param options - Search options (exact, maxPages). `useBulkPagination` defaults to `false`: pages advance one at a time via `goNext` so no intermediate page is skipped. Set it to `true` to opt into `goNextBulk` (faster, but skips the rows on jumped-over pages). `signal` cancels the page scan; the call rejects with `signal.reason`.
915
919
  */
@@ -0,0 +1,2 @@
1
+ /** Bound one asynchronous check by an existing deadline, without cancelling its underlying work. */
2
+ export declare function withinDeadline<T>(check: () => Promise<T>, deadline: number, expiredValue: T): Promise<T>;
@@ -0,0 +1,19 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.withinDeadline = withinDeadline;
4
+ /** Bound one asynchronous check by an existing deadline, without cancelling its underlying work. */
5
+ async function withinDeadline(check, deadline, expiredValue) {
6
+ if (!Number.isFinite(deadline))
7
+ return check();
8
+ let timer;
9
+ try {
10
+ const expired = new Promise(resolve => {
11
+ timer = setTimeout(() => resolve(expiredValue), Math.max(0, deadline - Date.now()));
12
+ });
13
+ // A late rejection is handled by the race even after the deadline wins.
14
+ return await Promise.race([check(), expired]);
15
+ }
16
+ finally {
17
+ clearTimeout(timer);
18
+ }
19
+ }
@@ -4,7 +4,7 @@ import { SmartRow } from '../types';
4
4
  */
5
5
  export interface SmartRowArray<T = any> extends Array<SmartRow<T>> {
6
6
  /**
7
- * Converts all rows to JSON objects
7
+ * Converts all rows to JSON objects, reading sequentially to avoid competing viewport moves.
8
8
  */
9
9
  toJSON(): Promise<T[]>;
10
10
  }
@@ -7,7 +7,10 @@ exports.createSmartRowArray = createSmartRowArray;
7
7
  function createSmartRowArray(rows) {
8
8
  const arr = rows;
9
9
  arr.toJSON = async () => {
10
- return Promise.all(rows.map(r => r.toJSON()));
10
+ const results = [];
11
+ for (const row of rows)
12
+ results.push(await row.toJSON());
13
+ return results;
11
14
  };
12
15
  return arr;
13
16
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rickcedwhat/playwright-smart-table",
3
- "version": "6.25.0-next.0d74239",
3
+ "version": "6.25.0-next.1e71a54",
4
4
  "description": "Smart, column-aware table interactions for Playwright",
5
5
  "author": "Cedrick Catalan",
6
6
  "license": "MIT",