From 60151c801ffb6cfc28351318f65cd5611408a9f0 Mon Sep 17 00:00:00 2001 From: Nick Shirokov Date: Wed, 27 May 2026 18:00:48 +0300 Subject: [PATCH] =?UTF-8?q?refactor(web-test):=20=D1=80=D0=B0=D1=81=D0=BF?= =?UTF-8?q?=D0=B8=D0=BB=20clickElement=20=D0=BF=D0=BE=20=D0=B4=D0=BE=D0=BC?= =?UTF-8?q?=D0=B5=D0=BD=D0=B0=D0=BC=20(Phase=205,=20=C2=A710)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit core/click.mjs (307 LOC) распилен на тонкий dispatcher (~105 LOC) + 3 доменных handler-файла. Закрывает §10 родительского плана (отклонение на этапе C.10 — «split — наследие плана»). Структура: - core/click.mjs (~105 LOC) — dispatcher: ensureConnected, spreadsheet-cell spec, highlight, confirmation/popup interception, findTarget, dispatch по target.kind - core/helpers.mjs +modifierClick(x, y, modifier, {dbl?}) — общий mouse-click helper с поддержкой Ctrl/Shift модификаторов - forms/click-popup.mjs (~90 LOC) — clickConfirmationButton + tryClickPopupItem (popup/confirmation внутри формы — форменный контекст, не навигация) - forms/click-form.mjs (~107 LOC) — clickFormTarget: button/tab/submenu + netMonitor lifecycle + post-click submenu detection + confirmation hint propagation - table/click-row.mjs (~95 LOC) — clickGridGroupTarget, clickGridTreeNodeTarget, clickGridRowTarget с переиспользованием modifierClick и существующих getGridToggleIcon/shouldClickToggle Контракт dispatcher → handler: (target, ctx) где ctx = {formNum, modifier, dblclick, toggle, expand, timeout, table, gridSelector}. Handler возвращает returnFormState({clicked, ...}). Граф зависимостей остаётся деревом: - core/click.mjs → table/click-row, forms/click-popup, forms/click-form, spreadsheet - table/{filter,grid,row-fill}.mjs → core/click.mjs (другие action-функции) - handler-модули → helpers, wait, grid-toggle (НЕ click.mjs) Поведение clickElement 1:1, публичный API без изменений. netMonitor переехал внутрь clickFormTarget со своим try/finally. Confirmation hint propagation (тот сайт что Phase 2 НЕ конвертировал) переехал в clickFormTarget — естественное место. Точечный регресс 7/7 (02-crud, 05-table, 08-hierarchy, 13-misc, 16-tree-form, 11-report, 01-navigation) + полный 19/19 зелёный. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../web-test/scripts/engine/core/click.mjs | 412 +++++------------- .../web-test/scripts/engine/core/helpers.mjs | 15 +- .../scripts/engine/forms/click-form.mjs | 107 +++++ .../scripts/engine/forms/click-popup.mjs | 90 ++++ .../scripts/engine/table/click-row.mjs | 95 ++++ 5 files changed, 411 insertions(+), 308 deletions(-) create mode 100644 .claude/skills/web-test/scripts/engine/forms/click-form.mjs create mode 100644 .claude/skills/web-test/scripts/engine/forms/click-popup.mjs create mode 100644 .claude/skills/web-test/scripts/engine/table/click-row.mjs diff --git a/.claude/skills/web-test/scripts/engine/core/click.mjs b/.claude/skills/web-test/scripts/engine/core/click.mjs index 3fee9659..149c403b 100644 --- a/.claude/skills/web-test/scripts/engine/core/click.mjs +++ b/.claude/skills/web-test/scripts/engine/core/click.mjs @@ -1,307 +1,105 @@ -// web-test core/click v1.19 — clickElement dispatcher: spreadsheet cells, submenus, grid groups/trees, buttons/links, tabs. -// Source: https://github.com/Nikolay-Shirokov/cc-1c-skills - -import { - page, ensureConnected, ACTION_WAIT, highlightMode, normYo, -} from './state.mjs'; -import { - detectFormScript, findClickTargetScript, resolveGridScript, readSubmenuScript, -} from '../../dom.mjs'; -import { dismissPendingErrors, checkForErrors, fetchErrorStack } from './errors.mjs'; -import { waitForStable, startNetworkMonitor } from './wait.mjs'; -import { highlight, unhighlight } from '../recording/highlight.mjs'; -import { safeClick, returnFormState } from './helpers.mjs'; -import { getGridToggleIcon, shouldClickToggle } from '../table/grid-toggle.mjs'; -import { - clickSpreadsheetCell, findSpreadsheetCellByText, -} from '../spreadsheet/spreadsheet.mjs'; -import { getFormState } from '../forms/state.mjs'; - -/** 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(); - return returnFormState({ clicked: { kind: 'confirmation', name: btnResult.name } }); - } - - // 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 extras = { clicked: { kind: 'submenuArrow', name: found.name } }; - if (Array.isArray(nestedItems)) { - extras.submenu = nestedItems.map(i => i.name); - extras.hint = 'Call web_click again with a submenu item name to select it'; - } - return returnFormState(extras); - } - // 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(); - return returnFormState({ clicked: { kind: 'popupItem', name: found.name } }); - } - // 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); - return returnFormState({ - clicked: { kind: target.kind, name: target.name, toggled: shouldClick, ...(modifier ? { modifier } : {}) }, - hint: shouldClick ? 'Group toggled. Use readTable to see updated list.' : 'Group already in desired state.', - }); - } - // Default: dblclick to enter group / go up to parent - await modDblClick(target.x, target.y); - await waitForStable(formNum); - return returnFormState({ clicked: { kind: target.kind, name: target.name, ...(modifier ? { modifier } : {}) } }); - } - 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); - return returnFormState({ - clicked: { kind: 'gridTreeNode', name: target.name, toggled: shouldClick, ...(modifier ? { modifier } : {}) }, - hint: shouldClick ? 'Tree node toggled. Use readTable to see updated tree.' : 'Tree node already in desired state.', - }); - } - // Default: select row (click text, no expand/collapse) - await modClick(target.x, target.y); - await waitForStable(formNum); - return returnFormState({ - clicked: { kind: 'gridTreeNode', name: target.name, ...(modifier ? { modifier } : {}) }, - hint: 'Row selected. Use { expand: true } to expand/collapse.', - }); - } - if (target.kind === 'gridRow') { - if (dblclick) { - await modDblClick(target.x, target.y); - await waitForStable(); - return returnFormState({ clicked: { kind: 'gridRow', name: target.name, dblclick: true, ...(modifier ? { modifier } : {}) } }); - } - await modClick(target.x, target.y); - await waitForStable(); - return returnFormState({ clicked: { kind: 'gridRow', name: target.name, ...(modifier ? { modifier } : {}) } }); - } - - // 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 extras = { clicked: { kind: 'submenu', name: target.name } }; - if (Array.isArray(submenuItems)) { - extras.submenu = submenuItems.map(i => i.name); - extras.hint = 'Call web_click again with a submenu item name to select it'; - } - return returnFormState(extras); - } - - 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) { - return returnFormState({ - clicked: { kind: 'submenu', name: target.name }, - submenu: openedPopup.map(i => i.name), - hint: 'Call web_click again with a submenu item name to select it', - }); - } - - // 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 {} - } -} +// web-test core/click v1.20 — clickElement dispatcher: routes to spreadsheet / popup / grid-row / form-element handlers by target kind. +// Source: https://github.com/Nikolay-Shirokov/cc-1c-skills + +import { page, ensureConnected, highlightMode } from './state.mjs'; +import { + detectFormScript, findClickTargetScript, resolveGridScript, + readSubmenuScript, +} from '../../dom.mjs'; +import { dismissPendingErrors, checkForErrors } from './errors.mjs'; +import { waitForStable } from './wait.mjs'; +import { highlight, unhighlight } from '../recording/highlight.mjs'; +import { modifierClick, returnFormState } from './helpers.mjs'; +import { + clickGridGroupTarget, clickGridTreeNodeTarget, clickGridRowTarget, +} from '../table/click-row.mjs'; +import { + clickConfirmationButton, tryClickPopupItem, +} from '../forms/click-popup.mjs'; +import { clickFormTarget } from '../forms/click-form.mjs'; +import { + clickSpreadsheetCell, findSpreadsheetCellByText, +} from '../spreadsheet/spreadsheet.mjs'; + +/** 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 {} + } + + try { + // 1. Intercept open confirmation dialog (Да/Нет/Отмена) — match button by text. + const pending = await checkForErrors(); + if (pending?.confirmation) { + return await clickConfirmationButton(text); + } + + // 2. Intercept open popup (from previous submenu/split-button click). + // Returns null if popup is open but `text` doesn't match — fall through. + const popupItems = await page.evaluate(readSubmenuScript()); + if (Array.isArray(popupItems) && popupItems.length > 0) { + const popupResult = await tryClickPopupItem(text, popupItems); + if (popupResult) return popupResult; + } + + // 3. Find a target on the current form. + let formNum = await page.evaluate(detectFormScript()); + if (formNum === null) throw new Error(`clickElement: no form found`); + + 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; + } + + 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). + 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; + } + } + } + + // Spreadsheet fallback: search 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; + await modifierClick(cx, cy, modifier, { dbl: !!dblclick }); + await waitForStable(); + return returnFormState({ + clicked: { kind: 'spreadsheetCell', name: ssCell.text, ...(dblclick ? { dblclick: true } : {}) }, + }); + } + throw new Error(`clickElement: "${text}" not found. Available: ${target.available?.join(', ') || 'none'}`); + } + + // 4. Dispatch to the right handler by target kind. + const ctx = { formNum, modifier, dblclick, toggle, expand, timeout, table, gridSelector }; + if (target.kind === 'gridGroup' || target.kind === 'gridParent') return await clickGridGroupTarget(target, ctx); + if (target.kind === 'gridTreeNode') return await clickGridTreeNodeTarget(target, ctx); + if (target.kind === 'gridRow') return await clickGridRowTarget(target, ctx); + return await clickFormTarget(target, ctx); + } finally { + if (highlightMode) try { await unhighlight(); } catch {} + } +} diff --git a/.claude/skills/web-test/scripts/engine/core/helpers.mjs b/.claude/skills/web-test/scripts/engine/core/helpers.mjs index 9f0aa46b..fecec2f3 100644 --- a/.claude/skills/web-test/scripts/engine/core/helpers.mjs +++ b/.claude/skills/web-test/scripts/engine/core/helpers.mjs @@ -1,4 +1,4 @@ -// web-test core/helpers v1.19 — private, cross-cutting helpers used by the +// web-test core/helpers v1.20 — private, cross-cutting helpers used by the // public action functions (clickElement/fillFields/selectValue/etc). // Source: https://github.com/Nikolay-Shirokov/cc-1c-skills @@ -162,3 +162,16 @@ export async function returnFormState(extras = {}) { if (err) state.errors = err; return state; } + +/** + * Mouse click at (x, y) with an optional modifier key held down for the duration. + * Supports `'ctrl'` / `'shift'` (used by clickElement for multi-select). + * Pass `{ dbl: true }` for double-click. + */ +export async function modifierClick(x, y, modifier, { dbl = false } = {}) { + 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); +} diff --git a/.claude/skills/web-test/scripts/engine/forms/click-form.mjs b/.claude/skills/web-test/scripts/engine/forms/click-form.mjs new file mode 100644 index 00000000..b4ccfc52 --- /dev/null +++ b/.claude/skills/web-test/scripts/engine/forms/click-form.mjs @@ -0,0 +1,107 @@ +// web-test forms/click-form v1.0 — click handler for form-element targets: button, tab, submenu, link. +// Source: https://github.com/Nikolay-Shirokov/cc-1c-skills +// +// Called by core/click.mjs dispatcher after target is found. +// Owns the CDP network-monitor lifecycle for button clicks (server roundtrip waits), +// post-click submenu detection (split buttons like "Создать на основании"), +// and confirmation hint propagation in the final state. + +import { page, ACTION_WAIT } from '../core/state.mjs'; +import { + detectFormScript, readSubmenuScript, +} from '../../dom.mjs'; +import { checkForErrors } from '../core/errors.mjs'; +import { waitForStable, startNetworkMonitor } from '../core/wait.mjs'; +import { safeClick, returnFormState } from '../core/helpers.mjs'; + +/** + * Click a form target (button, tab, submenu, link) using its resolved {kind, id, x, y, name}. + * Handles three special concerns: + * 1. **netMonitor** for `kind: 'button'` — captures CDP requests started by the click + * so we can wait for them (when the form doesn't change) before stabilising. + * 2. **Submenu detection** — both pre-click (`kind: 'submenu'` already known) and + * post-click (split buttons like "Создать на основании" which open a popup). + * Returns `submenu[]` items as a hint for the caller. + * 3. **Confirmation propagation** — if a confirmation dialog opens as a result of the + * click, surface `confirmation` and `hint` fields on the returned state so the + * caller can react with Да/Нет/Отмена on the next call. + */ +export async function clickFormTarget(target, ctx) { + const { formNum, timeout } = ctx; + let netMonitor = null; + + try { + // CDP network monitor BEFORE the click for buttons — captures all server requests + // triggered by the click so we can wait for them after. + 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 }); + } + + // Pre-known 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 extras = { clicked: { kind: 'submenu', name: target.name } }; + if (Array.isArray(submenuItems)) { + extras.submenu = submenuItems.map(i => i.name); + extras.hint = 'Call web_click again with a submenu item name to select it'; + } + return returnFormState(extras); + } + + 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) { + return returnFormState({ + clicked: { kind: 'submenu', name: target.name }, + submenu: openedPopup.map(i => i.name), + hint: 'Call web_click again with a submenu item name to select it', + }); + } + + // For buttons that trigger server-side operations (post, write, etc.), + // the DOM may stabilise BEFORE the server response arrives. + // The CDP monitor (started before click) lets us wait for all in-flight requests + // to complete (300ms debounce) or for a modal/balloon/confirm to appear. + // 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) { + await netMonitor.waitDone(timeout); + await waitForStable(); + } + } + } + + // Build final state with confirmation propagation + // (the one custom branch deliberately skipped by Phase 2 — surfaces confirmation + // + hint when a save/delete dialog opened as a result of the click). + const extras = { clicked: { kind: target.kind, name: target.name } }; + const err = await checkForErrors(); + if (err?.confirmation) { + extras.confirmation = err.confirmation; + extras.hint = 'Call web_click with a button name (e.g. "Да", "Нет", "Отмена") to respond'; + } + return returnFormState(extras); + } finally { + if (netMonitor) try { await netMonitor.cleanup(); } catch {} + } +} diff --git a/.claude/skills/web-test/scripts/engine/forms/click-popup.mjs b/.claude/skills/web-test/scripts/engine/forms/click-popup.mjs new file mode 100644 index 00000000..bc1f7745 --- /dev/null +++ b/.claude/skills/web-test/scripts/engine/forms/click-popup.mjs @@ -0,0 +1,90 @@ +// web-test forms/click-popup v1.0 — click handlers for in-form popups: confirmation dialogs and open submenus. +// Source: https://github.com/Nikolay-Shirokov/cc-1c-skills +// +// Both handlers run BEFORE clickElement's regular target-finding flow: +// - clickConfirmationButton intercepts when a pending confirmation dialog is open +// - tryClickPopupItem intercepts when a submenu/popup is open from a previous click + +import { page, ACTION_WAIT, normYo } from '../core/state.mjs'; +import { readSubmenuScript } from '../../dom.mjs'; +import { waitForStable } from '../core/wait.mjs'; +import { returnFormState } from '../core/helpers.mjs'; + +/** + * Click a button in the currently-open confirmation dialog (Да/Нет/Отмена, etc). + * Caller is responsible for verifying that a confirmation is actually pending + * (via checkForErrors().confirmation) before invoking this handler. + * + * Throws if no button matching `text` is found in the dialog. + */ +export async function clickConfirmationButton(text) { + 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(); + return returnFormState({ clicked: { kind: 'confirmation', name: btnResult.name } }); +} + +/** + * Try to click an item inside an already-open submenu/popup. + * + * Returns a form-state result on match (kind: 'popupItem' or 'submenuArrow'), + * or `null` if the requested text doesn't match any visible popup item — in + * which case the caller should fall through to regular form-element finding. + * + * @param {string} text — fuzzy-matched against item labels (NBSP/ё-normalised) + * @param {Array} popupItems — items already read via readSubmenuScript() + */ +export async function tryClickPopupItem(text, popupItems) { + 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) return null; + + // 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 extras = { clicked: { kind: 'submenuArrow', name: found.name } }; + if (Array.isArray(nestedItems)) { + extras.submenu = nestedItems.map(i => i.name); + extras.hint = 'Call web_click again with a submenu item name to select it'; + } + return returnFormState(extras); + } + + // 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(); + return returnFormState({ clicked: { kind: 'popupItem', name: found.name } }); +} diff --git a/.claude/skills/web-test/scripts/engine/table/click-row.mjs b/.claude/skills/web-test/scripts/engine/table/click-row.mjs new file mode 100644 index 00000000..c931f4bd --- /dev/null +++ b/.claude/skills/web-test/scripts/engine/table/click-row.mjs @@ -0,0 +1,95 @@ +// web-test table/click-row v1.0 — click handlers for grid row targets: gridGroup, gridTreeNode, gridRow. +// Source: https://github.com/Nikolay-Shirokov/cc-1c-skills +// +// All handlers are called by core/click.mjs dispatcher after target is found. +// Each takes (target, ctx) where ctx = { formNum, modifier, dblclick, toggle, expand, ... } +// and returns a form state with `clicked: { kind, name, ... }`. + +import { waitForStable } from '../core/wait.mjs'; +import { modifierClick, returnFormState } from '../core/helpers.mjs'; +import { getGridToggleIcon, shouldClickToggle } from './grid-toggle.mjs'; + +/** + * Click handler for gridGroup / gridParent targets (hierarchy mode). + * With `expand`/`toggle` — click the level-indicator icon to expand/collapse the group. + * Without — dblclick the row to enter the group / go up to parent. + */ +export async function clickGridGroupTarget(target, ctx) { + const { formNum, modifier, toggle, expand } = ctx; + if (expand != null || toggle) { + // Expand/collapse group — 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 modifierClick(levelIconInfo.x, levelIconInfo.y, modifier); + } else { + // Fallback: dblclick (standard hierarchy navigation) + await modifierClick(target.x, target.y, modifier, { dbl: true }); + } + } + await waitForStable(formNum); + return returnFormState({ + clicked: { kind: target.kind, name: target.name, toggled: shouldClick, ...(modifier ? { modifier } : {}) }, + hint: shouldClick ? 'Group toggled. Use readTable to see updated list.' : 'Group already in desired state.', + }); + } + // Default: dblclick to enter group / go up to parent + await modifierClick(target.x, target.y, modifier, { dbl: true }); + await waitForStable(formNum); + return returnFormState({ clicked: { kind: target.kind, name: target.name, ...(modifier ? { modifier } : {}) } }); +} + +/** + * Click handler for gridTreeNode targets (tree-style grid). + * With `expand`/`toggle` — click the tree icon to expand/collapse. + * Without — single-click to select the row (no expand). + */ +export async function clickGridTreeNodeTarget(target, ctx) { + const { formNum, modifier, toggle, expand } = ctx; + if (expand != null || toggle) { + // Expand/collapse tree node — click the tree icon [tree="true"]. + 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 modifierClick(treeIconInfo.x, treeIconInfo.y, modifier); + } else { + // Fallback: dblclick on row (works for trees without clickable +/- icons) + await modifierClick(target.x, target.y, modifier, { dbl: true }); + } + } + await waitForStable(formNum); + return returnFormState({ + clicked: { kind: 'gridTreeNode', name: target.name, toggled: shouldClick, ...(modifier ? { modifier } : {}) }, + hint: shouldClick ? 'Tree node toggled. Use readTable to see updated tree.' : 'Tree node already in desired state.', + }); + } + // Default: select row (click text, no expand/collapse) + await modifierClick(target.x, target.y, modifier); + await waitForStable(formNum); + return returnFormState({ + clicked: { kind: 'gridTreeNode', name: target.name, ...(modifier ? { modifier } : {}) }, + hint: 'Row selected. Use { expand: true } to expand/collapse.', + }); +} + +/** + * Click handler for gridRow targets (flat list row). + * Single click selects the row; `dblclick: true` opens the item. + */ +export async function clickGridRowTarget(target, ctx) { + const { modifier, dblclick } = ctx; + await modifierClick(target.x, target.y, modifier, { dbl: !!dblclick }); + await waitForStable(); + return returnFormState({ + clicked: { kind: 'gridRow', name: target.name, ...(dblclick ? { dblclick: true } : {}), ...(modifier ? { modifier } : {}) }, + }); +}