mirror of
https://github.com/Nikolay-Shirokov/cc-1c-skills.git
synced 2026-06-10 16:14:54 +03:00
refactor(web-test): распил clickElement по доменам (Phase 5, §10)
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) <noreply@anthropic.com>
This commit is contained in:
@@ -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 {}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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 {}
|
||||
}
|
||||
}
|
||||
@@ -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 } });
|
||||
}
|
||||
@@ -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 } : {}) },
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user