From 767b9fcaf08d46d296f06db6fccbfa25702eaa8a Mon Sep 17 00:00:00 2001 From: Nick Shirokov Date: Fri, 13 Mar 2026 19:10:31 +0300 Subject: [PATCH 1/9] fix(web-test): refactor pickFromSelectionForm + fillTableRow for tree grids and row commit MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit pickFromSelectionForm: - 3-step escalation: scan visible → simple search → advanced search (Alt+F) - Extract helpers: scanGridRows, dblclickAndVerify, advancedSearchInline - dblclickAndVerify uses click+Enter instead of dblclick (dblclick toggles tree groups) - Returns ok:false when selection form stays open (group/folder not selectable) - Distinguish not_found vs not_selectable errors - trySelect wrapper continues escalation on ok:false fillTableRow direct-edit (tree grids): - Click → dblclick → F4 escalation for entering edit mode - F4 from INPUT mode for tree grid ref fields - isTypeDialog check + pickFromTypeDialog for composite types - Commit via click on different row instead of Escape (Escape cancels in tree grids) fillTableRow regular path: - Commit new row after fill loop by clicking another row or grid header - Prevents Escape (e.g. from closeForm) from cancelling uncommitted new row - Fixes accumulated unclosed forms from closeForm failing to close Co-Authored-By: Claude Opus 4.6 --- .claude/skills/web-test/scripts/browser.mjs | 644 +++++++++++++++++--- 1 file changed, 544 insertions(+), 100 deletions(-) diff --git a/.claude/skills/web-test/scripts/browser.mjs b/.claude/skills/web-test/scripts/browser.mjs index 5483f75e..8c93774f 100644 --- a/.claude/skills/web-test/scripts/browser.mjs +++ b/.claude/skills/web-test/scripts/browser.mjs @@ -739,72 +739,50 @@ export async function readSpreadsheet() { } /** - * Pick a value from an opened selection form: filter + dblclick matching row. - * - * Strategy: - * - string: simple search via filterList → dblclick first row - * - object: advanced search (Alt+F) for each field sequentially - * (searches by specific column, efficient on large tables) → dblclick positioned row - * - * @param {number} selFormNum - selection form number - * @param {string} fieldName - field being filled (for error messages) - * @param {string|Object} search - string for simple search, or { field: value } for per-field search - * @param {number} origFormNum - original form number (to verify we returned) - * @returns {{ field, ok, method }} or {{ field, error, message }} + * Scan visible grid rows for a text match (exact → startsWith → includes). + * Returns center coords of the matched row, or null if not found. + * When searchLower is empty, returns coords of the first row (fallback). */ -async function pickFromSelectionForm(selFormNum, fieldName, search, origFormNum) { - // 1. Apply filters based on search type - if (typeof search === 'string') { - // Simple text search - if (search) { - try { - await filterList(search); - } catch (e) { - await page.keyboard.press('Escape'); - await waitForStable(); - return { field: fieldName, error: 'filter_failed', message: e.message }; - } - } - } else if (search && typeof search === 'object') { - // Per-field advanced search (Alt+F) for each entry — searches by specific column, - // more efficient than simple search on large tables (no full-text scan across all columns). - const entries = Object.entries(search); - for (const [fld, val] of entries) { - try { - await filterList(String(val), { field: fld }); - } catch (e) { - // Advanced search failed — fall through and try with what we have - } - } - } - - // 2. Find the target row — currently selected (positioned by advanced search) or first - const rowTarget = await page.evaluate(`(() => { - const p = 'form${selFormNum}_'; +async function scanGridRows(formNum, searchLower) { + return page.evaluate(`(() => { + const p = 'form${formNum}_'; const grid = document.querySelector('[id^="' + p + '"].grid, [id^="' + p + '"] .grid'); if (!grid) return null; const body = grid.querySelector('.gridBody'); if (!body) return null; const lines = [...body.querySelectorAll('.gridLine')]; if (!lines.length) return { rowCount: 0 }; - // Prefer selected/active row (positioned by advanced search), fall back to first - const sel = lines.find(l => l.classList.contains('select') || l.classList.contains('active')) || lines[0]; + const searchLower = ${JSON.stringify(searchLower || '')}; + let sel = null; + if (searchLower) { + const norm = s => (s || '').replace(/\\u00a0/g, ' ').trim().toLowerCase().replace(/ё/gi, 'е'); + const rowData = lines.map(l => ({ el: l, text: norm(l.innerText) })); + sel = rowData.find(r => r.text === searchLower)?.el + || rowData.find(r => r.text.startsWith(searchLower))?.el + || rowData.find(r => r.text.includes(searchLower))?.el; + } else { + sel = lines[0]; // empty search → first row + } + if (!sel) return null; const r = sel.getBoundingClientRect(); - return { rowCount: lines.length, matched: true, - x: Math.round(r.x + r.width / 2), y: Math.round(r.y + r.height / 2) }; + return { rowCount: lines.length, x: Math.round(r.x + r.width / 2), y: Math.round(r.y + r.height / 2) }; })()`); +} - if (!rowTarget?.matched) { - await page.keyboard.press('Escape'); - await waitForStable(); - const searchDesc = typeof search === 'string' ? '"' + search + '"' : JSON.stringify(search); - return { field: fieldName, error: 'not_found', - message: 'No matches in selection form for ' + searchDesc + - (rowTarget?.rowCount === 0 ? ' (grid empty)' : '') }; - } - - // 3. Dblclick the target row - await page.mouse.dblclick(rowTarget.x, rowTarget.y); +/** + * Select a row in a selection form via click + Enter, verify it closed. + * Uses click + Enter instead of dblclick because dblclick toggles + * expand/collapse in tree-style selection forms. + * Returns { field, ok: true, method: 'form' } on success, + * or { field, ok: false, reason: 'still_open' } if the item couldn't be selected (e.g. group row). + */ +async function dblclickAndVerify(coords, selFormNum, fieldName) { + // Click to highlight the row, then Enter to confirm selection. + // This works for both flat grids and tree forms (dblclick would + // toggle expand/collapse on tree group rows). + await page.mouse.click(coords.x, coords.y); + await page.waitForTimeout(200); + await page.keyboard.press('Enter'); await waitForStable(selFormNum); // Verify selection form closed @@ -813,19 +791,9 @@ async function pickFromSelectionForm(selFormNum, fieldName, search, origFormNum) return [...document.querySelectorAll('[id^="' + p + '"]')].some(el => el.offsetWidth > 0); })()`); if (stillOpen) { - // Dblclick may have opened a folder — try Enter to select current row - await page.keyboard.press('Enter'); - await waitForStable(selFormNum); - - // Still open? Close and report - const stillOpen2 = await page.evaluate(`(() => { - const p = 'form${selFormNum}_'; - return [...document.querySelectorAll('[id^="' + p + '"]')].some(el => el.offsetWidth > 0); - })()`); - if (stillOpen2) { - await page.keyboard.press('Escape'); - await waitForStable(); - } + // Enter didn't select — item is likely a non-selectable group. + // Don't Escape here — let the caller decide (may want to try another row). + return { field: fieldName, ok: false, reason: 'still_open' }; } // Check for 1C error modals after selection @@ -839,6 +807,187 @@ async function pickFromSelectionForm(selFormNum, fieldName, search, origFormNum) return { field: fieldName, ok: true, method: 'form' }; } +/** + * Inline advanced search on a selection form via Alt+F. + * Does NOT click any column — FieldSelector auto-populates with main representation. + * Switches to "по части строки" (CompareType#1) to avoid composite type issues. + * Does not throw — returns silently on failure. + */ +async function advancedSearchInline(formNum, text) { + try { + // 1. Open advanced search via Alt+F + await page.keyboard.press('Alt+f'); + await page.waitForTimeout(2000); + + const dialogForm = await page.evaluate(detectFormScript()); + if (dialogForm === formNum || dialogForm === null) return; // Alt+F didn't open dialog + + // 2. Switch to "по части строки" (CompareType#1) + const radioClicked = await page.evaluate(`(() => { + const p = 'form${dialogForm}_'; + const el = document.getElementById(p + 'CompareType#1#radio'); + if (!el || el.offsetWidth === 0) return false; + if (el.classList.contains('select')) return true; // already selected + const r = el.getBoundingClientRect(); + return { x: Math.round(r.x + r.width / 2), y: Math.round(r.y + r.height / 2) }; + })()`); + if (radioClicked && typeof radioClicked === 'object') { + await page.mouse.click(radioClicked.x, radioClicked.y); + await page.waitForTimeout(300); + } + + // 3. Fill Pattern field via clipboard paste + const patternId = await page.evaluate(`(() => { + const p = 'form${dialogForm}_'; + const el = [...document.querySelectorAll('input.editInput[id^="' + p + '"]')] + .find(el => el.offsetWidth > 0 && /Pattern/i.test(el.id)); + return el ? el.id : null; + })()`); + if (!patternId) { + await page.keyboard.press('Escape'); + await page.waitForTimeout(300); + return; + } + await page.click(`[id="${patternId}"]`); + await page.waitForTimeout(200); + await page.keyboard.press('Control+A'); + await page.evaluate(`navigator.clipboard.writeText(${JSON.stringify(String(text))})`); + await page.keyboard.press('Control+V'); + await page.waitForTimeout(300); + + // 4. Click "Найти" + const findBtn = await page.evaluate(`(() => { + const btns = [...document.querySelectorAll('a.press')].filter(el => el.offsetWidth > 0); + const btn = btns.find(el => el.innerText?.trim() === 'Найти'); + if (!btn) return null; + const r = btn.getBoundingClientRect(); + return { x: Math.round(r.x + r.width / 2), y: Math.round(r.y + r.height / 2) }; + })()`); + if (findBtn) { + await page.mouse.click(findBtn.x, findBtn.y); + await page.waitForTimeout(2000); + } + + // 5. Close advanced search dialog + for (let attempt = 0; attempt < 3; attempt++) { + const dialogVisible = await page.evaluate(`(() => { + const p = 'form${dialogForm}_'; + return [...document.querySelectorAll('[id^="' + p + '"]')].some(el => el.offsetWidth > 0); + })()`); + if (!dialogVisible) break; + await page.keyboard.press('Escape'); + await page.waitForTimeout(500); + } + await waitForStable(formNum); + } catch { /* silently fail — caller will re-scan and handle not_found */ } +} + +/** + * Pick a value from an opened selection form. + * + * Strategy (escalating): + * 1. Scan visible rows for text match (exact → startsWith → includes) + * 2. Simple search (search input + Enter) → re-scan + * 3. Advanced search (Alt+F, "по части строки") → re-scan + * 4. Not found → Escape → error + * + * For object search {field: value}: steps 1, then filterList(val, {field}) per entry, then re-scan. + * For empty search: pick first visible row. + * + * @param {number} selFormNum - selection form number + * @param {string} fieldName - field being filled (for error messages) + * @param {string|Object} search - string for simple search, or { field: value } for per-field search + * @param {number} origFormNum - original form number (to verify we returned) + * @returns {{ field, ok, method }} or {{ field, error, message }} + */ +async function pickFromSelectionForm(selFormNum, fieldName, search, origFormNum) { + const searchText = typeof search === 'string' + ? search : (search ? Object.values(search).join(' ') : ''); + const searchLower = normYo((searchText || '').toLowerCase()); + + // Helper: try to select a row; returns result if ok, null if item wasn't selectable (group). + let hadUnselectableMatch = false; + async function trySelect(row) { + const r = await dblclickAndVerify(row, selFormNum, fieldName); + if (r.ok) return r; + hadUnselectableMatch = true; // found match but couldn't select (group row) + return null; // form still open, try next step + } + + // Step 1: Scan visible rows (no filtering) + if (searchLower) { + const row = await scanGridRows(selFormNum, searchLower); + if (row?.x) { + const r = await trySelect(row); + if (r) return r; + } + } + + // Step 2: Simple search via search input (directly on the known form, avoids filterList form-detection) + if (typeof search === 'string' && searchLower) { + const searchInputId = await page.evaluate(`(() => { + const p = 'form${selFormNum}_'; + const el = [...document.querySelectorAll('input.editInput[id^="' + p + '"]')] + .find(el => el.offsetWidth > 0 && /Строк[аи]Поиска|SearchString/i.test(el.id)); + return el ? el.id : null; + })()`); + if (searchInputId) { + try { + await page.click(`[id="${searchInputId}"]`); + await page.waitForTimeout(200); + await page.keyboard.press('Control+A'); + await page.evaluate(`navigator.clipboard.writeText(${JSON.stringify(String(searchText))})`); + await page.keyboard.press('Control+V'); + await page.waitForTimeout(300); + await page.keyboard.press('Enter'); + await waitForStable(selFormNum); + } catch { /* proceed to advanced search */ } + const row = await scanGridRows(selFormNum, searchLower); + if (row?.x) { + const r = await trySelect(row); + if (r) return r; + } + } + } + + // Step 3: Advanced search + if (typeof search === 'object' && search) { + // Per-field advanced search via filterList(val, {field}) + for (const [fld, val] of Object.entries(search)) { + try { await filterList(String(val), { field: fld }); } catch { /* proceed */ } + } + } else if (searchLower) { + // Inline advanced search (Alt+F, "по части строки") + await advancedSearchInline(selFormNum, searchText); + } + if (searchLower) { + const row = await scanGridRows(selFormNum, searchLower); + if (row?.x) { + const r = await trySelect(row); + if (r) return r; + } + } + + // Step 4: Empty search → pick first row; otherwise not found + if (!searchLower) { + const row = await scanGridRows(selFormNum, ''); + if (row?.x) { + const r = await trySelect(row); + if (r) return r; + } + } + + await page.keyboard.press('Escape'); + await waitForStable(); + const searchDesc = typeof search === 'string' ? '"' + search + '"' : JSON.stringify(search); + if (hadUnselectableMatch) { + return { field: fieldName, error: 'not_selectable', + message: 'Found ' + searchDesc + ' in selection form but it is not selectable (group/folder row)' }; + } + return { field: fieldName, error: 'not_found', + message: 'No matches in selection form for ' + searchDesc }; +} + /** * Detect whether a form is a type selection dialog ("Выбор типа данных"). * Type dialogs appear when selecting a value for a composite-type field. @@ -1985,34 +2134,266 @@ export async function fillTableRow(fields, { tab, add, row } = {}) { if (cellCoords.error) throw new Error(`fillTableRow: ${cellCoords.error}${cellCoords.total ? ' (total rows: ' + cellCoords.total + ')' : ''}`); - await page.mouse.dblclick(cellCoords.x, cellCoords.y); - // Poll for edit mode instead of fixed 500ms wait + // Click first (tree grids enter edit on single click; dblclick toggles expand/collapse). + // Then escalate: dblclick → F4 if needed. + await page.mouse.click(cellCoords.x, cellCoords.y); let inEdit = false; - for (let dw = 0; dw < 5; dw++) { + let directEditForm = null; + for (let dw = 0; dw < 4; dw++) { await page.waitForTimeout(150); inEdit = await page.evaluate(`(() => { const f = document.activeElement; return f && f.tagName === 'INPUT'; })()`); if (inEdit) break; + directEditForm = await page.evaluate(`(() => { + const forms = {}; + document.querySelectorAll('[id]').forEach(el => { + if (el.offsetWidth === 0 && el.offsetHeight === 0) return; + const m = el.id.match(/^form(\\d+)_/); + if (m) forms[m[1]] = true; + }); + const nums = Object.keys(forms).map(Number).filter(n => n > ${formNum}); + return nums.length > 0 ? Math.max(...nums) : null; + })()`); + if (directEditForm !== null) break; } - if (!inEdit) throw new Error(`fillTableRow: double-click on row ${row} did not enter edit mode`); - } - - // 3. Verify we're in grid edit mode (active INPUT inside a .grid) - const editCheck = await page.evaluate(`(() => { - const f = document.activeElement; - if (!f || f.tagName !== 'INPUT') return { inEdit: false, tag: f?.tagName }; - let node = f; - while (node) { - if (node.classList?.contains('grid')) return { inEdit: true }; - node = node.parentElement; + // Click didn't enter edit — try dblclick (works for flat grids) + if (!inEdit && directEditForm === null) { + await page.mouse.dblclick(cellCoords.x, cellCoords.y); + for (let dw = 0; dw < 4; dw++) { + await page.waitForTimeout(150); + inEdit = await page.evaluate(`(() => { + const f = document.activeElement; + return f && f.tagName === 'INPUT'; + })()`); + if (inEdit) break; + directEditForm = await page.evaluate(`(() => { + const forms = {}; + document.querySelectorAll('[id]').forEach(el => { + if (el.offsetWidth === 0 && el.offsetHeight === 0) return; + const m = el.id.match(/^form(\\d+)_/); + if (m) forms[m[1]] = true; + }); + const nums = Object.keys(forms).map(Number).filter(n => n > ${formNum}); + return nums.length > 0 ? Math.max(...nums) : null; + })()`); + if (directEditForm !== null) break; + } + } + // Still nothing — try F4 (opens selection for direct-edit cells) + if (!inEdit && directEditForm === null) { + await page.keyboard.press('F4'); + for (let fw = 0; fw < 8; fw++) { + await page.waitForTimeout(200); + inEdit = await page.evaluate(`(() => { + const f = document.activeElement; + return f && f.tagName === 'INPUT'; + })()`); + if (inEdit) break; + directEditForm = await page.evaluate(`(() => { + const forms = {}; + document.querySelectorAll('[id]').forEach(el => { + if (el.offsetWidth === 0 && el.offsetHeight === 0) return; + const m = el.id.match(/^form(\\d+)_/); + if (m) forms[m[1]] = true; + }); + const nums = Object.keys(forms).map(Number).filter(n => n > ${formNum}); + return nums.length > 0 ? Math.max(...nums) : null; + })()`); + if (directEditForm !== null) break; + } } - return { inEdit: false, hint: 'input not inside grid' }; - })()`); - if (!editCheck.inEdit) { - throw new Error('fillTableRow: not in grid edit mode. Use add:true or click a cell first.'); + // When click entered INPUT mode but no selection form yet — try F4 (tree grid ref fields) + if (inEdit && directEditForm === null) { + await page.keyboard.press('F4'); + for (let fw = 0; fw < 8; fw++) { + await page.waitForTimeout(200); + directEditForm = await page.evaluate(`(() => { + const forms = {}; + document.querySelectorAll('[id]').forEach(el => { + if (el.offsetWidth === 0 && el.offsetHeight === 0) return; + const m = el.id.match(/^form(\\d+)_/); + if (m) forms[m[1]] = true; + }); + const nums = Object.keys(forms).map(Number).filter(n => n > ${formNum}); + return nums.length > 0 ? Math.max(...nums) : null; + })()`); + if (directEditForm !== null) break; + } + // If F4 didn't open a selection form, the cell is a plain text field — fall through to Tab loop + } + + // Direct-edit mode: selection form opened on dblclick/F4 (e.g. tree grid with immediate editing). + // Handle each field by picking from selection form, then dblclick next cell. + if (directEditForm !== null) { + const pending = new Map(); + for (const [key, val] of Object.entries(fields)) { + if (val && typeof val === 'object' && 'value' in val) { + pending.set(key, { value: String(val.value), type: val.type || null, filled: false }); + } else { + pending.set(key, { value: String(val), type: null, filled: false }); + } + } + const results = []; + + // Helper: handle type dialog + pick from selection form + async function directEditPick(openedForm, key, info) { + let selForm = openedForm; + // Check if opened form is a type selection dialog (composite type field) + if (await isTypeDialog(selForm)) { + if (info.type) { + await pickFromTypeDialog(selForm, info.type); + await waitForStable(selForm); + // After type selection, detect the actual selection form + selForm = await page.evaluate(`(() => { + const forms = {}; + document.querySelectorAll('[id]').forEach(el => { + if (el.offsetWidth === 0 && el.offsetHeight === 0) return; + const m = el.id.match(/^form(\\d+)_/); + if (m) forms[m[1]] = true; + }); + const nums = Object.keys(forms).map(Number).filter(n => n > ${formNum}); + return nums.length > 0 ? Math.max(...nums) : null; + })()`); + if (selForm === null) { + return { field: key, error: 'no_selection_after_type', message: `Type selected but no selection form opened for "${key}"` }; + } + } else { + // No type specified — close type dialog and report error + await page.keyboard.press('Escape'); + await page.waitForTimeout(300); + return { field: key, error: 'composite_type', message: `Composite type field "${key}" requires {value, type}` }; + } + } + const pr = await pickFromSelectionForm(selForm, key, info.value, formNum); + return pr.ok ? { field: key, ok: true, method: 'form' } : { field: key, error: pr.error, message: pr.message }; + } + + // First field: selection form is already open from the dblclick above + const firstKey = Object.keys(fields)[0]; + const firstInfo = pending.get(firstKey); + const pickResult = await directEditPick(directEditForm, firstKey, firstInfo); + firstInfo.filled = true; + results.push(pickResult); + + // Remaining fields: dblclick on each column cell individually + for (const [key, info] of pending) { + 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]; + if (!grid) return null; + const head = grid.querySelector('.gridHead'); + const body = grid.querySelector('.gridBody'); + if (!head || !body) return null; + const headLine = head.querySelector('.gridLine') || head; + const cols = []; + [...headLine.children].forEach((box, i) => { + if (box.offsetWidth === 0) return; + const t = box.querySelector('.gridBoxText'); + cols.push({ idx: i, text: ((t || box).innerText?.trim() || '').toLowerCase() }); + }); + const kl = ${JSON.stringify(key.toLowerCase())}; + const klNoSpace = kl.replace(/\\s+/g, ''); + let colIdx = -1; + const exact = cols.find(c => c.text === kl); + if (exact) colIdx = exact.idx; + else { + const inc = cols.find(c => c.text.includes(kl) || kl.includes(c.text) + || c.text.includes(klNoSpace) || klNoSpace.includes(c.text)); + if (inc) colIdx = inc.idx; + } + if (colIdx < 0) return null; + const rows = [...body.querySelectorAll('.gridLine')]; + if (${row} >= rows.length) return null; + const line = rows[${row}]; + const boxes = [...line.children].filter(b => b.offsetWidth > 0 && !b.classList.contains('gridBoxComp')); + const box = boxes[colIdx]; + if (!box) return null; + const cell = box.querySelector('.gridBoxText') || box; + const r = cell.getBoundingClientRect(); + return { x: Math.round(r.x + r.width / 2), y: Math.round(r.y + r.height / 2) }; + })()`); + if (!nextCoords) { + info.filled = true; + results.push({ field: key, error: 'column_not_found', message: `Column for "${key}" not found` }); + continue; + } + await page.mouse.dblclick(nextCoords.x, nextCoords.y); + // Poll for selection form (with F4 fallback if dblclick didn't open it) + let selForm = null; + for (let attempt = 0; attempt < 2 && selForm === null; attempt++) { + if (attempt === 1) await page.keyboard.press('F4'); // F4 fallback + for (let sw = 0; sw < 6; sw++) { + await page.waitForTimeout(200); + selForm = await page.evaluate(`(() => { + const forms = {}; + document.querySelectorAll('[id]').forEach(el => { + if (el.offsetWidth === 0 && el.offsetHeight === 0) return; + const m = el.id.match(/^form(\\d+)_/); + if (m) forms[m[1]] = true; + }); + const nums = Object.keys(forms).map(Number).filter(n => n > ${formNum}); + return nums.length > 0 ? Math.max(...nums) : null; + })()`); + if (selForm !== null) break; + } + } + if (selForm === null) { + info.filled = true; + results.push({ field: key, error: 'no_selection_form', message: `Dblclick on "${key}" did not open selection form` }); + continue; + } + const pr = await directEditPick(selForm, key, info); + info.filled = true; + results.push(pr); + } + // 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]; + if (!grid) return null; + const body = grid.querySelector('.gridBody'); + if (!body) return null; + const rows = [...body.querySelectorAll('.gridLine')]; + const otherIdx = ${row} === 0 ? 1 : 0; + const other = rows[otherIdx]; + if (!other) return null; + const box = [...other.children].filter(b => b.offsetWidth > 0)[0]; + if (!box) return null; + const r = box.getBoundingClientRect(); + return { x: Math.round(r.x + r.width / 2), y: Math.round(r.y + r.height / 2) }; + })()`); + if (commitCoords) { + await page.mouse.click(commitCoords.x, commitCoords.y); + } else { + await page.keyboard.press('Escape'); + } + await waitForStable(formNum); + return results; + } + + if (!inEdit) throw new Error(`fillTableRow: click on row ${row} did not enter edit mode`); + } else { + // No row specified — verify we're in grid edit mode (active INPUT inside a .grid or .gridContent) + const editCheck = await page.evaluate(`(() => { + const f = document.activeElement; + if (!f || f.tagName !== 'INPUT') return { inEdit: false, tag: f?.tagName }; + let node = f; + while (node) { + if (node.classList?.contains('grid') || node.classList?.contains('gridContent')) return { inEdit: true }; + node = node.parentElement; + } + return { inEdit: false, hint: 'input not inside grid' }; + })()`); + + if (!editCheck.inEdit) { + throw new Error('fillTableRow: not in grid edit mode. Use add:true or click a cell first.'); + } } // 4. Prepare pending fields for fuzzy matching @@ -2522,6 +2903,48 @@ export async function fillTableRow(fields, { tab, add, row } = {}) { // Tab already pressed — we're on next cell } + // Commit the new row: click on a different row or outside the grid. + // Without this, the row stays in "uncommitted add" state and a subsequent + // 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]; + if (!grid) return null; + const body = grid.querySelector('.gridBody'); + if (!body) return null; + const rows = [...body.querySelectorAll('.gridLine')]; + // Find the currently active row (contains the focused input) + const activeInput = document.activeElement; + let activeRowIdx = -1; + if (activeInput) { + for (let i = 0; i < rows.length; i++) { + if (rows[i].contains(activeInput)) { activeRowIdx = i; break; } + } + } + // Click a DIFFERENT row to commit + const targetIdx = activeRowIdx === 0 ? 1 : 0; + const target = rows[targetIdx]; + if (target) { + const box = [...target.children].find(b => b.offsetWidth > 0); + if (box) { + const r = box.getBoundingClientRect(); + return { x: Math.round(r.x + r.width / 2), y: Math.round(r.y + r.height / 2) }; + } + } + // Fallback: click the grid header + const head = grid.querySelector('.gridHead'); + if (head) { + const r = head.getBoundingClientRect(); + return { x: Math.round(r.x + r.width / 2), y: Math.round(r.y + r.height / 2) }; + } + return null; + })()`); + if (commitTarget) { + await page.mouse.click(commitTarget.x, commitTarget.y); + await page.waitForTimeout(500); + } + // Dismiss any leftover error modals const err = await checkForErrors(); if (err?.modal) { @@ -2635,20 +3058,40 @@ export async function filterList(text, { field, exact } = {}) { .find(el => el.offsetWidth > 0 && /Строк[аи]Поиска|SearchString/i.test(el.id)); return el ? el.id : null; })()`); - if (!searchId) throw new Error('filterList: no search input found on this form'); - await page.click(`[id="${searchId}"]`); - await page.waitForTimeout(200); - await page.keyboard.press('Control+A'); - await page.evaluate(`navigator.clipboard.writeText(${JSON.stringify(String(text))})`); - await page.keyboard.press('Control+V'); + if (searchId) { + await page.click(`[id="${searchId}"]`); + await page.waitForTimeout(200); + await page.keyboard.press('Control+A'); + await page.evaluate(`navigator.clipboard.writeText(${JSON.stringify(String(text))})`); + await page.keyboard.press('Control+V'); + await page.waitForTimeout(300); + await page.keyboard.press('Enter'); + await waitForStable(formNum); + + const state = await getFormState(); + state.filtered = { type: 'search', text }; + return state; + } + + // No search input — Ctrl+F opens advanced search on such forms. + // Click first grid cell then fall through to advanced search path below. + const firstCell = await page.evaluate(`(() => { + const p = 'form${formNum}_'; + const grid = [...document.querySelectorAll('[id^="' + p + '"].grid, [id^="' + p + '"] .grid')] + .find(g => g.offsetWidth > 0); + if (!grid) return null; + const rows = [...grid.querySelectorAll('.gridBody .gridLine')]; + if (!rows.length) return null; + const cells = [...rows[0].querySelectorAll('.gridBox')]; + if (!cells.length) return null; + const r = cells[0].getBoundingClientRect(); + return { x: Math.round(r.x + r.width / 2), y: Math.round(r.y + r.height / 2) }; + })()`); + if (!firstCell) throw new Error('filterList: no search input and no grid found on this form'); + await page.mouse.click(firstCell.x, firstCell.y); await page.waitForTimeout(300); - await page.keyboard.press('Enter'); - await waitForStable(formNum); - - const state = await getFormState(); - state.filtered = { type: 'search', text }; - return state; + field = ''; // fall through to advanced search, skip DLB (empty field = keep auto-selected) } // --- Advanced search: click target column cell → Alt+F → fill Pattern → Найти --- @@ -2724,7 +3167,8 @@ export async function filterList(text, { field, exact } = {}) { } // 2b. If column wasn't in the grid, change FieldSelector via DLB dropdown - if (needDlb) { + // Skip DLB when field is empty (fallback from no-search-input path — keep auto-selected field) + if (needDlb && field) { const fsInfo = await page.evaluate(`(() => { const p = 'form' + ${JSON.stringify(String(dialogForm))} + '_'; const fsInput = [...document.querySelectorAll('input.editInput[id^="' + p + '"]')] From 0ca2faa6a6a8938700c6b45de2a84f512fd14b3d Mon Sep 17 00:00:00 2001 From: Nick Shirokov Date: Fri, 13 Mar 2026 20:05:12 +0300 Subject: [PATCH 2/9] feat(web-test): navigateSection newline normalization + fillTableRow cell skip navigateSection now normalizes \r\n to spaces, so callers don't need literal newlines in section names. fillTableRow direct-edit path skips cells that already contain the desired value (method: 'skip'). Co-Authored-By: Claude Opus 4.6 --- .claude/skills/web-test/scripts/browser.mjs | 39 ++++++++++++++++++--- .claude/skills/web-test/scripts/dom.mjs | 4 +-- 2 files changed, 36 insertions(+), 7 deletions(-) diff --git a/.claude/skills/web-test/scripts/browser.mjs b/.claude/skills/web-test/scripts/browser.mjs index 8c93774f..ed5c0814 100644 --- a/.claude/skills/web-test/scripts/browser.mjs +++ b/.claude/skills/web-test/scripts/browser.mjs @@ -2129,11 +2129,24 @@ export async function fillTableRow(fields, { tab, add, row } = {}) { if (!box) return { error: 'no_cell' }; const cell = box.querySelector('.gridBoxText') || box; const r = cell.getBoundingClientRect(); - return { x: Math.round(r.x + r.width / 2), y: Math.round(r.y + r.height / 2) }; + const currentText = (cell.innerText?.trim() || '').replace(/\u00a0/g, ' '); + return { x: Math.round(r.x + r.width / 2), y: Math.round(r.y + r.height / 2), currentText }; })()`); if (cellCoords.error) throw new Error(`fillTableRow: ${cellCoords.error}${cellCoords.total ? ' (total rows: ' + cellCoords.total + ')' : ''}`); + // Skip if cell already contains the desired value (single-field optimization) + const firstKey0 = Object.keys(fields)[0]; + const firstVal0 = typeof fields[firstKey0] === 'object' ? fields[firstKey0].value : String(fields[firstKey0]); + let firstFieldSkipped = false; + if (cellCoords.currentText && firstVal0 && + cellCoords.currentText.toLowerCase().includes(firstVal0.toLowerCase())) { + firstFieldSkipped = true; + if (Object.keys(fields).length === 1) { + return [{ field: firstKey0, ok: true, method: 'skip', value: cellCoords.currentText }]; + } + } + // Click first (tree grids enter edit on single click; dblclick toggles expand/collapse). // Then escalate: dblclick → F4 if needed. await page.mouse.click(cellCoords.x, cellCoords.y); @@ -2274,9 +2287,17 @@ export async function fillTableRow(fields, { tab, add, row } = {}) { // First field: selection form is already open from the dblclick above const firstKey = Object.keys(fields)[0]; const firstInfo = pending.get(firstKey); - const pickResult = await directEditPick(directEditForm, firstKey, firstInfo); - firstInfo.filled = true; - results.push(pickResult); + if (firstFieldSkipped) { + firstInfo.filled = true; + results.push({ field: firstKey, ok: true, method: 'skip', value: cellCoords.currentText }); + // Close the selection form that opened from the click + await page.keyboard.press('Escape'); + await waitForStable(formNum); + } else { + const pickResult = await directEditPick(directEditForm, firstKey, firstInfo); + firstInfo.filled = true; + results.push(pickResult); + } // Remaining fields: dblclick on each column cell individually for (const [key, info] of pending) { @@ -2315,13 +2336,21 @@ export async function fillTableRow(fields, { tab, add, row } = {}) { if (!box) return null; const cell = box.querySelector('.gridBoxText') || box; const r = cell.getBoundingClientRect(); - return { x: Math.round(r.x + r.width / 2), y: Math.round(r.y + r.height / 2) }; + const currentText = (cell.innerText?.trim() || '').replace(/\\u00a0/g, ' '); + return { x: Math.round(r.x + r.width / 2), y: Math.round(r.y + r.height / 2), currentText }; })()`); if (!nextCoords) { info.filled = true; results.push({ field: key, error: 'column_not_found', message: `Column for "${key}" not found` }); continue; } + // Skip if cell already contains the desired value + if (nextCoords.currentText && info.value && + nextCoords.currentText.toLowerCase().includes(info.value.toLowerCase())) { + info.filled = true; + results.push({ field: key, ok: true, method: 'skip', value: nextCoords.currentText }); + continue; + } await page.mouse.dblclick(nextCoords.x, nextCoords.y); // Poll for selection form (with F4 fallback if dblclick didn't open it) let selForm = null; diff --git a/.claude/skills/web-test/scripts/dom.mjs b/.claude/skills/web-test/scripts/dom.mjs index 27fd6a2c..2ff64c33 100644 --- a/.claude/skills/web-test/scripts/dom.mjs +++ b/.claude/skills/web-test/scripts/dom.mjs @@ -476,8 +476,8 @@ export function getFormStateScript() { */ export function navigateSectionScript(name) { return `(() => { - const norm = s => (s?.trim().replace(/\\u00a0/g, ' ') || '').replace(/ё/gi, 'е'); - const target = ${JSON.stringify(name.toLowerCase().replace(/ё/g, 'е'))}; + const norm = s => (s?.trim().replace(/\\u00a0/g, ' ').replace(/[\\r\\n]+/g, ' ').replace(/ +/g, ' ') || '').replace(/ё/gi, 'е'); + const target = ${JSON.stringify(name.toLowerCase().replace(/ё/g, 'е').replace(/[\r\n]+/g, ' ').replace(/ +/g, ' '))}; const els = [...document.querySelectorAll('[id^="themesCell_theme_"]')]; let bestEl = els.find(el => norm(el.innerText).toLowerCase() === target); if (!bestEl) bestEl = els.find(el => norm(el.innerText).toLowerCase().includes(target)); From 1abc44334c279c13f3e8b8cdabce78704323b51a Mon Sep 17 00:00:00 2001 From: Nick Shirokov Date: Sat, 14 Mar 2026 11:27:37 +0300 Subject: [PATCH 3/9] feat(web-test): add table parameter for multi-grid forms MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .claude/skills/web-test/scripts/browser.mjs | 78 ++++++++--- .claude/skills/web-test/scripts/dom.mjs | 141 +++++++++++++++++--- 2 files changed, 176 insertions(+), 43 deletions(-) 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); From 7e56cd79db5a549328e75a1a484a526b7e9d231a Mon Sep 17 00:00:00 2001 From: Nick Shirokov Date: Sat, 14 Mar 2026 11:48:57 +0300 Subject: [PATCH 4/9] fix(web-test): skip checkbox columns in row clicks + document table parameter Row selection clicks in deleteTableRow and fillTableRow commit now target the second visible gridBox instead of the first, avoiding accidental checkbox toggles on forms with checkbox columns (e.g. BP links master). Also documents the new `table` parameter in SKILL.md for readTable, clickElement, fillTableRow, deleteTableRow, and getFormState tables[]. Co-Authored-By: Claude Opus 4.6 --- .claude/skills/web-test/SKILL.md | 31 +++++++++++++++++---- .claude/skills/web-test/scripts/browser.mjs | 15 ++++++---- 2 files changed, 35 insertions(+), 11 deletions(-) diff --git a/.claude/skills/web-test/SKILL.md b/.claude/skills/web-test/SKILL.md index 3dc761f1..276071c0 100644 --- a/.claude/skills/web-test/SKILL.md +++ b/.claude/skills/web-test/SKILL.md @@ -118,12 +118,14 @@ Switch to an already-open tab/window (fuzzy match). ### Reading form state -#### `getFormState()` → `{ fields, buttons, tabs, table, filters, reportSettings? }` +#### `getFormState()` → `{ fields, buttons, tabs, table, tables, filters, reportSettings? }` Returns current form structure. This is the primary way to understand what's on screen. **fields** — each field has: `name`, `value`, `label?`, `actions?` (select, clear, open), `required?` (true for unfilled mandatory fields) -**table** — summary only: `{ name, columns, rowCount }`. Use `readTable()` for actual data. +**tables** — array of all visible grids: `[{ name, columns, rowCount }]`. Use `readTable()` for actual data. + +**table** — backward-compatible alias for the first grid: `{ present, columns, rowCount }`. **reportSettings** — for DCS reports: human-readable filter settings instead of raw technical names: ```js @@ -140,13 +142,14 @@ const form = await getFormState(); ### Reading data -#### `readTable({ maxRows?, offset? })` → `{ columns, rows, total, shown, offset }` +#### `readTable({ maxRows?, offset?, table? })` → `{ columns, rows, total, shown, offset }` Read actual grid data with pagination. Each row is `{ columnName: value }`. | Option | Default | Description | |--------|---------|-------------| | `maxRows` | 20 | Max rows to return per call | | `offset` | 0 | Skip first N rows | +| `table` | — | Grid name from `tables[]` (for multi-grid forms) | Special row fields: - `_kind: 'group'` — hierarchical group row @@ -190,9 +193,13 @@ Sections + all open tabs. ### Actions -#### `clickElement(text, { dblclick? })` → form state +#### `clickElement(text, { dblclick?, table? })` → form state Click button, hyperlink, tab, or grid row (fuzzy match). +- `table` — scope button search to a specific grid's command panel (by name from `tables[]`): + ```js + await clickElement('Добавить', { table: 'Исходящие' }); // clicks "Добавить" near "Исходящие" grid + ``` - Single click selects a row in a list. **Double-click opens** the item: ```js await clickElement('0000-000227', { dblclick: true }); // opens document @@ -250,6 +257,13 @@ Also supports DCS labels — auto-enables the paired checkbox. #### `fillTableRow(fields, opts)` → form state Fill table row cells via Tab navigation. Value is a plain string or `{ value, type }` for composite-type cells. +| Option | Description | +|--------|-------------| +| `tab` | Switch to tab before filling | +| `add` | Add new row before filling | +| `row` | Edit existing row by 0-based index | +| `table` | Grid name from `tables[]` (for multi-grid forms) | + ```js // Add new row: await fillTableRow( @@ -261,6 +275,11 @@ await fillTableRow( { 'Количество': '20' }, { tab: 'Товары', row: 0 } ); +// Multi-grid form — add row to specific table: +await fillTableRow( + { 'Объект': 'БДДС' }, + { table: 'Исходящие', add: true } +); // Composite-type cell (e.g. SubConto accepting multiple types): await fillTableRow( { 'СубконтоКт1': { value: 'Голованов', type: 'Физическое лицо' } }, @@ -272,8 +291,8 @@ await fillTableRow( - Fuzzy cell match: "Количество" matches "ТоварыКоличество" - Reference cells auto-detected by autocomplete popup -#### `deleteTableRow(row, { tab? })` → form state -Delete row by 0-based index. +#### `deleteTableRow(row, { tab?, table? })` → form state +Delete row by 0-based index. `table` targets a specific grid on multi-grid forms. #### `closeForm({ save? })` → form state Close the current form via Escape. diff --git a/.claude/skills/web-test/scripts/browser.mjs b/.claude/skills/web-test/scripts/browser.mjs index 99ab67ba..7cf4026e 100644 --- a/.claude/skills/web-test/scripts/browser.mjs +++ b/.claude/skills/web-test/scripts/browser.mjs @@ -2417,7 +2417,8 @@ export async function fillTableRow(fields, { tab, add, row, table } = {}) { const otherIdx = ${row} === 0 ? 1 : 0; const other = rows[otherIdx]; if (!other) return null; - const box = [...other.children].filter(b => b.offsetWidth > 0)[0]; + const visBoxes = [...other.children].filter(b => b.offsetWidth > 0 && !b.classList.contains('gridBoxComp')); + const box = visBoxes.length > 1 ? visBoxes[1] : visBoxes[0]; if (!box) return null; const r = box.getBoundingClientRect(); return { x: Math.round(r.x + r.width / 2), y: Math.round(r.y + r.height / 2) }; @@ -2981,7 +2982,8 @@ export async function fillTableRow(fields, { tab, add, row, table } = {}) { const targetIdx = activeRowIdx === 0 ? 1 : 0; const target = rows[targetIdx]; if (target) { - const box = [...target.children].find(b => b.offsetWidth > 0); + const visBoxes = [...target.children].filter(b => b.offsetWidth > 0 && !b.classList.contains('gridBoxComp')); + const box = visBoxes.length > 1 ? visBoxes[1] : visBoxes[0]; if (box) { const r = box.getBoundingClientRect(); return { x: Math.round(r.x + r.width / 2), y: Math.round(r.y + r.height / 2) }; @@ -3062,9 +3064,12 @@ export async function deleteTableRow(row, { tab, table } = {}) { const rows = [...body.querySelectorAll('.gridLine')]; if (${row} >= rows.length) return { error: 'row_out_of_range', total: rows.length }; const line = rows[${row}]; - const cells = [...line.querySelectorAll('.gridBoxText')]; - const cell = cells.length > 1 ? cells[1] : cells[0]; - if (!cell) return { error: 'no_cell' }; + // Use visible gridBox containers (not gridBoxText) to avoid clicking checkboxes + const boxes = [...line.children].filter(b => b.offsetWidth > 0 && !b.classList.contains('gridBoxComp')); + // Skip first column (row number / checkbox) — pick second visible box + const box = boxes.length > 1 ? boxes[1] : boxes[0]; + if (!box) return { error: 'no_cell' }; + const cell = box.querySelector('.gridBoxText') || box; const r = cell.getBoundingClientRect(); return { x: Math.round(r.x + r.width / 2), y: Math.round(r.y + r.height / 2), total: rows.length }; })()`); From f2bd42c54c1165559ef874bbd9de3a09608bbf84 Mon Sep 17 00:00:00 2001 From: Nick Shirokov Date: Sat, 14 Mar 2026 11:56:46 +0300 Subject: [PATCH 5/9] docs(web-test): add multi-grid forms pattern to SKILL.md MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add "Work with multi-grid forms" section to Common patterns showing the discover-then-act workflow: getFormState().tables → use table name. Co-Authored-By: Claude Opus 4.6 --- .claude/skills/web-test/SKILL.md | 31 +++++++++++++++++++++++++++++++ 1 file changed, 31 insertions(+) diff --git a/.claude/skills/web-test/SKILL.md b/.claude/skills/web-test/SKILL.md index 276071c0..402c1c83 100644 --- a/.claude/skills/web-test/SKILL.md +++ b/.claude/skills/web-test/SKILL.md @@ -378,6 +378,37 @@ console.log('Title:', report.title); console.log('Data rows:', report.data?.length); ``` +### Work with multi-grid forms + +Some forms have multiple grids (e.g. "Входящие" and "Исходящие" tables on a single form). Without `table`, buttons like "Добавить" hit the first match and `readTable` reads the first grid — which may not be the one you need. + +**Step 1 — discover tables** via `getFormState()`: +```js +const form = await getFormState(); +// form.tables = [ +// { name: "ДеревоБизнесПроцессов", columns: ["Полный код", "Бизнес-процесс"], rowCount: 21 }, +// { name: "Входящие", columns: ["Объект", "Бизнес-процесс источник", ...], rowCount: 1 }, +// { name: "Исходящие", columns: ["Объект", "Бизнес-процесс приемник", ...], rowCount: 1 } +// ] +``` + +**Step 2 — use `table` name** in any grid operation: +```js +// Read specific table +const t = await readTable({ table: 'Исходящие' }); + +// Add row — fillTableRow with add:true already clicks the right "Добавить" button +await fillTableRow({ 'Объект': 'БДДС' }, { table: 'Исходящие', add: true }); + +// Or click buttons separately +await clickElement('Добавить', { table: 'Входящие' }); + +// Delete from specific table +await deleteTableRow(0, { table: 'Исходящие' }); +``` + +Table name matching is fuzzy: `'Исходящие'` matches grid id `form1_Исходящие`. If the grid id is technical (e.g. `ТаблицаТоваров`), use that name — it's from `tables[].name`, not the visual label. + ### Keyboard shortcuts (via `page.keyboard.press`) | Key | Context | Action | From 91b5204ab2b5a9b96eafa6dde622292e8176fb2e Mon Sep 17 00:00:00 2001 From: Nick Shirokov Date: Sat, 14 Mar 2026 12:05:23 +0300 Subject: [PATCH 6/9] feat(web-test): add visual label support for multi-grid tables MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Extract group title text from #title_div DOM elements so tables can be referenced by their visible on-screen names (e.g. "Входящие") in addition to technical attribute names. Labels appear in getFormState().tables[] and resolveGridScript cascade matching (exact name → exact label → contains). Co-Authored-By: Claude Opus 4.6 --- .claude/skills/web-test/SKILL.md | 8 ++++---- .claude/skills/web-test/scripts/dom.mjs | 20 +++++++++++++++----- 2 files changed, 19 insertions(+), 9 deletions(-) diff --git a/.claude/skills/web-test/SKILL.md b/.claude/skills/web-test/SKILL.md index 402c1c83..7c9b989d 100644 --- a/.claude/skills/web-test/SKILL.md +++ b/.claude/skills/web-test/SKILL.md @@ -123,7 +123,7 @@ Returns current form structure. This is the primary way to understand what's on **fields** — each field has: `name`, `value`, `label?`, `actions?` (select, clear, open), `required?` (true for unfilled mandatory fields) -**tables** — array of all visible grids: `[{ name, columns, rowCount }]`. Use `readTable()` for actual data. +**tables** — array of all visible grids: `[{ name, columns, rowCount, label? }]`. `label` is the visual group title shown on screen (e.g. "Входящие"), absent when grid has no visible title. Use `readTable()` for actual data. **table** — backward-compatible alias for the first grid: `{ present, columns, rowCount }`. @@ -387,8 +387,8 @@ Some forms have multiple grids (e.g. "Входящие" and "Исходящие" const form = await getFormState(); // form.tables = [ // { name: "ДеревоБизнесПроцессов", columns: ["Полный код", "Бизнес-процесс"], rowCount: 21 }, -// { name: "Входящие", columns: ["Объект", "Бизнес-процесс источник", ...], rowCount: 1 }, -// { name: "Исходящие", columns: ["Объект", "Бизнес-процесс приемник", ...], rowCount: 1 } +// { name: "Входящие", label: "Входящие", columns: ["Объект", "Бизнес-процесс источник", ...], rowCount: 1 }, +// { name: "Исходящие", label: "Исходящие", columns: ["Объект", "Бизнес-процесс приемник", ...], rowCount: 1 } // ] ``` @@ -407,7 +407,7 @@ await clickElement('Добавить', { table: 'Входящие' }); await deleteTableRow(0, { table: 'Исходящие' }); ``` -Table name matching is fuzzy: `'Исходящие'` matches grid id `form1_Исходящие`. If the grid id is technical (e.g. `ТаблицаТоваров`), use that name — it's from `tables[].name`, not the visual label. +Table matching accepts both technical name (`tables[].name`) and visual label (`tables[].label`). Label is the group title shown on screen — useful when working from screenshots. Name match takes priority over label match. ### Keyboard shortcuts (via `page.keyboard.press`) diff --git a/.claude/skills/web-test/scripts/dom.mjs b/.claude/skills/web-test/scripts/dom.mjs index 36164300..4d560022 100644 --- a/.claude/skills/web-test/scripts/dom.mjs +++ b/.claude/skills/web-test/scripts/dom.mjs @@ -204,7 +204,10 @@ const READ_FORM_FN = `function readForm(p) { }); } const rowCount = body ? body.querySelectorAll('.gridLine').length : 0; - return { name, columns, rowCount }; + // Visual label from group title (e.g. "Входящие:" for grid "Входящие") + const titleEl = document.getElementById(p + name + '#title_div'); + const label = titleEl ? (titleEl.innerText?.trim().replace(/:\\s*$/, '').replace(/\\u00a0/g, ' ') || null) : null; + return { name, columns, rowCount, ...(label ? { label } : {}) }; }); result.tables = tables; // Backward compat: table = first grid summary @@ -391,13 +394,20 @@ export function resolveGridScript(formNum, tableName) { if (text) columns.push(text); }); } - return { idx, gridId, gridName, columns, el: g }; + // Visual label from group title element + const titleEl = document.getElementById(p + gridName + '#title_div'); + const label = titleEl ? (titleEl.innerText?.trim().replace(/:\s*$/, '').replace(/\u00a0/g, ' ') || '') : ''; + return { idx, gridId, gridName, label, columns, el: g }; }); // 1. Exact gridName match (case-insensitive) let found = infos.find(i => norm(i.gridName).toLowerCase() === target); - // 2. gridName contains target + // 2. Exact label match + if (!found) found = infos.find(i => i.label && norm(i.label).toLowerCase() === target); + // 3. gridName contains target if (!found) found = infos.find(i => norm(i.gridName).toLowerCase().includes(target)); - // 3. Any column contains target + // 4. Label contains target + if (!found) found = infos.find(i => i.label && norm(i.label).toLowerCase().includes(target)); + // 5. Any column contains target if (!found) found = infos.find(i => i.columns.some(c => norm(c).toLowerCase().includes(target))); if (found) { return { @@ -411,7 +421,7 @@ export function resolveGridScript(formNum, tableName) { return { error: 'not_found', message: 'Table "' + ${JSON.stringify(tableName)} + '" not found', - available: infos.map(i => ({ name: i.gridName, columns: i.columns })) + available: infos.map(i => ({ name: i.gridName, ...(i.label ? { label: i.label } : {}), columns: i.columns })) }; })()`; } From 24a48b4a9f8d7ea5b1b97143d965d23de37016b8 Mon Sep 17 00:00:00 2001 From: Nick Shirokov Date: Sat, 14 Mar 2026 12:11:10 +0300 Subject: [PATCH 7/9] =?UTF-8?q?fix(web-test):=20add=20=D0=93=D1=80=D1=83?= =?UTF-8?q?=D0=BF=D0=BF=D0=B0+name=20fallback=20for=20grid=20label=20extra?= =?UTF-8?q?ction?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit On some forms, #title_div is on the parent group element (e.g. form0_ГруппаБизнесПроцессы#title_div) rather than on the grid itself. Add fallback lookup for both getFormState and resolveGridScript. Co-Authored-By: Claude Opus 4.6 --- .claude/skills/web-test/scripts/dom.mjs | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/.claude/skills/web-test/scripts/dom.mjs b/.claude/skills/web-test/scripts/dom.mjs index 4d560022..2a5f2419 100644 --- a/.claude/skills/web-test/scripts/dom.mjs +++ b/.claude/skills/web-test/scripts/dom.mjs @@ -205,7 +205,8 @@ const READ_FORM_FN = `function readForm(p) { } const rowCount = body ? body.querySelectorAll('.gridLine').length : 0; // Visual label from group title (e.g. "Входящие:" for grid "Входящие") - const titleEl = document.getElementById(p + name + '#title_div'); + const titleEl = document.getElementById(p + name + '#title_div') + || document.getElementById(p + 'Группа' + name + '#title_div'); const label = titleEl ? (titleEl.innerText?.trim().replace(/:\\s*$/, '').replace(/\\u00a0/g, ' ') || null) : null; return { name, columns, rowCount, ...(label ? { label } : {}) }; }); @@ -395,7 +396,8 @@ export function resolveGridScript(formNum, tableName) { }); } // Visual label from group title element - const titleEl = document.getElementById(p + gridName + '#title_div'); + const titleEl = document.getElementById(p + gridName + '#title_div') + || document.getElementById(p + 'Группа' + gridName + '#title_div'); const label = titleEl ? (titleEl.innerText?.trim().replace(/:\s*$/, '').replace(/\u00a0/g, ' ') || '') : ''; return { idx, gridId, gridName, label, columns, el: g }; }); From 07be2bcafddeb0ff20e605e9f9e7907a459d7a87 Mon Sep 17 00:00:00 2001 From: Nick Shirokov Date: Sat, 14 Mar 2026 12:28:50 +0300 Subject: [PATCH 8/9] fix(web-test): use includes instead of startsWith for grid button id-prefix fallback MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Button ids like allActionsРазделыКоманднаяПанель contain gridName in the middle, not at the start. Using includes() catches both prefix patterns (ИсходящиеКоманднаяПанель_Добавить) and infix patterns (allActionsРазделыКоманднаяПанель). Co-Authored-By: Claude Opus 4.6 --- .claude/skills/web-test/scripts/dom.mjs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.claude/skills/web-test/scripts/dom.mjs b/.claude/skills/web-test/scripts/dom.mjs index 2a5f2419..2c32bee4 100644 --- a/.claude/skills/web-test/scripts/dom.mjs +++ b/.claude/skills/web-test/scripts/dom.mjs @@ -660,7 +660,7 @@ export function findClickTargetScript(formNum, text, { tableName, gridSelector } // 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)); + const prefixItems = items.filter(i => i.label && i.label.includes(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)); From 21de2a47492e281d641323980a37f87a4bfbe400 Mon Sep 17 00:00:00 2001 From: Nick Shirokov Date: Sat, 14 Mar 2026 13:50:03 +0300 Subject: [PATCH 9/9] fix(web-test): strip dashes in fuzzy match for fillTableRow cell names MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CamelCase cell IDs like "ИсходящиеБизнесПроцессПриемник" have no dashes, but user keys like "Бизнес-процесс приемник" do. The previous regex only stripped spaces, leaving the dash and causing match failure. Now strip both spaces and dashes with /[\s\-]+/g in both the Tab-loop path and the row/dblclick column-lookup path. Co-Authored-By: Claude Opus 4.6 --- .claude/skills/web-test/scripts/browser.mjs | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/.claude/skills/web-test/scripts/browser.mjs b/.claude/skills/web-test/scripts/browser.mjs index 7cf4026e..af6a6544 100644 --- a/.claude/skills/web-test/scripts/browser.mjs +++ b/.claude/skills/web-test/scripts/browser.mjs @@ -2342,7 +2342,7 @@ export async function fillTableRow(fields, { tab, add, row, table } = {}) { cols.push({ idx: i, text: ((t || box).innerText?.trim() || '').toLowerCase() }); }); const kl = ${JSON.stringify(key.toLowerCase())}; - const klNoSpace = kl.replace(/\\s+/g, ''); + const klNoSpace = kl.replace(/[\\s\\-]+/g, ''); let colIdx = -1; const exact = cols.find(c => c.text === kl); if (exact) colIdx = exact.idx; @@ -2515,8 +2515,8 @@ export async function fillTableRow(fields, { tab, add, row, table } = {}) { matchedKey = key; break; } - // CamelCase cell names have no spaces — try matching without spaces - const klNoSpace = kl.replace(/\s+/g, ''); + // CamelCase cell names have no spaces/dashes — try matching without spaces and dashes + const klNoSpace = kl.replace(/[\s\-]+/g, ''); if (klNoSpace && (cellLower.endsWith(klNoSpace) || cellLower.includes(klNoSpace))) { matchedKey = key; break;