From d41c91697e5c0befdca01cae967dc4c8a41a4a0f Mon Sep 17 00:00:00 2001 From: rickcedwhat-ai Date: Tue, 28 Jul 2026 19:25:05 -0400 Subject: [PATCH 1/2] feat(filtering): support columnOverrides.read in findRow/findRows/countRows (#385) Columns with a `read` override are split out of the DOM filter chain and post-filtered in Node.js by evaluating the override's `read` function. DOM filters still use Playwright's fast locator chain; override filters run after, matching the read value against string/number/RegExp filters. Co-Authored-By: Claude Opus 4.6 --- src/engine/rowFinder.ts | 154 +++++++++++++++++++++++++++------- src/useTable.ts | 62 ++++++++++++-- tests/override-filter.spec.ts | 74 ++++++++++++++++ 3 files changed, 254 insertions(+), 36 deletions(-) create mode 100644 tests/override-filter.spec.ts diff --git a/src/engine/rowFinder.ts b/src/engine/rowFinder.ts index 17e3c32b..eac8c126 100644 --- a/src/engine/rowFinder.ts +++ b/src/engine/rowFinder.ts @@ -25,6 +25,56 @@ export class RowFinder { this.resolve = resolve; } + private splitFilters(filters: Record): { + domFilters: Record; + overrideFilters: Record; + } { + const domFilters: Record = {}; + const overrideFilters: Record = {}; + for (const [key, value] of Object.entries(filters)) { + if (this.config.columnOverrides?.[key as keyof T]?.read) { + overrideFilters[key] = value; + } else { + domFilters[key] = value; + } + } + return { domFilters, overrideFilters }; + } + + private async matchesOverrideFilters( + rowLocator: Locator, + overrideFilters: Record, + map: Map, + exact: boolean + ): Promise { + for (const [colName, filterValue] of Object.entries(overrideFilters)) { + const colIndex = map.get(colName); + if (colIndex === undefined) continue; + const override = this.config.columnOverrides![colName as keyof T]!; + const cell = this.resolve(this.config.cellSelector, rowLocator).nth(colIndex); + const getCell = (name: string) => { + const idx = map.get(name); + if (idx === undefined) throw new Error(`Column "${name}" not found`); + return this.resolve(this.config.cellSelector, rowLocator).nth(idx); + }; + const context = { + row: this.makeSmartRow(rowLocator, map, undefined), + columnName: colName, + columnIndex: colIndex, + getCell, + }; + const readValue = String(await override.read!(cell, context)); + + if (typeof filterValue === 'string' || typeof filterValue === 'number') { + const target = String(filterValue); + if (exact ? readValue !== target : !readValue.includes(target)) return false; + } else if (filterValue instanceof RegExp) { + if (!filterValue.test(readValue)) return false; + } + } + return true; + } + public async findRow( filters: Record, options: { exact?: boolean, maxPages?: number } = {} @@ -68,13 +118,15 @@ export class RowFinder { const tracker = new ElementTracker('findRows'); try { + const { domFilters, overrideFilters } = this.splitFilters(filtersRecord); + const hasOverrideFilters = Object.keys(overrideFilters).length > 0; + const collectMatches = async () => { let rowLocators = this.resolve(this.config.rowSelector, this.rootLocator); - // Only apply filters if we have them - if (Object.keys(filtersRecord).length > 0) { + if (Object.keys(domFilters).length > 0) { rowLocators = this.filterEngine.applyFilters( rowLocators, - filtersRecord, + domFilters, map, options?.exact ?? false, this.rootLocator.page(), @@ -115,6 +167,11 @@ export class RowFinder { continue; // 'skip' } + if (hasOverrideFilters && !await this.matchesOverrideFilters(currentRows[idx], overrideFilters, map, options?.exact ?? false)) { + barrier?.markFinished(); + continue; + } + allRows.push(smartRow); added++; } @@ -181,37 +238,74 @@ export class RowFinder { } const allRows = this.resolve(this.config.rowSelector, this.rootLocator); - const matchedRows = this.filterEngine.applyFilters( - allRows, - filters, - map, - options.exact || false, - this.rootLocator.page(), - this.rootLocator - ); + const { domFilters, overrideFilters } = this.splitFilters(filters); + const hasOverrideFilters = Object.keys(overrideFilters).length > 0; + + let matchedRows = allRows; + if (Object.keys(domFilters).length > 0) { + matchedRows = this.filterEngine.applyFilters( + allRows, + domFilters, + map, + options.exact || false, + this.rootLocator.page(), + this.rootLocator + ); + } + + if (!hasOverrideFilters) { + const count = await matchedRows.count(); + logDebug(this.config, 'verbose',`Page ${this.tableState.currentPageIndex}: Found ${count} matches.`); + + if (count > 1) { + const sampleData: string[] = []; + try { + const firstFewRows = await matchedRows.all(); + const sampleCount = Math.min(firstFewRows.length, 3); + for (let i = 0; i < sampleCount; i++) { + const rowData = await this.makeSmartRow(firstFewRows[i], map, 0, this.tableState.currentPageIndex).toJSON(); + sampleData.push(JSON.stringify(rowData)); + } + } catch (e) { } + const sampleMsg = sampleData.length > 0 ? `\nSample matching rows:\n${sampleData.map((d, i) => ` ${i + 1}. ${d}`).join('\n')}` : ''; + + throw new Error( + `Ambiguous Row: Found ${count} rows matching ${JSON.stringify(filters)} on page ${this.tableState.currentPageIndex}. ` + + `Expected exactly one match. Try adding more filters to make your query unique.${sampleMsg}` + ); + } - const count = await matchedRows.count(); - logDebug(this.config, 'verbose',`Page ${this.tableState.currentPageIndex}: Found ${count} matches.`); - - if (count > 1) { - const sampleData: string[] = []; - try { - const firstFewRows = await matchedRows.all(); - const sampleCount = Math.min(firstFewRows.length, 3); - for (let i = 0; i < sampleCount; i++) { - const rowData = await this.makeSmartRow(firstFewRows[i], map, 0, this.tableState.currentPageIndex).toJSON(); - sampleData.push(JSON.stringify(rowData)); + if (count === 1) return matchedRows.first(); + } else { + const candidates = await matchedRows.all(); + logDebug(this.config, 'verbose',`Page ${this.tableState.currentPageIndex}: ${candidates.length} DOM candidate(s), post-filtering with override columns`); + const overrideMatches: Locator[] = []; + for (const candidate of candidates) { + if (await this.matchesOverrideFilters(candidate, overrideFilters, map, options.exact || false)) { + overrideMatches.push(candidate); } - } catch (e) { } - const sampleMsg = sampleData.length > 0 ? `\nSample matching rows:\n${sampleData.map((d, i) => ` ${i + 1}. ${d}`).join('\n')}` : ''; + } + logDebug(this.config, 'verbose',`Page ${this.tableState.currentPageIndex}: ${overrideMatches.length} match(es) after override filter`); + + if (overrideMatches.length > 1) { + const sampleData: string[] = []; + try { + const sampleCount = Math.min(overrideMatches.length, 3); + for (let i = 0; i < sampleCount; i++) { + const rowData = await this.makeSmartRow(overrideMatches[i], map, 0, this.tableState.currentPageIndex).toJSON(); + sampleData.push(JSON.stringify(rowData)); + } + } catch (e) { } + const sampleMsg = sampleData.length > 0 ? `\nSample matching rows:\n${sampleData.map((d, i) => ` ${i + 1}. ${d}`).join('\n')}` : ''; - throw new Error( - `Ambiguous Row: Found ${count} rows matching ${JSON.stringify(filters)} on page ${this.tableState.currentPageIndex}. ` + - `Expected exactly one match. Try adding more filters to make your query unique.${sampleMsg}` - ); - } + throw new Error( + `Ambiguous Row: Found ${overrideMatches.length} rows matching ${JSON.stringify(filters)} on page ${this.tableState.currentPageIndex}. ` + + `Expected exactly one match. Try adding more filters to make your query unique.${sampleMsg}` + ); + } - if (count === 1) return matchedRows.first(); + if (overrideMatches.length === 1) return overrideMatches[0]; + } if (pagesScanned < effectiveMaxPages) { logDebug(this.config, 'verbose',`Page ${this.tableState.currentPageIndex}: Not found. Attempting pagination...`); diff --git a/src/useTable.ts b/src/useTable.ts index 36615130..746fdb6f 100644 --- a/src/useTable.ts +++ b/src/useTable.ts @@ -331,19 +331,67 @@ export const useTable = (rootLocator: Locator, configOptions: TableConf const pag = config.strategies.pagination; const hasPagination = effectiveMaxPages > 1 && !!(pag?.goNext || pag?.goNextBulk); + const domFilters: Record = {}; + const overrideFilters: Record = {}; + if (hasFilters) { + for (const [key, value] of Object.entries(filtersRecord)) { + if (config.columnOverrides?.[key as keyof T]?.read) { + overrideFilters[key] = value; + } else { + domFilters[key] = value; + } + } + } + const hasOverrideFilters = Object.keys(overrideFilters).length > 0; + const resolveRows = () => { let rows = resolve(config.rowSelector, rootLocator); - if (hasFilters) { + if (Object.keys(domFilters).length > 0) { const map = tableMapper.getMapSync(); if (!map) throw new Error('Initialization Error: Table map not available. Call "await table.init()" first.'); - rows = filterEngine.applyFilters(rows, filtersRecord, map, options?.exact ?? false, rootLocator.page(), rootLocator); + rows = filterEngine.applyFilters(rows, domFilters, map, options?.exact ?? false, rootLocator.page(), rootLocator); } return rows; }; + const countOverrideMatches = async (rowLocators: import('@playwright/test').Locator, indices: number[]): Promise => { + if (!hasOverrideFilters) return indices.length; + const map = tableMapper.getMapSync()!; + const candidates = await rowLocators.all(); + let count = 0; + for (const idx of indices) { + const row = candidates[idx]; + let match = true; + for (const [colName, filterValue] of Object.entries(overrideFilters)) { + const colIndex = map.get(colName); + if (colIndex === undefined) continue; + const override = config.columnOverrides![colName as keyof T]!; + const cell = resolve(config.cellSelector, row).nth(colIndex); + const getCell = (name: string) => { + const ci = map.get(name); + if (ci === undefined) throw new Error(`Column "${name}" not found`); + return resolve(config.cellSelector, row).nth(ci); + }; + const ctx = { row: _makeSmart(row, map, undefined), columnName: colName, columnIndex: colIndex, getCell }; + const readValue = String(await override.read!(cell, ctx)); + if (typeof filterValue === 'string' || typeof filterValue === 'number') { + const target = String(filterValue); + if (options?.exact ? readValue !== target : !readValue.includes(target)) { match = false; break; } + } else if (filterValue instanceof RegExp) { + if (!filterValue.test(readValue)) { match = false; break; } + } + } + if (match) count++; + } + return count; + }; + if (!hasPagination) { log(`countRows: counting rows in current viewport (no pagination)${hasFilters ? ` filters=${safeStringify(filtersRecord)}` : ''}`); - return resolveRows().count(); + if (!hasOverrideFilters) return resolveRows().count(); + const rows = resolveRows(); + const all = await rows.all(); + return countOverrideMatches(rows, all.map((_, i) => i)); } log(`countRows: paginating up to ${effectiveMaxPages} page(s)${hasFilters ? ` filters=${safeStringify(filtersRecord)}` : ''}`); @@ -353,9 +401,11 @@ export const useTable = (rootLocator: Locator, configOptions: TableConf let paginationError: unknown; try { while (true) { - const newIndices = await tracker.getUnseenIndices(resolveRows()); - total += newIndices.length; - log(`countRows: page ${pagesScanned} — ${newIndices.length} row(s) (running total: ${total})`); + const rows = resolveRows(); + const newIndices = await tracker.getUnseenIndices(rows); + const matched = await countOverrideMatches(rows, newIndices); + total += matched; + log(`countRows: page ${pagesScanned} — ${matched} row(s) (running total: ${total})`); if (pagesScanned >= effectiveMaxPages) break; if (!await _advancePage(false)) break; pagesScanned++; diff --git a/tests/override-filter.spec.ts b/tests/override-filter.spec.ts new file mode 100644 index 00000000..04a9dba3 --- /dev/null +++ b/tests/override-filter.spec.ts @@ -0,0 +1,74 @@ +import { test, expect } from '@playwright/test'; +import { useTable } from '../src/index'; + +const TABLE = ` + + + + + + + +
NameLinkStatus
AlphalinkActive
BetalinkActive
GammalinkInactive
+`; + +test.describe('findRow/findRows with columnOverride filters (#385)', () => { + const makeTable = (page: any) => + useTable(page.locator('#t'), { + columnOverrides: { + Link: { + read: async (cell) => { + const anchor = cell.locator('a'); + if (await anchor.count() > 0) { + return await anchor.getAttribute('href') ?? cell.innerText(); + } + return cell.innerText(); + }, + }, + }, + }); + + test('findRow filters on override-produced value', async ({ page }) => { + await page.setContent(TABLE); + const table = await makeTable(page).init(); + + const row = await table.findRow({ Link: '/d/beta' }, { exact: true }); + const data = await row.toJSON() as Record; + expect(data.Name).toBe('Beta'); + }); + + test('findRows filters on override-produced value', async ({ page }) => { + await page.setContent(TABLE); + const table = await makeTable(page).init(); + + const rows = await table.findRows({ Link: '/d/' }); + expect(rows.length).toBe(3); + }); + + test('findRow combines DOM filter + override filter', async ({ page }) => { + await page.setContent(TABLE); + const table = await makeTable(page).init(); + + const row = await table.findRow({ Status: 'Active', Link: '/d/alpha' }, { exact: true }); + const data = await row.toJSON() as Record; + expect(data.Name).toBe('Alpha'); + }); + + test('findRow throws Ambiguous when override filter matches multiple', async ({ page }) => { + await page.setContent(TABLE); + const table = await makeTable(page).init(); + + await expect( + table.findRow({ Link: '/d/' }) + ).rejects.toThrow(/Ambiguous Row/); + }); + + test('countRows with override filter counts only matching rows', async ({ page }) => { + await page.setContent(TABLE); + const table = await makeTable(page).init(); + + expect(await table.countRows({ Link: '/d/alpha' }, { exact: true })).toBe(1); + expect(await table.countRows({ Link: '/d/' })).toBe(3); + expect(await table.countRows({ Link: '/d/nonexistent' }, { exact: true })).toBe(0); + }); +}); From 136830f853d9f5cfe04d70d81ab8c2cbb4a25c4b Mon Sep 17 00:00:00 2001 From: rickcedwhat-ai Date: Tue, 28 Jul 2026 19:57:28 -0400 Subject: [PATCH 2/2] fix(filtering): harden override filter matching and deduplicate ambiguity logic MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add RowFinder.matchReadValue() — centralized value matching used by both rowFinder and countRows; throws on unsupported function-typed FilterValue - Extract throwIfAmbiguous() helper to deduplicate ambiguity error handling - Evaluate override filters concurrently via Promise.all in findRowLocator - Guard tableMapper.getMapSync() in countOverrideMatches instead of bare ! - Eliminate double DOM query in no-pagination override counting path - Type test helper parameter as Page instead of any - Strengthen findRows test to filter selectively and assert the matched row Co-Authored-By: Claude Opus 4.6 --- src/engine/rowFinder.ts | 90 ++++++++++++++++------------------- src/useTable.ts | 26 +++++----- tests/override-filter.spec.ts | 10 ++-- 3 files changed, 57 insertions(+), 69 deletions(-) diff --git a/src/engine/rowFinder.ts b/src/engine/rowFinder.ts index eac8c126..ff5fd72c 100644 --- a/src/engine/rowFinder.ts +++ b/src/engine/rowFinder.ts @@ -41,6 +41,23 @@ export class RowFinder { return { domFilters, overrideFilters }; } + static matchReadValue(readValue: string, filterValue: FilterValue, exact: boolean): boolean { + if (typeof filterValue === 'function') { + throw new Error( + `[SmartTable] Function filters are not supported for columns with columnOverrides.read. ` + + `Use a string, number, or RegExp filter instead.` + ); + } + if (typeof filterValue === 'string' || typeof filterValue === 'number') { + const target = String(filterValue); + return exact ? readValue === target : readValue.includes(target); + } + if (filterValue instanceof RegExp) { + return filterValue.test(readValue); + } + return true; + } + private async matchesOverrideFilters( rowLocator: Locator, overrideFilters: Record, @@ -64,13 +81,7 @@ export class RowFinder { getCell, }; const readValue = String(await override.read!(cell, context)); - - if (typeof filterValue === 'string' || typeof filterValue === 'number') { - const target = String(filterValue); - if (exact ? readValue !== target : !readValue.includes(target)) return false; - } else if (filterValue instanceof RegExp) { - if (!filterValue.test(readValue)) return false; - } + if (!RowFinder.matchReadValue(readValue, filterValue, exact)) return false; } return true; } @@ -256,54 +267,17 @@ export class RowFinder { if (!hasOverrideFilters) { const count = await matchedRows.count(); logDebug(this.config, 'verbose',`Page ${this.tableState.currentPageIndex}: Found ${count} matches.`); - - if (count > 1) { - const sampleData: string[] = []; - try { - const firstFewRows = await matchedRows.all(); - const sampleCount = Math.min(firstFewRows.length, 3); - for (let i = 0; i < sampleCount; i++) { - const rowData = await this.makeSmartRow(firstFewRows[i], map, 0, this.tableState.currentPageIndex).toJSON(); - sampleData.push(JSON.stringify(rowData)); - } - } catch (e) { } - const sampleMsg = sampleData.length > 0 ? `\nSample matching rows:\n${sampleData.map((d, i) => ` ${i + 1}. ${d}`).join('\n')}` : ''; - - throw new Error( - `Ambiguous Row: Found ${count} rows matching ${JSON.stringify(filters)} on page ${this.tableState.currentPageIndex}. ` + - `Expected exactly one match. Try adding more filters to make your query unique.${sampleMsg}` - ); - } - + if (count > 1) await this.throwIfAmbiguous(await matchedRows.all(), filters, map); if (count === 1) return matchedRows.first(); } else { const candidates = await matchedRows.all(); logDebug(this.config, 'verbose',`Page ${this.tableState.currentPageIndex}: ${candidates.length} DOM candidate(s), post-filtering with override columns`); - const overrideMatches: Locator[] = []; - for (const candidate of candidates) { - if (await this.matchesOverrideFilters(candidate, overrideFilters, map, options.exact || false)) { - overrideMatches.push(candidate); - } - } + const results = await Promise.all( + candidates.map(c => this.matchesOverrideFilters(c, overrideFilters, map, options.exact || false)) + ); + const overrideMatches = candidates.filter((_, i) => results[i]); logDebug(this.config, 'verbose',`Page ${this.tableState.currentPageIndex}: ${overrideMatches.length} match(es) after override filter`); - - if (overrideMatches.length > 1) { - const sampleData: string[] = []; - try { - const sampleCount = Math.min(overrideMatches.length, 3); - for (let i = 0; i < sampleCount; i++) { - const rowData = await this.makeSmartRow(overrideMatches[i], map, 0, this.tableState.currentPageIndex).toJSON(); - sampleData.push(JSON.stringify(rowData)); - } - } catch (e) { } - const sampleMsg = sampleData.length > 0 ? `\nSample matching rows:\n${sampleData.map((d, i) => ` ${i + 1}. ${d}`).join('\n')}` : ''; - - throw new Error( - `Ambiguous Row: Found ${overrideMatches.length} rows matching ${JSON.stringify(filters)} on page ${this.tableState.currentPageIndex}. ` + - `Expected exactly one match. Try adding more filters to make your query unique.${sampleMsg}` - ); - } - + if (overrideMatches.length > 1) await this.throwIfAmbiguous(overrideMatches, filters, map); if (overrideMatches.length === 1) return overrideMatches[0]; } @@ -345,6 +319,22 @@ export class RowFinder { * same selector/scope as everywhere else, so a string, function, or Locator rowSelector * all behave identically. (#350) */ + private async throwIfAmbiguous(rows: Locator[], filters: Record, map: Map): Promise { + const sampleData: string[] = []; + try { + const sampleCount = Math.min(rows.length, 3); + for (let i = 0; i < sampleCount; i++) { + const rowData = await this.makeSmartRow(rows[i], map, 0, this.tableState.currentPageIndex).toJSON(); + sampleData.push(JSON.stringify(rowData)); + } + } catch (e) { } + const sampleMsg = sampleData.length > 0 ? `\nSample matching rows:\n${sampleData.map((d, i) => ` ${i + 1}. ${d}`).join('\n')}` : ''; + throw new Error( + `Ambiguous Row: Found ${rows.length} rows matching ${JSON.stringify(filters)} on page ${this.tableState.currentPageIndex}. ` + + `Expected exactly one match. Try adding more filters to make your query unique.${sampleMsg}` + ); + } + private async scanDomPosition(rowLocator: Locator): Promise { const targetHandle = await rowLocator.elementHandle(); if (!targetHandle) return undefined; diff --git a/src/useTable.ts b/src/useTable.ts index 746fdb6f..7d1ae15b 100644 --- a/src/useTable.ts +++ b/src/useTable.ts @@ -354,10 +354,11 @@ export const useTable = (rootLocator: Locator, configOptions: TableConf return rows; }; - const countOverrideMatches = async (rowLocators: import('@playwright/test').Locator, indices: number[]): Promise => { + const countOverrideMatches = async (candidates: import('@playwright/test').Locator[], indices: number[]): Promise => { if (!hasOverrideFilters) return indices.length; - const map = tableMapper.getMapSync()!; - const candidates = await rowLocators.all(); + const map = tableMapper.getMapSync(); + if (!map) throw new Error('Initialization Error: Table map not available. Call "await table.init()" first.'); + const exact = options?.exact ?? false; let count = 0; for (const idx of indices) { const row = candidates[idx]; @@ -374,12 +375,7 @@ export const useTable = (rootLocator: Locator, configOptions: TableConf }; const ctx = { row: _makeSmart(row, map, undefined), columnName: colName, columnIndex: colIndex, getCell }; const readValue = String(await override.read!(cell, ctx)); - if (typeof filterValue === 'string' || typeof filterValue === 'number') { - const target = String(filterValue); - if (options?.exact ? readValue !== target : !readValue.includes(target)) { match = false; break; } - } else if (filterValue instanceof RegExp) { - if (!filterValue.test(readValue)) { match = false; break; } - } + if (!RowFinder.matchReadValue(readValue, filterValue, exact)) { match = false; break; } } if (match) count++; } @@ -389,9 +385,8 @@ export const useTable = (rootLocator: Locator, configOptions: TableConf if (!hasPagination) { log(`countRows: counting rows in current viewport (no pagination)${hasFilters ? ` filters=${safeStringify(filtersRecord)}` : ''}`); if (!hasOverrideFilters) return resolveRows().count(); - const rows = resolveRows(); - const all = await rows.all(); - return countOverrideMatches(rows, all.map((_, i) => i)); + const all = await resolveRows().all(); + return countOverrideMatches(all, all.map((_, i) => i)); } log(`countRows: paginating up to ${effectiveMaxPages} page(s)${hasFilters ? ` filters=${safeStringify(filtersRecord)}` : ''}`); @@ -401,9 +396,10 @@ export const useTable = (rootLocator: Locator, configOptions: TableConf let paginationError: unknown; try { while (true) { - const rows = resolveRows(); - const newIndices = await tracker.getUnseenIndices(rows); - const matched = await countOverrideMatches(rows, newIndices); + const rowLocators = resolveRows(); + const newIndices = await tracker.getUnseenIndices(rowLocators); + const candidates = await rowLocators.all(); + const matched = await countOverrideMatches(candidates, newIndices); total += matched; log(`countRows: page ${pagesScanned} — ${matched} row(s) (running total: ${total})`); if (pagesScanned >= effectiveMaxPages) break; diff --git a/tests/override-filter.spec.ts b/tests/override-filter.spec.ts index 04a9dba3..1352f501 100644 --- a/tests/override-filter.spec.ts +++ b/tests/override-filter.spec.ts @@ -1,4 +1,4 @@ -import { test, expect } from '@playwright/test'; +import { test, expect, type Page } from '@playwright/test'; import { useTable } from '../src/index'; const TABLE = ` @@ -13,7 +13,7 @@ const TABLE = ` `; test.describe('findRow/findRows with columnOverride filters (#385)', () => { - const makeTable = (page: any) => + const makeTable = (page: Page) => useTable(page.locator('#t'), { columnOverrides: { Link: { @@ -41,8 +41,10 @@ test.describe('findRow/findRows with columnOverride filters (#385)', () => { await page.setContent(TABLE); const table = await makeTable(page).init(); - const rows = await table.findRows({ Link: '/d/' }); - expect(rows.length).toBe(3); + const rows = await table.findRows({ Link: '/d/beta' }, { exact: true }); + expect(rows.length).toBe(1); + const data = await rows[0].toJSON() as Record; + expect(data.Name).toBe('Beta'); }); test('findRow combines DOM filter + override filter', async ({ page }) => {