// web-test browser v1.16 — Playwright browser management for 1C web client // Source: https://github.com/Nikolay-Shirokov/cc-1c-skills /** * Playwright browser management for 1C web client. * * Maintains a single browser instance across MCP tool calls. * Handles connection, navigation, waiting, screenshots. */ import { chromium } from 'playwright'; import { spawn, execFileSync } from 'child_process'; import { statSync, mkdirSync, existsSync as fsExistsSync, writeFileSync, readFileSync, rmSync, readdirSync } from 'fs'; import { dirname, resolve as pathResolve, join as pathJoin, basename, extname } from 'path'; import { tmpdir } from 'os'; import { fileURLToPath, pathToFileURL } from 'url'; import { readSectionsScript, readTabsScript, readCommandsScript, readFormScript, navigateSectionScript, openCommandScript, findClickTargetScript, findFieldButtonScript, readSubmenuScript, resolveFieldsScript, getFormStateScript, detectFormScript, readTableScript, checkErrorsScript, switchTabScript, resolveGridScript } from './dom.mjs'; // Module-level state, constants, normYo and resolveProjectPath live in core/state.mjs. // Imported as live bindings — reads stay current; writes go through setters. import { browser, page, sessionPrefix, seanceId, recorder, lastCaptions, lastRecordingDuration, highlightMode, persistentUserDataDir, preserveClipboard, clipboardWarnLogged, contexts, activeContextName, activeMode, setBrowser, setPage, setSessionPrefix, setSeanceId, setRecorder, setLastCaptions, setLastRecordingDuration, setHighlightMode, setPersistentUserDataDir, setActiveContextName, setActiveMode, setClipboardWarnLogged, LOAD_TIMEOUT, INIT_TIMEOUT, ACTION_WAIT, MAX_WAIT, POLL_INTERVAL, STABLE_CYCLES, EXT_ID, projectRoot, resolveProjectPath, normYo, isConnected, ensureConnected, getPage, setPreserveClipboard, } from './core/state.mjs'; export { isConnected, getPage, setPreserveClipboard, ensureConnected }; export async function saveClipboard() { if (!page) return; try { await page.evaluate(async () => { try { const items = await navigator.clipboard.read(); const saved = []; for (const item of items) { const types = {}; for (const t of item.types) types[t] = await item.getType(t); saved.push(types); } window.__webTestSavedClipboard = saved; delete window.__webTestClipboardError; } catch (e) { window.__webTestSavedClipboard = null; window.__webTestClipboardError = e?.name || String(e); } }); } catch { // page.evaluate itself failed (closed page, navigation in flight) — skip. } } export async function restoreClipboard() { if (!page) return; let err = null; try { err = await page.evaluate(async () => { const saved = window.__webTestSavedClipboard; const captured = window.__webTestClipboardError || null; delete window.__webTestSavedClipboard; delete window.__webTestClipboardError; try { if (!saved || saved.length === 0) { // Save failed (e.g. CF_HDROP from Explorer not readable via Clipboard API) // or buffer was empty. Either way, the test's writeText already destroyed // any prior native formats in the OS clipboard, so explicitly clear here // to avoid leaking the test value into the user's clipboard. await navigator.clipboard.writeText(''); return captured; } const items = saved.map(types => new ClipboardItem(types)); await navigator.clipboard.write(items); return null; } catch (e) { return e?.name || String(e); } }); } catch { return; } if (err && !clipboardWarnLogged) { setClipboardWarnLogged(true); console.error(`[web-test] clipboard preserve skipped: ${err} (logged once per session)`); } } /** * Paste `text` via OS clipboard (the only trusted-paste path that 1C respects * for autocomplete and Cyrillic). Wraps the writeText+confirm-key pair in a * narrow save/restore so a user's clipboard survives the test run — the window * between save and restore is microseconds. * * - `confirm` — key (string) or sequence (array) to press after writeText. * Defaults to 'Control+V'. Use ['Control+a', 'Control+v'] for select-all-then-paste, * or 'Shift+F11' for the goto-link dialog. * - `postDelay` — ms to wait between confirm-press and restore, for dialogs * that read clipboard asynchronously (e.g. Shift+F11). Default 0. */ export async function pasteText(text, { confirm = 'Control+V', postDelay = 0 } = {}) { if (!page) return; if (preserveClipboard) await saveClipboard(); try { await page.evaluate(`navigator.clipboard.writeText(${JSON.stringify(String(text))})`); if (Array.isArray(confirm)) { for (const key of confirm) await page.keyboard.press(key); } else if (confirm) { await page.keyboard.press(confirm); } if (postDelay) await page.waitForTimeout(postDelay); } finally { if (preserveClipboard) await restoreClipboard(); } } // ============================================================ // Session lifecycle + multi-context — extracted to core/session.mjs // ============================================================ export { connect, disconnect, attach, detach, getSession, createContext, setActiveContext, listContexts, getActiveContext, hasContext, closeContext, } from './core/session.mjs'; // ============================================================ // Wait + error/modal handling — extracted to core/{wait,errors}.mjs // ============================================================ import { waitForStable, waitForCondition, startNetworkMonitor, } from './core/wait.mjs'; import { closeModals, checkForErrors, dismissPendingErrors, fetchErrorStack, _detectPlatformDialogs, _closePlatformDialogs, } from './core/errors.mjs'; import { safeClick, findFieldInputId, readEdd, returnFormState, detectNewForm as helperDetectNewForm, } from './core/helpers.mjs'; import { getGridToggleIcon, shouldClickToggle } from './table/grid-toggle.mjs'; // Re-export only what was publicly exported before the refactor. // waitForStable/waitForCondition/startNetworkMonitor/closeModals/checkForErrors/ // dismissPendingErrors are internal helpers — imported above for local use only. export { fetchErrorStack } from './core/errors.mjs'; /* getPage moved to core/state.mjs */ // ============================================================ // Navigation — extracted to nav/navigation.mjs // ============================================================ export { getPageState, getSections, navigateSection, getCommands, openCommand, switchTab, openFile, navigateLink, } from './nav/navigation.mjs'; /** Read current form state. Single evaluate call via combined script. */ export async function getFormState() { ensureConnected(); const state = await page.evaluate(getFormStateScript()); const err = await checkForErrors(); if (err) { state.errors = err; if (err.confirmation) { state.confirmation = err.confirmation; state.hint = 'Call web_click with a button name (e.g. "Да", "Нет", "Отмена") to respond'; } } // Detect platform-level dialogs (About, Support Info, Error Report) // These are NOT 1C forms — invisible to detectForms() and not closeable via Escape. const pd = await _detectPlatformDialogs(); if (pd.length) state.platformDialogs = pd; return state; } /** Read structured table data with pagination. Returns columns, rows, total count. */ 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'); 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 })); } // --- Spreadsheet helpers (shared by readSpreadsheet and clickElement) --- /** * Scan spreadsheet iframes for the current form and collect all cells. * Returns { allCells: Map<'r_c', {r,c,t}>, frameMap: Map<'r_c', frameIndex> } * where frameIndex is the Playwright frames[] index (1-based, 0 = main). */ async function scanSpreadsheetCells(formNum) { const prefix = `form${formNum ?? 0}_`; const iframeHandles = await page.$$('iframe'); const allCells = new Map(); const frameMap = new Map(); // key 'r_c' → Playwright Frame object for (const handle of iframeHandles) { const ok = await handle.evaluate((f, pfx) => { if (f.offsetWidth < 100) return false; let el = f.parentElement; for (let d = 0; el && d < 30; d++, el = el.parentElement) { if (el.id && el.id.startsWith(pfx)) return true; } return false; }, prefix); if (!ok) continue; const frame = await handle.contentFrame(); if (!frame) continue; try { const cells = await frame.evaluate(`(() => { const cells = []; document.querySelectorAll('div[x]').forEach(d => { const span = d.querySelector('span'); const text = span?.innerText?.replace(/\\n/g, ' ')?.trim() || ''; if (!text) return; const rowDiv = d.parentElement; const row = rowDiv?.getAttribute('y') || rowDiv?.className?.match(/R(\\d+)/)?.[1] || null; const col = d.getAttribute('x'); if (row != null && col != null) cells.push({ r: parseInt(row), c: parseInt(col), t: text }); }); return cells; })()`); for (const cell of cells) { const key = `${cell.r}_${cell.c}`; if (!allCells.has(key) || cell.t.length > allCells.get(key).t.length) { allCells.set(key, cell); frameMap.set(key, frame); } } } catch { /* skip inaccessible frames */ } } return { allCells, frameMap }; } /** * Build structured mapping from raw cells: headers, column map, data/totals row indices. * Returns { rows, sortedRows, maxCol, colNames, headerRowIdx, dataStartIdx, totalsRowIdx, rowMap } * or null if header detection fails. */ function buildSpreadsheetMapping(allCells) { const rowMap = new Map(); let maxCol = 0; for (const cell of allCells.values()) { if (!rowMap.has(cell.r)) rowMap.set(cell.r, new Map()); rowMap.get(cell.r).set(cell.c, cell.t); if (cell.c > maxCol) maxCol = cell.c; } const sortedRows = [...rowMap.keys()].sort((a, b) => a - b); const rows = sortedRows.map(r => { const cm = rowMap.get(r); const arr = []; for (let c = 0; c <= maxCol; c++) arr.push(cm.get(c) || ''); return arr; }); // Generic numeric check: digits with optional spaces/commas, excludes codes like "68/78" // Accepts bare integers (e.g. account codes "50", "84") — used for hasNumber / totals classification. const isNumericVal = (c) => { if (!c || !/\d/.test(c)) return false; const s = c.replace(/^[-\s\u00a0]+/, '').replace(/[\s\u00a0]/g, ''); return /^\d[\d,]*$/.test(s); }; // Data-formatted numeric value: requires a formatting signal (grouping space, decimal comma, or leading minus). // Used as the anchor for first data row — avoids false positives on bare account codes like "50", "51". const isDataNumericVal = (c) => { if (!isNumericVal(c)) return false; return /[\s\u00a0,]/.test(c) || /^-/.test(c); }; const hasNumber = (row) => row.some(c => isNumericVal(c)); const nonEmpty = (row) => row.filter(c => c !== '').length; // Build a rich mapping (group/super/DCS) anchored at a known detailIdx + firstDataIdx. // Shared by Level 1 (DCS-code anchor) and Level 2 (formatted-number anchor). const buildRichMapping = (detailIdx, firstDataIdx) => { let groupIdx = -1; if (detailIdx > 0 && nonEmpty(rows[detailIdx - 1]) >= 2) groupIdx = detailIdx - 1; const detailRow = rows[detailIdx]; const groupRow = groupIdx >= 0 ? rows[groupIdx] : null; // Detect optional third header level above group row (bounds carry-forward) let superRow = null; if (groupIdx > 0 && nonEmpty(rows[groupIdx - 1]) >= 2) { superRow = rows[groupIdx - 1]; } // Build column names (group + detail merge) const groupFilled = new Array(maxCol + 1).fill(''); if (groupRow) { let cur = ''; for (let c = 0; c <= maxCol; c++) { if (groupRow[c]) { cur = groupRow[c]; } else if (superRow && superRow[c]) { // New top-level header starts here — stop carry-forward cur = ''; } groupFilled[c] = cur; } } const detailCounts = {}; for (let c = 0; c <= maxCol; c++) { const n = detailRow[c]; if (n) detailCounts[n] = (detailCounts[n] || 0) + 1; } // Detect DCS column codes (К1, К2, ...) — always prefix with group when present const detailNonEmpty = detailRow.filter(c => c); const isDcsCodeRow = detailNonEmpty.length >= 2 && detailNonEmpty.every(c => /^К\d+$/.test(c)); const colNames = []; for (let c = 0; c <= maxCol; c++) { const detail = detailRow[c]; const group = groupFilled[c]; const sup = superRow ? superRow[c] : ''; if (detail) { // Prefer group prefix; fall back to superRow for DCS code columns without sub-group const prefix = group && group !== detail ? group : (isDcsCodeRow && sup ? sup : ''); const needPrefix = prefix && (isDcsCodeRow || detailCounts[detail] > 1 || (groupRow && groupRow[c] === '')); colNames.push(needPrefix ? `${prefix} / ${detail}` : detail); } else if (group) { colNames.push(group); } else if (sup) { colNames.push(sup); } else { colNames.push(null); } } const colMap = new Map(); for (let c = 0; c < colNames.length; c++) { if (colNames[c]) colMap.set(colNames[c], c); } // Classify data rows: separate data indices and totals index const dataRowIndices = []; let totalsRowIdx = -1; for (let i = firstDataIdx; i < rows.length; i++) { if (!hasNumber(rows[i]) && nonEmpty(rows[i]) === 0) continue; const first = rows[i][0]?.trim().toLowerCase(); if (first === 'итого' || first === 'всего') { totalsRowIdx = i; } else { dataRowIndices.push(i); } } const superRowIdx = superRow ? groupIdx - 1 : -1; return { rows, sortedRows, maxCol, colNames, colMap, headerRowIdx: detailIdx, groupRowIdx: groupIdx, superRowIdx, dataStartIdx: firstDataIdx, dataRowIndices, totalsRowIdx, rowMap, hasNumber, nonEmpty, }; }; // --- Level 1: DCS-code row anchor --- // ФСД / СКД-отчёты всегда содержат строку "К1, К2, ..." — rock-solid structural marker. // Якорение через неё — детерминированное, работает даже если все данные — голые целые (отчёт в "тыс.руб"). for (let i = 0; i < rows.length; i++) { const detailNonEmpty = rows[i].filter(c => c); if (detailNonEmpty.length >= 2 && detailNonEmpty.every(c => /^К\d+$/.test(c))) { // Find first non-empty row after the К-codes row as data start let firstDataIdx = rows.length; for (let j = i + 1; j < rows.length; j++) { if (nonEmpty(rows[j]) > 0) { firstDataIdx = j; break; } } return buildRichMapping(i, firstDataIdx); } } // --- Level 2: formatted-number anchor (heuristic for reports without DCS codes) --- let firstDataIdx = rows.length; for (let i = 0; i < rows.length; i++) { if (rows[i].filter(c => isDataNumericVal(c)).length >= 2) { firstDataIdx = i; break; } } if (firstDataIdx === rows.length) { for (let i = 0; i < rows.length; i++) { if (rows[i].some(c => isDataNumericVal(c))) { firstDataIdx = i; break; } } } if (firstDataIdx < rows.length) { let detailIdx = -1; for (let i = firstDataIdx - 1; i >= 0; i--) { if (nonEmpty(rows[i]) >= Math.min(3, maxCol + 1)) { detailIdx = i; break; } } if (detailIdx !== -1) return buildRichMapping(detailIdx, firstDataIdx); } // --- Level 3: single-row header fallback (text-only data, query console) --- // First "wide" row (nonEmpty >= 2) = headers, rest = data. No multi-level composition. let headerIdx = -1; for (let i = 0; i < rows.length; i++) { if (nonEmpty(rows[i]) >= 2) { headerIdx = i; break; } } // Single-column tables: accept nonEmpty >= 1 if (headerIdx === -1 && maxCol === 0) { for (let i = 0; i < rows.length; i++) { if (nonEmpty(rows[i]) >= 1) { headerIdx = i; break; } } } if (headerIdx === -1) return null; // truly empty — top-level fallback to { rows, total } const detailRow = rows[headerIdx]; const colNames = []; for (let c = 0; c <= maxCol; c++) colNames.push(detailRow[c] || null); const colMap = new Map(); for (let c = 0; c < colNames.length; c++) { if (colNames[c]) colMap.set(colNames[c], c); } const dataRowIndices = []; let totalsRowIdx = -1; for (let i = headerIdx + 1; i < rows.length; i++) { if (!hasNumber(rows[i]) && nonEmpty(rows[i]) === 0) continue; const first = rows[i][0]?.trim().toLowerCase(); if (first === 'итого' || first === 'всего') { totalsRowIdx = i; } else { dataRowIndices.push(i); } } return { rows, sortedRows, maxCol, colNames, colMap, headerRowIdx: headerIdx, groupRowIdx: -1, superRowIdx: -1, dataStartIdx: headerIdx + 1, dataRowIndices, totalsRowIdx, rowMap, hasNumber, nonEmpty, }; } /** * Scroll SpreadsheetDocument to make a cell visible using arrow keys. * Uses native platform scroll — keeps headers, data, and scrollbar synchronized. * * How it works: * 1. Check target cell visibility via Playwright boundingBox (page-level coords). * 2. Click a fully-visible cell via page.mouse.click through the mxlCurrBody overlay. * This is the same native click that clickSpreadsheetCell uses — it gives keyboard * focus to the spreadsheet and keeps headers/data/scrollbar in sync. * (frame.locator().click() bypasses overlay → desyncs frozen headers; * page.mouse.click() + frameEl.focus() doesn't transfer keyboard focus.) * 3. Press ArrowRight/ArrowLeft until the target cell is fully within the viewport. * * @param {Frame} frame - Playwright Frame containing the spreadsheet cells * @param {number} physRow - physical row (y attribute) in the frame * @param {number} physCol - physical column (x attribute) in the frame * @param {Locator} cellLoc - Playwright locator for the target cell (from caller) */ async function scrollSpreadsheetToCell(frame, physRow, physCol, cellLoc) { const pageVw = await page.evaluate('window.innerWidth'); // Get iframe bounds — the actual visible region on page. // The iframe may extend behind the section panel on the left, so cells with // x >= 0 but x < iframeBox.x are behind the panel. Clicking them hits the panel. const frameElm = await frame.frameElement(); const frameBox = await frameElm.boundingBox(); const visLeft = frameBox ? frameBox.x : 0; const visRight = frameBox ? Math.min(frameBox.x + frameBox.width, pageVw) : pageVw; const getBox = async () => { try { return await cellLoc.boundingBox({ timeout: 500 }); } catch { return null; } }; const isFullyVisible = (box) => box && box.x >= visLeft && (box.x + box.width) <= visRight; let box = await getBox(); if (!box) return; // cell not in DOM if (isFullyVisible(box)) return; const direction = (box.x + box.width) > pageVw ? 'ArrowRight' : 'ArrowLeft'; // Find a fully-visible cell to click for focus. // Prefer cells in the target row (scrollable area), fall back to any row. const targetRowSel = `div[y="${physRow}"] div[x]`; const anyRowSel = 'div[x]'; let focusClicked = false; for (const sel of [targetRowSel, anyRowSel]) { const locs = frame.locator(sel); const count = await locs.count(); const candidates = []; for (let ci = 0; ci < count; ci++) { const b = await locs.nth(ci).boundingBox(); if (b && b.width > 5 && b.x >= visLeft && (b.x + b.width) <= visRight) { candidates.push({ ci, box: b }); } } if (candidates.length === 0) continue; candidates.sort((a, b) => a.box.x - b.box.x); // ArrowRight → rightmost fully-visible (each press scrolls right immediately) // ArrowLeft → leftmost fully-visible (each press scrolls left immediately) const pick = direction === 'ArrowRight' ? candidates[candidates.length - 1] : candidates[0]; // Native click through overlay — gives keyboard focus + no header desync. await page.mouse.click(pick.box.x + pick.box.width / 2, pick.box.y + pick.box.height / 2); await page.waitForTimeout(100); focusClicked = true; break; } if (!focusClicked) return; // no visible cells — can't scroll // Arrow keys until cell is fully visible or we detect no progress. const MAX_STALE = 5; // bail out if arrows aren't scrolling (lost focus?) let prevCx = box.x + box.width / 2; let staleCount = 0; for (let i = 0; i < 100; i++) { await page.keyboard.press(direction); await page.waitForTimeout(50); box = await getBox(); if (!box) break; if (isFullyVisible(box)) break; const cx = box.x + box.width / 2; if (Math.abs(cx - prevCx) >= 1) { staleCount = 0; } else { staleCount++; if (staleCount >= MAX_STALE) break; } prevCx = cx; } await page.waitForTimeout(200); } /** * Click a cell in SpreadsheetDocument by logical coordinates. * target: { row: number|'totals'|{colName: value}, column: string } * Internal helper — called from clickElement when first arg is an object. */ async function clickSpreadsheetCell(target, { dblclick: dbl, modifier } = {}) { ensureConnected(); const formNum = await page.evaluate(detectFormScript()); const { allCells, frameMap } = await scanSpreadsheetCells(formNum); if (allCells.size === 0) throw new Error('clickElement: no SpreadsheetDocument found on current form.'); const mapping = buildSpreadsheetMapping(allCells); if (!mapping) throw new Error('clickElement: could not detect spreadsheet headers. Use readSpreadsheet() to check report structure.'); const { rows, sortedRows, colNames, colMap, dataRowIndices, totalsRowIdx } = mapping; // Resolve column (exact → endsWith " / X" → includes) let colName = target.column; if (!colMap.has(colName)) { const available = colNames.filter(n => n); const suffix = ' / ' + colName; const match = available.find(n => n.endsWith(suffix)) || available.find(n => n.includes(colName)); if (!match) throw new Error(`clickElement: column "${colName}" not found. Available: ${available.join(', ')}`); colName = match; } const physCol = colMap.get(colName); // Resolve row → index into rows[] array let rowIdx; const row = target.row; if (row === 'totals') { if (totalsRowIdx === -1) throw new Error('clickElement: no totals row found in spreadsheet.'); rowIdx = totalsRowIdx; } else if (typeof row === 'number') { if (row < 0 || row >= dataRowIndices.length) throw new Error(`clickElement: row index ${row} out of range (0..${dataRowIndices.length - 1}).`); rowIdx = dataRowIndices[row]; } else if (typeof row === 'object') { // Filter: { colName: value } — find first data row where column matches const filterEntries = Object.entries(row); const norm = s => s?.replace(/\u00a0/g, ' ').trim().toLowerCase() || ''; const resolveCol = (name) => { if (colMap.has(name)) return colMap.get(name); const suffix = ' / ' + name; const available = colNames.filter(n => n); const m = available.find(n => n.endsWith(suffix)) || available.find(n => n.includes(name)); return m ? colMap.get(m) : null; }; rowIdx = dataRowIndices.find(i => { return filterEntries.every(([fCol, fVal]) => { const fColIdx = resolveCol(fCol); if (fColIdx == null) return false; const cellText = norm(rows[i][fColIdx]); const search = norm(fVal); return cellText === search || cellText.includes(search); }); }); if (rowIdx == null) throw new Error(`clickElement: no row matching ${JSON.stringify(row)} found in spreadsheet data.`); } else { throw new Error('clickElement: row must be a number, "totals", or { colName: value } filter object.'); } // Map rows[] index → physical row number const physRow = sortedRows[rowIdx]; const cellKey = `${physRow}_${physCol}`; const frame = frameMap.get(cellKey); if (!frame) { // Cell exists in mapping but might be empty — try clicking anyway throw new Error(`clickElement: cell at row=${JSON.stringify(target.row)}, column="${colName}" is empty or not rendered.`); } // Use [y]+[x] attributes — CSS class RxCy uses different numbering than y/x attrs. const cellDiv = frame.locator(`div[y="${physRow}"] div[x="${physCol}"]`).first(); // Scroll cell into view using arrow keys — the only reliable way to scroll // 1C SpreadsheetDocument without desynchronizing headers, data, and scrollbar. await scrollSpreadsheetToCell(frame, physRow, physCol, cellDiv); const box = await cellDiv.boundingBox(); if (!box) throw new Error(`clickElement: cell y=${physRow} x=${physCol} not visible (no bounding box).`); const x = box.x + box.width / 2; const y = box.y + box.height / 2; const modKey = modifier === 'ctrl' ? 'Control' : modifier === 'shift' ? 'Shift' : null; if (modKey) await page.keyboard.down(modKey); if (dbl) { await page.mouse.dblclick(x, y); } else { await page.mouse.click(x, y); } if (modKey) await page.keyboard.up(modKey); await waitForStable(); const state = await getFormState(); state.clicked = { kind: 'spreadsheetCell', row: target.row, column: colName, ...(dbl ? { dblclick: true } : {}) }; return state; } /** * Search spreadsheet iframes for a cell matching text (for text fallback in clickElement). * Returns { frameIndex, physRow, physCol, box } or null if not found. */ async function findSpreadsheetCellByText(formNum, searchText) { const { allCells, frameMap } = await scanSpreadsheetCells(formNum); if (allCells.size === 0) return null; const norm = s => s?.replace(/\u00a0/g, ' ').trim().toLowerCase() || ''; const target = norm(searchText); // Exact match first, then includes let found = null; for (const [key, cell] of allCells) { if (norm(cell.t) === target) { found = { key, cell }; break; } } if (!found) { for (const [key, cell] of allCells) { if (norm(cell.t).includes(target)) { found = { key, cell }; break; } } } if (!found) return null; const frame = frameMap.get(found.key); if (!frame) return null; // Scroll cell into view using native arrow-key mechanism const cellDiv = frame.locator(`div[y="${found.cell.r}"] div[x="${found.cell.c}"]`).first(); await scrollSpreadsheetToCell(frame, found.cell.r, found.cell.c, cellDiv); const box = await cellDiv.boundingBox(); if (!box) return null; return { frame, physRow: found.cell.r, physCol: found.cell.c, text: found.cell.t, box }; } /** * Read report output (SpreadsheetDocumentField) rendered in iframes. * 1C renders spreadsheet documents as absolutely-positioned div cells inside iframes. * Each cell is a div[x] inside a row div[y], text content in . * * Returns structured data: * { title, headers, data: [{col: val}], totals: {col: val}, total } * If header detection fails, falls back to { rows: string[][], total }. */ export async function readSpreadsheet() { ensureConnected(); const formNum = await page.evaluate(detectFormScript()); const { allCells } = await scanSpreadsheetCells(formNum); if (allCells.size === 0) { // Check for state window messages (info bar) that explain why the report is empty const err = await checkForErrors(); const hint = err?.stateText?.length ? err.stateText.join('; ') : ''; throw new Error('readSpreadsheet: no SpreadsheetDocument found.' + (hint ? ' State: ' + hint : ' Report may not be generated yet.')); } const mapping = buildSpreadsheetMapping(allCells); if (!mapping) { // Fallback: return raw rows const rowMap = new Map(); let maxCol = 0; for (const cell of allCells.values()) { if (!rowMap.has(cell.r)) rowMap.set(cell.r, new Map()); rowMap.get(cell.r).set(cell.c, cell.t); if (cell.c > maxCol) maxCol = cell.c; } const sortedRows = [...rowMap.keys()].sort((a, b) => a - b); const rows = sortedRows.map(r => { const cm = rowMap.get(r); const arr = []; for (let c = 0; c <= maxCol; c++) arr.push(cm.get(c) || ''); return arr; }); return { rows, total: rows.length }; } const { rows, colNames, dataStartIdx, maxCol, groupRowIdx, headerRowIdx, superRowIdx, hasNumber, nonEmpty } = mapping; // Convert data rows to objects const data = []; let totals = null; const toObj = (row) => { const obj = {}; for (let c = 0; c < colNames.length; c++) { if (colNames[c] && row[c]) obj[colNames[c]] = row[c]; } return obj; }; for (let i = dataStartIdx; i < rows.length; i++) { if (!hasNumber(rows[i]) && nonEmpty(rows[i]) === 0) continue; const first = rows[i][0]?.trim().toLowerCase(); if (first === 'итого' || first === 'всего') { totals = toObj(rows[i]); } else { data.push(toObj(rows[i])); } } // Meta: title, params, filters from rows before header (superRow is part of header, not meta) const metaEnd = superRowIdx >= 0 ? superRowIdx : (groupRowIdx >= 0 ? groupRowIdx : headerRowIdx); let title = ''; const meta = []; for (let i = 0; i < metaEnd; i++) { const parts = rows[i].filter(c => c); if (!parts.length) continue; if (!title) { title = parts.join(' '); continue; } meta.push(parts.join(' ')); } return { title: title || undefined, meta: meta.length ? meta : undefined, headers: colNames.filter(n => n), data, totals: totals || undefined, total: data.length, }; } // ============================================================ // Value selection (DLB/CB) — extracted to forms/select-value.mjs // ============================================================ export { selectValue } from './forms/select-value.mjs'; import { selectValue, pickFromSelectionForm, isTypeDialog, pickFromTypeDialog, fillReferenceField, } from './forms/select-value.mjs'; /** Fill fields on the current form via Playwright page.fill(). Returns fill results + updated form. */ export async function fillFields(fields) { ensureConnected(); await dismissPendingErrors(); const formNum = await page.evaluate(detectFormScript()); if (formNum === null) throw new Error('fillFields: no form found'); // Resolve field names to element IDs const resolved = await page.evaluate(resolveFieldsScript(formNum, fields)); const results = []; for (const r of resolved) { if (r.error) { results.push(r); continue; } // Auto-highlight the field input before filling if (highlightMode && r.inputId) { try { await page.evaluate(({ id }) => { const target = document.getElementById(id); if (!target) return; let div = document.getElementById('__web_test_highlight'); if (!div) { div = document.createElement('div'); div.id = '__web_test_highlight'; document.body.appendChild(div); } const r = target.getBoundingClientRect(); div.style.cssText = 'position:fixed;pointer-events:none;z-index:999998;top:' + (r.y-4) + 'px;left:' + (r.x-4) + 'px;width:' + (r.width+8) + 'px;height:' + (r.height+8) + 'px;outline:3px solid #e74c3c;border-radius:4px;box-shadow:0 0 16px #e74c3c80'; }, { id: r.inputId }); await page.waitForTimeout(500); await unhighlight(); } catch {} } try { // Auto-enable DCS checkbox if resolved via label if (r.dcsCheckbox && !r.dcsCheckbox.checked) { await page.click(`[id="${r.dcsCheckbox.inputId}"]`); await waitForStable(); } const selector = `[id="${r.inputId}"]`; // Clear field via Shift+F4 if value is empty (not applicable to checkbox/radio) const rawValue = fields[r.field]; const isEmpty = rawValue === '' || rawValue === null || rawValue === undefined; if (isEmpty && !r.isCheckbox && !r.isRadio) { await page.click(selector); await page.waitForTimeout(200); await page.keyboard.press('Shift+F4'); await page.waitForTimeout(300); await page.keyboard.press('Tab'); await waitForStable(); results.push({ field: r.field, ok: true, value: '', method: 'clear' }); continue; } if (r.isCheckbox) { // Checkbox: compare desired with current, toggle if mismatch const desired = String(fields[r.field]).toLowerCase(); const wantChecked = ['true', '1', 'да', 'yes', 'on'].includes(desired); if (wantChecked !== r.checked) { await page.click(selector); await waitForStable(); } results.push({ field: r.field, ok: true, value: String(wantChecked), method: 'toggle' }); } else if (r.isRadio) { // Radio button: find option by label (fuzzy match) and click it const desired = normYo(String(fields[r.field]).toLowerCase()); const opt = r.options.find(o => normYo(o.label.toLowerCase()) === desired) || r.options.find(o => normYo(o.label.toLowerCase()).includes(desired)); if (opt) { // Option 0 = base element (no suffix), options 1+ = #N#radio const radioId = opt.index === 0 ? r.inputId : `${r.inputId}#${opt.index}#radio`; await page.click(`[id="${radioId}"]`); await waitForStable(); results.push({ field: r.field, ok: true, value: opt.label, method: 'radio' }); } else { results.push({ field: r.field, error: 'option_not_found', available: r.options.map(o => o.label) }); } } else if (r.hasSelect) { // Combobox/reference with DLB: DLB-first, then paste fallback const refResult = await fillReferenceField(selector, r.field, fields[r.field], formNum); results.push(refResult); } else if (r.hasPick && r.isDate) { // Date/time field with calendar CB — use paste (calendar is not a selection form) await page.click(selector); await page.waitForTimeout(200); await page.keyboard.press('Control+A'); await pasteText(fields[r.field]); await page.waitForTimeout(300); await page.keyboard.press('Tab'); await waitForStable(); results.push({ field: r.field, ok: true, value: String(fields[r.field]), method: 'paste' }); } else if (r.hasPick) { // Reference field with CB (non-editable or editable ref): delegate to selectValue (F4 → selection form) const svResult = await selectValue(r.field, String(fields[r.field])); if (svResult?.error) { results.push({ field: r.field, error: svResult.error, message: svResult.message }); } else { results.push({ field: r.field, ok: true, value: svResult.value || String(fields[r.field]), method: svResult.method || 'form' }); } } else { // Plain field: clipboard paste + Tab to commit // page.fill() sets DOM value but doesn't trigger 1C input events; // clipboard paste (Ctrl+V) is a trusted event that 1C processes correctly. await page.click(selector); await page.waitForTimeout(200); await page.keyboard.press('Control+A'); await pasteText(fields[r.field]); await page.waitForTimeout(300); await page.keyboard.press('Tab'); await waitForStable(); results.push({ field: r.field, ok: true, value: String(fields[r.field]), method: 'paste' }); } } catch (e) { results.push({ field: r.field, error: e.message }); } if (highlightMode) try { await unhighlight(); } catch {} } const formData = await page.evaluate(readFormScript(formNum)); const failed = results.filter(r => r.error); if (failed.length > 0) { const details = failed.map(f => ` ${f.field}: ${f.message || f.error}${f.available ? ' (available: ' + f.available.join(', ') + ')' : ''}`).join('\n'); throw new Error(`fillFields: ${failed.length} of ${results.length} field(s) failed:\n${details}`); } return { filled: results, form: formData }; } /** Convenience alias: fill a single field. Same as fillFields({ name: value }). */ export async function fillField(name, value) { return fillFields({ [name]: value }); } /** Click a button/hyperlink/tab on the current form. Use {dblclick: true} to double-click (open items from lists). * First argument can also be an object { row, column } to click a SpreadsheetDocument cell. */ export async function clickElement(text, { dblclick, table, toggle, expand, modifier, timeout } = {}) { ensureConnected(); // Dispatch to spreadsheet cell handler when first arg is { row, column } if (typeof text === 'object' && text !== null && text.column != null) { await dismissPendingErrors(); return clickSpreadsheetCell(text, { dblclick, modifier }); } await dismissPendingErrors(); if (highlightMode) try { await highlight(text, { table }); await page.waitForTimeout(500); await unhighlight(); } catch {} let netMonitor = null; try { // First check if there's a confirmation dialog — click matching button const pending = await checkForErrors(); if (pending?.confirmation) { const btnResult = await page.evaluate(`(() => { const norm = s => s?.trim().replace(/\\u00a0/g, ' ') || ''; const ny = s => s.replace(/ё/gi, 'е').replace(/\\u00a0/g, ' '); const target = ny(${JSON.stringify(text.toLowerCase())}); const btns = [...document.querySelectorAll('a.press.pressButton')].filter(el => el.offsetWidth > 0); let best = btns.find(el => ny(norm(el.innerText).toLowerCase()) === target); if (!best) best = btns.find(el => ny(norm(el.innerText).toLowerCase()).includes(target)); if (best) { const r = best.getBoundingClientRect(); return { name: norm(best.innerText), x: Math.round(r.x + r.width/2), y: Math.round(r.y + r.height/2) }; } return { error: 'not_found', available: btns.map(el => norm(el.innerText)).filter(Boolean) }; })()`); if (btnResult?.error) throw new Error(`clickElement: "${text}" not found among confirmation buttons. Available: ${btnResult.available?.join(', ') || 'none'}`); await page.mouse.click(btnResult.x, btnResult.y); await waitForStable(); const state = await getFormState(); state.clicked = { kind: 'confirmation', name: btnResult.name }; return state; } // Check if there's an open popup — if so, try to click inside it const popupItems = await page.evaluate(readSubmenuScript()); if (Array.isArray(popupItems) && popupItems.length > 0) { const target = normYo(text.toLowerCase()); let found = popupItems.find(i => normYo(i.name.toLowerCase()) === target); if (!found) found = popupItems.find(i => normYo(i.name.toLowerCase()).includes(target)); if (found) { // submenuArrow items (group headers like "Создать", "Печать") — hover to expand nested submenu if (found.kind === 'submenuArrow') { // page.hover(selector) is more reliable than page.mouse.move(x,y) — // some submenu groups don't expand with plain mouse.move if (found.id) { await page.hover(`[id="${found.id}"]`); } else { await page.mouse.move(found.x, found.y); } await page.waitForTimeout(ACTION_WAIT); const nestedItems = await page.evaluate(readSubmenuScript()); const state = await getFormState(); state.clicked = { kind: 'submenuArrow', name: found.name }; if (Array.isArray(nestedItems)) { state.submenu = nestedItems.map(i => i.name); state.hint = 'Call web_click again with a submenu item name to select it'; } return state; } // Regular submenu/dropdown items — trusted events required. // Use mouse.click(x,y) when in viewport; use :visible selector for clipped items // (same ID can exist hidden in parent cloud AND visible in nested cloud). const vpHeight = await page.evaluate('window.innerHeight'); if (found.x && found.y && found.y > 0 && found.y < vpHeight) { await page.mouse.click(found.x, found.y); } else if (found.id) { await page.click(`[id="${found.id}"]:visible`); } else if (found.x && found.y) { await page.mouse.click(found.x, found.y); } await waitForStable(); const state = await getFormState(); state.clicked = { kind: 'popupItem', name: found.name }; const err = await checkForErrors(); if (err) state.errors = err; return state; } // No match in popup — fall through to form elements } 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, { 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. if (target?.error) { for (let retry = 0; retry < 4; retry++) { await page.waitForTimeout(500); const newForm = await page.evaluate(detectFormScript()); if (newForm !== null && newForm !== formNum) { formNum = newForm; target = await page.evaluate(findClickTargetScript(formNum, text, { tableName: table, gridSelector })); if (!target?.error) break; } } } // Fallback: search spreadsheet iframes for text match before giving up if (target?.error) { const ssCell = await findSpreadsheetCellByText(formNum, text); if (ssCell) { const cx = ssCell.box.x + ssCell.box.width / 2; const cy = ssCell.box.y + ssCell.box.height / 2; const modKey = modifier === 'ctrl' ? 'Control' : modifier === 'shift' ? 'Shift' : null; if (modKey) await page.keyboard.down(modKey); if (dblclick) await page.mouse.dblclick(cx, cy); else await page.mouse.click(cx, cy); if (modKey) await page.keyboard.up(modKey); await waitForStable(); const state = await getFormState(); state.clicked = { kind: 'spreadsheetCell', name: ssCell.text, ...(dblclick ? { dblclick: true } : {}) }; return state; } throw new Error(`clickElement: "${text}" not found. Available: ${target.available?.join(', ') || 'none'}`); } // Helper: click with optional modifier key (Ctrl/Shift for multi-select) const modKey = modifier === 'ctrl' ? 'Control' : modifier === 'shift' ? 'Shift' : null; async function modClick(x, y) { if (modKey) await page.keyboard.down(modKey); await page.mouse.click(x, y); if (modKey) await page.keyboard.up(modKey); } async function modDblClick(x, y) { if (modKey) await page.keyboard.down(modKey); await page.mouse.dblclick(x, y); if (modKey) await page.keyboard.up(modKey); } // Grid row targets — use coordinate click (single or double) if (target.kind === 'gridGroup' || target.kind === 'gridParent') { if (expand != null || toggle) { // Expand/collapse group in hierarchy mode — click the triangle icon (.gridListH/.gridListV). // expand=true: only expand (skip if already expanded), expand=false: only collapse, toggle: always click. const levelIconInfo = await getGridToggleIcon(target, formNum, { iconSelector: '.gridListH, .gridListV', isExpandedExpr: "icon.classList.contains('gridListV')", }); const shouldClick = shouldClickToggle(levelIconInfo, expand, toggle); if (shouldClick) { if (levelIconInfo) { await modClick(levelIconInfo.x, levelIconInfo.y); } else { // Fallback: dblclick (standard hierarchy navigation) await modDblClick(target.x, target.y); } } await waitForStable(formNum); const state = await getFormState(); state.clicked = { kind: target.kind, name: target.name, toggled: shouldClick, ...(modifier ? { modifier } : {}) }; state.hint = shouldClick ? 'Group toggled. Use readTable to see updated list.' : 'Group already in desired state.'; return state; } // Default: dblclick to enter group / go up to parent await modDblClick(target.x, target.y); await waitForStable(formNum); const state = await getFormState(); state.clicked = { kind: target.kind, name: target.name, ...(modifier ? { modifier } : {}) }; return state; } if (target.kind === 'gridTreeNode') { if (expand != null || toggle) { // Expand/collapse tree node — click the tree icon [tree="true"]. // expand=true: only expand (skip if already expanded), expand=false: only collapse, toggle: always click. const treeIconInfo = await getGridToggleIcon(target, formNum, { iconSelector: '.gridBoxImg [tree="true"]', isExpandedExpr: '(icon.style.backgroundImage || "").includes("gx=0")', }); const shouldClick = shouldClickToggle(treeIconInfo, expand, toggle); if (shouldClick) { if (treeIconInfo) { await modClick(treeIconInfo.x, treeIconInfo.y); } else { // Fallback: dblclick on row (works for trees without clickable +/- icons) await modDblClick(target.x, target.y); } } await waitForStable(formNum); const state = await getFormState(); state.clicked = { kind: 'gridTreeNode', name: target.name, toggled: shouldClick, ...(modifier ? { modifier } : {}) }; state.hint = shouldClick ? 'Tree node toggled. Use readTable to see updated tree.' : 'Tree node already in desired state.'; return state; } // Default: select row (click text, no expand/collapse) await modClick(target.x, target.y); await waitForStable(formNum); const state = await getFormState(); state.clicked = { kind: 'gridTreeNode', name: target.name, ...(modifier ? { modifier } : {}) }; state.hint = 'Row selected. Use { expand: true } to expand/collapse.'; return state; } if (target.kind === 'gridRow') { if (dblclick) { await modDblClick(target.x, target.y); await waitForStable(); const state = await getFormState(); state.clicked = { kind: 'gridRow', name: target.name, dblclick: true, ...(modifier ? { modifier } : {}) }; return state; } await modClick(target.x, target.y); await waitForStable(); const state = await getFormState(); state.clicked = { kind: 'gridRow', name: target.name, ...(modifier ? { modifier } : {}) }; return state; } // Start CDP network monitor BEFORE the click for buttons — // so we capture all server requests triggered by the click. if (target.kind === 'button') { try { netMonitor = await startNetworkMonitor(); } catch {} } // Tabs without ID — use coordinate click to avoid global [data-content] ambiguity if (target.kind === 'tab' && !target.id && target.x && target.y) { await page.mouse.click(target.x, target.y); } else { const selector = `[id="${target.id}"]`; // Use Playwright click for proper mousedown/mouseup events await safeClick(selector, { timeout: 5000 }); } // If submenu button — read popup items and return them as hints if (target.kind === 'submenu') { await page.waitForTimeout(ACTION_WAIT); const submenuItems = await page.evaluate(readSubmenuScript()); const state = await getFormState(); state.clicked = { kind: 'submenu', name: target.name }; if (Array.isArray(submenuItems)) { state.submenu = submenuItems.map(i => i.name); state.hint = 'Call web_click again with a submenu item name to select it'; } return state; } await waitForStable(formNum); // Check if the click opened a popup/submenu (split buttons like "Создать на основании") const openedPopup = await page.evaluate(readSubmenuScript()); if (Array.isArray(openedPopup) && openedPopup.length > 0) { const state = await getFormState(); state.clicked = { kind: 'submenu', name: target.name }; state.submenu = openedPopup.map(i => i.name); state.hint = 'Call web_click again with a submenu item name to select it'; return state; } // For buttons that trigger server-side operations (post, write, etc.), // the DOM may stabilize BEFORE the server response arrives. // Use waitForSelector to detect error modal — this doesn't block the JS event loop. // Skip for grid edit mode (e.g. "Добавить" row) — no server round-trip expected. if (target.kind === 'button') { const postForm = await page.evaluate(detectFormScript()); if (postForm === formNum) { const inGridEdit = await page.evaluate(`(() => { const f = document.activeElement; if (!f || (f.tagName !== 'INPUT' && f.tagName !== 'TEXTAREA')) return false; let n = f; while (n) { if (n.classList?.contains('grid')) return true; n = n.parentElement; } return false; })()`); if (!inGridEdit && netMonitor) { // Form didn't change — server might still be processing. // CDP monitor was started before click — wait for all requests to complete // (300ms debounce) or for a modal/balloon/confirm to appear. await netMonitor.waitDone(timeout); await waitForStable(); } } } // Form may have changed — re-detect const state = await getFormState(); state.clicked = { kind: target.kind, name: target.name }; const err = await checkForErrors(); if (err) { state.errors = err; if (err.confirmation) { state.confirmation = err.confirmation; state.hint = 'Call web_click with a button name (e.g. "Да", "Нет", "Отмена") to respond'; } } return state; } finally { if (netMonitor) try { await netMonitor.cleanup(); } catch {} if (highlightMode) try { await unhighlight(); } catch {} } } /** * Close the current form/dialog via Escape. * @param {Object} [opts] * @param {boolean} [opts.save] - Handle "Save changes?" confirmation automatically: * true → click "Да" (save and close) * false → click "Нет" (discard and close) * undefined → return confirmation as hint for caller to decide */ export async function closeForm({ save } = {}) { ensureConnected(); await dismissPendingErrors(); // If platform dialogs are open, close them instead of pressing Escape const pd = await _detectPlatformDialogs(); if (pd.length) { await _closePlatformDialogs(); await page.waitForTimeout(300); const state = await getFormState(); state.closed = true; state.closedPlatformDialogs = pd; return state; } const beforeForm = await page.evaluate(detectFormScript()); await page.keyboard.press('Escape'); await waitForStable(beforeForm); const state = await getFormState(); const err = await checkForErrors(); if (err?.confirmation) { if (save === true || save === false) { const label = save ? 'Да' : 'Нет'; const btnSel = `#form${err.confirmation.formNum}_container a.press.pressButton`; const btns = await page.$$(btnSel); for (const b of btns) { const txt = (await b.textContent()).trim(); if (txt === label) { if (recorder) await page.waitForTimeout(500); // show confirmation to viewer during recording await b.click({ force: true }); await waitForStable(beforeForm); break; } } const afterState = await getFormState(); afterState.closed = afterState.form !== beforeForm; return afterState; } state.confirmation = err.confirmation; state.hint = 'Confirmation dialog shown. Click "Да" to confirm or "Нет" to cancel'; return state; } state.closed = state.form !== beforeForm; return state; } /** * Fill cells in the current table row via Tab navigation. * Grid cells are only accessible sequentially (Tab) — no random access. * * After "Добавить", 1C enters inline edit mode on the first cell. * All inputs in the row are created hidden (offsetWidth=0); only the active one is visible. * Tab moves through cells in a fixed order determined by the form configuration. * * @param {Object} fields - { fieldName: value } map (fuzzy match: "Номенклатура" → "ТоварыНоменклатура") * @param {Object} [options] * @param {string} [options.tab] - Switch to this form tab before operating * @param {boolean} [options.add] - Click "Добавить" to create a new row first * @returns {{ filled[], notFilled[]?, form }} */ 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) { await clickElement(tab); } // 2. Add new row if requested let addedRowIdx = -1; if (add) { // Count rows before add — new row will be appended at this index addedRowIdx = await page.evaluate(`(() => { const grid = ${gridSelector ? `document.querySelector(${JSON.stringify(gridSelector)})` : `(() => { const grids = [...document.querySelectorAll('.grid')].filter(el => el.offsetWidth > 0); return grids[grids.length - 1]; })()`}; const body = grid?.querySelector('.gridBody'); return body ? body.querySelectorAll('.gridLine').length : 0; })()`); 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); const ready = await page.evaluate(`(() => { const f = document.activeElement; if (!f || (f.tagName !== 'INPUT' && f.tagName !== 'TEXTAREA')) return false; let n = f; while (n) { if (n.classList?.contains('grid')) return true; n = n.parentElement; } return false; })()`); if (ready) break; } } // 2b. Enter edit mode on existing row by dblclick if (row != null) { // Sort fields by colindex (leftmost first) so Tab traversal covers all fields left-to-right const sortedKeys = await page.evaluate(`(() => { 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'); if (!head) return null; const headLine = head.querySelector('.gridLine') || head; const cols = []; [...headLine.children].forEach(box => { if (box.offsetWidth === 0) return; const t = ((box.querySelector('.gridBoxText') || box).innerText?.trim() || '').toLowerCase(); const ci = parseInt(box.getAttribute('colindex') || '-1'); if (t) cols.push({ text: t, colindex: ci }); }); const keys = ${JSON.stringify(Object.keys(fields).map(k => k.toLowerCase()))}; const mapped = keys.map(k => { const exact = cols.find(c => c.text === k); if (exact) return { key: k, colindex: exact.colindex }; const inc = cols.find(c => c.text.includes(k) || k.includes(c.text)); return { key: k, colindex: inc ? inc.colindex : 999 }; }); mapped.sort((a, b) => a.colindex - b.colindex); return mapped.map(m => m.key); })()`); if (sortedKeys) { // Rebuild fields in sorted order const sortedFields = {}; for (const kl of sortedKeys) { const origKey = Object.keys(fields).find(k => k.toLowerCase() === kl); if (origKey) sortedFields[origKey] = fields[origKey]; } // Add any keys not matched in header (preserve original order for those) for (const k of Object.keys(fields)) { if (!(k in sortedFields)) sortedFields[k] = fields[k]; } fields = sortedFields; } const fieldKeys = JSON.stringify(Object.keys(fields).map(k => k.toLowerCase())); const cellCoords = await page.evaluate(`(() => { 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'); if (!head || !body) return { error: 'no_grid_body' }; // Read column headers to find target colindex const headLine = head.querySelector('.gridLine') || head; const cols = []; [...headLine.children].forEach(box => { if (box.offsetWidth === 0) return; const t = box.querySelector('.gridBoxText'); const ci = box.getAttribute('colindex'); cols.push({ colindex: ci, text: ((t || box).innerText?.trim() || '').toLowerCase() }); }); const keys = ${fieldKeys}; let targetColindex = null; for (const key of keys) { const exact = cols.find(c => c.text === key); if (exact) { targetColindex = exact.colindex; break; } const inc = cols.find(c => c.text.includes(key) || key.includes(c.text)); if (inc) { targetColindex = inc.colindex; break; } } const rows = [...body.querySelectorAll('.gridLine')]; if (${row} >= rows.length) return { error: 'row_out_of_range', total: rows.length }; const line = rows[${row}]; // Find body cell by colindex (reliable across merged headers) let box = null; if (targetColindex != null) { box = [...line.children].find(b => b.offsetWidth > 0 && b.getAttribute('colindex') === targetColindex); } // Fallback: second visible box (skip checkbox/N column) if (!box) { const boxes = [...line.children].filter(b => b.offsetWidth > 0 && !b.classList.contains('gridBoxComp')); box = boxes.length > 1 ? boxes[1] : boxes[0]; } if (!box) return { error: 'no_cell' }; // Scroll into view if off-screen box.scrollIntoView({ block: 'nearest', inline: 'nearest' }); const cell = box.querySelector('.gridBoxText') || box; const r = cell.getBoundingClientRect(); 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 rawFirstVal = fields[firstKey0]; const firstVal0 = rawFirstVal === null || rawFirstVal === undefined || rawFirstVal === '' ? '' : (typeof rawFirstVal === 'object' ? rawFirstVal.value : String(rawFirstVal)); 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); // Clear cell via Shift+F4 if value is empty if (firstVal0 === '') { await page.waitForTimeout(500); // Check if click opened a selection form — close it first let openedForm = 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 (openedForm !== null) { await page.keyboard.press('Escape'); await page.waitForTimeout(500); } else { // No form opened — need to enter edit mode first (dblclick), then close any form that opens await page.mouse.dblclick(cellCoords.x, cellCoords.y); await page.waitForTimeout(500); openedForm = 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 (openedForm !== null) { await page.keyboard.press('Escape'); await page.waitForTimeout(500); } } await page.keyboard.press('Shift+F4'); await page.waitForTimeout(300); const results = [{ field: firstKey0, ok: true, method: 'clear', value: '' }]; // If more fields remain, process them on the same row const remaining = { ...fields }; delete remaining[firstKey0]; if (Object.keys(remaining).length > 0) { const more = await fillTableRow(remaining, { row, table }); if (Array.isArray(more)) results.push(...more); else if (more?.filled) results.push(...more.filled); } const formData = await getFormState(); return { filled: results, form: formData }; } // Check if clicked cell is a checkbox (toggle-on-click, no edit mode) const checkboxInfo = await page.evaluate(`(() => { const el = document.elementFromPoint(${cellCoords.x}, ${cellCoords.y}); const cell = el?.closest('.gridBox'); if (!cell) return null; const chk = cell.querySelector('.checkbox'); if (!chk) return null; const r = chk.getBoundingClientRect(); return { checked: chk.classList.contains('select'), x: Math.round(r.x + r.width/2), y: Math.round(r.y + r.height/2) }; })()`); if (checkboxInfo !== null) { // Checkbox cell found — click directly on the checkbox icon (not cell center) const desired = ['true', 'да', '1', 'yes'].includes(String(firstVal0).toLowerCase().trim()); if (checkboxInfo.checked !== desired) { await page.mouse.click(checkboxInfo.x, checkboxInfo.y); await page.waitForTimeout(300); } const results = [{ field: firstKey0, ok: true, method: 'toggle', value: desired }]; await waitForStable(formNum); // If more fields remain, process them on the same row const remaining = { ...fields }; delete remaining[firstKey0]; if (Object.keys(remaining).length > 0) { const more = await fillTableRow(remaining, { row, table }); results.push(...more); } return results; } let inEdit = false; 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; } // 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; } } // When click entered INPUT mode but no selection form yet — try F4 only for tree grids // (tree grid ref fields need F4 to open selection form; flat grids work via Tab-loop) if (inEdit && directEditForm === null) { const isTreeGrid = await page.evaluate(`(() => { const grid = ${gridSelector ? `document.querySelector(${JSON.stringify(gridSelector)})` : `(() => { const grids = [...document.querySelectorAll('.grid')].filter(el => el.offsetWidth > 0); return grids[grids.length - 1]; })()`}; return grid ? !!grid.querySelector('.gridBoxTree') : false; })()`); if (isTreeGrid) { 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, 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); 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) { if (info.filled) continue; // Find column for this key and dblclick on it const nextCoords = await page.evaluate(`(() => { 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'); if (!head || !body) return null; const headLine = head.querySelector('.gridLine') || head; const cols = []; [...headLine.children].forEach(box => { if (box.offsetWidth === 0) return; const t = box.querySelector('.gridBoxText'); const ci = box.getAttribute('colindex'); cols.push({ colindex: ci, text: ((t || box).innerText?.trim() || '').toLowerCase() }); }); const kl = ${JSON.stringify(key.toLowerCase())}; const klNoSpace = kl.replace(/[\\s\\-]+/g, ''); let targetColindex = null; const exact = cols.find(c => c.text === kl); if (exact) targetColindex = exact.colindex; else { const inc = cols.find(c => c.text.includes(kl) || kl.includes(c.text) || c.text.includes(klNoSpace) || klNoSpace.includes(c.text)); if (inc) targetColindex = inc.colindex; } if (targetColindex == null) return null; const rows = [...body.querySelectorAll('.gridLine')]; if (${row} >= rows.length) return null; const line = rows[${row}]; const box = [...line.children].find(b => b.offsetWidth > 0 && b.getAttribute('colindex') === targetColindex); if (!box) return null; box.scrollIntoView({ block: 'nearest', inline: 'nearest' }); const cell = box.querySelector('.gridBoxText') || box; const r = cell.getBoundingClientRect(); 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); await page.waitForTimeout(300); // Check if dblclick entered INPUT mode (plain text/numeric field) — before F4 which may open calculator const inInputAfterDblclick = await page.evaluate(`(() => { const f = document.activeElement; if (!f || (f.tagName !== 'INPUT' && f.tagName !== 'TEXTAREA')) return false; let n = f; while (n) { if (n.classList?.contains('grid')) return true; n = n.parentElement; } return false; })()`); // Also check if a selection form already appeared let 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 && inInputAfterDblclick) { // Plain text/numeric field — fill via clipboard paste await pasteText(info.value, { confirm: ['Control+a', 'Control+v'] }); await page.waitForTimeout(400); // Dismiss EDD autocomplete if it appeared const hasEdd = await page.evaluate(`(() => { const edd = document.getElementById('editDropDown'); return edd && edd.offsetWidth > 0; })()`); if (hasEdd) { await page.keyboard.press('Escape'); await page.waitForTimeout(200); } info.filled = true; results.push({ field: key, ok: true, method: 'paste' }); continue; } // Poll for selection form (with F4 fallback if dblclick didn't open it) if (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 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; const rows = [...body.querySelectorAll('.gridLine')]; const otherIdx = ${row} === 0 ? 1 : 0; const other = rows[otherIdx]; if (!other) return null; 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) }; })()`); 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 const pending = new Map(); for (const [key, val] of Object.entries(fields)) { if (val === null || val === undefined || val === '') { pending.set(key, { value: '', type: null, filled: false }); } else if (val && typeof val === 'object' && 'value' in val) { const innerVal = val.value; pending.set(key, { value: innerVal === null || innerVal === undefined || innerVal === '' ? '' : String(innerVal), type: val.type || null, filled: false }); } else { pending.set(key, { value: String(val), type: null, filled: false }); } } const results = []; const MAX_ITER = 40; let prevCellId = null; let nonInputCount = 0; let firstCellId = null; for (let iter = 0; iter < MAX_ITER; iter++) { // Read focused element (INPUT or TEXTAREA inside grid = editable cell) const cell = await page.evaluate(`(() => { const f = document.activeElement; if (!f) return { tag: 'none' }; if (f.tagName === 'INPUT' || f.tagName === 'TEXTAREA') { const inGrid = (() => { let n = f; while (n) { if (n.classList?.contains('grid') || n.classList?.contains('gridContent')) return true; n = n.parentElement; } return false; })(); if (inGrid) { let headerText = ''; let grid = f; while (grid && !grid.classList?.contains('grid')) grid = grid.parentElement; if (grid) { const fr = f.getBoundingClientRect(); const head = grid.querySelector('.gridHead'); const hl = head?.querySelector('.gridLine') || head; if (hl) for (const h of hl.children) { if (h.offsetWidth === 0) continue; const hr = h.getBoundingClientRect(); if (fr.x >= hr.x && fr.x < hr.x + hr.width) { const t = h.querySelector('.gridBoxText'); headerText = (t || h).innerText?.trim() || ''; break; } } } return { tag: 'INPUT', id: f.id, fullName: f.id.replace(/^form\\d+_/, '').replace(/_i\\d+$/, ''), headerText }; } } return { tag: f.tagName || 'none' }; })()`); if (cell.tag !== 'INPUT' || !cell.fullName) { // Not in an editable grid cell — Tab past (ERP has DIV focus between cells) nonInputCount++; // If only checkbox fields remain unfilled, stop Tab'ing to avoid creating extra rows const onlyCheckboxLeft = [...pending.values()].every(p => p.filled || ['true', 'false', 'да', 'нет', '1', '0', 'yes', 'no'].includes(p.value.toLowerCase().trim())); if (nonInputCount > 3 || onlyCheckboxLeft) break; await page.keyboard.press('Tab'); await page.waitForTimeout(300); continue; } nonInputCount = 0; // Track first cell to detect wrap-around (Tab looped back to row start) if (firstCellId === null) firstCellId = cell.id; else if (cell.id === firstCellId) break; // wrapped around — all cells visited // Stuck detection: same cell twice in a row → force Tab if (cell.id === prevCellId) { await page.keyboard.press('Tab'); await page.waitForTimeout(500); prevCellId = null; continue; } prevCellId = cell.id; // Fuzzy match cell name to user field: exact → suffix → includes → no-space includes const cellLower = cell.fullName.toLowerCase(); let matchedKey = null; for (const [key, info] of pending) { if (info.filled) continue; const kl = key.toLowerCase(); if (cellLower === kl || cellLower.endsWith(kl) || cellLower.includes(kl)) { matchedKey = key; break; } // 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; } } // Fallback: match by column header text (handles metadata typos in cell id) if (!matchedKey && cell.headerText) { const htLower = cell.headerText.toLowerCase(); for (const [key, info] of pending) { if (info.filled) continue; const kl = key.toLowerCase(); if (htLower === kl || htLower.endsWith(kl) || htLower.includes(kl)) { matchedKey = key; break; } } } if (!matchedKey) { // Skip this cell await page.keyboard.press('Tab'); await page.waitForTimeout(300); continue; } const info = pending.get(matchedKey); const text = info.value; // Clear cell if value is empty (Shift+F4 = native 1C clear) if (text === '') { await page.keyboard.press('Shift+F4'); await page.waitForTimeout(300); info.filled = true; results.push({ field: matchedKey, cell: cell.fullName, ok: true, method: 'clear', value: '' }); if ([...pending.values()].every(p => p.filled)) break; await page.keyboard.press('Tab'); await page.waitForTimeout(500); continue; } // If user specified a type, always clear and use type selection flow if (info.type) { await page.keyboard.press('Shift+F4'); // Clear cell to reset any inherited type await page.waitForTimeout(300); await page.keyboard.press('F4'); // Poll for type dialog form to appear let typeForm = null; for (let tw = 0; tw < 6; tw++) { await page.waitForTimeout(200); typeForm = 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 (typeForm !== null) break; } if (typeForm !== null && await isTypeDialog(typeForm)) { await pickFromTypeDialog(typeForm, info.type); await waitForStable(typeForm); // After type selection, check if a selection form opened (ref types) const 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) { // Primitive type — poll for calculator/calendar popup or settle on INPUT let hasPopup = null; for (let pw = 0; pw < 5; pw++) { await page.waitForTimeout(200); hasPopup = await page.evaluate(`(() => { const calc = document.querySelector('.calculate'); if (calc && calc.offsetWidth > 0) return 'calculator'; const cal = document.querySelector('.frameCalendar'); if (cal && cal.offsetWidth > 0) return 'calendar'; return null; })()`); if (hasPopup) break; } if (hasPopup) { await page.keyboard.press('Escape'); // Poll for popup to disappear for (let dw = 0; dw < 4; dw++) { await page.waitForTimeout(150); const gone = await page.evaluate(`(() => { const calc = document.querySelector('.calculate'); if (calc && calc.offsetWidth > 0) return false; const cal = document.querySelector('.frameCalendar'); if (cal && cal.offsetWidth > 0) return false; return true; })()`); if (gone) break; } } // Ensure we are in an editable INPUT for this cell const inInput = await page.evaluate(`(() => { const f = document.activeElement; return f && (f.tagName === 'INPUT' || f.tagName === 'TEXTAREA'); })()`); if (!inInput) { const cellRect = await page.evaluate(`(() => { const el = document.getElementById(${JSON.stringify(cell.id)}); if (!el) return null; const r = el.getBoundingClientRect(); return { x: r.x + r.width / 2, y: r.y + r.height / 2 }; })()`); if (cellRect) { await page.mouse.dblclick(cellRect.x, cellRect.y); // Poll for INPUT focus for (let fw = 0; fw < 4; fw++) { await page.waitForTimeout(150); const ok = await page.evaluate(`(() => { const f = document.activeElement; return f && (f.tagName === 'INPUT' || f.tagName === 'TEXTAREA'); })()`); if (ok) break; } } } await pasteText(text, { confirm: ['Control+a', 'Control+v'] }); await page.waitForTimeout(400); await page.keyboard.press('Tab'); await page.waitForTimeout(300); info.filled = true; results.push({ field: matchedKey, cell: cell.fullName, ok: true, method: 'type-direct', type: info.type }); continue; } const pickResult = await pickFromSelectionForm(selForm, matchedKey, text, formNum); info.filled = true; results.push(pickResult.ok ? { field: matchedKey, cell: cell.fullName, ok: true, method: 'form', type: info.type } : { field: matchedKey, cell: cell.fullName, error: pickResult.error, message: pickResult.message }); continue; } // F4 opened something but not a type dialog — close and report if (typeForm !== null) { await page.keyboard.press('Escape'); await page.waitForTimeout(300); } info.filled = true; results.push({ field: matchedKey, cell: cell.fullName, error: 'type_dialog_failed', message: `Cell "${matchedKey}": F4 did not open type dialog for type "${info.type}"` }); await page.keyboard.press('Tab'); await page.waitForTimeout(500); continue; } // === Fill this cell: clipboard paste (trusted event) === await page.keyboard.press('Control+A'); await pasteText(text); await page.waitForTimeout(1500); // Check if paste was rejected (composite-type cell blocks text input until type is selected) const inputAfterPaste = await page.evaluate(`document.activeElement?.value || ''`); if (!inputAfterPaste && text) { // No type specified — can't fill this composite-type cell info.filled = true; results.push({ field: matchedKey, cell: cell.fullName, error: 'type_required', message: `Cell "${matchedKey}" rejected text input (composite-type). Use { value: '...', type: 'Тип' } syntax` }); await page.keyboard.press('Tab'); await page.waitForTimeout(500); continue; } // Check for EDD autocomplete (indicates reference field) const eddItems = await page.evaluate(`(() => { const edd = document.getElementById('editDropDown'); if (!edd || edd.offsetWidth === 0) return null; return [...edd.querySelectorAll('.eddText')] .filter(el => el.offsetWidth > 0) .map(el => el.innerText?.trim() || ''); })()`); if (eddItems && eddItems.length > 0) { // Reference field with autocomplete — click best match // Filter out reference field "create" actions (Создать элемент, Создать группу, Создать: ...) // but keep standalone enum values like "Создать" (no space/colon after) const realItems = eddItems.filter(i => !/^Создать[\s:]/.test(i)); if (realItems.length > 0) { const tgt = normYo(text.toLowerCase()); let pick = realItems.find(i => normYo(i.replace(/\s*\([^)]*\)\s*$/, '').toLowerCase()) === tgt); if (!pick) pick = realItems.find(i => normYo(i.toLowerCase()).includes(tgt)); if (!pick) pick = realItems[0]; // Click EDD item via dispatchEvent (bypasses div.surface overlay) const pickLower = pick.toLowerCase(); await page.evaluate(`(() => { const edd = document.getElementById('editDropDown'); if (!edd) return; for (const el of edd.querySelectorAll('.eddText')) { if (el.offsetWidth === 0) continue; if (el.innerText.trim().toLowerCase().includes(${JSON.stringify(pickLower)})) { const r = el.getBoundingClientRect(); const opts = { bubbles:true, cancelable:true, clientX: r.x + r.width/2, clientY: r.y + r.height/2 }; el.dispatchEvent(new MouseEvent('mousedown', opts)); el.dispatchEvent(new MouseEvent('mouseup', opts)); el.dispatchEvent(new MouseEvent('click', opts)); return; } } })()`); await waitForStable(); info.filled = true; results.push({ field: matchedKey, cell: cell.fullName, ok: true, method: 'dropdown', value: pick.replace(/\s*\([^)]*\)\s*$/, '') }); } else { // Only "Создать:" items — value not found in autocomplete await page.keyboard.press('Escape'); await page.waitForTimeout(300); info.filled = true; results.push({ field: matchedKey, cell: cell.fullName, error: 'not_found', message: `No match for "${text}"` }); } // Done? If so, don't Tab (avoids creating a new row after last cell) if ([...pending.values()].every(p => p.filled)) break; // Tab to move to next cell await page.keyboard.press('Tab'); await page.waitForTimeout(500); continue; } // No EDD — press Tab to commit the value await page.keyboard.press('Tab'); await page.waitForTimeout(1000); // Check for "нет в списке" cloud popup (reference field, value not found) const notInList = await page.evaluate(`(() => { for (const el of document.querySelectorAll('div')) { if (el.offsetWidth === 0 || el.offsetHeight === 0) continue; const s = getComputedStyle(el); if (s.position !== 'absolute' && s.position !== 'fixed') continue; if ((parseInt(s.zIndex) || 0) < 100) continue; if ((el.innerText || '').includes('нет в списке')) return true; } return false; })()`); if (notInList) { // Cloud has "Показать все" link — try to open selection form via it const clickedShowAll = await page.evaluate(`(() => { for (const el of document.querySelectorAll('div')) { if (el.offsetWidth === 0 || el.offsetHeight === 0) continue; const s = getComputedStyle(el); if (s.position !== 'absolute' && s.position !== 'fixed') continue; if ((parseInt(s.zIndex) || 0) < 100) continue; if (!(el.innerText || '').includes('нет в списке')) continue; // Found the cloud — look for "Показать все" hyperlink inside const links = [...el.querySelectorAll('a, span, div')] .filter(e => e.offsetWidth > 0 && e.children.length === 0); const showAll = links.find(e => { const t = (e.innerText?.trim() || '').toLowerCase(); return t === 'показать все' || t === 'show all'; }); if (showAll) { const r = showAll.getBoundingClientRect(); const opts = { bubbles:true, cancelable:true, clientX: r.x + r.width/2, clientY: r.y + r.height/2 }; showAll.dispatchEvent(new MouseEvent('mousedown', opts)); showAll.dispatchEvent(new MouseEvent('mouseup', opts)); showAll.dispatchEvent(new MouseEvent('click', opts)); return true; } return false; } return false; })()`); if (clickedShowAll) { await waitForStable(formNum); // Check if selection form opened const selForm = await page.evaluate(`(() => { const forms = {}; document.querySelectorAll('input.editInput[id], a.press[id]').forEach(el => { if (el.offsetWidth === 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) { const pickResult = await pickFromSelectionForm(selForm, matchedKey, text, formNum); info.filled = true; if (pickResult.ok) { results.push({ field: matchedKey, cell: cell.fullName, ok: true, method: 'form' }); continue; } // Not found in selection form — fall through to clear + skip results.push({ field: matchedKey, cell: cell.fullName, error: pickResult.error, message: pickResult.message }); } else { info.filled = true; results.push({ field: matchedKey, cell: cell.fullName, error: 'not_found', message: `Value "${text}" not in list` }); } } else { info.filled = true; results.push({ field: matchedKey, cell: cell.fullName, error: 'not_found', message: `Value "${text}" not in list` }); } // 1C won't let us Tab away from an invalid ref value. // Must clear the field first, then Tab to move on. // Escape dismisses the cloud; Ctrl+A + Delete clears the text. await page.keyboard.press('Escape'); await page.waitForTimeout(300); await page.keyboard.press('Control+A'); await page.keyboard.press('Delete'); await page.waitForTimeout(300); await page.keyboard.press('Tab'); await page.waitForTimeout(500); continue; } // Check for a new form (broad detection — also catches type dialogs whose buttons lack IDs) const newForm = await helperDetectNewForm(formNum); if (newForm !== null) { if (await isTypeDialog(newForm)) { // Composite-type cell — need type to proceed if (info.type) { await pickFromTypeDialog(newForm, info.type); await waitForStable(newForm); // After type selection, the actual selection form should open const 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) { // Primitive type — poll for calculator/calendar popup or settle on INPUT let hasPopup = null; for (let pw = 0; pw < 5; pw++) { await page.waitForTimeout(200); hasPopup = await page.evaluate(`(() => { const calc = document.querySelector('.calculate'); if (calc && calc.offsetWidth > 0) return 'calculator'; const cal = document.querySelector('.frameCalendar'); if (cal && cal.offsetWidth > 0) return 'calendar'; return null; })()`); if (hasPopup) break; } if (hasPopup) { await page.keyboard.press('Escape'); for (let dw = 0; dw < 4; dw++) { await page.waitForTimeout(150); const gone = await page.evaluate(`(() => { const calc = document.querySelector('.calculate'); if (calc && calc.offsetWidth > 0) return false; const cal = document.querySelector('.frameCalendar'); if (cal && cal.offsetWidth > 0) return false; return true; })()`); if (gone) break; } } const inInput = await page.evaluate(`(() => { const f = document.activeElement; return f && (f.tagName === 'INPUT' || f.tagName === 'TEXTAREA'); })()`); if (!inInput) { const cellRect = await page.evaluate(`(() => { const el = document.getElementById(${JSON.stringify(cell.id)}); if (!el) return null; const r = el.getBoundingClientRect(); return { x: r.x + r.width / 2, y: r.y + r.height / 2 }; })()`); if (cellRect) { await page.mouse.dblclick(cellRect.x, cellRect.y); for (let fw = 0; fw < 4; fw++) { await page.waitForTimeout(150); const ok = await page.evaluate(`(() => { const f = document.activeElement; return f && (f.tagName === 'INPUT' || f.tagName === 'TEXTAREA'); })()`); if (ok) break; } } } await pasteText(text, { confirm: ['Control+a', 'Control+v'] }); await page.waitForTimeout(400); await page.keyboard.press('Tab'); await page.waitForTimeout(300); info.filled = true; results.push({ field: matchedKey, cell: cell.fullName, ok: true, method: 'type-direct', type: info.type }); continue; } const pickResult = await pickFromSelectionForm(selForm, matchedKey, text, formNum); info.filled = true; results.push(pickResult.ok ? { field: matchedKey, cell: cell.fullName, ok: true, method: 'form', type: info.type } : { field: matchedKey, cell: cell.fullName, error: pickResult.error, message: pickResult.message }); continue; } else { // No type specified — close dialog, clear cell, report error await page.keyboard.press('Escape'); await page.waitForTimeout(300); await page.keyboard.press('Control+A'); await page.keyboard.press('Delete'); await page.waitForTimeout(300); await page.keyboard.press('Tab'); await page.waitForTimeout(500); info.filled = true; results.push({ field: matchedKey, cell: cell.fullName, error: 'type_required', message: `Cell "${matchedKey}" opened a type selection dialog. Use { value: '...', type: 'Тип' } syntax` }); continue; } } // Not a type dialog — normal selection form const pickResult = await pickFromSelectionForm(newForm, matchedKey, text, formNum); info.filled = true; results.push(pickResult.ok ? { field: matchedKey, cell: cell.fullName, ok: true, method: 'form' } : { field: matchedKey, cell: cell.fullName, error: pickResult.error, message: pickResult.message }); continue; } // Plain field — value committed via Tab info.filled = true; results.push({ field: matchedKey, cell: cell.fullName, ok: true, method: 'direct' }); // All done? if ([...pending.values()].every(p => p.filled)) break; // Tab already pressed — we're on next cell } // Commit the new row: click on the grid header to exit edit mode. // Clicking a different data row would re-enter edit mode on that row. // Without this commit click, 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(`(() => { 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'); 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); } else { // Fallback: Tab out of the last cell to commit the row await page.keyboard.press('Tab'); await page.waitForTimeout(500); } // Dismiss any leftover error modals const err = await checkForErrors(); if (err?.modal) { try { const btn = await page.$('a.press.pressDefault'); if (btn) { await btn.click(); await page.waitForTimeout(500); } } catch { /* OK */ } } const notFilled = [...pending].filter(([_, info]) => !info.filled).map(([key]) => key); // Retry unfilled checkbox fields via direct click (Tab skips checkbox cells) if (notFilled.length > 0) { const checkboxFields = {}; for (const key of notFilled) { const val = String(pending.get(key).value).toLowerCase().trim(); if (['true', 'false', 'да', 'нет', '1', '0', 'yes', 'no'].includes(val)) { checkboxFields[key] = pending.get(key).value; } } if (Object.keys(checkboxFields).length > 0) { // Use row index: addedRowIdx (from add mode) or fallback to selected row const currentRow = addedRowIdx >= 0 ? addedRowIdx : (row != null ? row : await page.evaluate(`(() => { 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 -1; const body = grid.querySelector('.gridBody'); if (!body) return -1; const lines = [...body.querySelectorAll('.gridLine')]; const sel = lines.findIndex(l => l.classList.contains('selected')); return sel >= 0 ? sel : lines.length - 1; })()`) ); if (currentRow >= 0) { const more = await fillTableRow(checkboxFields, { row: currentRow, table }); if (Array.isArray(more)) { results.push(...more); } else if (more?.filled) { results.push(...more.filled); } for (const key of Object.keys(checkboxFields)) { const idx = notFilled.indexOf(key); if (idx >= 0) notFilled.splice(idx, 1); } } } } const formData = await getFormState(); const result = { filled: results }; if (notFilled.length > 0) result.notFilled = notFilled; result.form = formData; return result; } catch (e) { if (e.message.startsWith('fillTableRow:')) throw e; throw new Error(`fillTableRow: ${e.message}`); } } /** * Delete a row from the current table part. * Single click to select the row, then Delete key to remove it. * * @param {number} row - 0-based row index to delete * @param {Object} [options] * @param {string} [options.tab] - Switch to this form tab before operating * @returns {{ deleted, rowsBefore, rowsAfter, form }} */ 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); await page.waitForTimeout(500); } // 2. Find the target row and click to select it const cellCoords = await page.evaluate(`(() => { 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' }; const rows = [...body.querySelectorAll('.gridLine')]; if (${row} >= rows.length) return { error: 'row_out_of_range', total: rows.length }; const line = rows[${row}]; // 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 }; })()`); if (cellCoords.error) throw new Error(`deleteTableRow: ${cellCoords.error}${cellCoords.total ? ' (total rows: ' + cellCoords.total + ')' : ''}`); const rowsBefore = cellCoords.total; // Single click to select the row await page.mouse.click(cellCoords.x, cellCoords.y); await page.waitForTimeout(300); // 3. Press Delete to remove the row await page.keyboard.press('Delete'); await waitForStable(); // 4. Count rows after deletion const rowsAfter = await page.evaluate(`(() => { 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; })()`); const formData = await getFormState(); return { deleted: row, rowsBefore, rowsAfter, form: formData }; } /** * Filter the current list by field value, or search via search bar. * * Without field: simple search via the search bar (filters by all columns, no badge). * With field: advanced search — clicks target column cell to auto-populate FieldSelector, * opens dialog (Alt+F), fills Pattern, clicks Найти. Creates a real filter badge. * Handles text, reference (with Tab autocomplete), and date fields automatically. * Multiple filters can be chained by calling filterList multiple times. * * @param {string} text - Search text or date (e.g. "Мишка", "КП00", "10.03.2016") * @param {object} [opts] * @param {string} [opts.field] - Column name for advanced search (e.g. "Наименование", "Получатель", "Дата") * @param {boolean} [opts.exact] - Exact match (text fields only; dates/numbers/refs always exact) */ export async function filterList(text, { field, exact } = {}) { ensureConnected(); await dismissPendingErrors(); const formNum = await page.evaluate(detectFormScript()); if (formNum === null) throw new Error('filterList: no form found'); if (!field) { // --- Simple search: fill search input + Enter --- const searchId = await page.evaluate(`(() => { const p = 'form${formNum}_'; const el = [...document.querySelectorAll('input.editInput[id^="' + p + '"]')] .find(el => el.offsetWidth > 0 && /Строк[аи]Поиска|SearchString/i.test(el.id)); return el ? el.id : null; })()`); if (searchId) { await page.click(`[id="${searchId}"]`); await page.waitForTimeout(200); await page.keyboard.press('Control+A'); await pasteText(text); 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); field = ''; // fall through to advanced search, skip DLB (empty field = keep auto-selected) } // --- Advanced search: click target column cell → Alt+F → fill Pattern → Найти --- // Clicking a cell in the target column makes it active, so when Alt+F opens the // advanced search dialog, FieldSelector is auto-populated with the correct field name. // This avoids changing FieldSelector programmatically (which can cause errors). const isDateValue = /^\d{2}\.\d{2}\.\d{4}$/.test(text.trim()); // 1. Click a cell in the target column to activate it (auto-populates FieldSelector). // If the column isn't visible in the grid, click any cell and use DLB fallback later. let needDlb = false; const gridEl = 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 { error: 'no_grid' }; const targetField = ${JSON.stringify(field)}; const headers = [...grid.querySelectorAll('.gridHead .gridBox')]; let colIndex = -1; let startsWithIdx = -1; let includesIdx = -1; for (let i = 0; i < headers.length; i++) { const t = headers[i].innerText?.trim().replace(/\\u00a0/g, ' '); if (!t) continue; const ny = s => s.replace(/ё/gi, 'е').replace(/\\u00a0/g, ' '); const tl = ny(t.toLowerCase()), fl = ny(targetField.toLowerCase()); if (tl === fl) { colIndex = i; break; } if (startsWithIdx < 0 && tl.startsWith(fl)) { startsWithIdx = i; } else if (includesIdx < 0 && tl.includes(fl)) { includesIdx = i; } } if (colIndex < 0) colIndex = startsWithIdx >= 0 ? startsWithIdx : includesIdx; const rows = [...grid.querySelectorAll('.gridBody .gridLine')]; if (!rows.length) return { error: 'no_rows' }; if (colIndex < 0) { // Column not in grid — click first cell of first row, will use DLB to change field const cells = [...rows[0].querySelectorAll('.gridBox')]; if (!cells.length) return { error: 'no_cells' }; const r = cells[0].getBoundingClientRect(); return { x: Math.round(r.x + r.width / 2), y: Math.round(r.y + r.height / 2), needDlb: true }; } const cells = [...rows[0].querySelectorAll('.gridBox')]; if (colIndex >= cells.length) return { error: 'cell_not_found' }; const r = cells[colIndex].getBoundingClientRect(); return { x: Math.round(r.x + r.width / 2), y: Math.round(r.y + r.height / 2) }; })()`); if (gridEl.error) throw new Error(`filterList: ${gridEl.error}`); needDlb = !!gridEl.needDlb; await page.mouse.click(gridEl.x, gridEl.y); await page.waitForTimeout(500); // 2. Open advanced search dialog via Alt+F (with fallback to Еще menu) await page.keyboard.press('Alt+f'); await page.waitForTimeout(2000); let dialogForm = await page.evaluate(detectFormScript()); if (dialogForm === formNum) { // Alt+F didn't open dialog — fallback to Еще → Расширенный поиск await clickElement('Еще'); await page.waitForTimeout(500); const menu = await page.evaluate(readSubmenuScript()); const searchItem = Array.isArray(menu) && menu.find(i => i.name.replace(/\u00a0/g, ' ').toLowerCase().includes('расширенный поиск')); if (!searchItem) { await page.keyboard.press('Escape'); throw new Error('filterList: advanced search dialog could not be opened'); } await page.mouse.click(searchItem.x, searchItem.y); await page.waitForTimeout(2000); dialogForm = await page.evaluate(detectFormScript()); if (dialogForm === formNum) { throw new Error('filterList: advanced search dialog did not open'); } } // 2b. If column wasn't in the grid, change FieldSelector via DLB dropdown // 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 + '"]')] .find(el => el.offsetWidth > 0 && /FieldSelector/i.test(el.id)); const dlb = document.getElementById(p + 'FieldSelector_DLB'); return { current: fsInput?.value?.trim() || '', dlbX: dlb && dlb.offsetWidth > 0 ? Math.round(dlb.getBoundingClientRect().x + dlb.getBoundingClientRect().width / 2) : 0, dlbY: dlb && dlb.offsetWidth > 0 ? Math.round(dlb.getBoundingClientRect().y + dlb.getBoundingClientRect().height / 2) : 0 }; })()`); if (normYo(fsInfo.current.toLowerCase()) !== normYo(field.toLowerCase())) { await page.mouse.click(fsInfo.dlbX, fsInfo.dlbY); await page.waitForTimeout(1500); const ddResult = await page.evaluate(`(() => { const edd = document.getElementById('editDropDown'); if (!edd || edd.offsetWidth === 0) return { error: 'no_dropdown' }; const ny = s => s.replace(/ё/gi, 'е').replace(/\\u00a0/g, ' '); const target = ny(${JSON.stringify(field.toLowerCase())}); const items = [...edd.querySelectorAll('div')].filter(el => el.offsetWidth > 0 && el.innerText?.trim() && !el.innerText.includes('\\n')); const match = items.find(el => ny(el.innerText.trim().toLowerCase()) === target) || items.find(el => ny(el.innerText.trim().toLowerCase()).includes(target)); if (!match) return { error: 'field_not_found', available: items.map(el => el.innerText.trim()) }; const r = match.getBoundingClientRect(); return { x: Math.round(r.x + r.width / 2), y: Math.round(r.y + r.height / 2), name: match.innerText.trim() }; })()`); if (ddResult.error) { await page.keyboard.press('Escape'); await page.waitForTimeout(500); await page.keyboard.press('Escape'); await page.waitForTimeout(500); throw new Error(`filterList: field "${field}" not found in FieldSelector. Available: ${ddResult.available?.join(', ') || 'none'}`); } await page.mouse.click(ddResult.x, ddResult.y); await page.waitForTimeout(3000); } } // 3. Read dialog state and fill Pattern // Detect field type by Pattern's sibling buttons: // - iCalendB → date field (Home+Shift+End+Ctrl+V to replace date value) // - iDLB on Pattern → reference field (paste + Tab for autocomplete) // - neither → plain text field (just paste) const dialogInfo = await page.evaluate(`(() => { const p = 'form' + ${JSON.stringify(String(dialogForm))} + '_'; const fsInput = [...document.querySelectorAll('input.editInput[id^="' + p + '"]')] .find(el => el.offsetWidth > 0 && /FieldSelector/i.test(el.id)); const ptInput = [...document.querySelectorAll('input.editInput[id^="' + p + '"]')] .find(el => el.offsetWidth > 0 && /Pattern/i.test(el.id)); const ptLabel = ptInput?.closest('label'); const btns = ptLabel ? [...ptLabel.querySelectorAll('span.btn')].map(b => b.className) : []; const isDate = btns.some(c => c.includes('iCalendB')); const isRef = !isDate && btns.some(c => c.includes('iDLB')); return { fieldSelector: fsInput?.value?.trim() || '', patternValue: ptInput?.value?.trim() || '', patternId: ptInput?.id || '', isDate, isRef }; })()`); if (dialogInfo.isDate) { // Date field: fill via Home → Shift+End (select all) → Ctrl+V (paste) if (isDateValue && dialogInfo.patternValue !== text.trim()) { await page.click(`[id="${dialogInfo.patternId}"]`); await page.waitForTimeout(200); await page.keyboard.press('Home'); await page.waitForTimeout(100); await page.keyboard.press('Shift+End'); await page.waitForTimeout(100); await pasteText(text); await page.waitForTimeout(500); } } else { // Text or reference field: fill Pattern via clipboard paste await page.click(`[id="${dialogInfo.patternId}"]`); await page.waitForTimeout(200); await page.keyboard.press('Control+A'); await pasteText(text); await page.waitForTimeout(300); if (dialogInfo.isRef) { // Reference field: Tab triggers autocomplete to resolve text → reference value await page.keyboard.press('Tab'); await page.waitForTimeout(2000); } } // 3b. Switch CompareType if exact match requested (text fields only). // Date/number: always exact, CompareType disabled. Reference: default exact (selects ref). if (exact && !dialogInfo.isDate && !dialogInfo.isRef) { const exactRadio = await page.evaluate(`(() => { const p = 'form' + ${JSON.stringify(String(dialogForm))} + '_'; // Check if CompareType group is disabled (dates, numbers) const group = document.getElementById(p + 'CompareType'); if (group && group.classList.contains('disabled')) return { already: true }; const el = document.getElementById(p + 'CompareType#2#radio'); if (!el || el.offsetWidth === 0) return null; if (el.classList.contains('select')) return { already: true }; const r = el.getBoundingClientRect(); return { x: Math.round(r.x + r.width / 2), y: Math.round(r.y + r.height / 2) }; })()`); if (exactRadio && !exactRadio.already) { await page.mouse.click(exactRadio.x, exactRadio.y); await page.waitForTimeout(300); } } // 4. Click "Найти" via mouse.click (dialog is modal — page.click may be blocked) const findBtnCoords = 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 (findBtnCoords) { await page.mouse.click(findBtnCoords.x, findBtnCoords.y); } else { await clickElement('Найти'); } await page.waitForTimeout(2000); // 5. Close advanced search dialog if it stayed open (some forms keep it open after Найти). // Check the specific dialog form — not generic modalSurface — to avoid closing parent modals // (e.g. a selection form that opened this advanced search). 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); const state = await getFormState(); state.filtered = { type: 'advanced', field, text, exact: !!exact }; return state; } /** * Remove active filters/search from the current list. * * Without field: clears ALL filters (Ctrl+Q for advanced search + clear search field). * With field: clicks the × button on the specific filter badge (selective removal). * * @param {object} [opts] * @param {string} [opts.field] - Remove only the filter for this field (clicks badge ×) */ export async function unfilterList({ field } = {}) { ensureConnected(); await dismissPendingErrors(); const formNum = await page.evaluate(detectFormScript()); if (formNum === null) throw new Error('unfilterList: no form found'); if (field) { // --- Selective: click × on specific filter badge --- const closeBtn = await page.evaluate(`(() => { const p = 'form${formNum}_'; const norm = s => s?.trim().replace(/\\u00a0/g, ' ').replace(/:$/, '').replace(/\\n/g, ' ') || ''; const ny = s => s.replace(/ё/gi, 'е').replace(/\\u00a0/g, ' '); const target = ny(${JSON.stringify(field.toLowerCase())}); const items = [...document.querySelectorAll('[id^="' + p + '"].trainItem')].filter(el => el.offsetWidth > 0); for (const item of items) { const titleEl = item.querySelector('.trainName'); const title = ny(norm(titleEl?.innerText).toLowerCase()); if (title === target || title.includes(target)) { const close = item.querySelector('.trainClose'); if (close) { const r = close.getBoundingClientRect(); return { x: Math.round(r.x + r.width / 2), y: Math.round(r.y + r.height / 2), field: norm(titleEl?.innerText) }; } } } const available = items.map(item => norm(item.querySelector('.trainName')?.innerText)); return { error: 'not_found', available }; })()`); if (closeBtn?.error) throw new Error(`unfilterList: filter badge "${field}" not found. Available: ${closeBtn.available?.join(', ') || 'none'}`); await page.mouse.click(closeBtn.x, closeBtn.y); await waitForStable(formNum); const state = await getFormState(); state.unfiltered = { field: closeBtn.field }; return state; } // --- Clear ALL filters --- // 1. Remove all advanced filter badges (.trainItem × buttons) for (let attempt = 0; attempt < 20; attempt++) { const badge = await page.evaluate(`(() => { const p = 'form${formNum}_'; const item = [...document.querySelectorAll('[id^="' + p + '"].trainItem')] .find(el => el.offsetWidth > 0); if (!item) return null; const close = item.querySelector('.trainClose'); if (!close) return null; const r = close.getBoundingClientRect(); return { x: Math.round(r.x + r.width / 2), y: Math.round(r.y + r.height / 2) }; })()`); if (!badge) break; await page.mouse.click(badge.x, badge.y); await waitForStable(formNum); } // 2. Cancel active search via Ctrl+Q await page.keyboard.press('Control+q'); await waitForStable(formNum); // 3. Clear simple search field if it has a value const searchInfo = await page.evaluate(`(() => { const p = 'form${formNum}_'; const el = [...document.querySelectorAll('input.editInput[id^="' + p + '"]')] .find(el => el.offsetWidth > 0 && /Строк[аи]Поиска|SearchString/i.test(el.id)); return el ? { id: el.id, value: el.value || '' } : null; })()`); if (searchInfo?.value) { await page.click(`[id="${searchInfo.id}"]`); await page.waitForTimeout(200); await page.keyboard.press('Control+A'); await page.keyboard.press('Delete'); await page.keyboard.press('Enter'); await waitForStable(formNum); } const state = await getFormState(); state.unfiltered = true; return state; } // ============================================================ // Recording, captions, narration, highlight — extracted to recording/* // ============================================================ export { screenshot, wait, isRecording, startRecording, stopRecording, } from './recording/capture.mjs'; export { showCaption, hideCaption, getCaptions, showTitleSlide, hideTitleSlide, showImage, hideImage, } from './recording/captions.mjs'; export { highlight, unhighlight, setHighlight, isHighlightMode, } from './recording/highlight.mjs'; export { addNarration } from './recording/narration.mjs'; /* ensureConnected moved to core/state.mjs */