diff --git a/.claude/skills/web-test/scripts/browser.mjs b/.claude/skills/web-test/scripts/browser.mjs index ed5c0814..99ab67ba 100644 --- a/.claude/skills/web-test/scripts/browser.mjs +++ b/.claude/skills/web-test/scripts/browser.mjs @@ -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; diff --git a/.claude/skills/web-test/scripts/dom.mjs b/.claude/skills/web-test/scripts/dom.mjs index 2ff64c33..36164300 100644 --- a/.claude/skills/web-test/scripts/dom.mjs +++ b/.claude/skills/web-test/scripts/dom.mjs @@ -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/grids — collect 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);