@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.
@@ -13,13 +13,18 @@ export declare class RowFinder<T = any> {
13
13
  private tableState;
14
14
  private advancePage;
15
15
  private resolve;
16
- constructor(rootLocator: Locator, config: FinalTableConfig, resolve: (item: Selector, parent: Locator | Page) => Locator, filterEngine: FilterEngine, tableMapper: TableMapper, makeSmartRow: (loc: Locator, map: Map<string, number>, index: number | undefined, tablePageIndex?: number, barrier?: NavigationBarrier) => SmartRow<T>, tableState?: {
16
+ constructor(rootLocator: Locator, config: FinalTableConfig, resolve: (item: Selector, parent: Locator | Page) => Locator, filterEngine: FilterEngine, tableMapper: TableMapper, makeSmartRow: (loc: Locator, map: Map<string, number>, index: number | undefined, tablePageIndex?: number, barrier?: NavigationBarrier, rowSelector?: string) => SmartRow<T>, tableState?: {
17
17
  currentPageIndex: number;
18
18
  }, advancePage?: (useBulk: boolean) => Promise<boolean>);
19
+ private splitFilters;
20
+ static matchReadValue(readValue: string, filterValue: FilterValue, exact: boolean): boolean;
21
+ matchesOverrideFilters(rowLocator: Locator, overrideFilters: Record<string, FilterValue>, map: Map<string, number>, exact: boolean): Promise<boolean>;
22
+ matchesSyntheticFilters(rowLocator: Locator, syntheticFilters: Record<string, FilterValue>, map: Map<string, number>, exact: boolean): Promise<boolean>;
19
23
  findRow(filters: Record<string, FilterValue>, options?: {
20
24
  exact?: boolean;
21
25
  maxPages?: number;
22
26
  }): Promise<SmartRow<T>>;
27
+ private waitForTableReady;
23
28
  findRows(filters?: Record<string, FilterValue>, options?: {
24
29
  exact?: boolean;
25
30
  maxPages?: number;
@@ -36,5 +41,6 @@ export declare class RowFinder<T = any> {
36
41
  * same selector/scope as everywhere else, so a string, function, or Locator rowSelector
37
42
  * all behave identically. (#350)
38
43
  */
44
+ private throwIfAmbiguous;
39
45
  private scanDomPosition;
40
46
  }
@@ -18,6 +18,73 @@ class RowFinder {
18
18
  this.advancePage = advancePage;
19
19
  this.resolve = resolve;
20
20
  }
21
+ splitFilters(filters) {
22
+ var _a, _b, _c;
23
+ const domFilters = {};
24
+ const overrideFilters = {};
25
+ const syntheticFilters = {};
26
+ for (const [key, value] of Object.entries(filters)) {
27
+ if ((_a = this.config.syntheticColumns) === null || _a === void 0 ? void 0 : _a[key]) {
28
+ syntheticFilters[key] = value;
29
+ }
30
+ else if ((_c = (_b = this.config.columnOverrides) === null || _b === void 0 ? void 0 : _b[key]) === null || _c === void 0 ? void 0 : _c.read) {
31
+ overrideFilters[key] = value;
32
+ }
33
+ else {
34
+ domFilters[key] = value;
35
+ }
36
+ }
37
+ return { domFilters, overrideFilters, syntheticFilters };
38
+ }
39
+ static matchReadValue(readValue, filterValue, exact) {
40
+ if (typeof filterValue === 'function') {
41
+ throw new Error(`[SmartTable] Function filters are not supported for columns with columnOverrides.read. ` +
42
+ `Use a string, number, or RegExp filter instead.`);
43
+ }
44
+ if (typeof filterValue === 'string' || typeof filterValue === 'number') {
45
+ const target = String(filterValue);
46
+ return exact ? readValue === target : readValue.includes(target);
47
+ }
48
+ if (filterValue instanceof RegExp) {
49
+ return filterValue.test(readValue);
50
+ }
51
+ return true;
52
+ }
53
+ async matchesOverrideFilters(rowLocator, overrideFilters, map, exact) {
54
+ for (const [colName, filterValue] of Object.entries(overrideFilters)) {
55
+ const colIndex = map.get(colName);
56
+ if (colIndex === undefined)
57
+ continue;
58
+ const override = this.config.columnOverrides[colName];
59
+ const cell = this.resolve(this.config.cellSelector, rowLocator).nth(colIndex);
60
+ const getCell = (name) => {
61
+ const idx = map.get(name);
62
+ if (idx === undefined)
63
+ throw new Error(`Column "${name}" not found`);
64
+ return this.resolve(this.config.cellSelector, rowLocator).nth(idx);
65
+ };
66
+ const context = {
67
+ row: this.makeSmartRow(rowLocator, map, undefined),
68
+ columnName: colName,
69
+ columnIndex: colIndex,
70
+ getCell,
71
+ };
72
+ const readValue = String(await override.read(cell, context));
73
+ if (!RowFinder.matchReadValue(readValue, filterValue, exact))
74
+ return false;
75
+ }
76
+ return true;
77
+ }
78
+ async matchesSyntheticFilters(rowLocator, syntheticFilters, map, exact) {
79
+ const smartRow = this.makeSmartRow(rowLocator, map, undefined);
80
+ for (const [colName, filterValue] of Object.entries(syntheticFilters)) {
81
+ const def = this.config.syntheticColumns[colName];
82
+ const computedValue = String(await def.compute(smartRow));
83
+ if (!RowFinder.matchReadValue(computedValue, filterValue, exact))
84
+ return false;
85
+ }
86
+ return true;
87
+ }
21
88
  async findRow(filters, options = {}) {
22
89
  (0, debugUtils_1.logDebug)(this.config, 'info', 'Searching for row', filters);
23
90
  await this.tableMapper.getMap();
@@ -26,8 +93,8 @@ class RowFinder {
26
93
  (0, debugUtils_1.logDebug)(this.config, 'info', 'Row found');
27
94
  await (0, debugUtils_1.debugDelay)(this.config, 'findRow');
28
95
  const map = await this.tableMapper.getMap();
29
- const rowIndex = await this.resolveRowIndex(rowLocator);
30
- return this.makeSmartRow(rowLocator, map, rowIndex, this.tableState.currentPageIndex);
96
+ const resolved = await this.resolveRowIndex(rowLocator);
97
+ 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);
31
98
  }
32
99
  (0, debugUtils_1.logDebug)(this.config, 'error', 'Row not found', filters);
33
100
  await (0, debugUtils_1.debugDelay)(this.config, 'findRow');
@@ -37,6 +104,22 @@ class RowFinder {
37
104
  smartRow[sentinel_1.SENTINEL_ROW] = true;
38
105
  return smartRow;
39
106
  }
107
+ async waitForTableReady() {
108
+ var _a;
109
+ const isTableLoading = (_a = this.config.strategies.loading) === null || _a === void 0 ? void 0 : _a.isTableLoading;
110
+ if (!isTableLoading)
111
+ return;
112
+ const context = {
113
+ root: this.rootLocator,
114
+ config: this.config,
115
+ page: this.rootLocator.page(),
116
+ resolve: this.resolve
117
+ };
118
+ while (await isTableLoading(context)) {
119
+ (0, debugUtils_1.logDebug)(this.config, 'verbose', 'Table is loading... waiting');
120
+ await this.rootLocator.page().waitForTimeout(200);
121
+ }
122
+ }
40
123
  async findRows(filters = {}, options) {
41
124
  var _a, _b, _c;
42
125
  const filtersRecord = filters;
@@ -47,12 +130,15 @@ class RowFinder {
47
130
  (0, debugUtils_1.logDebug)(this.config, 'verbose', `findRows: starting (maxPages=${effectiveMaxPages}, filters=${JSON.stringify(filtersRecord)})`);
48
131
  const tracker = new elementTracker_1.ElementTracker('findRows');
49
132
  try {
133
+ await this.waitForTableReady();
134
+ const { domFilters, overrideFilters, syntheticFilters } = this.splitFilters(filtersRecord);
135
+ const hasOverrideFilters = Object.keys(overrideFilters).length > 0;
136
+ const hasSyntheticFilters = Object.keys(syntheticFilters).length > 0;
50
137
  const collectMatches = async () => {
51
- var _a, _b;
138
+ var _a, _b, _c, _d;
52
139
  let rowLocators = this.resolve(this.config.rowSelector, this.rootLocator);
53
- // Only apply filters if we have them
54
- if (Object.keys(filtersRecord).length > 0) {
55
- rowLocators = this.filterEngine.applyFilters(rowLocators, filtersRecord, map, (_a = options === null || options === void 0 ? void 0 : options.exact) !== null && _a !== void 0 ? _a : false, this.rootLocator.page(), this.rootLocator);
140
+ if (Object.keys(domFilters).length > 0) {
141
+ rowLocators = this.filterEngine.applyFilters(rowLocators, domFilters, map, (_a = options === null || options === void 0 ? void 0 : options.exact) !== null && _a !== void 0 ? _a : false, this.rootLocator.page(), this.rootLocator);
56
142
  }
57
143
  // Get only newly seen matched rows
58
144
  const newIndices = await tracker.getUnseenIndices(rowLocators);
@@ -62,8 +148,8 @@ class RowFinder {
62
148
  const useBarrier = this.config.concurrency === 'synchronized' && newIndices.length > 1;
63
149
  const barrier = useBarrier ? new navigationBarrier_1.NavigationBarrier(newIndices.length) : undefined;
64
150
  for (const idx of newIndices) {
65
- const rowIndex = await (0, rowResolution_1.resolveLogicalRowIndex)(currentRows[idx], this.config, () => allRows.length);
66
- const smartRow = this.makeSmartRow(currentRows[idx], map, rowIndex, this.tableState.currentPageIndex, barrier);
151
+ const resolved = await (0, rowResolution_1.resolveLogicalRowIndex)(currentRows[idx], this.config, () => allRows.length);
152
+ 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);
67
153
  // findRows skips a still-loading row when no timeout is configured (legacy
68
154
  // behavior) — see resolveRowLoading's `noTimeoutAction: 'skip'`.
69
155
  const loadingOutcome = await (0, rowResolution_1.resolveRowLoading)(smartRow, this.config.strategies.loading, 'skip', (msg) => (0, debugUtils_1.logDebug)(this.config, 'verbose', `findRows: ${msg}`));
@@ -74,6 +160,14 @@ class RowFinder {
74
160
  }
75
161
  continue; // 'skip'
76
162
  }
163
+ 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)) {
164
+ barrier === null || barrier === void 0 ? void 0 : barrier.markFinished();
165
+ continue;
166
+ }
167
+ 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)) {
168
+ barrier === null || barrier === void 0 ? void 0 : barrier.markFinished();
169
+ continue;
170
+ }
77
171
  allRows.push(smartRow);
78
172
  added++;
79
173
  }
@@ -91,7 +185,8 @@ class RowFinder {
91
185
  const prevPage = this.tableState.currentPageIndex;
92
186
  const didPaginate = await this.advancePage(useBulk);
93
187
  if (!didPaginate) {
94
- (0, debugUtils_1.logDebug)(this.config, 'verbose', `findRows: pagination returned false — end of data`);
188
+ (0, debugUtils_1.logDebug)(this.config, 'verbose', `findRows: pagination returned false — final scan`);
189
+ await collectMatches();
95
190
  break;
96
191
  }
97
192
  const pagesJumped = this.tableState.currentPageIndex - prevPage;
@@ -108,52 +203,50 @@ class RowFinder {
108
203
  return (0, smartRowArray_1.createSmartRowArray)(allRows);
109
204
  }
110
205
  async findRowLocator(filters, options = {}) {
111
- var _a, _b, _c;
206
+ var _a, _b;
112
207
  const map = await this.tableMapper.getMap();
113
208
  const effectiveMaxPages = (_a = options.maxPages) !== null && _a !== void 0 ? _a : this.config.maxPages;
114
209
  let pagesScanned = 1;
115
210
  (0, debugUtils_1.logDebug)(this.config, 'verbose', `Looking for row: ${JSON.stringify(filters)} (MaxPages: ${effectiveMaxPages})`);
116
211
  while (true) {
117
- // Check Loading
118
- if ((_b = this.config.strategies.loading) === null || _b === void 0 ? void 0 : _b.isTableLoading) {
119
- const isLoading = await this.config.strategies.loading.isTableLoading({
120
- root: this.rootLocator,
121
- config: this.config,
122
- page: this.rootLocator.page(),
123
- resolve: this.resolve
124
- });
125
- if (isLoading) {
126
- (0, debugUtils_1.logDebug)(this.config, 'verbose', 'Table is loading... waiting');
127
- await this.rootLocator.page().waitForTimeout(200);
128
- continue;
129
- }
130
- }
212
+ await this.waitForTableReady();
131
213
  const allRows = this.resolve(this.config.rowSelector, this.rootLocator);
132
- const matchedRows = this.filterEngine.applyFilters(allRows, filters, map, options.exact || false, this.rootLocator.page(), this.rootLocator);
133
- const count = await matchedRows.count();
134
- (0, debugUtils_1.logDebug)(this.config, 'verbose', `Page ${this.tableState.currentPageIndex}: Found ${count} matches.`);
135
- if (count > 1) {
136
- const sampleData = [];
137
- try {
138
- const firstFewRows = await matchedRows.all();
139
- const sampleCount = Math.min(firstFewRows.length, 3);
140
- for (let i = 0; i < sampleCount; i++) {
141
- const rowData = await this.makeSmartRow(firstFewRows[i], map, 0, this.tableState.currentPageIndex).toJSON();
142
- sampleData.push(JSON.stringify(rowData));
143
- }
144
- }
145
- catch (e) { }
146
- const sampleMsg = sampleData.length > 0 ? `\nSample matching rows:\n${sampleData.map((d, i) => ` ${i + 1}. ${d}`).join('\n')}` : '';
147
- throw new Error(`Ambiguous Row: Found ${count} rows matching ${JSON.stringify(filters)} on page ${this.tableState.currentPageIndex}. ` +
148
- `Expected exactly one match. Try adding more filters to make your query unique.${sampleMsg}`);
214
+ const { domFilters, overrideFilters, syntheticFilters } = this.splitFilters(filters);
215
+ const hasPostFilters = Object.keys(overrideFilters).length > 0 || Object.keys(syntheticFilters).length > 0;
216
+ let matchedRows = allRows;
217
+ if (Object.keys(domFilters).length > 0) {
218
+ matchedRows = this.filterEngine.applyFilters(allRows, domFilters, map, options.exact || false, this.rootLocator.page(), this.rootLocator);
219
+ }
220
+ if (!hasPostFilters) {
221
+ const count = await matchedRows.count();
222
+ (0, debugUtils_1.logDebug)(this.config, 'verbose', `Page ${this.tableState.currentPageIndex}: Found ${count} matches.`);
223
+ if (count > 1)
224
+ await this.throwIfAmbiguous(await matchedRows.all(), filters, map);
225
+ if (count === 1)
226
+ return matchedRows.first();
227
+ }
228
+ else {
229
+ const candidates = await matchedRows.all();
230
+ (0, debugUtils_1.logDebug)(this.config, 'verbose', `Page ${this.tableState.currentPageIndex}: ${candidates.length} DOM candidate(s), post-filtering with override/synthetic columns`);
231
+ const results = await Promise.all(candidates.map(async (c) => {
232
+ if (Object.keys(overrideFilters).length > 0 && !await this.matchesOverrideFilters(c, overrideFilters, map, options.exact || false))
233
+ return false;
234
+ if (Object.keys(syntheticFilters).length > 0 && !await this.matchesSyntheticFilters(c, syntheticFilters, map, options.exact || false))
235
+ return false;
236
+ return true;
237
+ }));
238
+ const postFilterMatches = candidates.filter((_, i) => results[i]);
239
+ (0, debugUtils_1.logDebug)(this.config, 'verbose', `Page ${this.tableState.currentPageIndex}: ${postFilterMatches.length} match(es) after post-filter`);
240
+ if (postFilterMatches.length > 1)
241
+ await this.throwIfAmbiguous(postFilterMatches, filters, map);
242
+ if (postFilterMatches.length === 1)
243
+ return postFilterMatches[0];
149
244
  }
150
- if (count === 1)
151
- return matchedRows.first();
152
245
  if (pagesScanned < effectiveMaxPages) {
153
246
  (0, debugUtils_1.logDebug)(this.config, 'verbose', `Page ${this.tableState.currentPageIndex}: Not found. Attempting pagination...`);
154
247
  // Default to single-step goNext; bulk is opt-in via useBulkPagination: true (#349).
155
248
  // Bulk-by-default made findRow jump past the page holding the target row.
156
- const useBulk = options.useBulkPagination === true && !!((_c = this.config.strategies.pagination) === null || _c === void 0 ? void 0 : _c.goNextBulk);
249
+ const useBulk = options.useBulkPagination === true && !!((_b = this.config.strategies.pagination) === null || _b === void 0 ? void 0 : _b.goNextBulk);
157
250
  const prevPage = this.tableState.currentPageIndex;
158
251
  const didLoadMore = await this.advancePage(useBulk);
159
252
  if (didLoadMore) {
@@ -164,7 +257,9 @@ class RowFinder {
164
257
  continue;
165
258
  }
166
259
  else {
167
- (0, debugUtils_1.logDebug)(this.config, 'verbose', `Page ${this.tableState.currentPageIndex}: Pagination failed (end of data).`);
260
+ (0, debugUtils_1.logDebug)(this.config, 'verbose', `Page ${this.tableState.currentPageIndex}: pagination returned false — final scan`);
261
+ pagesScanned = effectiveMaxPages;
262
+ continue;
168
263
  }
169
264
  }
170
265
  return null;
@@ -183,6 +278,20 @@ class RowFinder {
183
278
  * same selector/scope as everywhere else, so a string, function, or Locator rowSelector
184
279
  * all behave identically. (#350)
185
280
  */
281
+ async throwIfAmbiguous(rows, filters, map) {
282
+ const sampleData = [];
283
+ try {
284
+ const sampleCount = Math.min(rows.length, 3);
285
+ for (let i = 0; i < sampleCount; i++) {
286
+ const rowData = await this.makeSmartRow(rows[i], map, 0, this.tableState.currentPageIndex).toJSON();
287
+ sampleData.push(JSON.stringify(rowData));
288
+ }
289
+ }
290
+ catch (e) { }
291
+ const sampleMsg = sampleData.length > 0 ? `\nSample matching rows:\n${sampleData.map((d, i) => ` ${i + 1}. ${d}`).join('\n')}` : '';
292
+ throw new Error(`Ambiguous Row: Found ${rows.length} rows matching ${JSON.stringify(filters)} on page ${this.tableState.currentPageIndex}. ` +
293
+ `Expected exactly one match. Try adding more filters to make your query unique.${sampleMsg}`);
294
+ }
186
295
  async scanDomPosition(rowLocator) {
187
296
  const targetHandle = await rowLocator.elementHandle();
188
297
  if (!targetHandle)
@@ -1,5 +1,12 @@
1
1
  import type { Locator } from '@playwright/test';
2
- import type { FinalTableConfig, LoadingStrategy, SmartRow } from '../types';
2
+ import type { FinalTableConfig, LoadingStrategy, RowIndexResult, SmartRow } from '../types';
3
+ /** Normalized result from resolveRowIndex — always has `.index`, optionally `.selector`. */
4
+ export interface ResolvedRowIndex {
5
+ index: number;
6
+ selector?: string;
7
+ }
8
+ /** Extract the numeric index from a {@link RowIndexResult}. */
9
+ export declare function normalizeRowIndexResult(result: RowIndexResult): ResolvedRowIndex;
3
10
  /**
4
11
  * Single source of truth for "what is this row's logical index?" (#362 consolidation).
5
12
  *
@@ -7,10 +14,10 @@ import type { FinalTableConfig, LoadingStrategy, SmartRow } from '../types';
7
14
  * otherwise, and when the strategy returns `undefined`, defers to the caller-provided
8
15
  * `fallback` — a running counter for iteration/`findRows`, or a DOM-position scan for `findRow`.
9
16
  *
10
- * This replaces the three inline variants that previously answered the index question
11
- * (findRow's private method, findRows' inline expression, and — later — map's counter).
17
+ * Returns a {@link ResolvedRowIndex} with the numeric index and, when the strategy provides
18
+ * one, a CSS selector for building a self-healing row locator.
12
19
  */
13
- export declare function resolveLogicalRowIndex(row: Locator, config: Pick<FinalTableConfig, 'strategies'>, fallback: () => number | undefined | Promise<number | undefined>): Promise<number | undefined>;
20
+ export declare function resolveLogicalRowIndex(row: Locator, config: Pick<FinalTableConfig, 'strategies'>, fallback: () => number | undefined | Promise<number | undefined>): Promise<ResolvedRowIndex | undefined>;
14
21
  /** What the caller should do with a row after checking its loading state. */
15
22
  export type RowLoadingOutcome = 'process' | 'skip' | 'throw';
16
23
  /**
@@ -1,7 +1,12 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.normalizeRowIndexResult = normalizeRowIndexResult;
3
4
  exports.resolveLogicalRowIndex = resolveLogicalRowIndex;
4
5
  exports.resolveRowLoading = resolveRowLoading;
6
+ /** Extract the numeric index from a {@link RowIndexResult}. */
7
+ function normalizeRowIndexResult(result) {
8
+ return typeof result === 'number' ? { index: result } : result;
9
+ }
5
10
  /**
6
11
  * Single source of truth for "what is this row's logical index?" (#362 consolidation).
7
12
  *
@@ -9,18 +14,18 @@ exports.resolveRowLoading = resolveRowLoading;
9
14
  * otherwise, and when the strategy returns `undefined`, defers to the caller-provided
10
15
  * `fallback` — a running counter for iteration/`findRows`, or a DOM-position scan for `findRow`.
11
16
  *
12
- * This replaces the three inline variants that previously answered the index question
13
- * (findRow's private method, findRows' inline expression, and — later — map's counter).
17
+ * Returns a {@link ResolvedRowIndex} with the numeric index and, when the strategy provides
18
+ * one, a CSS selector for building a self-healing row locator.
14
19
  */
15
20
  async function resolveLogicalRowIndex(row, config, fallback) {
16
21
  const strategy = config.strategies.resolveRowIndex;
17
22
  if (strategy) {
18
23
  const resolved = await strategy(row);
19
24
  if (resolved !== undefined)
20
- return resolved;
21
- // Strategy returned undefined — fall through to the caller's fallback.
25
+ return normalizeRowIndexResult(resolved);
22
26
  }
23
- return fallback();
27
+ const fb = await fallback();
28
+ return fb !== undefined ? { index: fb } : undefined;
24
29
  }
25
30
  /**
26
31
  * Single source of truth for the per-row loading wait (#362 consolidation).
@@ -7,7 +7,7 @@ export interface TableIterationEnv<T = any> {
7
7
  getRowLocators: () => Locator;
8
8
  getMap: () => Map<string, number>;
9
9
  advancePage: (useBulk: boolean) => Promise<boolean>;
10
- makeSmartRow: (rowLocator: Locator, map: Map<string, number>, rowIndex: number, tablePageIndex?: number, barrier?: NavigationBarrier) => SmartRow<T>;
10
+ makeSmartRow: (rowLocator: Locator, map: Map<string, number>, rowIndex: number, tablePageIndex?: number, barrier?: NavigationBarrier, rowSelector?: string) => SmartRow<T>;
11
11
  createSmartRowArray: (rows: SmartRow<T>[]) => SmartRowArray<T>;
12
12
  config: FinalTableConfig<T>;
13
13
  getPage: () => Page;
@@ -37,11 +37,13 @@ async function runMap(env, callback, options = {}, label = 'map') {
37
37
  const tracker = new elementTracker_1.ElementTracker(label);
38
38
  log(env.config, `${label}: starting (maxPages=${effectiveMaxPages}, mode=${concurrency}, dedupe=${!!dedupeStrategy})`);
39
39
  const results = [];
40
+ const seenLogicalIndices = new Set();
40
41
  try {
41
42
  let rowIndex = 0;
42
43
  let stopped = false;
43
44
  let stoppedIndex = Infinity;
44
45
  let pagesScanned = 1;
46
+ let reachedEnd = false;
45
47
  const stop = (idx) => {
46
48
  if (!stopped) {
47
49
  log(env.config, `${label}: stop() called at row ${idx} — halting`);
@@ -58,11 +60,19 @@ async function runMap(env, callback, options = {}, label = 'map') {
58
60
  // they're picked up on a later page once scrolled into view — preventing overscan from
59
61
  // being ingested (#353) or double-collected via node recycling (#357). No-op when no
60
62
  // viewport reports visible rows.
63
+ //
64
+ // #384: overscan rows ABOVE the visible range have already scrolled past and will never
65
+ // re-enter the viewport — collect them now. Only defer rows BELOW the visible range
66
+ // (they'll scroll into view on a later page).
67
+ //
68
+ // #417 follow-up: skip viewport filtering on the final scan — there are no more pages
69
+ // to defer to, so collect all remaining unseen rows regardless of visibility.
61
70
  let candidateIndices = allIndices;
62
71
  const getVisibleRowIndices = (_f = env.config.strategies.viewport) === null || _f === void 0 ? void 0 : _f.getVisibleRowIndices;
63
- if (getVisibleRowIndices) {
72
+ if (getVisibleRowIndices && !reachedEnd) {
64
73
  const visible = new Set(await getVisibleRowIndices(env.getContext()));
65
- candidateIndices = allIndices.filter(idx => visible.has(idx));
74
+ const minVisible = visible.size > 0 ? Math.min(...visible) : Infinity;
75
+ candidateIndices = allIndices.filter(idx => visible.has(idx) || idx < minVisible);
66
76
  if (candidateIndices.length !== allIndices.length) {
67
77
  log(env.config, `${label}: viewport visible filter — ${candidateIndices.length}/${allIndices.length} row(s) in view`);
68
78
  }
@@ -90,8 +100,9 @@ async function runMap(env, callback, options = {}, label = 'map') {
90
100
  // correct on virtualized tables); otherwise it equals the enumeration counter.
91
101
  const smartRows = await Promise.all(newIndices.map(async (idx, i) => {
92
102
  var _a;
93
- const logicalIndex = (_a = await (0, rowResolution_1.resolveLogicalRowIndex)(pageRows[idx], env.config, () => positionBase + i)) !== null && _a !== void 0 ? _a : (positionBase + i);
94
- const sr = env.makeSmartRow(pageRows[idx], map, logicalIndex, env.getCurrentPageIndex(), barrier);
103
+ const resolved = await (0, rowResolution_1.resolveLogicalRowIndex)(pageRows[idx], env.config, () => positionBase + i);
104
+ const logicalIndex = (_a = resolved === null || resolved === void 0 ? void 0 : resolved.index) !== null && _a !== void 0 ? _a : (positionBase + i);
105
+ const sr = env.makeSmartRow(pageRows[idx], map, logicalIndex, env.getCurrentPageIndex(), barrier, resolved === null || resolved === void 0 ? void 0 : resolved.selector);
95
106
  // Mark as part of an iteration batch so toJSON's #366 re-pin uses rescan-only recovery
96
107
  // (no scroll-back) — scrolling here would disrupt sibling rows' positional locators.
97
108
  sr._inBatch = true;
@@ -104,6 +115,13 @@ async function runMap(env, callback, options = {}, label = 'map') {
104
115
  if (stopped && enumIndex > stoppedIndex) {
105
116
  return SKIP;
106
117
  }
118
+ // When resolveRowIndex is configured, identical logical indices across pages
119
+ // indicate the same record (e.g. re-created DOM elements in recycling
120
+ // virtualizers whose WeakMap entry was GC'd). Skip without processing.
121
+ if (row.rowIndex !== undefined && seenLogicalIndices.has(row.rowIndex)) {
122
+ log(env.config, `${label}: skipping duplicate logical row ${row.rowIndex}`);
123
+ return SKIP;
124
+ }
107
125
  // Wait for the row to finish loading BEFORE evaluating its dedupe key (Bug #355).
108
126
  // map/forEach/filter process a still-loading row when no timeout is set (unlike
109
127
  // findRows, which skips) — see resolveRowLoading's `noTimeoutAction`.
@@ -113,13 +131,13 @@ async function runMap(env, callback, options = {}, label = 'map') {
113
131
  if (loadingOutcome === 'throw') {
114
132
  throw new Error(`[SmartTable] Row ${row.rowIndex} did not finish loading within ${(_a = env.config.strategies.loading) === null || _a === void 0 ? void 0 : _a.rowLoadingTimeout}ms`);
115
133
  }
134
+ let dedupeKey;
116
135
  if (dedupeKeys && dedupeStrategy) {
117
- const key = await dedupeStrategy(row);
118
- if (dedupeKeys.has(key)) {
119
- log(env.config, `${label}: dedupe skip key="${key}"`);
136
+ dedupeKey = await dedupeStrategy(row);
137
+ if (dedupeKeys.has(dedupeKey)) {
138
+ log(env.config, `${label}: dedupe skip key="${dedupeKey}"`);
120
139
  return SKIP;
121
140
  }
122
- dedupeKeys.add(key);
123
141
  }
124
142
  // Execute callback (optionally serialized via mutex)
125
143
  const runCallback = async () => {
@@ -128,12 +146,16 @@ async function runMap(env, callback, options = {}, label = 'map') {
128
146
  log(env.config, `${label}: processing row (index ${enumIndex}, rowIndex ${row.rowIndex})`);
129
147
  return await callback({ row, index: enumIndex, rowIndex: row.rowIndex, pageIndex: env.getCurrentPageIndex(), stop: () => stop(enumIndex) });
130
148
  };
131
- if (actionMutex) {
132
- return await actionMutex.run(runCallback);
149
+ const result = actionMutex
150
+ ? await actionMutex.run(runCallback)
151
+ : await runCallback();
152
+ if (dedupeKeys && dedupeKey !== undefined && result !== SKIP) {
153
+ dedupeKeys.add(dedupeKey);
133
154
  }
134
- else {
135
- return await runCallback();
155
+ if (row.rowIndex !== undefined && result !== SKIP) {
156
+ seenLogicalIndices.add(row.rowIndex);
136
157
  }
158
+ return result;
137
159
  }
138
160
  finally {
139
161
  // Ensure the barrier is notified once per row, even on error or skip.
@@ -159,12 +181,13 @@ async function runMap(env, callback, options = {}, label = 'map') {
159
181
  }
160
182
  rowIndex += batchSize;
161
183
  }
162
- if (stopped || pagesScanned >= effectiveMaxPages)
184
+ if (stopped || pagesScanned >= effectiveMaxPages || reachedEnd)
163
185
  break;
164
186
  log(env.config, `${label}: advancing to next page (${pagesScanned} → ${pagesScanned + 1})`);
165
187
  if (!await env.advancePage(useBulk)) {
166
- log(env.config, `${label}: no more pages — done`);
167
- break;
188
+ log(env.config, `${label}: pagination returned false — final scan`);
189
+ reachedEnd = true;
190
+ continue;
168
191
  }
169
192
  pagesScanned++;
170
193
  }
@@ -14,7 +14,7 @@ class TableMapper {
14
14
  (0, debugUtils_1.logDebug)(this.config, 'verbose', msg);
15
15
  }
16
16
  async getMap(timeout) {
17
- var _a;
17
+ var _a, _b;
18
18
  if (this._headerMap)
19
19
  return this._headerMap;
20
20
  this.log('Mapping headers...');
@@ -56,8 +56,15 @@ class TableMapper {
56
56
  try {
57
57
  const rawHeaders = await strategy(context);
58
58
  const entries = await this.processHeaders(rawHeaders);
59
- // Success
60
- this._headerMap = new Map(entries);
59
+ // Success — validate before caching so a collision doesn't leave stale state
60
+ const headerMap = new Map(entries);
61
+ const syntheticNames = Object.keys((_b = this.config.syntheticColumns) !== null && _b !== void 0 ? _b : {});
62
+ const collisions = syntheticNames.filter(name => headerMap.has(name));
63
+ if (collisions.length > 0) {
64
+ throw new Error(`Synthetic column name(s) collide with real header(s): ${collisions.join(', ')}. ` +
65
+ `Rename the synthetic column or use columnOverrides for columns that exist in the DOM.`);
66
+ }
67
+ this._headerMap = headerMap;
61
68
  this.log(`Mapped ${entries.length} columns: ${JSON.stringify(entries.map(e => e[0]))}`);
62
69
  return this._headerMap;
63
70
  }
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, FilterValue, PaginationPrimitives, SortingStrategy, FillOptions, RowIterationContext, RowIterationOptions, TableContext, StrategyContext, BeforeCellReadFn, GetCellLocatorFn, GetActiveCellFn, DebugConfig, } from './types';
3
+ export type { TableConfig, TableResult, SmartRow, SmartCell, Selector, FilterValue, PaginationPrimitives, SortingStrategy, FillOptions, RowIterationContext, RowIterationOptions, TableContext, StrategyContext, BeforeCellReadFn, GetCellLocatorFn, GetActiveCellFn, DebugConfig, SyntheticColumnDef, ColumnOverride, ColumnOverrideReadContext, } from './types';
4
4
  export { Strategies } from './strategies';
5
5
  export * as presets from './presets';
6
6
  /** @deprecated Use `presets` instead. Plugins will be removed in v7.0.0. */
@@ -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.19.0";
6
+ export declare const PLAYWRIGHT_SMART_TABLE_VERSION: "6.20.1-next.19d3cd3";
@@ -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.19.0";
9
+ exports.PLAYWRIGHT_SMART_TABLE_VERSION = "6.20.1-next.19d3cd3";
@@ -23,7 +23,8 @@ export declare const Plugins: {
23
23
  debug?: import("..").DebugConfig | undefined;
24
24
  onReset?: ((context: import("..").TableContext) => Promise<void>) | undefined;
25
25
  strategies?: import("../types").TableStrategies | undefined;
26
- columnOverrides?: Partial<Record<string | number | symbol, import("../types").ColumnOverride<any>>> | undefined;
26
+ columnOverrides?: Partial<Record<string | number | symbol, import("..").ColumnOverride<any>>> | undefined;
27
+ syntheticColumns?: Record<string, import("..").SyntheticColumnDef<any>> | undefined;
27
28
  emptyState?: import("@playwright/test").Locator | undefined;
28
29
  };
29
30
  /** @deprecated Use `presets.glide` */
@@ -44,7 +45,8 @@ export declare const Plugins: {
44
45
  debug?: import("..").DebugConfig | undefined;
45
46
  onReset?: ((context: import("..").TableContext) => Promise<void>) | undefined;
46
47
  strategies?: import("../types").TableStrategies | undefined;
47
- columnOverrides?: Partial<Record<string | number | symbol, import("../types").ColumnOverride<any>>> | undefined;
48
+ columnOverrides?: Partial<Record<string | number | symbol, import("..").ColumnOverride<any>>> | undefined;
49
+ syntheticColumns?: Record<string, import("..").SyntheticColumnDef<any>> | undefined;
48
50
  emptyState?: import("@playwright/test").Locator | undefined;
49
51
  };
50
52
  /** @deprecated Use `presets.muiDataGrid` */
@@ -65,7 +67,8 @@ export declare const Plugins: {
65
67
  debug?: import("..").DebugConfig | undefined;
66
68
  onReset?: ((context: import("..").TableContext) => Promise<void>) | undefined;
67
69
  strategies?: import("../types").TableStrategies | undefined;
68
- columnOverrides?: Partial<Record<string | number | symbol, import("../types").ColumnOverride<any>>> | undefined;
70
+ columnOverrides?: Partial<Record<string | number | symbol, import("..").ColumnOverride<any>>> | undefined;
71
+ syntheticColumns?: Record<string, import("..").SyntheticColumnDef<any>> | undefined;
69
72
  emptyState?: import("@playwright/test").Locator | undefined;
70
73
  };
71
74
  };
@@ -1,6 +1,7 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.scrollRightHeader = void 0;
4
+ const debugUtils_1 = require("../../utils/debugUtils");
4
5
  /**
5
6
  * Scans for headers by finding a scrollable container and setting scrollLeft.
6
7
  */
@@ -47,10 +48,12 @@ const scrollRightHeader = async (context, options) => {
47
48
  }
48
49
  }
49
50
  else {
50
- console.warn("HeaderStrategies.scrollRight: Could not find scroller. Returning visible headers.");
51
+ (0, debugUtils_1.logDebug)(config, 'info', "HeaderStrategies.scrollRight: Could not find scroller. Returning visible headers.");
52
+ }
53
+ if (isScrollerFound) {
54
+ await scrollerHandle.evaluate(el => el.scrollLeft = 0);
55
+ await page.waitForTimeout(200);
51
56
  }
52
- await scrollerHandle.evaluate(el => el.scrollLeft = 0);
53
- await page.waitForTimeout(200);
54
57
  return Array.from(collectedHeaders);
55
58
  };
56
59
  exports.scrollRightHeader = scrollRightHeader;
@@ -200,7 +200,9 @@ function createMuiDataGrid(opts) {
200
200
  strategies: {
201
201
  resolveRowIndex: async (row) => {
202
202
  const v = await row.getAttribute('data-rowindex').catch(() => null);
203
- return v !== null && !isNaN(Number(v)) ? Number(v) : undefined;
203
+ if (v === null || isNaN(Number(v)))
204
+ return undefined;
205
+ return { index: Number(v), selector: `[data-rowindex="${v}"]` };
204
206
  },
205
207
  pagination: {
206
208
  goNext: async (context) => {