@rickcedwhat/playwright-smart-table 6.24.0-next.cc0fbcb → 6.25.0-next.0d74239

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.
@@ -9,6 +9,7 @@ const navigationBarrier_1 = require("../utils/navigationBarrier");
9
9
  const rowResolution_1 = require("./rowResolution");
10
10
  const resolveCellLocator_1 = require("../utils/resolveCellLocator");
11
11
  const scanPages_1 = require("./scanPages");
12
+ const locatorDescription_1 = require("../utils/locatorDescription");
12
13
  const loadingWait_1 = require("../utils/loadingWait");
13
14
  class RowFinder {
14
15
  constructor(rootLocator, config, resolve, filterEngine, tableMapper, makeSmartRow, tableState = { currentPageIndex: 0 }, advancePage = async () => false) {
@@ -111,12 +112,13 @@ class RowFinder {
111
112
  await (0, debugUtils_1.debugDelay)(this.config, 'findRow');
112
113
  const map = await this.tableMapper.getMap();
113
114
  const resolved = await this.resolveRowIndex(rowLocator);
114
- return 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);
115
+ const describedRow = (0, locatorDescription_1.describeLocator)(rowLocator, (0, locatorDescription_1.describeRowByFilters)(filters), this.config);
116
+ return this.makeSmartRow(describedRow, map, resolved === null || resolved === void 0 ? void 0 : resolved.index, this.tableState.currentPageIndex, undefined, resolved === null || resolved === void 0 ? void 0 : resolved.selector);
115
117
  }
116
118
  (0, debugUtils_1.logDebug)(this.config, 'error', 'Row not found', filters);
117
119
  await (0, debugUtils_1.debugDelay)(this.config, 'findRow');
118
- const sentinel = this.resolve(this.config.rowSelector, this.rootLocator)
119
- .filter({ hasText: "___SENTINEL_ROW_NOT_FOUND___" + Date.now() });
120
+ const sentinel = (0, locatorDescription_1.describeLocator)(this.resolve(this.config.rowSelector, this.rootLocator)
121
+ .filter({ hasText: "___SENTINEL_ROW_NOT_FOUND___" + Date.now() }), (0, locatorDescription_1.describeSentinelRow)(filters), this.config);
120
122
  const smartRow = this.makeSmartRow(sentinel, await this.tableMapper.getMap(), undefined);
121
123
  smartRow[sentinel_1.SENTINEL_ROW] = true;
122
124
  return smartRow;
@@ -134,6 +136,12 @@ class RowFinder {
134
136
  };
135
137
  await (0, loadingWait_1.waitWhileTableLoading)(this.config, context, this.rootLocator.page(), 'waitForTableReady');
136
138
  }
139
+ /** Unfiltered rows keep the plain `SmartRow #n` name that `_makeSmart` applies. */
140
+ nameFilteredRow(row, resolved, filters) {
141
+ if (!resolved || Object.keys(filters).length === 0)
142
+ return row;
143
+ return (0, locatorDescription_1.describeLocator)(row, (0, locatorDescription_1.describeRowByIndex)(resolved.index, filters), this.config);
144
+ }
137
145
  async findRows(filters = {}, options) {
138
146
  var _a;
139
147
  var _b, _c;
@@ -168,7 +176,8 @@ class RowFinder {
168
176
  const barrier = useBarrier ? new navigationBarrier_1.NavigationBarrier(newIndices.length) : undefined;
169
177
  for (const idx of newIndices) {
170
178
  const resolved = await (0, rowResolution_1.resolveLogicalRowIndex)(currentRows[idx], this.config, () => allRows.length);
171
- const smartRow = this.makeSmartRow(currentRows[idx], map, resolved === null || resolved === void 0 ? void 0 : resolved.index, this.tableState.currentPageIndex, barrier, resolved === null || resolved === void 0 ? void 0 : resolved.selector);
179
+ 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);
172
181
  const loadingOutcome = await (0, rowResolution_1.resolveRowLoading)(smartRow, this.config.strategies.loading, 'skip', (msg) => (0, debugUtils_1.logDebug)(this.config, 'verbose', `findRows: ${msg}`));
173
182
  if (loadingOutcome !== 'process') {
174
183
  barrier === null || barrier === void 0 ? void 0 : barrier.markFinished();
@@ -195,6 +204,7 @@ class RowFinder {
195
204
  useBulk,
196
205
  label: 'findRows',
197
206
  finalScanOnEof: hasPagination,
207
+ signal: options === null || options === void 0 ? void 0 : options.signal,
198
208
  });
199
209
  (0, debugUtils_1.logDebug)(this.config, 'verbose', `findRows: done — ${allRows.length} row(s) collected across ${pagesScanned} page(s)`);
200
210
  }
@@ -259,6 +269,7 @@ class RowFinder {
259
269
  useBulk,
260
270
  label: 'findRow',
261
271
  finalScanOnEof: true,
272
+ signal: options.signal,
262
273
  });
263
274
  return found;
264
275
  }
@@ -0,0 +1,28 @@
1
+ import type { Locator, Page } from '@playwright/test';
2
+ import type { FinalTableConfig, SmartRow, TableResult } from '../types';
3
+ import type { SmartRowInternals } from '../utils/smartRowInternals';
4
+ /** Everything `toJSON` needs from the SmartRow being read. */
5
+ export interface RowSnapshotEnv<T = any> {
6
+ config: FinalTableConfig<T>;
7
+ rootLocator: Locator;
8
+ resolve: (item: any, parent: Locator | Page) => Locator;
9
+ map: Map<string, number>;
10
+ row: SmartRow<T> & SmartRowInternals;
11
+ rowLocator: Locator;
12
+ rowIndex: number | undefined;
13
+ table: TableResult<T> | null;
14
+ renderWindowPosition: boolean;
15
+ }
16
+ /** `getHeaderCell` for strategy hooks: the table's when available, else an index lookup. */
17
+ export declare function headerCellGetter<T>(env: Pick<RowSnapshotEnv<T>, 'config' | 'rootLocator' | 'resolve' | 'map' | 'table'>): (columnName: string) => Promise<Locator>;
18
+ /**
19
+ * `toJSON({ atomic: true })`: snapshot the row in a single evaluate, then apply column
20
+ * overrides against a frozen reconstruction. Zero inter-column stagger — even in-place
21
+ * React re-renders can't affect it because overrides read from a detached clone.
22
+ */
23
+ export declare function readRowAtomic<T>(env: RowSnapshotEnv<T>, columns?: string[]): Promise<T>;
24
+ /**
25
+ * Default `toJSON()`: reads each column from the live DOM, navigating to it first
26
+ * (keeps synchronized batches in lock-step) and re-pinning the row before each read.
27
+ */
28
+ export declare function readRowLive<T>(env: RowSnapshotEnv<T>, columns?: string[]): Promise<T>;
@@ -0,0 +1,407 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.headerCellGetter = headerCellGetter;
4
+ exports.readRowAtomic = readRowAtomic;
5
+ exports.readRowLive = readRowLive;
6
+ const rowResolution_1 = require("./rowResolution");
7
+ const cellNavigation_1 = require("./cellNavigation");
8
+ const debugUtils_1 = require("../utils/debugUtils");
9
+ /** `getHeaderCell` for strategy hooks: the table's when available, else an index lookup. */
10
+ function headerCellGetter(env) {
11
+ const { table } = env;
12
+ return (table === null || table === void 0 ? void 0 : table.getHeaderCell)
13
+ ? table.getHeaderCell.bind(table)
14
+ : async (colName) => {
15
+ const idx = env.map.get(colName);
16
+ if (idx === undefined)
17
+ throw new Error(`Column "${colName}" not found`);
18
+ return env.resolve(env.config.headerSelector, env.rootLocator).nth(idx);
19
+ };
20
+ }
21
+ /** Adds synthetic columns to `result`; their compute() reads already-read columns from `result`. */
22
+ async function computeSyntheticColumns(env, result, columns) {
23
+ var _a;
24
+ const smart = env.row;
25
+ for (const [name, def] of Object.entries((_a = env.config.syntheticColumns) !== null && _a !== void 0 ? _a : {})) {
26
+ if (columns && !columns.includes(name))
27
+ continue;
28
+ const snapshotRow = Object.create(smart);
29
+ snapshotRow.getValue = async (col) => {
30
+ if (col in result)
31
+ return String(result[col]);
32
+ return smart.getValue(col);
33
+ };
34
+ result[name] = String(await def.compute(snapshotRow));
35
+ }
36
+ }
37
+ const isBatchRow = (row) => row._inBatch === true || !!row._barrier;
38
+ // ─── Atomic snapshot ─────────────────────────────────────────────────────────
39
+ /** Mounted row whose logical index (per resolveRowIndex) is `rowIndex`; tolerates rows detaching mid-scan. */
40
+ async function rescanForRow(env, rowIndex) {
41
+ const resolveRI = env.config.strategies.resolveRowIndex;
42
+ const rows = await env.resolve(env.config.rowSelector, env.rootLocator).all();
43
+ for (const r of rows) {
44
+ try {
45
+ const rResult = await resolveRI(r);
46
+ if (rResult !== undefined && (0, rowResolution_1.normalizeRowIndexResult)(rResult).index === rowIndex)
47
+ return r;
48
+ }
49
+ catch (_a) { /* element may have detached during scan */ }
50
+ }
51
+ return null;
52
+ }
53
+ async function logicalIndexOf(env, loc) {
54
+ try {
55
+ const r = await env.config.strategies.resolveRowIndex(loc);
56
+ return r !== undefined ? (0, rowResolution_1.normalizeRowIndexResult)(r).index : undefined;
57
+ }
58
+ catch (_a) {
59
+ return undefined;
60
+ }
61
+ }
62
+ const recycledRowHint = (inBatch, batchNote, otherwise) => inBatch ? batchNote : otherwise;
63
+ /** Scrolls the row back into view and rescans for it for up to 500ms. */
64
+ async function scrollBackAndRescan(env, rowIndex) {
65
+ var _a;
66
+ const { config, rootLocator, resolve } = env;
67
+ const scrollToRow = (_a = config.strategies.viewport) === null || _a === void 0 ? void 0 : _a.scrollToRow;
68
+ if (!scrollToRow)
69
+ return null;
70
+ const page = rootLocator.page();
71
+ await scrollToRow({ root: rootLocator, config, page, resolve }, rowIndex);
72
+ const deadline = Date.now() + 500;
73
+ let recovered = null;
74
+ while (!recovered && Date.now() < deadline) {
75
+ recovered = await rescanForRow(env, rowIndex);
76
+ if (!recovered)
77
+ await page.waitForTimeout(50);
78
+ }
79
+ return recovered;
80
+ }
81
+ /**
82
+ * Re-pin: the row locator is a positional nth-child from the iteration loop.
83
+ * On recycling virtualizers, elements can shift between SmartRow creation and
84
+ * the clone — the nth element may now show a different row's content.
85
+ * Verify via resolveRowIndex and rescan if drifted.
86
+ */
87
+ async function recoverDriftedRow(env, rowIndex) {
88
+ const inBatch = isBatchRow(env.row);
89
+ let recovered = await rescanForRow(env, rowIndex);
90
+ if (!recovered && !inBatch)
91
+ recovered = await scrollBackAndRescan(env, rowIndex);
92
+ if (recovered)
93
+ return recovered;
94
+ throw new Error(`[SmartTable] toJSON({ atomic: true }): row ${rowIndex} recycled out of the DOM and could not be recovered. ` +
95
+ recycledRowHint(inBatch, `(During a map/forEach batch, scroll-back recovery is disabled to avoid disrupting sibling rows.)`, `Ensure a viewport.scrollToRow strategy can restore the row.`));
96
+ }
97
+ /** The element to clone: the row itself, re-pinned if it drifted, after `contentReady` settles. */
98
+ async function resolveCloneTarget(env) {
99
+ const { config, rowIndex } = env;
100
+ const canRepin = !!config.strategies.resolveRowIndex && typeof rowIndex === 'number' && env.row._selfHealing !== true;
101
+ let cloneTarget = env.rowLocator;
102
+ if (canRepin && await logicalIndexOf(env, cloneTarget) !== rowIndex) {
103
+ cloneTarget = await recoverDriftedRow(env, rowIndex);
104
+ }
105
+ // Wait for async content renders to settle before cloning.
106
+ if (config.strategies.contentReady) {
107
+ await config.strategies.contentReady(cloneTarget, env.rootLocator.page());
108
+ // Revalidate: the element may have recycled during the await.
109
+ if (canRepin && await logicalIndexOf(env, cloneTarget) !== rowIndex) {
110
+ const reFound = await rescanForRow(env, rowIndex);
111
+ if (reFound)
112
+ cloneTarget = reFound;
113
+ }
114
+ }
115
+ return cloneTarget;
116
+ }
117
+ /**
118
+ * Clones the row in browser memory (NOT in the DOM) and extracts text + row HTML.
119
+ * Uses textContent (not innerText) because the clone is detached — innerText
120
+ * falls back to textContent on detached nodes per HTML spec, so be explicit.
121
+ */
122
+ async function cloneRow(target, cellSel) {
123
+ const cloneHandle = await target.evaluateHandle(el => el.cloneNode(true));
124
+ try {
125
+ return await cloneHandle.evaluate((row, sel) => {
126
+ const cells = row.querySelectorAll(sel);
127
+ return {
128
+ rowHTML: row.outerHTML,
129
+ cells: [...cells].map(c => ({
130
+ text: (c.textContent || '').trim(),
131
+ })),
132
+ };
133
+ }, cellSel);
134
+ }
135
+ finally {
136
+ await cloneHandle.dispose();
137
+ }
138
+ }
139
+ /**
140
+ * `toJSON({ atomic: true })`: snapshot the row in a single evaluate, then apply column
141
+ * overrides against a frozen reconstruction. Zero inter-column stagger — even in-place
142
+ * React re-renders can't affect it because overrides read from a detached clone.
143
+ */
144
+ async function readRowAtomic(env, columns) {
145
+ var _a, _b;
146
+ const { config, map, rootLocator } = env;
147
+ const cellSel = config.cellSelector;
148
+ if (typeof cellSel !== 'string') {
149
+ throw new Error('[SmartTable] toJSON({ atomic: true }) requires cellSelector to be a CSS string, not a function.');
150
+ }
151
+ const page = rootLocator.page();
152
+ const snapshot = await cloneRow(await resolveCloneTarget(env), cellSel);
153
+ // Determine which columns have overrides (need lazy materialization)
154
+ const columnsToProcess = [...map.entries()].filter(([col]) => !columns || columns.includes(col));
155
+ const hasAnyOverride = columnsToProcess.some(([col]) => { var _a, _b; return !!((_b = (_a = config.columnOverrides) === null || _a === void 0 ? void 0 : _a[col]) === null || _b === void 0 ? void 0 : _b.read); });
156
+ // Lazily materialize the row if any override needs a Locator.
157
+ // Uses position:fixed off-screen (NOT display:none — that breaks innerText).
158
+ const snapId = `__st_snap_${Math.random().toString(36).slice(2, 10)}`;
159
+ const snapCell = (colIdx) => page.locator(`#${snapId}`).locator(cellSel).nth(colIdx);
160
+ const getCell = (colName) => {
161
+ const colIdx = map.get(colName);
162
+ if (colIdx === undefined)
163
+ throw new Error(`[SmartTable] getCell: column "${colName}" not found`);
164
+ return snapCell(colIdx);
165
+ };
166
+ if (hasAnyOverride) {
167
+ await page.evaluate(([html, id]) => {
168
+ const c = document.createElement('div');
169
+ c.id = id;
170
+ c.style.cssText = 'position:fixed;left:-99999px;top:-99999px;opacity:0;pointer-events:none';
171
+ // Wrap in <table><tbody> so the browser's HTML parser keeps <tr>/<td> intact.
172
+ c.innerHTML = `<table><tbody>${html}</tbody></table>`;
173
+ document.body.appendChild(c);
174
+ }, [snapshot.rowHTML, snapId]);
175
+ }
176
+ try {
177
+ const result = {};
178
+ for (const [col, idx] of columnsToProcess) {
179
+ const mapper = (_b = (_a = config.columnOverrides) === null || _a === void 0 ? void 0 : _a[col]) === null || _b === void 0 ? void 0 : _b.read;
180
+ if (mapper) {
181
+ try {
182
+ result[col] = await mapper(snapCell(idx), { row: env.row, columnName: col, columnIndex: idx, getCell });
183
+ }
184
+ catch (err) {
185
+ const wrapped = new Error(`[SmartTable] toJSON({ atomic: true }): column override for "${col}" failed — ${err.message}`);
186
+ wrapped.cause = err;
187
+ throw wrapped;
188
+ }
189
+ }
190
+ else if (idx < snapshot.cells.length) {
191
+ result[col] = snapshot.cells[idx].text;
192
+ }
193
+ }
194
+ await computeSyntheticColumns(env, result, columns);
195
+ return result;
196
+ }
197
+ finally {
198
+ if (hasAnyOverride)
199
+ await page.evaluate(id => { var _a; return (_a = document.getElementById(id)) === null || _a === void 0 ? void 0 : _a.remove(); }, snapId);
200
+ }
201
+ }
202
+ // ─── Live read ───────────────────────────────────────────────────────────────
203
+ /**
204
+ * #366 / #392: on virtualized tables the row's DOM node can recycle between the
205
+ * per-column awaits of a live read. When a self-healing locator is in use (the row locator was
206
+ * rebuilt with a CSS selector, e.g. `[data-rowindex="47"]`), the locator automatically
207
+ * re-queries the DOM — no rescan needed. For plain-number strategies (JS-only, no
208
+ * selector), fall back to rescan-then-scrollToRow.
209
+ *
210
+ * Returns `resolveStableRow` (call before each column) and `current` (last known-good row).
211
+ */
212
+ function createRowPinner(env) {
213
+ const { config, rowLocator, rowIndex } = env;
214
+ const resolveRI = config.strategies.resolveRowIndex;
215
+ const canPin = !!resolveRI && typeof rowIndex === 'number' && env.row._selfHealing !== true;
216
+ let lastGood = rowLocator;
217
+ const resolveStableRow = async () => {
218
+ // Self-healing locator: always re-queries the DOM by attribute — no rescan needed.
219
+ if (!canPin)
220
+ return rowLocator;
221
+ const result = await resolveRI(lastGood);
222
+ if (result !== undefined && (0, rowResolution_1.normalizeRowIndexResult)(result).index === rowIndex)
223
+ return lastGood; // still correct
224
+ // Drifted (recycled). Re-locate the row carrying the expected logical index.
225
+ lastGood = await relocateRecycledRow(env, rowIndex);
226
+ return lastGood;
227
+ };
228
+ return { resolveStableRow, current: () => lastGood };
229
+ }
230
+ /** Mounted row whose logical index is `rowIndex` (strict: resolveRowIndex errors propagate). */
231
+ async function rescanStrict(env, rowIndex) {
232
+ const resolveRI = env.config.strategies.resolveRowIndex;
233
+ const rows = await env.resolve(env.config.rowSelector, env.rootLocator).all();
234
+ for (const r of rows) {
235
+ const rResult = await resolveRI(r);
236
+ if (rResult !== undefined && (0, rowResolution_1.normalizeRowIndexResult)(rResult).index === rowIndex)
237
+ return r;
238
+ }
239
+ return null;
240
+ }
241
+ /** Live-read recovery: rescan, then (outside batches) scrollToRow and rescan once more. */
242
+ async function relocateRecycledRow(env, rowIndex) {
243
+ var _a;
244
+ const { config, rootLocator, resolve } = env;
245
+ const inBatch = isBatchRow(env.row);
246
+ let recovered = await rescanStrict(env, rowIndex);
247
+ const scrollToRow = (_a = config.strategies.viewport) === null || _a === void 0 ? void 0 : _a.scrollToRow;
248
+ if (!recovered && !inBatch && scrollToRow) {
249
+ await scrollToRow({ root: rootLocator, config, page: rootLocator.page(), resolve }, rowIndex);
250
+ recovered = await rescanStrict(env, rowIndex);
251
+ }
252
+ if (recovered)
253
+ return recovered;
254
+ throw new Error(`[SmartTable] toJSON: row ${rowIndex} recycled out of the DOM mid-read and could not be recovered — ` +
255
+ `the returned object would mix fields from different rows. ` +
256
+ recycledRowHint(inBatch, `(During a map/forEach batch, scroll-back recovery is disabled to avoid disrupting sibling rows; the row must stay mounted for the read.)`, `Ensure a viewport.scrollToRow can restore the row.`));
257
+ }
258
+ function cellIn(env, row, colName, colIdx) {
259
+ const { config, rootLocator } = env;
260
+ if (config.strategies.getCellLocator) {
261
+ return config.strategies.getCellLocator({
262
+ row, root: rootLocator, columnName: colName,
263
+ columnIndex: colIdx, rowIndex: env.rowIndex, page: rootLocator.page(), config,
264
+ });
265
+ }
266
+ return env.resolve(config.cellSelector, row).nth(colIdx);
267
+ }
268
+ /** undefined = unset (read as-is immediately); 0 = no wait (immediate check); >0 = wait up to N ms */
269
+ function cellLoadingTimeoutOf(config) {
270
+ var _a;
271
+ const raw = (_a = config.strategies.loading) === null || _a === void 0 ? void 0 : _a.cellLoadingTimeout;
272
+ return raw !== undefined && Number.isFinite(raw) && raw >= 0 ? raw : undefined;
273
+ }
274
+ /** Polls `check` every `intervalMs` until it returns true or `deadline` passes. */
275
+ async function pollUntil(page, deadline, intervalMs, check, initial) {
276
+ let done = initial;
277
+ while (!done && Date.now() < deadline) {
278
+ await page.waitForTimeout(intervalMs);
279
+ done = await check();
280
+ }
281
+ return done;
282
+ }
283
+ /**
284
+ * First-paint race: the cell node can attach a tick before the loading
285
+ * indicator mounts. A single false-negative then reads "" and skips
286
+ * onCellLoadingTimeout. Only grace when the cell still looks empty.
287
+ */
288
+ async function graceForLoadingIndicator(page, targetCell, isLoading, timeout) {
289
+ const peek = await targetCell.evaluate((el) => (el.textContent || '').trim()).catch(() => '');
290
+ if (peek !== '')
291
+ return false;
292
+ return pollUntil(page, Date.now() + Math.min(250, timeout), 25, isLoading, false);
293
+ }
294
+ /** What to do when a cell is still loading after `cellLoadingTimeout`. */
295
+ async function onStillLoading(env, targetCell, col, timeout) {
296
+ var _a;
297
+ var _b;
298
+ const { config, row: smart } = env;
299
+ const onCellLoadingTimeout = (_b = (_a = config.strategies.loading) === null || _a === void 0 ? void 0 : _a.onCellLoadingTimeout) !== null && _b !== void 0 ? _b : 'read-as-is';
300
+ (0, debugUtils_1.logDebug)(config, 'verbose', `toJSON: cell "${col}" — still loading after ${timeout}ms, action: ${typeof onCellLoadingTimeout === 'function' ? 'callback' : onCellLoadingTimeout}`);
301
+ if (typeof onCellLoadingTimeout === 'function') {
302
+ return { read: false, value: await onCellLoadingTimeout(targetCell, col, smart) };
303
+ }
304
+ if (onCellLoadingTimeout === 'skip')
305
+ return { read: false, value: '' };
306
+ if (onCellLoadingTimeout === 'throw') {
307
+ throw new Error(`[SmartTable] Cell "${col}" (row ${env.rowIndex}) did not finish loading within ${timeout}ms`);
308
+ }
309
+ // 'read-as-is': fall through to normal read
310
+ return { read: true };
311
+ }
312
+ /**
313
+ * Applies `loading.isCellLoading` / `cellLoadingTimeout` / `onCellLoadingTimeout` to one cell.
314
+ * Returns `{ read: true }` to read the cell normally, or the value to use instead.
315
+ */
316
+ async function waitForCellLoaded(env, targetCell, col) {
317
+ var _a;
318
+ const { config, row: smart } = env;
319
+ const isCellLoading = (_a = config.strategies.loading) === null || _a === void 0 ? void 0 : _a.isCellLoading;
320
+ if (!isCellLoading)
321
+ return { read: true };
322
+ const page = env.rootLocator.page();
323
+ const isLoading = () => isCellLoading(targetCell, col, smart);
324
+ const timeout = cellLoadingTimeoutOf(config);
325
+ let loading = await isLoading();
326
+ if (!loading && timeout !== undefined && timeout > 0) {
327
+ loading = await graceForLoadingIndicator(page, targetCell, isLoading, timeout);
328
+ }
329
+ if (!loading || timeout === undefined)
330
+ return { read: true };
331
+ (0, debugUtils_1.logDebug)(config, 'verbose', `toJSON: cell "${col}" — waiting up to ${timeout}ms`);
332
+ const deadline = Date.now() + timeout;
333
+ const isLoaded = async () => !(await isLoading());
334
+ const cellResolved = await pollUntil(page, deadline, 100, isLoaded, await isLoaded()); // immediate check (handles timeout=0)
335
+ return cellResolved ? { read: true } : onStillLoading(env, targetCell, col, timeout);
336
+ }
337
+ /**
338
+ * Default `toJSON()`: reads each column from the live DOM, navigating to it first
339
+ * (keeps synchronized batches in lock-step) and re-pinning the row before each read.
340
+ */
341
+ async function readRowLive(env, columns) {
342
+ var _a, _b, _c;
343
+ const { config, map, rootLocator, rowIndex } = env;
344
+ const result = {};
345
+ const page = rootLocator.page();
346
+ const getHeaderCell = headerCellGetter(env);
347
+ const pinner = createRowPinner(env);
348
+ // getCell for column overrides: a live cell Locator on the last-known-good row,
349
+ // as fresh as possible, but raw (not override-processed).
350
+ const getCellLive = (colName) => {
351
+ const colIdx = map.get(colName);
352
+ if (colIdx === undefined)
353
+ throw new Error(`[SmartTable] getCell: column "${colName}" not found`);
354
+ return cellIn(env, pinner.current(), colName, colIdx);
355
+ };
356
+ for (const [col, idx] of map.entries()) {
357
+ if (columns && !columns.includes(col))
358
+ continue;
359
+ // Re-pin to the correct logical row before reading this column (#366).
360
+ const stableRow = await pinner.resolveStableRow();
361
+ const mapper = (_b = (_a = config.columnOverrides) === null || _a === void 0 ? void 0 : _a[col]) === null || _b === void 0 ? void 0 : _b.read;
362
+ const cell = cellIn(env, stableRow, col, idx);
363
+ // Always navigate to keep Lock-Step synchronization even if the cell is
364
+ // already present (navigateToCell handles the barrier check-in).
365
+ const navigatedCell = await (0, cellNavigation_1.navigateToCell)({
366
+ config,
367
+ rootLocator,
368
+ page,
369
+ resolve: env.resolve,
370
+ getHeaders: (_c = env.table) === null || _c === void 0 ? void 0 : _c.getHeaders,
371
+ column: col,
372
+ index: idx,
373
+ rowLocator: stableRow,
374
+ rowIndex,
375
+ renderWindowPosition: env.renderWindowPosition,
376
+ barrier: env.row._barrier,
377
+ allowMissingCell: !!mapper,
378
+ });
379
+ const targetCell = navigatedCell !== null && navigatedCell !== void 0 ? navigatedCell : cell;
380
+ // Fires for BOTH columnOverrides.read and the default innerText path.
381
+ if (config.strategies.beforeCellRead) {
382
+ await config.strategies.beforeCellRead({
383
+ cell: targetCell,
384
+ columnName: col,
385
+ columnIndex: idx,
386
+ row: stableRow,
387
+ page,
388
+ root: rootLocator,
389
+ getHeaderCell,
390
+ });
391
+ }
392
+ const loadOutcome = await waitForCellLoaded(env, targetCell, col);
393
+ if (!loadOutcome.read) {
394
+ result[col] = loadOutcome.value;
395
+ continue;
396
+ }
397
+ if (mapper) {
398
+ result[col] = await mapper(targetCell, { row: env.row, columnName: col, columnIndex: idx, getCell: getCellLive });
399
+ }
400
+ else {
401
+ const text = await targetCell.innerText();
402
+ result[col] = (text || '').trim();
403
+ }
404
+ }
405
+ await computeSyntheticColumns(env, result, columns);
406
+ return result;
407
+ }
@@ -21,10 +21,10 @@ import type { FinalTableConfig } from '../types';
21
21
  * | findRow | yes | options.useBulkPagination | yes | DOM/post filters only |
22
22
  * | countRows | yes | no (single-step) | yes | count-only / post filters |
23
23
  * | async iterator | yes | no | via runMap path | same as map |
24
- * | findRowByIndex | no* | no | n/a | scroll/search, not a full scan |
24
+ * | findRowByIndex | no | no | no | stops once the target index is mounted* |
25
25
  * | bringIntoView | n/a | path planner | n/a | not a page scan |
26
26
  *
27
- * \* findRowByIndex stops when the target index is mounted; it does not collect rows.
27
+ * \* findRowByIndex only falls back to scanPages after the mounted / viewport.scrollToRow paths miss.
28
28
  */
29
29
  export type ScanDecision = 'continue' | 'stop';
30
30
  export interface ScanPageContext {
@@ -52,6 +52,11 @@ export interface ScanPagesOptions {
52
52
  * Matches runMap / findRows EOF behavior. @default true
53
53
  */
54
54
  finalScanOnEof?: boolean;
55
+ /**
56
+ * Checked before each page and before each advance. When aborted, the scan throws
57
+ * `signal.reason` instead of returning partial results (#488).
58
+ */
59
+ signal?: AbortSignal;
55
60
  }
56
61
  export declare function scanPages(env: ScanPagesEnv, onPage: (ctx: ScanPageContext) => Promise<ScanDecision>, options: ScanPagesOptions): Promise<{
57
62
  pagesScanned: number;
@@ -2,6 +2,12 @@
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.scanPages = scanPages;
4
4
  const debugUtils_1 = require("../utils/debugUtils");
5
+ function throwIfAborted(env, label, signal) {
6
+ if (!(signal === null || signal === void 0 ? void 0 : signal.aborted))
7
+ return;
8
+ (0, debugUtils_1.logDebug)(env.config, 'info', `${label}: aborted via AbortSignal at page ${env.getCurrentPageIndex()}`);
9
+ throw signal.reason;
10
+ }
5
11
  async function scanPages(env, onPage, options) {
6
12
  var _a, _b;
7
13
  const maxPages = options.maxPages;
@@ -12,14 +18,17 @@ async function scanPages(env, onPage, options) {
12
18
  let reachedEnd = false;
13
19
  (0, debugUtils_1.logDebug)(env.config, 'verbose', `${label}: starting (maxPages=${maxPages}, useBulk=${useBulk}, finalScanOnEof=${finalScanOnEof})`);
14
20
  while (true) {
21
+ throwIfAborted(env, label, options.signal);
15
22
  if (env.waitForReady) {
16
23
  await env.waitForReady();
24
+ throwIfAborted(env, label, options.signal);
17
25
  }
18
26
  const decision = await onPage({
19
27
  pagesScanned,
20
28
  pageIndex: env.getCurrentPageIndex(),
21
29
  isFinalScan: reachedEnd,
22
30
  });
31
+ throwIfAborted(env, label, options.signal);
23
32
  if (decision === 'stop') {
24
33
  (0, debugUtils_1.logDebug)(env.config, 'verbose', `${label}: onPage returned stop at page ${env.getCurrentPageIndex()}`);
25
34
  break;
@@ -27,8 +36,10 @@ async function scanPages(env, onPage, options) {
27
36
  if (pagesScanned >= maxPages || reachedEnd) {
28
37
  break;
29
38
  }
39
+ throwIfAborted(env, label, options.signal);
30
40
  const prevPage = env.getCurrentPageIndex();
31
41
  const didAdvance = await env.advancePage(useBulk);
42
+ throwIfAborted(env, label, options.signal);
32
43
  if (!didAdvance) {
33
44
  if (finalScanOnEof) {
34
45
  (0, debugUtils_1.logDebug)(env.config, 'verbose', `${label}: pagination returned false — final scan`);
@@ -0,0 +1,11 @@
1
+ import type { Page } from '@playwright/test';
2
+ import type { FinalTableConfig, StrategyContext } from '../types';
3
+ export interface SortingEnv<T = any> {
4
+ config: FinalTableConfig<T>;
5
+ getPage: () => Page;
6
+ /** Strategy context including `getHeaderCell`. */
7
+ getContext: () => StrategyContext;
8
+ }
9
+ /** Drives the sorting strategy until `columnName` reports `direction`, retrying up to 3 times. */
10
+ export declare function applySort<T>(env: SortingEnv<T>, columnName: string, direction: 'asc' | 'desc'): Promise<void>;
11
+ export declare function getSortState<T>(env: SortingEnv<T>, columnName: string): Promise<'asc' | 'desc' | 'none'>;
@@ -0,0 +1,51 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.applySort = applySort;
4
+ exports.getSortState = getSortState;
5
+ const debugUtils_1 = require("../utils/debugUtils");
6
+ const loadingWait_1 = require("../utils/loadingWait");
7
+ const MAX_SORT_ATTEMPTS = 3;
8
+ function requireSorting(config) {
9
+ if (!config.strategies.sorting)
10
+ throw new Error('No sorting strategy has been configured.');
11
+ return config.strategies.sorting;
12
+ }
13
+ /** Waits for the table to settle after `doSort`: `isTableLoading` when configured, otherwise a fixed delay. */
14
+ async function waitForSortToSettle(env, context) {
15
+ var _a, _b, _c, _d;
16
+ const loading = env.config.strategies.loading;
17
+ if (loading === null || loading === void 0 ? void 0 : loading.isTableLoading) {
18
+ const timeout = (_b = (_a = loading.sortStabilizationTimeout) !== null && _a !== void 0 ? _a : loading.loadingTimeout) !== null && _b !== void 0 ? _b : 10000;
19
+ const pollInterval = (_c = loading.sortStabilizationPollInterval) !== null && _c !== void 0 ? _c : 100;
20
+ await (0, loadingWait_1.waitWhileTableLoading)(env.config, context, env.getPage(), 'sorting.apply', { timeout, pollInterval });
21
+ }
22
+ else {
23
+ await env.getPage().waitForTimeout((_d = loading === null || loading === void 0 ? void 0 : loading.sortStabilizationFallbackDelay) !== null && _d !== void 0 ? _d : 200);
24
+ }
25
+ }
26
+ /** Drives the sorting strategy until `columnName` reports `direction`, retrying up to 3 times. */
27
+ async function applySort(env, columnName, direction) {
28
+ const sorting = requireSorting(env.config);
29
+ (0, debugUtils_1.logDebug)(env.config, 'verbose', `Applying sort for column "${columnName}" (${direction})`);
30
+ const context = env.getContext();
31
+ for (let i = 0; i < MAX_SORT_ATTEMPTS; i++) {
32
+ const currentState = await sorting.getSortState({ columnName, context });
33
+ if (currentState === direction) {
34
+ (0, debugUtils_1.logDebug)(env.config, 'verbose', `Sort for "${columnName}" is already "${direction}".`);
35
+ return;
36
+ }
37
+ await sorting.doSort({ columnName, direction, context });
38
+ await waitForSortToSettle(env, context);
39
+ await (0, debugUtils_1.debugDelay)(env.config, 'default');
40
+ const newState = await sorting.getSortState({ columnName, context });
41
+ if (newState === direction) {
42
+ (0, debugUtils_1.logDebug)(env.config, 'verbose', `Successfully sorted "${columnName}" to "${direction}".`);
43
+ return;
44
+ }
45
+ }
46
+ throw new Error(`Failed to sort column "${columnName}" to "${direction}" after ${MAX_SORT_ATTEMPTS} attempts.`);
47
+ }
48
+ async function getSortState(env, columnName) {
49
+ const sorting = requireSorting(env.config);
50
+ return sorting.getSortState({ columnName, context: env.getContext() });
51
+ }
@@ -45,8 +45,9 @@ async function runMap(env, callback, options = {}, label = 'map') {
45
45
  let rowIndex = 0;
46
46
  let stopped = false;
47
47
  let stoppedIndex = Infinity;
48
+ // In parallel/synchronized mode rows can call stop() out of order; the earliest row wins.
48
49
  const stop = (idx) => {
49
- if (!stopped) {
50
+ if (idx < stoppedIndex) {
50
51
  log(env.config, `${label}: stop() called at row ${idx} — halting`);
51
52
  stopped = true;
52
53
  stoppedIndex = idx;
@@ -195,6 +196,7 @@ async function runMap(env, callback, options = {}, label = 'map') {
195
196
  useBulk,
196
197
  label,
197
198
  finalScanOnEof: true,
199
+ signal: options.signal,
198
200
  });
199
201
  log(env.config, `${label}: complete — ${results.length} result(s) across ${pagesScanned} page(s)`);
200
202
  }
package/dist/index.d.ts CHANGED
@@ -1,6 +1,6 @@
1
1
  export { PLAYWRIGHT_SMART_TABLE_VERSION } from './packageVersion';
2
2
  export { useTable } from './useTable';
3
- export type { TableConfig, TableResult, SmartRow, SmartCell, Selector, TableSelector, FilterValue, PaginationPrimitives, SortingStrategy, FillOptions, RowIterationContext, RowIterationOptions, TableContext, StrategyContext, BeforeCellReadFn, GetCellLocatorFn, GetActiveCellFn, DebugConfig, SyntheticColumnDef, ColumnOverride, ColumnOverrideReadContext, TableStrategies, LoadingStrategy, ViewportStrategy, FilterStrategy, NavigationPrimitives, DedupeStrategy, ContentReadyStrategy, FillStrategy, PaginationStrategy, HeaderStrategy, } from './types';
3
+ export type { TableConfig, TableResult, SmartRow, SmartCell, Selector, TableSelector, FilterValue, PaginationPrimitives, SortingStrategy, FillOptions, RowIterationContext, RowIterationOptions, TableContext, StrategyContext, BeforeCellReadFn, GetCellLocatorFn, GetActiveCellFn, DebugConfig, SyntheticColumnDef, ColumnOverride, ColumnOverrideReadContext, TableStrategies, LoadingStrategy, ViewportStrategy, FilterStrategy, NavigationPrimitives, DedupeStrategy, ContentReadyStrategy, RowReadyPredicate, FillStrategy, PaginationStrategy, HeaderStrategy, } from './types';
4
4
  export { Strategies } from './strategies';
5
5
  export * as presets from './presets';
6
6
  /**