feat(web-test): add table parameter for multi-grid forms

Add semantic table binding to readTable, clickElement, fillTableRow,
and deleteTableRow — resolves the correct grid by name when a form
has multiple tables (e.g. "Входящие"/"Исходящие" in BP links).

- New resolveGridScript() in dom.mjs: cascade match by gridName → columns
- findClickTargetScript: scoped button search within grid's parent container
- getFormState: reports all grids via tables[] array (table still present for compat)
- All grids[grids.length-1] fallbacks wrapped in gridSelector ternary

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Nick Shirokov
2026-03-14 11:27:37 +03:00
parent 0ca2faa6a6
commit 1abc44334c
2 changed files with 176 additions and 43 deletions
+57 -21
View File
@@ -18,7 +18,7 @@ import {
findClickTargetScript, findFieldButtonScript, readSubmenuScript,
resolveFieldsScript, getFormStateScript,
detectFormScript, readTableScript, checkErrorsScript,
switchTabScript
switchTabScript, resolveGridScript
} from './dom.mjs';
let browser = null;
@@ -548,11 +548,17 @@ export async function getFormState() {
}
/** Read structured table data with pagination. Returns columns, rows, total count. */
export async function readTable({ maxRows = 20, offset = 0 } = {}) {
export async function readTable({ maxRows = 20, offset = 0, table } = {}) {
ensureConnected();
const formNum = await page.evaluate(detectFormScript());
if (formNum === null) throw new Error('readTable: no form found');
return await page.evaluate(readTableScript(formNum, { maxRows, offset }));
let gridSelector;
if (table) {
const resolved = await page.evaluate(resolveGridScript(formNum, table));
if (resolved.error) throw new Error(`readTable: ${resolved.message || resolved.error}. Available: ${resolved.available?.map(a => a.name).join(', ') || 'none'}`);
gridSelector = resolved.gridSelector;
}
return await page.evaluate(readTableScript(formNum, { maxRows, offset, gridSelector }));
}
/**
@@ -1435,7 +1441,7 @@ export async function fillFields(fields) {
}
/** Click a button/hyperlink/tab on the current form. Use {dblclick: true} to double-click (open items from lists). */
export async function clickElement(text, { dblclick } = {}) {
export async function clickElement(text, { dblclick, table } = {}) {
ensureConnected();
await dismissPendingErrors();
if (highlightMode) try { await highlight(text); await page.waitForTimeout(500); await unhighlight(); } catch {}
@@ -1515,8 +1521,16 @@ export async function clickElement(text, { dblclick } = {}) {
let formNum = await page.evaluate(detectFormScript());
if (formNum === null) throw new Error(`clickElement: no form found`);
// Pre-resolve grid when table is specified
let gridSelector;
if (table) {
const resolved = await page.evaluate(resolveGridScript(formNum, table));
if (resolved.error) throw new Error(`clickElement: table "${table}" not found. Available: ${resolved.available?.map(a => a.name).join(', ') || 'none'}`);
gridSelector = resolved.gridSelector;
}
// Find the target element ID
let target = await page.evaluate(findClickTargetScript(formNum, text));
let target = await page.evaluate(findClickTargetScript(formNum, text, { tableName: table, gridSelector }));
// Retry: if not found, a modal form may still be loading (e.g. after F4).
// Wait up to 2s for a new form to appear and re-detect.
@@ -1526,7 +1540,7 @@ export async function clickElement(text, { dblclick } = {}) {
const newForm = await page.evaluate(detectFormScript());
if (newForm !== null && newForm !== formNum) {
formNum = newForm;
target = await page.evaluate(findClickTargetScript(formNum, text));
target = await page.evaluate(findClickTargetScript(formNum, text, { tableName: table, gridSelector }));
if (!target?.error) break;
}
}
@@ -2062,12 +2076,20 @@ export async function selectValue(fieldName, searchText, { type } = {}) {
* @param {boolean} [options.add] - Click "Добавить" to create a new row first
* @returns {{ filled[], notFilled[]?, form }}
*/
export async function fillTableRow(fields, { tab, add, row } = {}) {
export async function fillTableRow(fields, { tab, add, row, table } = {}) {
ensureConnected();
await dismissPendingErrors();
const formNum = await page.evaluate(detectFormScript());
if (formNum === null) throw new Error('fillTableRow: no form found');
// Pre-resolve grid when table is specified
let gridSelector;
if (table) {
const resolved = await page.evaluate(resolveGridScript(formNum, table));
if (resolved.error) throw new Error(`fillTableRow: table "${table}" not found. Available: ${resolved.available?.map(a => a.name).join(', ') || 'none'}`);
gridSelector = resolved.gridSelector;
}
try {
// 1. Switch tab if requested
if (tab) {
@@ -2076,7 +2098,7 @@ export async function fillTableRow(fields, { tab, add, row } = {}) {
// 2. Add new row if requested
if (add) {
await clickElement('Добавить');
await clickElement('Добавить', { table });
// Poll for edit mode (INPUT inside grid) instead of fixed 1000ms wait
for (let aw = 0; aw < 6; aw++) {
await page.waitForTimeout(150);
@@ -2094,8 +2116,9 @@ export async function fillTableRow(fields, { tab, add, row } = {}) {
if (row != null) {
const fieldKeys = JSON.stringify(Object.keys(fields).map(k => k.toLowerCase()));
const cellCoords = await page.evaluate(`(() => {
const grids = [...document.querySelectorAll('.grid')].filter(el => el.offsetWidth > 0);
const grid = grids[grids.length - 1];
const grid = ${gridSelector
? `document.querySelector(${JSON.stringify(gridSelector)})`
: `(() => { const grids = [...document.querySelectorAll('.grid')].filter(el => el.offsetWidth > 0); return grids[grids.length - 1]; })()`};
if (!grid) return { error: 'no_grid' };
const head = grid.querySelector('.gridHead');
const body = grid.querySelector('.gridBody');
@@ -2304,8 +2327,9 @@ export async function fillTableRow(fields, { tab, add, row } = {}) {
if (info.filled) continue;
// Find column for this key and dblclick on it
const nextCoords = await page.evaluate(`(() => {
const grids = [...document.querySelectorAll('.grid')].filter(el => el.offsetWidth > 0);
const grid = grids[grids.length - 1];
const grid = ${gridSelector
? `document.querySelector(${JSON.stringify(gridSelector)})`
: `(() => { const grids = [...document.querySelectorAll('.grid')].filter(el => el.offsetWidth > 0); return grids[grids.length - 1]; })()`};
if (!grid) return null;
const head = grid.querySelector('.gridHead');
const body = grid.querySelector('.gridBody');
@@ -2383,8 +2407,9 @@ export async function fillTableRow(fields, { tab, add, row } = {}) {
// Commit the edit: click on a different row (Escape cancels in tree grids).
// Find the first visible row that is NOT the edited row and click it.
const commitCoords = await page.evaluate(`(() => {
const grids = [...document.querySelectorAll('.grid')].filter(el => el.offsetWidth > 0);
const grid = grids[grids.length - 1];
const grid = ${gridSelector
? `document.querySelector(${JSON.stringify(gridSelector)})`
: `(() => { const grids = [...document.querySelectorAll('.grid')].filter(el => el.offsetWidth > 0); return grids[grids.length - 1]; })()`};
if (!grid) return null;
const body = grid.querySelector('.gridBody');
if (!body) return null;
@@ -2937,8 +2962,9 @@ export async function fillTableRow(fields, { tab, add, row } = {}) {
// Escape (e.g. from closeForm) would cancel the entire row.
const commitTarget = await page.evaluate(`(() => {
// Find the active grid
const grids = [...document.querySelectorAll('.grid')].filter(el => el.offsetWidth > 0);
const grid = grids[grids.length - 1];
const grid = ${gridSelector
? `document.querySelector(${JSON.stringify(gridSelector)})`
: `(() => { const grids = [...document.querySelectorAll('.grid')].filter(el => el.offsetWidth > 0); return grids[grids.length - 1]; })()`};
if (!grid) return null;
const body = grid.querySelector('.gridBody');
if (!body) return null;
@@ -3005,12 +3031,20 @@ export async function fillTableRow(fields, { tab, add, row } = {}) {
* @param {string} [options.tab] - Switch to this form tab before operating
* @returns {{ deleted, rowsBefore, rowsAfter, form }}
*/
export async function deleteTableRow(row, { tab } = {}) {
export async function deleteTableRow(row, { tab, table } = {}) {
ensureConnected();
await dismissPendingErrors();
const formNum = await page.evaluate(detectFormScript());
if (formNum === null) throw new Error('deleteTableRow: no form found');
// Pre-resolve grid when table is specified
let gridSelector;
if (table) {
const resolved = await page.evaluate(resolveGridScript(formNum, table));
if (resolved.error) throw new Error(`deleteTableRow: table "${table}" not found. Available: ${resolved.available?.map(a => a.name).join(', ') || 'none'}`);
gridSelector = resolved.gridSelector;
}
// 1. Switch tab if requested
if (tab) {
await clickElement(tab);
@@ -3019,8 +3053,9 @@ export async function deleteTableRow(row, { tab } = {}) {
// 2. Find the target row and click to select it
const cellCoords = await page.evaluate(`(() => {
const grids = [...document.querySelectorAll('.grid')].filter(el => el.offsetWidth > 0);
const grid = grids[grids.length - 1];
const grid = ${gridSelector
? `document.querySelector(${JSON.stringify(gridSelector)})`
: `(() => { const grids = [...document.querySelectorAll('.grid')].filter(el => el.offsetWidth > 0); return grids[grids.length - 1]; })()`};
if (!grid) return { error: 'no_grid' };
const body = grid.querySelector('.gridBody');
if (!body) return { error: 'no_grid_body' };
@@ -3048,8 +3083,9 @@ export async function deleteTableRow(row, { tab } = {}) {
// 4. Count rows after deletion
const rowsAfter = await page.evaluate(`(() => {
const grids = [...document.querySelectorAll('.grid')].filter(el => el.offsetWidth > 0);
const grid = grids[grids.length - 1];
const grid = ${gridSelector
? `document.querySelector(${JSON.stringify(gridSelector)})`
: `(() => { const grids = [...document.querySelectorAll('.grid')].filter(el => el.offsetWidth > 0); return grids[grids.length - 1]; })()`};
if (!grid) return 0;
const body = grid.querySelector('.gridBody');
return body ? body.querySelectorAll('.gridLine').length : 0;
+119 -22
View File
@@ -185,24 +185,31 @@ const READ_FORM_FN = `function readForm(p) {
}
});
// Table/grid — pick the first VISIBLE grid (tab switching hides inactive grids)
const grid = [...document.querySelectorAll('[id^="' + p + '"].grid, [id^="' + p + '"] .grid')]
.find(g => g.offsetWidth > 0 && g.offsetHeight > 0);
if (grid) {
const head = grid.querySelector('.gridHead');
const body = grid.querySelector('.gridBody');
const columns = [];
if (head) {
const headLine = head.querySelector('.gridLine') || head;
[...headLine.children].forEach(box => {
if (box.offsetWidth === 0) return;
const textEl = box.querySelector('.gridBoxText');
const text = (textEl || box).innerText?.trim().replace(/\\n/g, ' ') || '';
if (text) columns.push(text);
});
}
const rowCount = body ? body.querySelectorAll('.gridLine').length : 0;
result.table = { present: true, columns, rowCount };
// Tables/gridscollect ALL visible grids
const allGrids = [...document.querySelectorAll('[id^="' + p + '"].grid, [id^="' + p + '"] .grid')]
.filter(g => g.offsetWidth > 0 && g.offsetHeight > 0);
if (allGrids.length > 0) {
const tables = allGrids.map(grid => {
const name = grid.id ? grid.id.replace(p, '') : '';
const head = grid.querySelector('.gridHead');
const body = grid.querySelector('.gridBody');
const columns = [];
if (head) {
const headLine = head.querySelector('.gridLine') || head;
[...headLine.children].forEach(box => {
if (box.offsetWidth === 0) return;
const textEl = box.querySelector('.gridBoxText');
const text = (textEl || box).innerText?.trim().replace(/\\n/g, ' ') || '';
if (text) columns.push(text);
});
}
const rowCount = body ? body.querySelectorAll('.gridLine').length : 0;
return { name, columns, rowCount };
});
result.tables = tables;
// Backward compat: table = first grid summary
const first = tables[0];
result.table = { present: true, columns: first.columns, rowCount: first.rowCount };
}
// Active filters (train badges above grid: *СостояниеПросмотра)
@@ -356,18 +363,73 @@ export function readFormScript(formNum) {
})()`;
}
/**
* Resolve a specific grid by semantic name (table parameter).
* Cascade: exact gridName match → gridName contains → column contains.
* Returns { gridSelector, gridId, gridName, gridIndex, columns } or { error, available }.
*/
export function resolveGridScript(formNum, tableName) {
const p = `form${formNum}_`;
return `(() => {
const p = ${JSON.stringify(p)};
const target = ${JSON.stringify(tableName.toLowerCase().replace(/ё/g, 'е'))};
const norm = s => (s || '').replace(/ё/gi, 'е');
const allGrids = [...document.querySelectorAll('[id^="' + p + '"].grid, [id^="' + p + '"] .grid')]
.filter(g => g.offsetWidth > 0 && g.offsetHeight > 0);
if (!allGrids.length) return { error: 'no_grids', message: 'No grids found on form' };
const infos = allGrids.map((g, idx) => {
const gridId = g.id || '';
const gridName = gridId.replace(p, '');
const head = g.querySelector('.gridHead');
const columns = [];
if (head) {
const headLine = head.querySelector('.gridLine') || head;
[...headLine.children].forEach(box => {
if (box.offsetWidth === 0) return;
const textEl = box.querySelector('.gridBoxText');
const text = (textEl || box).innerText?.trim().replace(/\\n/g, ' ') || '';
if (text) columns.push(text);
});
}
return { idx, gridId, gridName, columns, el: g };
});
// 1. Exact gridName match (case-insensitive)
let found = infos.find(i => norm(i.gridName).toLowerCase() === target);
// 2. gridName contains target
if (!found) found = infos.find(i => norm(i.gridName).toLowerCase().includes(target));
// 3. Any column contains target
if (!found) found = infos.find(i => i.columns.some(c => norm(c).toLowerCase().includes(target)));
if (found) {
return {
gridSelector: found.gridId ? '#' + CSS.escape(found.gridId) : null,
gridId: found.gridId,
gridName: found.gridName,
gridIndex: found.idx,
columns: found.columns
};
}
return {
error: 'not_found',
message: 'Table "' + ${JSON.stringify(tableName)} + '" not found',
available: infos.map(i => ({ name: i.gridName, columns: i.columns }))
};
})()`;
}
/**
* Read table/grid data with pagination.
* Parses grid.innerText — \n separates rows, \t separates cells.
* First row = column headers.
* Returns { name, columns[], rows[{col:val}], total, offset, shown }.
*/
export function readTableScript(formNum, { maxRows = 20, offset = 0 } = {}) {
export function readTableScript(formNum, { maxRows = 20, offset = 0, gridSelector } = {}) {
const p = `form${formNum}_`;
return `(() => {
const p = ${JSON.stringify(p)};
const grid = [...document.querySelectorAll('[id^="' + p + '"].grid, [id^="' + p + '"] .grid')]
.find(g => g.offsetWidth > 0 && g.offsetHeight > 0);
const grid = ${gridSelector
? `document.querySelector(${JSON.stringify(gridSelector)})`
: `[...document.querySelectorAll('[id^="' + p + '"].grid, [id^="' + p + '"] .grid')]
.find(g => g.offsetWidth > 0 && g.offsetHeight > 0)`};
if (!grid) return { error: 'no_table', message: 'No table found on form ${formNum}' };
const name = grid.id ? grid.id.replace(p, '') : '';
@@ -507,12 +569,14 @@ export function openCommandScript(name) {
* Supports synonym matching: visible text AND internal name from DOM ID.
* Fuzzy order: exact name -> exact label -> includes name -> includes label.
*/
export function findClickTargetScript(formNum, text) {
export function findClickTargetScript(formNum, text, { tableName, gridSelector } = {}) {
const p = `form${formNum}_`;
return `(() => {
const norm = s => (s?.trim().replace(/\\u00a0/g, ' ') || '').replace(/ё/gi, 'е');
const target = ${JSON.stringify(text.toLowerCase().replace(/ё/g, 'е'))};
const p = ${JSON.stringify(p)};
const tableName = ${JSON.stringify(tableName || '')};
const gridSelector = ${JSON.stringify(gridSelector || '')};
const items = [];
// Buttons (a.press)
@@ -561,6 +625,39 @@ export function findClickTargetScript(formNum, text) {
items.push({ id: el.id, name: el.dataset.content, label: '', kind: 'tab' });
});
// When table is specified, scope button search to grid's parent container
if (gridSelector) {
const gridEl = document.querySelector(gridSelector);
if (gridEl) {
// Find parent container that has id with formPrefix and contains the grid
let container = gridEl.parentElement;
while (container && container !== document.body) {
if (container.id && container.id.startsWith(p)) break;
container = container.parentElement;
}
// Filter items to those inside the container
const containerItems = container && container !== document.body
? items.filter(i => { const el = document.getElementById(i.id); return el && container.contains(el); })
: [];
// Try fuzzy match within container first
let cf = containerItems.find(i => i.name.toLowerCase() === target);
if (!cf) cf = containerItems.find(i => i.label && i.label.toLowerCase() === target);
if (!cf) cf = containerItems.find(i => i.name.toLowerCase().includes(target));
if (!cf) cf = containerItems.find(i => i.label && i.label.toLowerCase().includes(target));
if (cf) return { id: cf.id, kind: cf.kind, name: cf.name };
// Fallback: filter by gridName id-prefix (e.g. ИсходящиеКоманднаяПанель_Добавить)
const gridName = gridEl.id ? gridEl.id.replace(p, '') : '';
if (gridName) {
const prefixItems = items.filter(i => i.label && i.label.startsWith(gridName));
let pf = prefixItems.find(i => i.name.toLowerCase() === target);
if (!pf) pf = prefixItems.find(i => i.label && i.label.toLowerCase().includes(target));
if (!pf) pf = prefixItems.find(i => i.name.toLowerCase().includes(target));
if (pf) return { id: pf.id, kind: pf.kind, name: pf.name };
}
}
// Fall through to unscoped search
}
// Fuzzy match: exact name -> exact label -> startsWith name -> startsWith label -> includes name -> includes label
let found = items.find(i => i.name.toLowerCase() === target);
if (!found) found = items.find(i => i.label && i.label.toLowerCase() === target);