diff --git a/.claude/skills/web-test/SKILL.md b/.claude/skills/web-test/SKILL.md new file mode 100644 index 00000000..b7e5022a --- /dev/null +++ b/.claude/skills/web-test/SKILL.md @@ -0,0 +1,371 @@ +--- +name: web-test +description: Тестирование 1С через веб-клиент — автоматизация действий в браузере. Используй когда пользователь просит проверить, протестировать, автоматизировать действия в 1С через браузер +argument-hint: "сценарий на естественном языке" +allowed-tools: + - Bash + - Read + - Write + - Glob + - Grep +--- + +# /web-test — Browser automation for 1C web client + +Automates user interactions with 1C:Enterprise web client via Playwright — navigating sections, filling forms, reading tables and reports, filtering lists. + +## Quick start + +```bash +RUN=".claude/skills/web-test/scripts/run.mjs" + +# One-shot: opens browser → runs script → closes browser → exits +node $RUN run http://localhost:8081/bpdemo test-scenario.js + +# Or pipe inline: +cat <<'SCRIPT' | node $RUN run http://localhost:8081/bpdemo - +await navigateSection('Продажи'); +await openCommand('Заказы клиентов'); +await clickElement('Создать'); +await fillFields({ 'Клиент': 'Альфа' }); +await clickElement('Провести и закрыть'); +SCRIPT +``` + +## Setup (first time) + +```bash +cd .claude/skills/web-test/scripts && npm install +``` + +Requires Node.js 18+. `npm install` downloads Playwright and Chromium. + +## URL resolution + +Read `.v8-project.json` from project root. Each database has `id` and optional `webUrl`. +Construct URL as `http://localhost:8081/` or use `webUrl` if set. +Use `/web-publish` first if the database is not published. + +## Execution modes + +### Autonomous mode (preferred for complete scenarios) + +```bash +node $RUN run script.js # exits when done, no session +``` + +### Interactive mode (step-by-step development) + +```bash +# 1. Start session (run_in_background=true, prints JSON when ready) +node $RUN start + +# 2. Execute scripts against running session +cat <<'SCRIPT' | node $RUN exec - +const form = await getFormState(); +console.log(JSON.stringify(form, null, 2)); +SCRIPT + +# 3. Screenshot +node $RUN shot result.png + +# 4. Stop (logout + close) +node $RUN stop +``` + +`start` runs an HTTP server in background. Use `exec`/`shot`/`stop` from other shells. + +### Writing exec scripts + +All browser.mjs exports are globals — no `import` needed. +`console.log()` output is captured in the JSON response. +`writeFileSync` / `readFileSync` also available. + +## API reference + +### Navigation + +#### `navigateSection(name)` → `{ navigated, sections, commands }` +Go to a top-level section (fuzzy match). Returns list of commands in that section. +```js +await navigateSection('Продажи'); +// { navigated: 'Продажи', sections: [...], commands: ['Заказы клиентов', ...] } +``` + +#### `openCommand(name)` → form state +Open a command from the function panel (fuzzy). Returns form state of the opened form. +```js +const form = await openCommand('Заказы клиентов'); +``` + +#### `navigateLink(url)` → form state +Open any 1C object by metadata path (Shift+F11 dialog). Bypasses section/command navigation. +```js +await navigateLink('Документ.ЗаказКлиента'); +await navigateLink('РегистрНакопления.ЗаказыКлиентов'); +await navigateLink('Справочник.Контрагенты'); +``` + +#### `switchTab(name)` → form state +Switch to an already-open tab/window (fuzzy match). + +### Reading form state + +#### `getFormState()` → `{ fields, buttons, tabs, table, filters, reportSettings? }` +Returns current form structure. This is the primary way to understand what's on screen. + +**fields** — each field has: `name`, `value`, `label?`, `actions?` (select, clear, open), `required?` (true for unfilled mandatory fields) + +**table** — summary only: `{ name, columns, rowCount }`. Use `readTable()` for actual data. + +**reportSettings** — for DCS reports: human-readable filter settings instead of raw technical names: +```js +const form = await getFormState(); +// form.reportSettings = [ +// { name: "Склад", enabled: true, value: "Склад бытовой техники", actions: ["select"] }, +// { name: "Номенклатура", enabled: false, value: "" } +// ] +``` + +**errorModal** — if present, 1C showed an error dialog. Read the message and decide how to proceed. + +**confirmation** — if present, a Yes/No dialog is shown. Call `clickElement('Да')` or `clickElement('Нет')`. + +### Reading data + +#### `readTable({ maxRows?, offset? })` → `{ columns, rows, total, shown, offset }` +Read actual grid data with pagination. Each row is `{ columnName: value }`. + +| Option | Default | Description | +|--------|---------|-------------| +| `maxRows` | 20 | Max rows to return per call | +| `offset` | 0 | Skip first N rows | + +Special row fields: +- `_kind: 'group'` — hierarchical group row +- `_kind: 'parent'` — parent row in hierarchy +- `_tree: 'expanded'|'collapsed'` — tree node state +- `_level: N` — nesting depth in tree view +- `hierarchical: true` — list has groups (on result object) +- `viewMode: 'tree'` — tree view active (on result object) + +```js +const t = await readTable({ maxRows: 50 }); +console.log('Columns:', t.columns); +console.log('Rows:', t.rows.length, 'of', t.total); +// Pagination: +const page2 = await readTable({ maxRows: 50, offset: 50 }); +``` + +#### `readSpreadsheet()` → `{ title?, headers?, data?, totals?, rows?, total }` +Read report output (SpreadsheetDocument) after clicking "Сформировать". + +Returns structured data when header row is detected: +```js +await clickElement('Сформировать'); +await wait(5); +const report = await readSpreadsheet(); +// { title: "Остатки товаров", headers: ["Номенклатура", "Склад", "Количество"], +// data: [{ "Номенклатура": "Бумага", "Склад": "Основной", "Количество": "150" }, ...], +// totals: { "Количество": "1250" }, total: 42 } +``` + +Falls back to `{ rows: string[][], total }` when headers can't be detected. + +#### `getSections()` → `{ activeSection, sections, commands }` +Read section panel and commands without navigating. + +#### `getCommands()` → `string[]` +Commands of the current section. + +#### `getPageState()` → `{ activeSection, activeTab, sections, tabs }` +Sections + all open tabs. + +### Actions + +#### `clickElement(text, { dblclick? })` → form state +Click button, hyperlink, tab, or grid row (fuzzy match). + +- Single click selects a row in a list. **Double-click opens** the item: + ```js + await clickElement('0000-000227', { dblclick: true }); // opens document + ``` +- Returns `submenu[]` when a menu opens — click again with item name: + ```js + const r = await clickElement('Ещё'); + // r.submenu = ['Расширенный поиск', 'Настройки', ...] + await clickElement('Расширенный поиск'); + ``` +- Handles tree nodes: clicking a tree icon expands/collapses. + +#### `fillFields({ name: value })` → `{ filled, form }` +Fill form fields by label (fuzzy match). Auto-detects field type. + +| Value | Field type | Method | +|-------|-----------|--------| +| `'Конфетпром'` | Reference | Clipboard paste + typeahead | +| `'5000'` | Plain text | Clipboard paste | +| `'true'` / `'да'` | Checkbox | Toggle | +| `'Оплата поставщику'` | Radio | Fuzzy label match | + +**DCS report filters**: use human-readable label names. Checkbox is auto-enabled: +```js +await fillFields({ + 'Склад': 'Склад бытовой техники', // auto-enables "Склад" checkbox + fills value + 'Номенклатура': 'Вентилятор' // same: enables checkbox + fills +}); +``` + +Returns `{ filled: [{ field, ok, value, method }], form: {...} }`. +Method is one of: `'toggle'` | `'radio'` | `'paste'` | `'dropdown'` | `'form'` | `'typeahead'` + +#### `selectValue(field, search)` → form state with `selected` +Select a value from reference field via dropdown or selection form. More reliable than `fillFields` for reference fields that need exact selection from a catalog. + +```js +await selectValue('Организация', 'Конфетпром'); +// result.selected = { field: 'Организация', search: 'Конфетпром', method: 'dropdown'|'form' } +``` + +Also supports DCS labels — auto-enables the paired checkbox. + +#### `fillTableRow(fields, opts)` → form state +Fill table row cells via Tab navigation. + +```js +// Add new row: +await fillTableRow( + { 'Номенклатура': 'Бумага', 'Количество': '10', 'Цена': '100' }, + { tab: 'Товары', add: true } +); +// Edit existing row: +await fillTableRow( + { 'Количество': '20' }, + { tab: 'Товары', row: 0 } +); +``` + +- Tab-based sequential navigation — field order set by 1C form config +- Fuzzy cell match: "Количество" matches "ТоварыКоличество" +- Reference cells auto-detected by autocomplete popup + +#### `deleteTableRow(row, { tab? })` → form state +Delete row by 0-based index. + +#### `closeForm({ save? })` → form state +Close the current form via Escape. + +| Argument | Behavior | +|----------|----------| +| `{ save: false }` | Auto-clicks "Нет" on confirmation | +| `{ save: true }` | Auto-clicks "Да" on confirmation | +| `{}` (omitted) | Returns `confirmation` field if dialog appears | + +Preferred over `clickElement('×')` — close buttons on tabs are ambiguous. + +#### `filterList(text, opts?)` → form state +Filter list. Simple mode searches all columns, advanced mode targets a specific field. + +```js +await filterList('КП00-000018'); // simple — all columns +await filterList('Мишка', { field: 'Наименование' }); // advanced — specific column +await filterList('Мишка', { field: 'Наименование', exact: true }); // exact match +``` + +Works on hierarchical catalogs too (flattens the view). + +#### `unfilterList({ field? })` → form state +Clear filters. Without arguments clears all, with `{ field }` clears specific badge. + +### Utility + +#### `screenshot()` → PNG Buffer +#### `wait(seconds)` → form state +#### `getPage()` → Playwright Page (raw, for advanced scripting) + +## Common patterns + +### Create and save a document + +```js +await navigateSection('Продажи'); +await openCommand('Заказы клиентов'); +await clickElement('Создать'); +await fillFields({ 'Организация': 'Конфетпром', 'Контрагент': 'Альфа' }); +await fillTableRow({ 'Номенклатура': 'Бумага', 'Количество': '10' }, { tab: 'Товары', add: true }); +await clickElement('Провести и закрыть'); +``` + +### Open item from list + +```js +await clickElement('КП00-000227', { dblclick: true }); +// Always use { dblclick: true } — single click only selects the row +``` + +### Work with hierarchical lists + +```js +await filterList('Конфетпром'); // flatten + search +await clickElement('Конфетпром ООО', { dblclick: true }); // open +await closeForm(); +await unfilterList(); // restore hierarchy +``` + +### Generate and read a report + +```js +// Fill report filters using readable labels +await fillFields({ 'Склад': 'Основной склад' }); +await clickElement('Сформировать'); +await wait(5); +const report = await readSpreadsheet(); +console.log('Title:', report.title); +console.log('Data rows:', report.data?.length); +``` + +### Keyboard shortcuts (via `page.keyboard.press`) + +| Key | Context | Action | +|-----|---------|--------| +| `F8` | Reference field focused | Create new catalog item | +| `Shift+F4` | Reference field focused | Clear field value | +| `F4` | Reference field focused | Open selection form | +| `Alt+F` | List/table form | Open advanced search dialog | + +### Closing forms — which method to use + +| Goal | Method | +|------|--------| +| Post & close document | `clickElement('Провести и закрыть')` | +| Save & close catalog | `clickElement('Записать и закрыть')` | +| Close without saving | `closeForm({ save: false })` | +| Close and save | `closeForm({ save: true })` | +| Close (manual confirm) | `closeForm()` — returns `confirmation` if dialog appears | + +## Exec response format + +```json +{ "ok": true, "output": "...console.log output...", "elapsed": 3.2 } +``` + +On error (auto-screenshot taken): +```json +{ "ok": false, "error": "Element not found", "output": "...", "screenshot": "error-shot.png", "elapsed": 1.5 } +``` + +## Avoiding loops + +- **Max 2 attempts per operation.** If an action fails twice with the same approach — stop and report to the user +- **Not found = not found.** If `filterList` returns 0 rows or `readTable` is empty after filtering — the item likely doesn't exist in this database. Don't retry the same search 5 times with slight variations +- **Try a different approach, not the same one.** Couldn't find via section navigation? Try `navigateLink`. Couldn't find via simple search? Try advanced search with a specific field. But don't repeat the same method +- **Report partial results.** If you found the list but not the specific item — say so. Show what IS available instead of silently retrying + +## Important notes + +- **Headed mode** — 1C requires visible browser, no headless +- **Startup time** — 1C loads 30-60s on initial connect (built into `start`) +- **Fuzzy matching** — all name lookups: exact > startsWith > includes +- **Clipboard paste** — all text fields filled via Ctrl+V (triggers 1C events properly) +- **Cyrillic in bash** — use `cat <<'SCRIPT' | node $RUN exec -` to avoid escaping issues +- **Non-breaking spaces** — 1C uses `\u00a0` instead of regular spaces. All matching is normalized internally diff --git a/.claude/skills/web-test/scripts/browser.mjs b/.claude/skills/web-test/scripts/browser.mjs new file mode 100644 index 00000000..95952ec2 --- /dev/null +++ b/.claude/skills/web-test/scripts/browser.mjs @@ -0,0 +1,2359 @@ +/** + * 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 { + readSectionsScript, readTabsScript, readCommandsScript, + readFormScript, navigateSectionScript, openCommandScript, + findClickTargetScript, findFieldButtonScript, readSubmenuScript, + resolveFieldsScript, getFormStateScript, + detectFormScript, readTableScript, checkErrorsScript, + switchTabScript +} from './dom.mjs'; + +let browser = null; +let page = null; +let sessionPrefix = null; // e.g. "http://localhost:8081/bpdemo/ru_RU" +let seanceId = null; + +const LOAD_TIMEOUT = 60000; +const INIT_TIMEOUT = 60000; +const ACTION_WAIT = 2000; // fallback minimum wait +const MAX_WAIT = 10000; // max wait for stability +const POLL_INTERVAL = 200; // polling interval +const STABLE_CYCLES = 3; // consecutive stable cycles needed + +/** Check if browser is connected and page is usable. */ +export function isConnected() { + return browser?.isConnected() && page && !page.isClosed(); +} + +/** + * Open browser and navigate to 1C web client URL. + * Waits for initialization (themesCell_theme_0 selector) and attempts to close startup modals. + */ +export async function connect(url) { + if (isConnected()) { + await page.goto(url, { waitUntil: 'domcontentloaded', timeout: LOAD_TIMEOUT }); + } else { + browser = await chromium.launch({ headless: false, args: ['--start-maximized'] }); + const context = await browser.newContext({ + viewport: null, + permissions: ['clipboard-read', 'clipboard-write'], + }); + page = await context.newPage(); + + // Capture seanceId from network requests for graceful logout + sessionPrefix = null; + seanceId = null; + page.on('request', req => { + if (seanceId) return; + const m = req.url().match(/^(https?:\/\/[^/]+\/[^/]+\/[^/]+)\/e1cib\/.+[?&]seanceId=([^&]+)/); + if (m) { sessionPrefix = m[1]; seanceId = m[2]; } + }); + + await page.goto(url, { waitUntil: 'domcontentloaded', timeout: LOAD_TIMEOUT }); + } + + // Wait for 1C to initialize — detect by section panel appearance + try { + await page.waitForSelector('#themesCell_theme_0', { timeout: INIT_TIMEOUT }); + } catch { + // Fallback: wait fixed time if selector doesn't appear (e.g. login page) + await page.waitForTimeout(5000); + } + + // Try to close startup modals (Путеводитель etc.) + await closeModals(); + + return await getPageState(); +} + +/** + * Gracefully terminate the 1C session and close the browser. + * Sends POST /e1cib/logout to release the license before closing. + */ +export async function disconnect() { + if (browser) { + // Graceful logout — release the 1C license + if (page && !page.isClosed() && seanceId && sessionPrefix) { + try { + const logoutUrl = `${sessionPrefix}/e1cib/logout?seanceId=${seanceId}`; + await page.evaluate(async (url) => { + await fetch(url, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: '{"root":{}}' + }); + }, logoutUrl); + await page.waitForTimeout(1000); + } catch {} + } + await browser.close().catch(() => {}); + browser = null; + page = null; + sessionPrefix = null; + seanceId = null; + } +} + +/** + * Attach to a running browser server via CDP WebSocket. + * Sets module state so all functions (getFormState, clickElement, etc.) work. + */ +export async function attach(wsEndpoint, session = {}) { + if (isConnected()) return; + browser = await chromium.connect(wsEndpoint); + const ctx = browser.contexts()[0]; + page = ctx?.pages()[0]; + if (!page) throw new Error('No page found in browser'); + sessionPrefix = session.sessionPrefix || null; + seanceId = session.seanceId || null; +} + +/** + * Detach from browser without closing it. + * Returns session state for persistence. + */ +export function detach() { + const session = { sessionPrefix, seanceId }; + browser = null; + page = null; + sessionPrefix = null; + seanceId = null; + return session; +} + +/** Get current session state (for saving between reconnections). */ +export function getSession() { + return { sessionPrefix, seanceId }; +} + +/** + * Close startup modals and guide tabs. + * Strategy: Escape → click default buttons → close extra tabs → repeat. + */ +async function closeModals() { + for (let attempt = 0; attempt < 5; attempt++) { + // 1. Press Escape to dismiss any popup/modal + await page.keyboard.press('Escape'); + await page.waitForTimeout(1000); + + // 2. Try clicking default "Закрыть"/"OK" buttons + const clicked = await page.evaluate(`(() => { + const btns = [...document.querySelectorAll('a.press.pressDefault')].filter(el => el.offsetWidth > 0); + for (const btn of btns) { + const text = (btn.innerText?.trim() || '').toLowerCase(); + if (['закрыть', 'ok', 'ок', 'нет', 'отмена'].includes(text)) { + btn.click(); + return text; + } + } + return null; + })()`); + if (clicked) { await page.waitForTimeout(1000); continue; } + + // 3. Close extra tabs (Путеводитель etc.) via openedClose button + const tabClosed = await page.evaluate(`(() => { + const btn = document.querySelector('.openedClose'); + if (btn && btn.offsetWidth > 0) { btn.click(); return true; } + return false; + })()`); + if (tabClosed) { await page.waitForTimeout(1000); continue; } + + // Nothing to close — done + break; + } +} + +/** + * Smart wait: poll until DOM is stable and no loading indicators are visible. + * Checks: form number change, loading indicators, DOM stability. + * @param {number|null} previousFormNum — form number before the action (null = don't check) + */ +async function waitForStable(previousFormNum = null) { + let stableCount = 0; + let lastSnapshot = ''; + const start = Date.now(); + + while (Date.now() - start < MAX_WAIT) { + await page.waitForTimeout(POLL_INTERVAL); + + // Check for loading indicators + const status = await page.evaluate(`(() => { + const loading = document.querySelector('.loadingImage, .waitCurtain, .progressBar'); + const isLoading = loading && loading.offsetWidth > 0; + const formCount = document.querySelectorAll('input.editInput[id], a.press[id]').length; + return { isLoading, formCount }; + })()`); + + if (status.isLoading) { + stableCount = 0; + continue; + } + + // Check DOM stability by comparing element count snapshot + const snapshot = String(status.formCount); + if (snapshot === lastSnapshot) { + stableCount++; + } else { + stableCount = 0; + lastSnapshot = snapshot; + } + + // If form was expected to change, ensure it did + if (previousFormNum !== null && stableCount === 1) { + const currentForm = await page.evaluate(detectFormScript()); + if (currentForm !== previousFormNum) { + // Form changed — still wait for stability + } + } + + if (stableCount >= STABLE_CYCLES) return; + } + // Fallback: max wait reached +} + +/** + * Poll until a JS expression returns truthy, or timeout (ms) expires. + * Resolves early — typically within 100-300ms instead of fixed delays. + */ +async function waitForCondition(evalScript, timeout = 2000) { + const start = Date.now(); + while (Date.now() - start < timeout) { + const result = await page.evaluate(evalScript); + if (result) return result; + await page.waitForTimeout(100); + } + return null; +} + +/** + * Check for validation errors / diagnostics after an action. + * Detects: inline balloon tooltip, messages panel, modal error dialog. + * Returns { balloon, messages[], modal } or null. + */ +async function checkForErrors() { + return await page.evaluate(checkErrorsScript()); +} + +/** + * Dismiss pending error modal if present (single OK button dialog). + * Called at the start of action functions so that a leftover error modal + * from a previous operation doesn't block the next action. + * Does NOT dismiss confirmations (Да/Нет — require user decision). + * Returns the dismissed error object or null. + */ +async function dismissPendingErrors() { + const err = await checkForErrors(); + if (!err?.modal) return null; + try { + // Target pressDefault within the modal's form container specifically + const formNum = err.modal.formNum; + const sel = formNum != null + ? `#form${formNum}_container a.press.pressDefault` + : 'a.press.pressDefault'; + const btn = await page.$(sel); + if (btn) { await btn.click({ force: true }); await page.waitForTimeout(500); } + } catch { /* OK */ } + await waitForStable(); + return err; +} + +/** Get the raw Playwright page object (for advanced scripting in skill mode). */ +export function getPage() { + ensureConnected(); + return page; +} + +/** + * Get current page state: active section, tabs. + * Combined into a single evaluate call. + */ +export async function getPageState() { + ensureConnected(); + const { sections, tabs } = await page.evaluate(`({ + sections: ${readSectionsScript()}, + tabs: ${readTabsScript()} + })`); + const activeSection = sections.find(s => s.active)?.name || null; + const activeTab = tabs.find(t => t.active)?.name || null; + return { activeSection, activeTab, sections, tabs }; +} + +/** Read section panel + commands in a single evaluate call. */ +export async function getSections() { + ensureConnected(); + const { sections, commands } = await page.evaluate(`({ + sections: ${readSectionsScript()}, + commands: ${readCommandsScript()} + })`); + const activeSection = sections.find(s => s.active)?.name || null; + return { activeSection, sections, commands }; +} + +/** Navigate to a section by name. Returns new state with commands. */ +export async function navigateSection(name) { + ensureConnected(); + await dismissPendingErrors(); + const result = await page.evaluate(navigateSectionScript(name)); + if (result?.error) return result; + + await waitForStable(); + const { sections, commands } = await page.evaluate(`({ + sections: ${readSectionsScript()}, + commands: ${readCommandsScript()} + })`); + return { navigated: result, sections, commands }; +} + +/** Read commands of the current section. */ +export async function getCommands() { + ensureConnected(); + return await page.evaluate(readCommandsScript()); +} + +/** Open a command from function panel by name. Returns new form state. */ +export async function openCommand(name) { + ensureConnected(); + await dismissPendingErrors(); + const formBefore = await page.evaluate(detectFormScript()); + const result = await page.evaluate(openCommandScript(name)); + if (result?.error) return result; + + await waitForStable(formBefore); + const state = await getFormState(); + const err = await checkForErrors(); + if (err) state.errors = err; + return state; +} + +/** Switch to an open tab by name (fuzzy match). Returns updated form state. */ +export async function switchTab(name) { + ensureConnected(); + const result = await page.evaluate(switchTabScript(name)); + if (result?.error) return result; + await waitForStable(); + return await getFormState(); +} + +// English → Russian metadata type mapping for e1cib navigation links +const E1CIB_TYPE_MAP = { + 'catalog': 'Справочник', 'catalogs': 'Справочник', + 'document': 'Документ', 'documents': 'Документ', + 'commonmodule': 'ОбщийМодуль', + 'enum': 'Перечисление', 'enums': 'Перечисление', + 'dataprocessor': 'Обработка', 'dataprocessors': 'Обработка', + 'report': 'Отчет', 'reports': 'Отчет', + 'accumulationregister': 'РегистрНакопления', + 'informationregister': 'РегистрСведений', + 'accountingregister': 'РегистрБухгалтерии', + 'calculationregister': 'РегистрРасчета', + 'chartofaccounts': 'ПланСчетов', + 'chartofcharacteristictypes': 'ПланВидовХарактеристик', + 'chartofcalculationtypes': 'ПланВидовРасчета', + 'businessprocess': 'БизнесПроцесс', + 'task': 'Задача', + 'exchangeplan': 'ПланОбмена', + 'constant': 'Константа', +}; + +// Types that open via e1cib/app/ (reports and data processors have their own app forms) +const E1CIB_APP_TYPES = new Set(['Отчет', 'Обработка']); + +function normalizeE1cibUrl(url) { + // Already a full e1cib link + if (url.startsWith('e1cib/')) return url; + // "ТипОбъекта.Имя" or "EnglishType.Имя" — translate type, pick list/ or app/ prefix + const dot = url.indexOf('.'); + if (dot > 0) { + const typePart = url.substring(0, dot); + const namePart = url.substring(dot + 1); + const ruType = E1CIB_TYPE_MAP[typePart.toLowerCase()] || typePart; + const prefix = E1CIB_APP_TYPES.has(ruType) ? 'e1cib/app' : 'e1cib/list'; + return `${prefix}/${ruType}.${namePart}`; + } + return `e1cib/list/${url}`; +} + +/** Navigate to a 1C navigation link via Shift+F11 dialog. Returns new form state. */ +export async function navigateLink(url) { + ensureConnected(); + await dismissPendingErrors(); + const link = normalizeE1cibUrl(url); + const formBefore = await page.evaluate(detectFormScript()); + + // Copy link to clipboard, press Shift+F11 (opens "Go to link" dialog with clipboard content) + await page.evaluate(`navigator.clipboard.writeText(${JSON.stringify(link)})`); + await page.keyboard.press('Shift+F11'); + await waitForStable(); + + // Click "Перейти" in the navigation dialog + const dialog = await page.evaluate(detectFormScript()); + if (dialog != null && dialog !== formBefore) { + const btns = await page.$$(`#form${dialog}_container a.press`); + for (const b of btns) { + const txt = (await b.textContent())?.trim(); + if (txt === 'Перейти') { await b.click(); break; } + } + } + + await waitForStable(formBefore); + const state = await getFormState(); + const err = await checkForErrors(); + if (err) state.errors = err; + return state; +} + +/** 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'; + } + } + return state; +} + +/** Read structured table data with pagination. Returns columns, rows, total count. */ +export async function readTable({ maxRows = 20, offset = 0 } = {}) { + ensureConnected(); + const formNum = await page.evaluate(detectFormScript()); + if (formNum === null) return { error: 'no_form' }; + return await page.evaluate(readTableScript(formNum, { maxRows, offset })); +} + +/** + * 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()); + + // Collect iframe indices that belong to the current form's spreadsheet container + const iframeIndices = await page.evaluate(`(() => { + const prefix = 'form${formNum ?? 0}_'; + const allIframes = [...document.querySelectorAll('iframe')]; + const indices = []; + for (let i = 0; i < allIframes.length; i++) { + const f = allIframes[i]; + if (f.offsetWidth < 100) continue; + let el = f.parentElement, found = false; + for (let d = 0; el && d < 30; d++, el = el.parentElement) { + if (el.id && el.id.startsWith(prefix)) { found = true; break; } + } + if (found) indices.push(i); + } + return indices; + })()`); + + const frames = page.frames(); + const allCells = new Map(); + + // Map page iframe indices to frame objects (frame 0 = main, iframes start at 1+) + for (const iframeIdx of iframeIndices) { + // Playwright frames: frame[0] is main, frame[1..N] map to iframes in DOM order + const frame = frames[iframeIdx + 1]; + 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?.textContent?.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); + } + } + } catch { /* skip inaccessible frames */ } + } + + if (allCells.size === 0) return { error: 'no_spreadsheet', hint: 'No SpreadsheetDocument found. Report may not be generated yet.' }; + + // Group by row, determine max columns + 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 colMap = rowMap.get(r); + const arr = []; + for (let c = 0; c <= maxCol; c++) arr.push(colMap.get(c) || ''); + return arr; + }); + + // --- Structured parsing --- + const hasNumber = (row) => row.some(c => /^[\d\s\u00a0]/.test(c) && /\d/.test(c)); + const nonEmpty = (row) => row.filter(c => c !== '').length; + + // 1. Find first data row (first row with numbers) + let firstDataIdx = rows.length; + for (let i = 0; i < rows.length; i++) { + if (hasNumber(rows[i])) { firstDataIdx = i; break; } + } + + // 2. Find header rows: scan backwards from data, pick last row with ≥3 cells as detail header + let detailIdx = -1; + for (let i = firstDataIdx - 1; i >= 0; i--) { + if (nonEmpty(rows[i]) >= 3) { detailIdx = i; break; } + } + if (detailIdx === -1) return { rows, total: rows.length }; + + // Group header: row before detail with ≥2 non-empty cells + 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; + + // 3. Build column names by merging group + detail rows + // Fill-forward group names across empty columns (merged cells) + const groupFilled = new Array(maxCol + 1).fill(''); + if (groupRow) { + let cur = ''; + for (let c = 0; c <= maxCol; c++) { + if (groupRow[c]) cur = groupRow[c]; + groupFilled[c] = cur; + } + } + + // For each column: use detail name if available, else group name + // Prefix with group when duplicates exist in detail row + const detailCounts = {}; + for (let c = 0; c <= maxCol; c++) { + const n = detailRow[c]; + if (n) detailCounts[n] = (detailCounts[n] || 0) + 1; + } + + const colNames = []; + for (let c = 0; c <= maxCol; c++) { + const detail = detailRow[c]; + const group = groupFilled[c]; + if (detail) { + // Use group prefix if duplicate detail names or if group differs from detail + const needPrefix = group && group !== detail && (detailCounts[detail] > 1 || (groupRow && groupRow[c] === '')); + colNames.push(needPrefix ? `${group} / ${detail}` : detail); + } else if (group) { + colNames.push(group); + } else { + colNames.push(null); + } + } + + // 4. Data starts at firstDataIdx + const dataStart = firstDataIdx; + + // 5. 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 = dataStart; 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])); + } + } + + // 6. Meta: title, params, filters from rows before header + const metaEnd = groupIdx >= 0 ? groupIdx : detailIdx; + 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, + }; +} + +/** + * Pick a value from an opened selection form: search + dblclick matching row. + * + * Strategy: + * 1. Find search input in selection form + * 2. Clipboard paste search text (trusted event, more reliable than page.fill) + * 3. Press Enter to apply search filter + * 4. Wait for grid to update, then score rows + * 5. Dblclick best match; if form persists (hit a folder), try Enter as fallback + * + * @returns {{ field, ok, method }} or {{ field, error, message }} + */ +async function pickFromSelectionForm(selFormNum, fieldName, text, origFormNum) { + // 1. Find search input in the selection form (strict — only named search fields, + // do NOT fall back to first input — it may be a filter field like ТипСоглашения) + const searchInputId = await page.evaluate(`(() => { + const p = 'form${selFormNum}_'; + const inputs = [...document.querySelectorAll('input.editInput[id^="' + p + '"]')].filter(el => el.offsetWidth > 0); + const searchInput = inputs.find(el => /поиск|search|строкапоиска|SearchString|find/i.test(el.id)); + return searchInput ? searchInput.id : null; + })()`) + + // 2. Fill search field via clipboard paste (more reliable than page.fill for 1C) + if (searchInputId && text) { + await page.click(`[id="${searchInputId}"]`); + await page.waitForTimeout(300); + // Select all existing text and replace with paste + await page.keyboard.press('Control+A'); + await page.evaluate(`navigator.clipboard.writeText(${JSON.stringify(text)})`); + await page.keyboard.press('Control+V'); + await page.waitForTimeout(500); + // Apply search + await page.keyboard.press('Enter'); + // Wait for search results: loading indicator + grid row count stabilization + await waitForStable(); + // Extra: wait for grid content to settle (loader inside grid, async row fetch) + let gridStable = 0, lastRowCount = -1; + for (let i = 0; i < 15 && gridStable < 3; i++) { + await page.waitForTimeout(POLL_INTERVAL); + const rc = await page.evaluate(`(() => { + const p = 'form${selFormNum}_'; + const grid = document.querySelector('[id^="' + p + '"].grid, [id^="' + p + '"] .grid'); + if (!grid) return -1; + const loading = grid.querySelector('.loadingImage, .waitCurtain, .progressBar'); + if (loading && loading.offsetWidth > 0) return -2; + const body = grid.querySelector('.gridBody'); + return body ? body.querySelectorAll('.gridLine').length : 0; + })()`); + if (rc === -2) { gridStable = 0; continue; } // still loading + if (rc === lastRowCount) { gridStable++; } else { gridStable = 0; lastRowCount = rc; } + } + } + + // 3. Read grid and find best matching row + const rowTarget = await page.evaluate(`(() => { + const p = 'form${selFormNum}_'; + const grid = document.querySelector('[id^="' + p + '"].grid, [id^="' + p + '"] .grid'); + if (!grid) return null; + const body = grid.querySelector('.gridBody'); + if (!body) return null; + const lines = [...body.querySelectorAll('.gridLine')]; + if (!lines.length) return { rowCount: 0 }; + const target = ${JSON.stringify(text.toLowerCase())}; + + // Score each row: exact cell match > row includes > partial cell match + let bestLine = null, bestScore = 0; + for (const line of lines) { + const boxes = [...line.querySelectorAll('.gridBoxText')].map(b => b.innerText?.trim() || ''); + const rowText = boxes.join(' ').toLowerCase(); + let score = 0; + if (boxes.some(b => b.toLowerCase() === target)) score = 3; // exact cell match + else if (rowText === target) score = 3; // exact row match + else if (boxes.some(b => b.toLowerCase().includes(target))) score = 2; // cell includes target + else if (rowText.includes(target)) score = 2; // row includes target + else if (target.includes(boxes[0]?.toLowerCase())) score = 1; // target includes first cell + if (score > bestScore) { bestScore = score; bestLine = line; } + } + + // If search was applied and only 1 row — pick it even without text match + if (!bestLine && lines.length === 1) { + bestLine = lines[0]; bestScore = 1; + } + if (!bestLine || bestScore === 0) return { rowCount: lines.length, score: 0 }; + const r = bestLine.getBoundingClientRect(); + return { rowCount: lines.length, score: bestScore, + x: Math.round(r.x + r.width / 2), y: Math.round(r.y + r.height / 2) }; + })()`); + + if (rowTarget?.x && rowTarget.score > 0) { + // 4. Dblclick the matched row + await page.mouse.dblclick(rowTarget.x, rowTarget.y); + await waitForStable(selFormNum); + + // Verify selection form closed + const stillOpen = await page.evaluate(`(() => { + const p = 'form${selFormNum}_'; + return [...document.querySelectorAll('[id^="' + p + '"]')].some(el => el.offsetWidth > 0); + })()`); + if (stillOpen) { + // Dblclick may have opened a folder — try Enter to select current row + await page.keyboard.press('Enter'); + await waitForStable(selFormNum); + + // Still open? Close and report + const stillOpen2 = await page.evaluate(`(() => { + const p = 'form${selFormNum}_'; + return [...document.querySelectorAll('[id^="' + p + '"]')].some(el => el.offsetWidth > 0); + })()`); + if (stillOpen2) { + await page.keyboard.press('Escape'); + await waitForStable(); + } + } + + // Check for 1C error modals after selection + const err = await page.evaluate(checkErrorsScript()); + if (err?.modal) { + try { + const btn = await page.$('a.press.pressDefault'); + if (btn) { await btn.click(); await page.waitForTimeout(500); } + } catch { /* OK */ } + } + return { field: fieldName, ok: true, method: 'form' }; + } + + // 5. No matching row or grid empty — close and report error + await page.keyboard.press('Escape'); + await waitForStable(); + return { field: fieldName, error: 'not_found', + message: 'No matches in selection form for "' + text + '"' + + (rowTarget?.rowCount ? ' (' + rowTarget.rowCount + ' rows checked)' : ' (grid empty)') }; +} + +/** + * Fill a reference field via clipboard paste + 1C autocomplete. + * + * Strategy: + * 1. Clear field if it has a value (Shift+F4 — native 1C mechanism, no JS errors) + * 2. Clipboard paste text (Ctrl+V = trusted event, triggers real 1C autocomplete) + * 3. Check editDropDown for autocomplete results → click match or Tab to resolve + * 4. Verify result: resolved → ok, not found → clear + error + * + * Clipboard paste was chosen because: + * - Ctrl+V produces trusted browser events that 1C respects for autocomplete + * - page.fill() + synthetic keydown/keyup only triggers hints, not real search + * - keyboard.type() garbles Cyrillic on some fields + * + * @returns {{ field, ok?, method?, error?, value?, message?, available? }} + */ +async function fillReferenceField(selector, fieldName, value, formNum) { + const text = String(value); + const escapedSel = selector.replace(/'/g, "\\'"); + + // Helper: detect new forms opened above the current one + async function detectNewForm() { + return 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; + })()`); + } + + // Helper: clear the field using Shift+F4 (native 1C mechanism) + async function clearField() { + try { + await page.click(selector, { timeout: 3000 }); + await page.keyboard.press('Shift+F4'); + await page.waitForTimeout(300); + await page.keyboard.press('Tab'); + await page.waitForTimeout(300); + } catch { /* OK */ } + } + + // Helper: check for "not in list" cloud popup (1C shows positioned div with "нет в списке") + async function checkNotInListCloud() { + return page.evaluate(`(() => { + const divs = document.querySelectorAll('div'); + for (const el of divs) { + if (el.offsetWidth === 0 || el.offsetHeight === 0) continue; + const style = getComputedStyle(el); + if (style.position !== 'absolute' && style.position !== 'fixed') continue; + const z = parseInt(style.zIndex) || 0; + if (z < 100) continue; + if ((el.innerText || '').includes('нет в списке')) return true; + } + return false; + })()`); + } + + // 0. Dismiss any leftover error modal from a previous operation + await dismissPendingErrors(); + + // 1. Focus (handle surface/modal overlay from previous interaction) + try { + await page.click(selector); + } catch (e) { + if (e.message.includes('intercepts pointer events')) { + // Try force click first (no side effects), then Escape as fallback + try { + await page.click(selector, { force: true }); + } catch (e2) { + if (e2.message.includes('intercepts pointer events')) { + await dismissPendingErrors(); + await page.keyboard.press('Escape'); + await page.waitForTimeout(500); + await page.click(selector); + } else throw e2; + } + } else throw e; + } + + // 2. If field already has a value, clear using Shift+F4 (native 1C mechanism). + const currentVal = await page.evaluate(`document.querySelector('${escapedSel}')?.value || ''`); + if (currentVal) { + await page.keyboard.press('Shift+F4'); + await page.waitForTimeout(500); + await page.keyboard.press('Tab'); + await page.waitForTimeout(500); + // Refocus + await page.click(selector); + } + + // 3. Paste text via clipboard (trusted event → triggers real 1C autocomplete) + await page.evaluate(`navigator.clipboard.writeText(${JSON.stringify(text)})`); + await page.keyboard.press('Control+V'); + await page.waitForTimeout(2000); + + // 4. Check editDropDown for autocomplete suggestions + const eddState = await page.evaluate(`(() => { + const edd = document.getElementById('editDropDown'); + if (!edd || edd.offsetWidth === 0) return { visible: false }; + const eddTexts = [...edd.querySelectorAll('.eddText')].filter(el => el.offsetWidth > 0); + return { + visible: true, + items: eddTexts.map(el => { + const r = el.getBoundingClientRect(); + return { name: el.innerText?.trim() || '', x: r.x + r.width / 2, y: r.y + r.height / 2 }; + }) + }; + })()`); + + if (eddState.visible && eddState.items?.length > 0) { + const target = text.toLowerCase(); + // Separate real matches from "Создать:" items + const candidates = eddState.items.filter(i => !i.name.startsWith('Создать')); + + if (candidates.length > 0) { + // Find best match (items have format "Name (Code)" — match against name part) + let match = candidates.find(i => { + const name = i.name.replace(/\s*\([^)]*\)\s*$/, '').toLowerCase(); + return name === target; + }); + if (!match) match = candidates.find(i => i.name.toLowerCase().includes(target)); + if (!match) match = candidates.find(i => { + const name = i.name.replace(/\s*\([^)]*\)\s*$/, '').toLowerCase(); + return name.includes(target) || target.includes(name); + }); + + if (match) { + await page.mouse.click(match.x, match.y); + await waitForStable(); + await dismissPendingErrors(); // business logic errors (e.g. СПАРК) may appear async + return { field: fieldName, ok: true, method: 'dropdown', + value: match.name.replace(/\s*\([^)]*\)\s*$/, '') }; + } + // Candidates exist but none match — report them + await page.keyboard.press('Escape'); + await page.waitForTimeout(300); + await clearField(); + return { field: fieldName, error: 'not_matched', + available: candidates.map(i => i.name.replace(/\s*\([^)]*\)\s*$/, '')) }; + } + + // Only "Создать:" items — no existing matches + await page.keyboard.press('Escape'); + await page.waitForTimeout(300); + await clearField(); + return { field: fieldName, error: 'not_found', + message: 'No existing values match "' + text + '"' }; + } + + // 4b. No edd — check for "not in list" cloud that may have appeared during paste + if (await checkNotInListCloud()) { + await page.keyboard.press('Escape'); + await page.waitForTimeout(300); + await clearField(); + return { field: fieldName, error: 'not_found', + message: 'Value "' + text + '" not found (not in list)' }; + } + + // 5. No edd at all — press Tab to trigger direct resolve + await page.keyboard.press('Tab'); + await waitForStable(); + await dismissPendingErrors(); + + // 5x. Check for "not in list" cloud popup after Tab + if (await checkNotInListCloud()) { + await page.keyboard.press('Escape'); + await page.waitForTimeout(300); + await clearField(); + return { field: fieldName, error: 'not_found', + message: 'Value "' + text + '" not found (not in list)' }; + } + + // 5a. New form opened? (creation form = value not found) + const newForm = await detectNewForm(); + if (newForm !== null) { + await page.keyboard.press('Escape'); + await waitForStable(); + await clearField(); + return { field: fieldName, error: 'not_found', + message: 'Value "' + text + '" not found' }; + } + + // 5b. Dropdown after Tab? + const popup = await page.evaluate(readSubmenuScript()); + if (Array.isArray(popup) && popup.length > 0) { + const realItems = popup.filter(i => !i.name.startsWith('Создать')); + await page.keyboard.press('Escape'); + await page.waitForTimeout(300); + await clearField(); + if (realItems.length > 0) { + return { field: fieldName, error: 'ambiguous', + message: 'Multiple matches for "' + text + '"', + available: realItems.map(i => i.name.replace(/\s*\([^)]*\)\s*$/, '')) }; + } + return { field: fieldName, error: 'not_found', + message: 'Value "' + text + '" not found' }; + } + + // 5c. Check final value + const finalVal = await page.evaluate(`document.querySelector('${escapedSel}')?.value || ''`); + if (!finalVal) { + // 6. Last resort: try F4 to open selection form and pick from there + try { + await page.click(selector); + await page.waitForTimeout(300); + } catch { /* OK — field may be unfocused */ } + await page.keyboard.press('F4'); + await page.waitForTimeout(ACTION_WAIT); + + const selFormNum = await detectNewForm(); + if (selFormNum !== null) { + const pickResult = await pickFromSelectionForm(selFormNum, fieldName, text, formNum); + if (pickResult.ok) return pickResult; + // pickFromSelectionForm already closed the form on error + } + + return { field: fieldName, error: 'not_found', + message: 'Value "' + text + '" not found (field is empty)' }; + } + + return { field: fieldName, ok: true, method: 'typeahead', value: finalVal }; +} + + +/** 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) return { error: 'no_form' }; + + // 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; + } + 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}"]`; + 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 = String(fields[r.field]).toLowerCase(); + const opt = r.options.find(o => o.label.toLowerCase() === desired) + || r.options.find(o => 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) { + // Reference field: DLB-based selection (dropdown or selection form) + const refResult = await fillReferenceField(selector, r.field, fields[r.field], formNum); + results.push(refResult); + } 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 page.evaluate(`navigator.clipboard.writeText(${JSON.stringify(String(fields[r.field]))})`); + await page.keyboard.press('Control+V'); + 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 }); + } + } + + const formData = await page.evaluate(readFormScript(formNum)); + return { filled: results, form: formData }; +} + +/** Click a button/hyperlink/tab on the current form. Use {dblclick: true} to double-click (open items from lists). */ +export async function clickElement(text, { dblclick } = {}) { + ensureConnected(); + await dismissPendingErrors(); + + // 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 target = ${JSON.stringify(text.toLowerCase())}; + const btns = [...document.querySelectorAll('a.press.pressButton')].filter(el => el.offsetWidth > 0); + let best = btns.find(el => norm(el.innerText).toLowerCase() === target); + if (!best) best = btns.find(el => 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) return btnResult; + 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 = text.toLowerCase(); + let found = popupItems.find(i => i.name.toLowerCase() === target); + if (!found) found = popupItems.find(i => 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 + } + + const formNum = await page.evaluate(detectFormScript()); + if (formNum === null) return { error: 'no_form' }; + + // Find the target element ID + const target = await page.evaluate(findClickTargetScript(formNum, text)); + if (target?.error) return target; + + // Grid row targets — use coordinate click (single or double) + if (target.kind === 'gridGroup' || target.kind === 'gridParent') { + // Dblclick to enter group / go up to parent + await page.mouse.dblclick(target.x, target.y); + await waitForStable(formNum); + const state = await getFormState(); + state.clicked = { kind: target.kind, name: target.name }; + return state; + } + if (target.kind === 'gridTreeNode') { + // Tree node: click the tree expand/collapse icon [tree="true"] for toggle + const treeIconCoords = await page.evaluate(`(() => { + const p = ${JSON.stringify(`form${formNum}_`)}; + const grid = document.querySelector('[id^="' + p + '"].grid'); + const body = grid?.querySelector('.gridBody'); + if (!body) return null; + const lines = [...body.querySelectorAll('.gridLine')]; + for (const line of lines) { + const textBoxes = [...line.querySelectorAll('.gridBoxText')].filter(b => b.offsetWidth > 0); + const text = textBoxes[0]?.innerText?.trim() || ''; + if (text.toLowerCase() === ${JSON.stringify(target.name.toLowerCase())}) { + const treeIcon = line.querySelector('.gridBoxImg [tree="true"]'); + if (treeIcon) { + const r = treeIcon.getBoundingClientRect(); + return { x: Math.round(r.x + r.width / 2), y: Math.round(r.y + r.height / 2) }; + } + } + } + return null; + })()`); + if (treeIconCoords) { + await page.mouse.click(treeIconCoords.x, treeIconCoords.y); + } else { + // Fallback: select row and use +/- keys + await page.mouse.click(target.x, target.y); + await page.waitForTimeout(300); + await page.keyboard.press('NumpadAdd'); + } + await waitForStable(formNum); + const state = await getFormState(); + state.clicked = { kind: 'gridTreeNode', name: target.name }; + state.hint = 'Tree node toggled. Use web_table to see updated tree.'; + return state; + } + if (target.kind === 'gridRow') { + if (dblclick) { + await page.mouse.dblclick(target.x, target.y); + await waitForStable(); + const state = await getFormState(); + state.clicked = { kind: 'gridRow', name: target.name, dblclick: true }; + return state; + } + await page.mouse.click(target.x, target.y); + await waitForStable(); + const state = await getFormState(); + state.clicked = { kind: 'gridRow', name: target.name }; + return state; + } + + // Build selector: tabs without ID use [data-content], others use [id] + const selector = (target.kind === 'tab' && !target.id) + ? `[data-content="${target.name}"]` + : `[id="${target.id}"]`; + + // Use Playwright click for proper mousedown/mouseup events + try { + await page.click(selector, { timeout: 5000 }); + } catch (clickErr) { + if (clickErr.message.includes('intercepts pointer events')) { + // Surface overlay intercepts — try force click first (no side effects), + // then Escape + retry as fallback (Escape can trigger save dialogs on forms) + try { + await page.click(selector, { force: true, timeout: 5000 }); + } catch (clickErr2) { + if (clickErr2.message.includes('intercepts pointer events')) { + await page.keyboard.press('Escape'); + await page.waitForTimeout(500); + await page.click(selector, { timeout: 5000 }); + } else { + throw clickErr2; + } + } + } else { + throw clickErr; + } + } + + // 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. + if (target.kind === 'button') { + const postForm = await page.evaluate(detectFormScript()); + if (postForm === formNum) { + // Form didn't change — server might still be processing. + // waitForSelector uses MutationObserver internally — doesn't block event loop. + try { + await page.waitForSelector( + '#modalSurface:not([style*="display: none"]), .balloon', + { state: 'visible', timeout: 10000 } + ); + } catch {} + 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; +} + +/** + * 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(); + await page.keyboard.press('Escape'); + await waitForStable(); + 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) { + await b.click({ force: true }); + await waitForStable(); + break; + } + } + return await getFormState(); + } + state.confirmation = err.confirmation; + state.hint = 'Confirmation dialog shown. Click "Да" to confirm or "Нет" to cancel'; + } + return state; +} + +/** + * Select a value from a reference field (compound operation). + * Handles three patterns: + * A) DLB opens an inline dropdown popup — click matching item + * B) DLB opens dropdown with history — click "Показать все" or F4 to open selection form + * C) DLB opens a separate selection form directly — search + dblclick in grid + */ +export async function selectValue(fieldName, searchText) { + ensureConnected(); + await dismissPendingErrors(); + const formNum = await page.evaluate(detectFormScript()); + if (formNum === null) return { error: 'no_form' }; + + // 1. Find DLB button (fallback to CB — ERP uses Choose Button instead of DLB for some fields) + let btn = await page.evaluate(findFieldButtonScript(formNum, fieldName, 'DLB')); + if (btn?.error === 'button_not_found') { + btn = await page.evaluate(findFieldButtonScript(formNum, fieldName, 'CB')); + } + if (btn?.error) return btn; + + // Auto-enable DCS checkbox if resolved via label + if (btn.dcsCheckbox) { + const cbSel = `[id="${btn.dcsCheckbox.inputId}"]`; + const isChecked = await page.$eval(cbSel, el => + el.classList.contains('checked') || el.classList.contains('checkboxOn') || el.classList.contains('select')); + if (!isChecked) { await page.click(cbSel); await waitForStable(); } + } + + // Helper: detect selection form (form number > formNum) + async function detectSelectionForm() { + return 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; + })()`); + } + + // Helper: open selection form and pick value + async function openFormAndPick() { + await waitForStable(formNum); + const selFormNum = await detectSelectionForm(); + if (selFormNum !== null) { + const pickResult = await pickFromSelectionForm(selFormNum, btn.fieldName, searchText || '', formNum); + const state = await getFormState(); + state.selected = { field: btn.fieldName, search: searchText || null, method: 'form' }; + if (pickResult.error) state.selected.error = pickResult.error; + if (pickResult.message) state.selected.message = pickResult.message; + const err = await checkForErrors(); + if (err) state.errors = err; + return state; + } + return null; + } + + // Helper: click EDD item via evaluate (bypasses div.surface overlay from DLB) + // page.mouse.click() doesn't work here — surface intercepts pointer events. + // Dispatching mousedown directly on the element avoids this. + async function clickEddItem(itemName) { + return page.evaluate(`(() => { + const edd = document.getElementById('editDropDown'); + if (!edd || edd.offsetWidth === 0) return null; + const target = ${JSON.stringify(itemName.toLowerCase())}; + // Search .eddText items + for (const el of edd.querySelectorAll('.eddText')) { + if (el.offsetWidth === 0) continue; + const t = (el.innerText?.trim() || '').toLowerCase(); + if (t === target || t.includes(target) || target.includes(t.replace(/\\s*\\([^)]*\\)\\s*$/, ''))) { + 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 el.innerText.trim(); + } + } + return null; + })()`); + } + + // Helper: click "Показать все" in EDD footer via evaluate + async function clickShowAll() { + return page.evaluate(`(() => { + const edd = document.getElementById('editDropDown'); + if (!edd || edd.offsetWidth === 0) return false; + let el = edd.querySelector('.eddBottom .hyperlink'); + if (!el || el.offsetWidth === 0) { + const candidates = [...edd.querySelectorAll('span, div, a')] + .filter(e => e.offsetWidth > 0 && e.children.length === 0); + el = candidates.find(e => { + const t = (e.innerText?.trim() || '').toLowerCase(); + return t === 'показать все' || t === 'show all'; + }); + } + if (!el) return false; + 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 true; + })()`); + } + + // 2. Click DLB + await page.click(`[id="${btn.buttonId}"]`); + await page.waitForTimeout(ACTION_WAIT); + + // 3A. Check if a dropdown popup appeared (inline quick selection) + const popupItems = await page.evaluate(readSubmenuScript()); + if (Array.isArray(popupItems) && popupItems.length > 0) { + const regularItems = popupItems.filter(i => i.kind !== 'showAll'); + const showAllItem = popupItems.find(i => i.kind === 'showAll'); + + if (searchText) { + const target = searchText.toLowerCase(); + // Try to find match among regular dropdown items + let match = regularItems.find(i => i.name.toLowerCase() === target); + if (!match) match = regularItems.find(i => i.name.toLowerCase().includes(target)); + if (!match) match = regularItems.find(i => { + const name = i.name.replace(/\s*\([^)]*\)\s*$/, '').toLowerCase(); + return name === target || name.includes(target) || target.includes(name); + }); + + if (match) { + // Click via evaluate to bypass div.surface overlay + await clickEddItem(match.name); + await waitForStable(); + const state = await getFormState(); + state.selected = { field: btn.fieldName, search: searchText, method: 'dropdown' }; + const err = await checkForErrors(); + if (err) state.errors = err; + return state; + } + + // No match in dropdown — try "Показать все" to open selection form + if (showAllItem) { + await clickShowAll(); + const formResult = await openFormAndPick(); + if (formResult) return formResult; + } + + // No "Показать все" — close dropdown, try F4 + await page.keyboard.press('Escape'); + await page.waitForTimeout(500); + + // Focus the field input and press F4 to open selection form + const inputId = await page.evaluate(`(() => { + const p = 'form${formNum}_'; + const name = ${JSON.stringify(btn.fieldName)}; + const el = document.querySelector('[id="' + p + name + '"], [id="' + p + name + '_i0"]'); + return el ? el.id : null; + })()`); + if (inputId) { + await page.click(`[id="${inputId}"]`); + await page.waitForTimeout(300); + } + await page.keyboard.press('F4'); + await page.waitForTimeout(ACTION_WAIT); + + const formResult = await openFormAndPick(); + if (formResult) return formResult; + + // Still nothing — report available items from original dropdown + return { error: 'not_found', field: btn.fieldName, search: searchText, + available: regularItems.map(i => i.name), + message: 'No match in dropdown, could not open selection form' }; + } + + // No search text — click first regular item + if (regularItems.length > 0) { + await clickEddItem(regularItems[0].name); + await waitForStable(); + const state = await getFormState(); + state.selected = { field: btn.fieldName, search: null, picked: regularItems[0].name, method: 'dropdown' }; + const err = await checkForErrors(); + if (err) state.errors = err; + return state; + } + } + + // 3B. Check if a new selection form opened directly + const selFormNum = await detectSelectionForm(); + if (selFormNum !== null) { + const pickResult = await pickFromSelectionForm(selFormNum, btn.fieldName, searchText || '', formNum); + const state = await getFormState(); + state.selected = { field: btn.fieldName, search: searchText || null, method: 'form' }; + if (pickResult.error) state.selected.error = pickResult.error; + if (pickResult.message) state.selected.message = pickResult.message; + const err = await checkForErrors(); + if (err) state.errors = err; + return state; + } + + // 3C. Neither popup nor form — try F4 as last resort + await page.keyboard.press('Escape'); + await page.waitForTimeout(300); + + const inputId = await page.evaluate(`(() => { + const p = 'form${formNum}_'; + const name = ${JSON.stringify(btn.fieldName)}; + const el = document.querySelector('[id="' + p + name + '"], [id="' + p + name + '_i0"]'); + return el ? el.id : null; + })()`); + if (inputId) { + await page.click(`[id="${inputId}"]`); + await page.waitForTimeout(300); + } + await page.keyboard.press('F4'); + await page.waitForTimeout(ACTION_WAIT); + + const formResult = await openFormAndPick(); + if (formResult) return formResult; + + return { error: 'selection_not_detected', field: btn.fieldName, + message: 'DLB click did not open a popup or selection form' }; +} + +/** + * 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 } = {}) { + ensureConnected(); + await dismissPendingErrors(); + const formNum = await page.evaluate(detectFormScript()); + if (formNum === null) return { error: 'no_form' }; + + try { + // 1. Switch tab if requested + if (tab) { + await clickElement(tab); + } + + // 2. Add new row if requested + if (add) { + await clickElement('Добавить'); + await page.waitForTimeout(1000); + } + + // 2b. Enter edit mode on existing row by dblclick + if (row != null) { + const fieldKeys = JSON.stringify(Object.keys(fields).map(k => k.toLowerCase())); + const cellCoords = await page.evaluate(`(() => { + const grids = [...document.querySelectorAll('.grid')].filter(el => el.offsetWidth > 0); + const grid = grids[grids.length - 1]; + 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 column index + const headLine = head.querySelector('.gridLine') || head; + const cols = []; + [...headLine.children].forEach((box, i) => { + if (box.offsetWidth === 0) return; + const t = box.querySelector('.gridBoxText'); + cols.push({ idx: i, text: ((t || box).innerText?.trim() || '').toLowerCase() }); + }); + + const keys = ${fieldKeys}; + let targetIdx = -1; + for (const key of keys) { + const exact = cols.find(c => c.text === key); + if (exact) { targetIdx = exact.idx; break; } + const inc = cols.find(c => c.text.includes(key) || key.includes(c.text)); + if (inc) { targetIdx = inc.idx; break; } + } + + const rows = [...body.querySelectorAll('.gridLine')]; + if (${row} >= rows.length) return { error: 'row_out_of_range', total: rows.length }; + const line = rows[${row}]; + const boxes = [...line.children].filter(b => b.offsetWidth > 0 && !b.classList.contains('gridBoxComp')); + + // Use matched column, or fall back to second visible box (skip N column) + const box = targetIdx >= 0 ? boxes[targetIdx] : (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) }; + })()`); + + if (cellCoords.error) return cellCoords; + + await page.mouse.dblclick(cellCoords.x, cellCoords.y); + await page.waitForTimeout(500); + + const inEdit = await page.evaluate(`(() => { + const f = document.activeElement; + return f && f.tagName === 'INPUT'; + })()`); + if (!inEdit) return { error: 'edit_mode_failed', + message: 'Double-click on row ' + row + ' did not enter edit mode' }; + } + + // 3. Verify we're in grid edit mode (active INPUT inside a .grid) + const editCheck = await page.evaluate(`(() => { + const f = document.activeElement; + if (!f || f.tagName !== 'INPUT') return { inEdit: false, tag: f?.tagName }; + let node = f; + while (node) { + if (node.classList?.contains('grid')) return { inEdit: true }; + node = node.parentElement; + } + return { inEdit: false, hint: 'input not inside grid' }; + })()`); + + if (!editCheck.inEdit) { + return { error: 'not_in_edit_mode', + message: '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)) { + pending.set(key, { value: String(val), 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) return { + tag: 'INPUT', id: f.id, + fullName: f.id.replace(/^form\\d+_/, '').replace(/_i\\d+$/, '') + }; + } + return { tag: f.tagName || 'none' }; + })()`); + + if (cell.tag !== 'INPUT') { + // Not in an editable grid cell — Tab past (ERP has DIV focus between cells) + nonInputCount++; + if (nonInputCount > 3) break; // truly exited edit mode + 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 + 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; + } + } + + if (!matchedKey) { + // Skip this cell + await page.keyboard.press('Tab'); + await page.waitForTimeout(300); + continue; + } + + const info = pending.get(matchedKey); + const text = info.value; + + // === Fill this cell: clipboard paste (trusted event) === + await page.keyboard.press('Control+A'); + await page.evaluate(`navigator.clipboard.writeText(${JSON.stringify(text)})`); + await page.keyboard.press('Control+V'); + await page.waitForTimeout(1500); + + // 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 + const realItems = eddItems.filter(i => !i.startsWith('Создать')); + + if (realItems.length > 0) { + const tgt = text.toLowerCase(); + let pick = realItems.find(i => + i.replace(/\s*\([^)]*\)\s*$/, '').toLowerCase() === tgt); + if (!pick) pick = realItems.find(i => 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 selection form (reference field opened selection) + const newForm = 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 (newForm !== null) { + // Selection form opened — search and pick + 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 + } + + // 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); + const formData = await getFormState(); + const result = { filled: results }; + if (notFilled.length > 0) result.notFilled = notFilled; + result.form = formData; + return result; + + } catch (e) { + const form = await getFormState().catch(() => null); + return { error: 'fillTableRow_failed', message: e.message, form }; + } +} + +/** + * 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 } = {}) { + ensureConnected(); + await dismissPendingErrors(); + const formNum = await page.evaluate(detectFormScript()); + if (formNum === null) return { error: 'no_form' }; + + // 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 grids = [...document.querySelectorAll('.grid')].filter(el => el.offsetWidth > 0); + const grid = 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}]; + const cells = [...line.querySelectorAll('.gridBoxText')]; + const cell = cells.length > 1 ? cells[1] : cells[0]; + if (!cell) return { error: 'no_cell' }; + 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) return cellCoords; + + 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 grids = [...document.querySelectorAll('.grid')].filter(el => el.offsetWidth > 0); + const grid = 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) return { error: 'no_form' }; + + 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) return { error: 'no_search_field', message: 'No search input found on this form' }; + + await page.click(`[id="${searchId}"]`); + await page.waitForTimeout(200); + await page.keyboard.press('Control+A'); + await page.evaluate(`navigator.clipboard.writeText(${JSON.stringify(String(text))})`); + await page.keyboard.press('Control+V'); + await page.waitForTimeout(300); + await page.keyboard.press('Enter'); + await waitForStable(formNum); + + const state = await getFormState(); + state.filtered = { type: 'search', text }; + return state; + } + + // --- 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 tl = t.toLowerCase(), fl = 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) return gridEl; + 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'); + return { error: 'no_advanced_search', message: '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) { + return { error: 'dialog_not_opened', message: 'Advanced search dialog did not open' }; + } + } + + // 2b. If column wasn't in the grid, change FieldSelector via DLB dropdown + if (needDlb) { + 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 (fsInfo.current.toLowerCase() !== 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 target = ${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 => el.innerText.trim().toLowerCase() === target) + || items.find(el => 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); + return ddResult; + } + 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 page.evaluate(`navigator.clipboard.writeText(${JSON.stringify(String(text))})`); + await page.keyboard.press('Control+V'); + 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 page.evaluate(`navigator.clipboard.writeText(${JSON.stringify(String(text))})`); + await page.keyboard.press('Control+V'); + 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 dialog if it stayed open (some forms keep it open after Найти) + // Check for modalSurface directly — more reliable than detectFormScript. + for (let attempt = 0; attempt < 3; attempt++) { + const hasModal = await page.evaluate(`(() => { + const m = document.getElementById('modalSurface'); + return m && m.offsetWidth > 0; + })()`); + if (!hasModal) 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) return { error: 'no_form' }; + + 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 target = ${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 = 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) return closeBtn; + 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; +} + +/** Take a screenshot. Returns PNG buffer. */ +export async function screenshot() { + ensureConnected(); + return await page.screenshot({ type: 'png' }); +} + +/** Wait for a specified number of seconds. */ +export async function wait(seconds) { + ensureConnected(); + await page.waitForTimeout(seconds * 1000); + return await getFormState(); +} + +function ensureConnected() { + if (!isConnected()) { + throw new Error('Browser not connected. Call web_connect first.'); + } +} diff --git a/.claude/skills/web-test/scripts/dom.mjs b/.claude/skills/web-test/scripts/dom.mjs new file mode 100644 index 00000000..6db13fee --- /dev/null +++ b/.claude/skills/web-test/scripts/dom.mjs @@ -0,0 +1,1034 @@ +/** + * DOM selectors and semantic mapping for 1C:Enterprise web client. + * + * All functions return JavaScript strings for page.evaluate(). + * They produce clean semantic structures — no DOM IDs or CSS classes leak out. + * Only non-default property values are included to minimize response size. + */ + +// --- Shared function strings (embedded in evaluate scripts) --- + +/** Detect active form number. Picks form with most visible elements, skipping form0. + * When modalSurface is visible — prefer the highest-numbered form (modal dialog). */ +const DETECT_FORM_FN = `function detectForm() { + const counts = {}; + document.querySelectorAll('input.editInput[id], a.press[id]').forEach(el => { + if (el.offsetWidth === 0) return; + const m = el.id.match(/^form(\\d+)_/); + if (m) counts[m[1]] = (counts[m[1]] || 0) + 1; + }); + const nums = Object.keys(counts).map(Number); + if (!nums.length) return null; + const candidates = nums.filter(n => n > 0); + if (!candidates.length) return nums[0]; + // When modal surface is visible, prefer the highest-numbered form (modal dialog) + const modal = document.getElementById('modalSurface'); + if (modal && modal.offsetWidth > 0) { + const maxForm = Math.max(...candidates); + if (counts[maxForm] >= 1) return maxForm; + } + return candidates.reduce((best, n) => counts[n] > counts[best] ? n : best); +}`; + +/** Read form state given prefix p. Returns { fields, buttons, tabs, texts, hyperlinks, table, iframes }. */ +const READ_FORM_FN = `function readForm(p) { + const result = {}; + const fields = []; + const buttons = []; + const formTabs = []; + const texts = []; + const hyperlinks = []; + + // Fields (inputs) + document.querySelectorAll('input.editInput[id^="' + p + '"]').forEach(el => { + if (el.offsetWidth === 0) return; + const name = el.id.replace(p, '').replace(/_i\\d+$/, ''); + const titleEl = document.getElementById(p + name + '#title_text') + || document.getElementById(p + name + '#title_div'); + const label = (titleEl?.innerText?.trim() || '').replace(/\\n/g, ' '); + const actions = []; + if (document.getElementById(p + name + '_DLB')?.offsetWidth > 0) actions.push('select'); + if (document.getElementById(p + name + '_OB')?.offsetWidth > 0) actions.push('open'); + if (document.getElementById(p + name + '_CLR')?.offsetWidth > 0) actions.push('clear'); + if (document.getElementById(p + name + '_CB')?.offsetWidth > 0) actions.push('pick'); + const field = { name, value: el.value || '' }; + if (label && label !== name) field.label = label; + if (el.readOnly) field.readonly = true; + if (el.disabled) field.disabled = true; + if (el.type && el.type !== 'text') field.type = el.type; + if (document.activeElement === el) field.focused = true; + if (actions.length) field.actions = actions; + if (el.closest('.inputsBox')?.classList.contains('markIncomplete')) field.required = true; + fields.push(field); + }); + + // Textareas + document.querySelectorAll('textarea[id^="' + p + '"]').forEach(el => { + if (el.offsetWidth === 0) return; + const name = el.id.replace(p, '').replace(/_i\\d+$/, ''); + const titleEl = document.getElementById(p + name + '#title_text') + || document.getElementById(p + name + '#title_div'); + const label = (titleEl?.innerText?.trim() || '').replace(/\\n/g, ' '); + const field = { name, value: el.value || '', type: 'textarea' }; + if (label && label !== name) field.label = label; + if (el.readOnly) field.readonly = true; + if (el.disabled) field.disabled = true; + if (document.activeElement === el) field.focused = true; + if (el.closest('.inputsBox')?.classList.contains('markIncomplete')) field.required = true; + fields.push(field); + }); + + // Checkboxes + document.querySelectorAll('[id^="' + p + '"].checkbox').forEach(el => { + if (el.offsetWidth === 0) return; + const name = el.id.replace(p, ''); + const titleEl = document.getElementById(p + name + '#title_text'); + const label = titleEl?.innerText?.trim() || ''; + const field = { + name, + value: el.classList.contains('checked') || el.classList.contains('checkboxOn') || el.classList.contains('select'), + type: 'checkbox' + }; + if (label && label !== name) field.label = label; + fields.push(field); + }); + + // Radio buttons — base element is option 0, others are #N#radio (N >= 1) + const radioGroups = {}; + document.querySelectorAll('[id^="' + p + '"].radio').forEach(el => { + if (el.offsetWidth === 0) return; + const id = el.id.replace(p, ''); + const m = id.match(/^(.+?)#(\\d+)#radio$/); + if (m) { + // Options 1, 2, ... have explicit #N#radio suffix + const [, groupName, idx] = m; + if (!radioGroups[groupName]) radioGroups[groupName] = []; + const labelEl = document.getElementById(p + groupName + '#' + idx + '#radio_text'); + const label = labelEl?.innerText?.trim() || 'option' + idx; + radioGroups[groupName].push({ index: parseInt(idx), label, selected: el.classList.contains('select') }); + } else if (!id.includes('#')) { + // Base element = option 0 (no #0#radio suffix) + if (!radioGroups[id]) radioGroups[id] = []; + const labelEl = document.getElementById(p + id + '#0#radio_text'); + const label = labelEl?.innerText?.trim() || 'option0'; + radioGroups[id].unshift({ index: 0, label, selected: el.classList.contains('select') }); + } + }); + for (const [name, options] of Object.entries(radioGroups)) { + const titleEl = document.getElementById(p + name + '#title_text'); + const label = titleEl?.innerText?.trim() || ''; + const selected = options.find(o => o.selected); + const field = { + name, + value: selected?.label || '', + type: 'radio', + options: options.map(o => o.label) + }; + if (label && label !== name) field.label = label; + fields.push(field); + } + + // Buttons (a.press) + document.querySelectorAll('a.press[id^="' + p + '"]').forEach(el => { + if (el.offsetWidth === 0) return; + const idName = el.id.replace(p, ''); + if (/_(?:DLB|CLR|OB|CB)$/.test(idName)) return; + const span = el.querySelector('.submenuText') || el.querySelector('span'); + const text = span?.textContent?.trim() || el.innerText?.trim() || ''; + if (!text && !el.classList.contains('pressCommand')) return; + const btn = { name: text || idName }; + if (el.classList.contains('pressDefault')) btn.default = true; + if (el.classList.contains('pressDisabled')) btn.disabled = true; + buttons.push(btn); + }); + + // Frame buttons + document.querySelectorAll('[id^="' + p + '"].frameButton, [id^="' + p + '"] .frameButton').forEach(el => { + if (el.offsetWidth === 0) return; + const text = el.innerText?.trim(); + if (!text) return; + buttons.push({ name: text, frame: true }); + }); + + // Tabs — scoped to form by checking ancestor IDs + document.querySelectorAll('[data-content]').forEach(el => { + if (el.offsetWidth === 0) return; + let node = el.parentElement; + let inForm = false; + while (node) { + if (node.id && node.id.startsWith(p)) { inForm = true; break; } + node = node.parentElement; + } + if (!inForm) return; + const tab = { name: el.dataset.content }; + if (el.classList.contains('select')) tab.active = true; + formTabs.push(tab); + }); + + // Static texts and hyperlinks + document.querySelectorAll('[id^="' + p + '"].staticText').forEach(el => { + if (el.offsetWidth === 0) return; + const name = el.id.replace(p, ''); + if (name.endsWith('_div') || name.includes('#title')) return; + const text = el.innerText?.trim(); + if (!text) return; + if (el.classList.contains('staticTextHyper')) { + hyperlinks.push({ name: text }); + } else { + const titleEl = document.getElementById(p + name + '#title_text'); + const label = titleEl?.innerText?.trim() || ''; + const entry = { name, value: text }; + if (label) entry.label = label; + texts.push(entry); + } + }); + + // Table/grid — pick the first VISIBLE grid (tab switching hides inactive grids) + const grid = [...document.querySelectorAll('[id^="' + p + '"].grid, [id^="' + p + '"] .grid')] + .find(g => g.offsetWidth > 0 && g.offsetHeight > 0); + if (grid) { + const head = grid.querySelector('.gridHead'); + const body = grid.querySelector('.gridBody'); + const columns = []; + if (head) { + const headLine = head.querySelector('.gridLine') || head; + [...headLine.children].forEach(box => { + if (box.offsetWidth === 0) return; + const textEl = box.querySelector('.gridBoxText'); + const text = (textEl || box).innerText?.trim().replace(/\\n/g, ' ') || ''; + if (text) columns.push(text); + }); + } + const rowCount = body ? body.querySelectorAll('.gridLine').length : 0; + result.table = { present: true, columns, rowCount }; + } + + // Active filters (train badges above grid: *СостояниеПросмотра) + const filters = []; + document.querySelectorAll('[id^="' + p + '"].trainItem').forEach(el => { + if (el.offsetWidth === 0) return; + const titleEl = el.querySelector('.trainName'); + const valueEl = el.querySelector('.trainTitle'); + if (!titleEl && !valueEl) return; + const field = (titleEl?.innerText?.trim() || '').replace(/\\n/g, ' ').replace(/\\s*:$/, '').trim(); + const value = valueEl?.innerText?.trim()?.replace(/\\n/g, ' ') || ''; + if (field || value) filters.push({ field, value }); + }); + // Also check search field value + const searchInput = [...document.querySelectorAll('input.editInput[id^="' + p + '"]')] + .find(el => el.offsetWidth > 0 && /Строк[аи]Поиска|SearchString/i.test(el.id)); + if (searchInput?.value) { + filters.push({ type: 'search', value: searchInput.value }); + } + if (filters.length) result.filters = filters; + + // Iframes + let iframeCount = 0; + document.querySelectorAll('[id^="' + p + '"] iframe, iframe[id^="' + p + '"]').forEach(el => { + if (el.offsetWidth > 0 && el.offsetHeight > 0) iframeCount++; + }); + if (iframeCount) result.iframes = iframeCount; + + if (fields.length) result.fields = fields; + if (buttons.length) result.buttons = buttons; + if (formTabs.length) result.tabs = formTabs; + if (texts.length) result.texts = texts; + if (hyperlinks.length) result.hyperlinks = hyperlinks; + + // Group DCS report settings into readable format + if (result.fields) { + const dcsRe = /^(.+Элемент(\\d+))(Использование|Значение|ВидСравнения)$/; + const dcsGroups = {}; + const dcsNames = new Set(); + for (const f of result.fields) { + const m = f.name.match(dcsRe); + if (!m) continue; + if (!dcsGroups[m[1]]) dcsGroups[m[1]] = { _n: parseInt(m[2]) }; + dcsGroups[m[1]][m[3]] = f; + dcsNames.add(f.name); + } + const dcsEntries = Object.entries(dcsGroups).sort((a, b) => a[1]._n - b[1]._n); + if (dcsEntries.length) { + result.reportSettings = dcsEntries.map(([, g]) => { + const cb = g['Использование']; + const val = g['Значение']; + if (!cb) return null; + const label = (val?.label || cb.label || cb.name).replace(/:$/, '').trim(); + const s = { name: label, enabled: !!cb.value }; + if (val) { + s.value = val.value || ''; + if (val.actions && val.actions.length) s.actions = val.actions; + } + return s; + }).filter(Boolean); + result.fields = result.fields.filter(f => !dcsNames.has(f.name)); + if (!result.fields.length) delete result.fields; + } + } + + return result; +}`; + +// --- Exported script generators --- + +/** + * Detect the active form number. + * Picks the form with the most visible elements (excluding form0 = home page). + */ +export function detectFormScript() { + return `(() => { + ${DETECT_FORM_FN} + return detectForm(); + })()`; +} + +/** Read sections panel (left sidebar). */ +export function readSectionsScript() { + return `(() => { + const sections = []; + document.querySelectorAll('[id^="themesCell_theme_"]').forEach(el => { + const entry = { name: el.innerText?.trim() || '' }; + if (el.classList.contains('select')) entry.active = true; + sections.push(entry); + }); + return sections; + })()`; +} + +/** Read open tabs bar. */ +export function readTabsScript() { + return `(() => { + const norm = s => s?.trim().replace(/\\u00a0/g, ' ') || ''; + const tabs = []; + document.querySelectorAll('[id^="openedCell_cmd_"]').forEach(el => { + const text = norm(el.innerText); + if (!text) return; + const entry = { name: text }; + if (el.classList.contains('select')) entry.active = true; + tabs.push(entry); + }); + return tabs; + })()`; +} + +/** Switch to a tab by name (fuzzy match). Returns matched name or { error, available }. */ +export function switchTabScript(name) { + return `(() => { + const norm = s => s?.trim().replace(/\\u00a0/g, ' ') || ''; + const target = ${JSON.stringify(name.toLowerCase())}; + const tabs = [...document.querySelectorAll('[id^="openedCell_cmd_"]')].filter(el => el.offsetWidth > 0 && norm(el.innerText)); + let best = tabs.find(el => norm(el.innerText).toLowerCase() === target); + if (!best) best = tabs.find(el => norm(el.innerText).toLowerCase().includes(target)); + if (best) { best.click(); return norm(best.innerText); } + return { error: 'not_found', available: tabs.map(el => norm(el.innerText)) }; + })()`; +} + +/** Read commands in the function panel (current section). */ +export function readCommandsScript() { + return `(() => { + const groups = []; + const container = document.querySelector('#funcPanel_container table tr'); + if (!container) return groups; + for (const td of container.children) { + const commands = []; + td.querySelectorAll('[id^="cmd_"][id$="_txt"]').forEach(el => { + if (el.offsetWidth === 0) return; + commands.push(el.innerText?.trim() || ''); + }); + if (commands.length > 0) groups.push(commands); + } + return groups; + })()`; +} + +/** + * Read full form state for a given form number. + * Uses shared READ_FORM_FN. + */ +export function readFormScript(formNum) { + const p = `form${formNum}_`; + return `(() => { + ${READ_FORM_FN} + return readForm(${JSON.stringify(p)}); + })()`; +} + +/** + * Read table/grid data with pagination. + * Parses grid.innerText — \n separates rows, \t separates cells. + * First row = column headers. + * Returns { name, columns[], rows[{col:val}], total, offset, shown }. + */ +export function readTableScript(formNum, { maxRows = 20, offset = 0 } = {}) { + const p = `form${formNum}_`; + return `(() => { + const p = ${JSON.stringify(p)}; + const grid = [...document.querySelectorAll('[id^="' + p + '"].grid, [id^="' + p + '"] .grid')] + .find(g => g.offsetWidth > 0 && g.offsetHeight > 0); + if (!grid) return { error: 'no_table', message: 'No table found on form ${formNum}' }; + const name = grid.id ? grid.id.replace(p, '') : ''; + + // DOM-based parsing: gridHead → columns, gridBody → gridLine rows → gridBox cells + const head = grid.querySelector('.gridHead'); + const body = grid.querySelector('.gridBody'); + if (!head || !body) { + // Fallback: innerText-based (for non-standard grids) + const gText = grid.innerText?.trim() || ''; + const lines = gText.split('\\n').filter(Boolean); + return { name, columns: [], rows: [], total: lines.length, offset: 0, shown: 0, + hint: 'Grid has no gridHead/gridBody structure' }; + } + + // Extract column headers with X-coordinates for alignment + const columns = []; + const headLine = head.querySelector('.gridLine') || head; + [...headLine.children].forEach(box => { + if (box.offsetWidth === 0) return; + const textEl = box.querySelector('.gridBoxText'); + const text = (textEl || box).innerText?.trim().replace(/\\n/g, ' ') || ''; + if (!text) return; + const r = box.getBoundingClientRect(); + columns.push({ text, x: r.x, w: r.width, right: r.x + r.width }); + }); + + // Extract data rows from gridBody + const allLines = body.querySelectorAll('.gridLine'); + const total = allLines.length; + const rows = []; + const end = Math.min(${offset} + ${maxRows}, total); + for (let i = ${offset}; i < end; i++) { + const line = allLines[i]; + if (!line) break; + const row = {}; + columns.forEach(c => { row[c.text] = ''; }); + [...line.children].forEach(box => { + if (box.offsetWidth === 0) return; + const textEl = box.querySelector('.gridBoxText'); + const val = (textEl || box).innerText?.trim().replace(/\\n/g, ' ') || ''; + if (!val) return; + // Match cell to column by X-coordinate overlap + const r = box.getBoundingClientRect(); + const cx = r.x + r.width / 2; + const col = columns.find(c => cx >= c.x && cx < c.right); + if (col) { + row[col.text] = row[col.text] ? row[col.text] + ' / ' + val : val; + } + }); + // Detect row kind: group (gridListH), parent/up (gridListV), or element + const imgBox = line.querySelector('.gridBoxImg'); + if (imgBox) { + if (imgBox.querySelector('.gridListH')) row._kind = 'group'; + else if (imgBox.querySelector('.gridListV')) row._kind = 'parent'; + } + // Tree mode: detect expand/collapse state and indent level + const treeBox = line.querySelector('.gridBoxTree'); + if (treeBox) { + const treeIcon = imgBox?.querySelector('[tree="true"]'); + if (treeIcon) { + const bg = treeIcon.style.backgroundImage || ''; + row._tree = bg.includes('gx=0') ? 'expanded' : 'collapsed'; + } + row._level = imgBox ? imgBox.querySelectorAll('.dIB').length - 1 : 0; + } + rows.push(row); + } + const isTree = !!body.querySelector('.gridBoxTree'); + const hasGroups = rows.some(r => r._kind === 'group'); + const result = { name, columns: columns.map(c => c.text), rows, total, offset: ${offset}, shown: rows.length }; + if (isTree) result.viewMode = 'tree'; + if (hasGroups) result.hierarchical = true; + return result; + })()`; +} + +/** + * Combined: detect form + read form + read open tabs. + * Single evaluate call instead of 3. Used by browser.getFormState(). + */ +export function getFormStateScript() { + return `(() => { + ${DETECT_FORM_FN} + ${READ_FORM_FN} + const formNum = detectForm(); + if (formNum === null) return { form: null, message: 'No form detected' }; + const p = 'form' + formNum + '_'; + const formData = readForm(p); + // Open tabs bar + const openTabs = []; + document.querySelectorAll('[id^="openedCell_cmd_"]').forEach(el => { + const text = el.innerText?.trim(); + if (!text) return; + const entry = { name: text }; + if (el.classList.contains('select')) entry.active = true; + openTabs.push(entry); + }); + const activeTab = openTabs.find(t => t.active)?.name || null; + return { form: formNum, activeTab, ...formData }; + })()`; +} + +/** + * Navigate to a section by name (fuzzy match). + * Returns the matched section name, or { error, available }. + */ +export function navigateSectionScript(name) { + return `(() => { + const norm = s => s?.trim().replace(/\\u00a0/g, ' ') || ''; + const target = ${JSON.stringify(name.toLowerCase())}; + const els = [...document.querySelectorAll('[id^="themesCell_theme_"]')]; + let bestEl = els.find(el => norm(el.innerText).toLowerCase() === target); + if (!bestEl) bestEl = els.find(el => norm(el.innerText).toLowerCase().includes(target)); + if (bestEl) { bestEl.click(); return norm(bestEl.innerText); } + return { error: 'not_found', available: els.map(el => norm(el.innerText)).filter(Boolean) }; + })()`; +} + +/** + * Open a command from function panel by name (fuzzy match). + */ +export function openCommandScript(name) { + return `(() => { + const norm = s => s?.trim().replace(/\\u00a0/g, ' ') || ''; + const target = ${JSON.stringify(name.toLowerCase())}; + const els = [...document.querySelectorAll('[id^="cmd_"][id$="_txt"]')].filter(el => el.offsetWidth > 0); + let bestEl = els.find(el => norm(el.innerText).toLowerCase() === target); + if (!bestEl) bestEl = els.find(el => norm(el.innerText).toLowerCase().includes(target)); + if (bestEl) { bestEl.click(); return norm(bestEl.innerText); } + return { error: 'not_found', available: els.map(el => norm(el.innerText)).filter(Boolean) }; + })()`; +} + +/** + * Find a clickable element on the current form (button, hyperlink, tab, frame button). + * Returns { id, kind, name } for Playwright page.click(), or { error, available }. + * Supports synonym matching: visible text AND internal name from DOM ID. + * Fuzzy order: exact name -> exact label -> includes name -> includes label. + */ +export function findClickTargetScript(formNum, text) { + const p = `form${formNum}_`; + return `(() => { + const norm = s => s?.trim().replace(/\\u00a0/g, ' ') || ''; + const target = ${JSON.stringify(text.toLowerCase())}; + const p = ${JSON.stringify(p)}; + const items = []; + + // Buttons (a.press) + [...document.querySelectorAll('a.press[id^="' + p + '"]')].filter(el => el.offsetWidth > 0).forEach(el => { + const idName = el.id.replace(p, ''); + if (/_(?:DLB|CLR|OB|CB)$/.test(idName)) return; + const span = el.querySelector('.submenuText') || el.querySelector('span'); + const text = norm(span?.textContent) || norm(el.innerText); + if (!text && !el.classList.contains('pressCommand')) return; + const isSubmenu = /^(?:Подменю|allActions)/i.test(idName); + items.push({ id: el.id, name: text || idName, label: idName, kind: isSubmenu ? 'submenu' : 'button' }); + }); + + // Hyperlinks (staticTextHyper) + [...document.querySelectorAll('[id^="' + p + '"].staticTextHyper')].filter(el => el.offsetWidth > 0).forEach(el => { + const idName = el.id.replace(p, ''); + const text = norm(el.innerText); + items.push({ id: el.id, name: text, label: idName, kind: 'hyperlink' }); + }); + + // Frame buttons + [...document.querySelectorAll('[id^="' + p + '"] .frameButton, [id^="' + p + '"].frameButton')].filter(el => el.offsetWidth > 0).forEach(el => { + const text = norm(el.innerText); + if (!text) return; + items.push({ id: el.id, name: text, label: '', kind: 'frameButton' }); + }); + + // Checkboxes (div.checkbox) — match by label or internal name + [...document.querySelectorAll('[id^="' + p + '"].checkbox')].filter(el => el.offsetWidth > 0).forEach(el => { + const idName = el.id.replace(p, ''); + const titleEl = document.getElementById(p + idName + '#title_text'); + const label = norm(titleEl?.innerText || '').replace(/:/g, '').trim(); + items.push({ id: el.id, name: label || idName, label: idName, kind: 'checkbox' }); + }); + + // Tabs (scoped to form) + [...document.querySelectorAll('[data-content]')].filter(el => { + if (el.offsetWidth === 0) return false; + let node = el.parentElement; + while (node) { + if (node.id && node.id.startsWith(p)) return true; + node = node.parentElement; + } + return false; + }).forEach(el => { + items.push({ id: el.id, name: el.dataset.content, label: '', kind: 'tab' }); + }); + + // Fuzzy match: exact name -> exact label -> includes name -> includes label + let found = items.find(i => i.name.toLowerCase() === target); + if (!found) found = items.find(i => i.label && i.label.toLowerCase() === target); + if (!found) found = items.find(i => i.name.toLowerCase().includes(target)); + if (!found) found = items.find(i => i.label && i.label.toLowerCase().includes(target)); + + if (found) { + return { id: found.id, kind: found.kind, name: found.name }; + } + + // Grid rows — fallback: search in table rows (for hierarchical/tree navigation) + const grid = document.querySelector('[id^="' + p + '"].grid'); + if (grid) { + const body = grid.querySelector('.gridBody'); + if (body) { + const lines = [...body.querySelectorAll('.gridLine')]; + for (const line of lines) { + const textBoxes = [...line.querySelectorAll('.gridBoxText')].filter(b => b.offsetWidth > 0); + const rowTexts = textBoxes.map(b => b.innerText?.trim() || '').filter(Boolean); + const firstCell = rowTexts[0]?.toLowerCase() || ''; + const rowText = rowTexts.join(' ').toLowerCase(); + if (firstCell === target || rowText === target || firstCell.includes(target) || rowText.includes(target)) { + const imgBox = line.querySelector('.gridBoxImg'); + const isGroup = imgBox?.querySelector('.gridListH') !== null; + const isParent = imgBox?.querySelector('.gridListV') !== null; + const isTreeNode = line.querySelector('.gridBoxTree') !== null; + const hasChildren = imgBox?.querySelector('[tree="true"]') !== null; + let kind; + if (isGroup) kind = 'gridGroup'; + else if (isParent) kind = 'gridParent'; + else if (isTreeNode && hasChildren) kind = 'gridTreeNode'; + else kind = 'gridRow'; + const r = line.getBoundingClientRect(); + return { id: '', kind, name: rowTexts[0] || '', + x: Math.round(r.x + r.width / 2), y: Math.round(r.y + r.height / 2) }; + } + } + } + } + + return { error: 'not_found', available: items.map(i => i.name).filter(Boolean) }; + })()`; +} + +/** + * Find a field's action button (DLB, OB, CLR, CB) by fuzzy field name. + * Returns { fieldName, buttonId, buttonType } or { error, available }. + */ +export function findFieldButtonScript(formNum, fieldName, buttonSuffix = 'DLB') { + const p = `form${formNum}_`; + return `(() => { + const p = ${JSON.stringify(p)}; + const target = ${JSON.stringify(fieldName.toLowerCase())}; + const suffix = ${JSON.stringify(buttonSuffix)}; + const allFields = []; + document.querySelectorAll('input.editInput[id^="' + p + '"], textarea[id^="' + p + '"]').forEach(el => { + if (el.offsetWidth === 0) return; + const name = el.id.replace(p, '').replace(/_i\\d+$/, ''); + const titleEl = document.getElementById(p + name + '#title_text') + || document.getElementById(p + name + '#title_div'); + const label = (titleEl?.innerText?.trim() || '').replace(/\\n/g, ' ').replace(/:$/, ''); + allFields.push({ name, label }); + }); + // Also collect checkboxes for DCS pair matching + const allCheckboxes = []; + document.querySelectorAll('[id^="' + p + '"].checkbox').forEach(el => { + if (el.offsetWidth === 0) return; + const name = el.id.replace(p, ''); + const titleEl = document.getElementById(p + name + '#title_text'); + const label = (titleEl?.innerText?.trim() || '').replace(/\\n/g, ' ').replace(/:$/, ''); + allCheckboxes.push({ inputId: el.id, name, label }); + }); + // Build DCS pairs: checkbox label → paired value field + const dcsPairs = {}; + for (const f of [...allFields, ...allCheckboxes]) { + const m = f.name.match(/^(.+Элемент\\d+)(Использование|Значение)$/); + if (!m) continue; + if (!dcsPairs[m[1]]) dcsPairs[m[1]] = {}; + dcsPairs[m[1]][m[2]] = f; + } + let found = allFields.find(f => f.name.toLowerCase() === target); + if (!found) found = allFields.find(f => f.label && f.label.toLowerCase() === target); + if (!found) found = allFields.find(f => f.name.toLowerCase().includes(target)); + if (!found) found = allFields.find(f => f.label && f.label.toLowerCase().includes(target)); + // DCS pair: match checkbox or value label → resolve to paired value field + let dcsCheckbox = null; + if (!found) { + for (const pair of Object.values(dcsPairs)) { + const cb = pair['Использование']; + const val = pair['Значение']; + if (!cb || !val) continue; + const pairLabel = ((val.label || cb.label || '').replace(/:$/, '')).toLowerCase(); + if (pairLabel && (pairLabel === target || pairLabel.includes(target) || target.includes(pairLabel))) { + found = val; + dcsCheckbox = cb; + break; + } + } + } + if (!found) { + return { error: 'field_not_found', available: allFields.map(f => f.label ? f.name + ' (' + f.label + ')' : f.name) }; + } + const btnId = p + found.name + '_' + suffix; + const btn = document.getElementById(btnId); + if (!btn || btn.offsetWidth === 0) { + return { error: 'button_not_found', fieldName: found.name, message: suffix + ' button not visible for field ' + found.name }; + } + const result = { fieldName: found.name, buttonId: btnId, buttonType: suffix }; + if (dcsCheckbox) result.dcsCheckbox = { inputId: dcsCheckbox.inputId }; + return result; + })()`; +} + +/** + * Read open popup/submenu items. + * Looks for absolutely positioned visible popup containers with a.press items inside. + * Returns [{ id, name }] or { error }. + */ +export function readSubmenuScript() { + return `(() => { + const items = []; + const norm = s => s?.trim().replace(/\\u00a0/g, ' ') || ''; + + // 1. DLB dropdown (#editDropDown with .eddText items) + const edd = document.getElementById('editDropDown'); + if (edd && edd.offsetWidth > 0 && edd.offsetHeight > 0) { + edd.querySelectorAll('.eddText').forEach(el => { + if (el.offsetWidth === 0) return; + const text = norm(el.innerText); + if (!text) return; + const r = el.getBoundingClientRect(); + items.push({ id: '', name: text, kind: 'dropdown', + x: Math.round(r.x + r.width / 2), y: Math.round(r.y + r.height / 2) }); + }); + // Detect "Показать все" link in EDD footer + // Structure: div.eddBottom > div > span.hyperlink "Показать все" + let showAllEl = edd.querySelector('.eddBottom .hyperlink'); + if (!showAllEl || showAllEl.offsetWidth === 0) { + // Fallback: scan all visible elements for text match + const candidates = [...edd.querySelectorAll('a.press, a, span, div')] + .filter(el => el.offsetWidth > 0 && el.children.length === 0); + showAllEl = candidates.find(el => { + const t = norm(el.innerText).toLowerCase(); + return t === 'показать все' || t === 'show all'; + }); + } + if (showAllEl) { + const r = showAllEl.getBoundingClientRect(); + items.push({ id: showAllEl.id || '', name: norm(showAllEl.innerText), kind: 'showAll', + x: Math.round(r.x + r.width / 2), y: Math.round(r.y + r.height / 2) }); + } + if (items.length > 0) return items; + } + + // 2. Cloud submenu (allActions / command panel menus — div.cloud with .submenuText items) + // Read ALL visible high-z clouds (main menu + nested submenus) + const clouds = [...document.querySelectorAll('.cloud')].filter(c => c.offsetWidth > 0 && c.offsetHeight > 0); + const seen = new Set(); + clouds.forEach(c => { + const z = parseInt(getComputedStyle(c).zIndex) || 0; + if (z <= 1000) return; + c.querySelectorAll('.submenuText').forEach(el => { + if (el.offsetWidth === 0) return; + const text = norm(el.innerText); + if (!text || seen.has(text)) return; + seen.add(text); + const block = el.closest('.submenuBlock'); + if (block && block.classList.contains('submenuBlockDisabled')) return; + const hasSub = block && /_sub$/.test(block.id); + const r = el.getBoundingClientRect(); + items.push({ id: block?.id || '', name: text, kind: hasSub ? 'submenuArrow' : 'submenu', + x: Math.round(r.x + r.width / 2), y: Math.round(r.y + r.height / 2) }); + }); + }); + if (items.length > 0) return items; + + // 3. Submenu popups — find the topmost positioned container with non-form a.press items + const popups = [...document.querySelectorAll('div')].filter(c => { + const style = getComputedStyle(c); + return (style.position === 'absolute' || style.position === 'fixed') + && c.offsetWidth > 0 && c.offsetHeight > 0; + }).sort((a, b) => { + const za = parseInt(getComputedStyle(a).zIndex) || 0; + const zb = parseInt(getComputedStyle(b).zIndex) || 0; + return zb - za; + }); + for (const container of popups) { + // Only direct a.press children or those not nested in another positioned div + const menuItems = [...container.querySelectorAll('a.press')].filter(el => { + if (el.offsetWidth === 0) return false; + if (el.id && /^form\\d+_/.test(el.id)) return false; + // Skip if this a.press is inside a deeper positioned container + let parent = el.parentElement; + while (parent && parent !== container) { + const ps = getComputedStyle(parent).position; + if (ps === 'absolute' || ps === 'fixed') return false; + parent = parent.parentElement; + } + return true; + }); + if (menuItems.length < 2) continue; // Not a real menu + const seen = new Set(); + menuItems.forEach(el => { + const text = norm(el.innerText); + if (!text) return; + if (seen.has(text)) return; + seen.add(text); + const r = el.getBoundingClientRect(); + items.push({ id: el.id || '', name: text, kind: 'submenu', + x: Math.round(r.x + r.width / 2), y: Math.round(r.y + r.height / 2) }); + }); + if (items.length > 0) break; // Found the popup menu + } + + if (items.length === 0) return { error: 'no_popup', message: 'No open popup/submenu found' }; + return items; + })()`; +} + +/** + * Click a popup/dropdown item by text match (evaluate-based for items without IDs). + * Returns true if clicked, false if not found. + */ +export function clickPopupItemScript(text) { + return `(() => { + const target = ${JSON.stringify(text.toLowerCase())}; + // 1. DLB dropdown (#editDropDown .eddText items) + const edd = document.getElementById('editDropDown'); + if (edd && edd.offsetWidth > 0) { + for (const el of edd.querySelectorAll('.eddText')) { + if (el.offsetWidth === 0) continue; + const t = el.innerText?.trim() || ''; + if (t.toLowerCase() === target || t.toLowerCase().includes(target)) { + el.click(); + return t; + } + } + } + + // 2. Submenu popups (a.press in absolutely positioned containers) + const containers = [...document.querySelectorAll('div')].filter(c => { + const style = getComputedStyle(c); + return (style.position === 'absolute' || style.position === 'fixed') + && c.offsetWidth > 0 && c.offsetHeight > 0; + }); + for (const container of containers) { + const items = [...container.querySelectorAll('a.press')] + .filter(el => el.offsetWidth > 0); + for (const el of items) { + const t = el.innerText?.trim() || ''; + if (t.toLowerCase() === target || t.toLowerCase().includes(target)) { + el.click(); + return t; + } + } + } + return null; + })()`; +} + +/** + * Check for validation errors / diagnostics after an action. + * Detects three patterns: + * 1. Inline balloon tooltip (div.balloon with .balloonMessage) + * 2. Messages panel (div.messages with msg0, msg1... grid rows) + * 3. Modal error dialog (high-numbered form with pressDefault + static texts) + * Returns { balloon, messages[], modal } or null if no errors. + */ +export function checkErrorsScript() { + return `(() => { + const result = {}; + + // 1. Inline balloon tooltip + const balloon = document.querySelector('.balloon'); + if (balloon && balloon.offsetWidth > 0) { + const msg = balloon.querySelector('.balloonMessage'); + const title = balloon.querySelector('.balloonTitle'); + if (msg) { + result.balloon = { + title: title?.innerText?.trim() || 'Ошибка', + message: msg.innerText?.trim() || '' + }; + // Count navigation arrows to indicate total errors + const fwd = balloon.querySelector('.balloonJumpFwd'); + const back = balloon.querySelector('.balloonJumpBack'); + const fwdDisabled = fwd?.classList.contains('disabled'); + const backDisabled = back?.classList.contains('disabled'); + if (fwd && !fwdDisabled) result.balloon.hasNext = true; + if (back && !backDisabled) result.balloon.hasPrev = true; + } + } + + // 2. Messages panel (div.messages — pick visible one, multiple may exist across tabs) + const msgPanels = [...document.querySelectorAll('.messages')].filter(el => el.offsetWidth > 0); + for (const msgPanel of msgPanels) { + const msgs = []; + msgPanel.querySelectorAll('[id^="msg"]').forEach(line => { + if (line.offsetWidth === 0) return; + const textEl = line.querySelector('.gridBoxText'); + const text = (textEl || line).innerText?.trim(); + if (text) msgs.push(text); + }); + if (msgs.length > 0) { result.messages = msgs; break; } + } + + // 3+4. Modal dialogs: confirmation (multiple buttons) or error (single pressDefault) + // Uses form container ancestry to group buttons — pressButton elements often lack form-prefixed IDs + // Note: 1C shows some modals WITHOUT #modalSurface (e.g. "Не удалось записать" uses ps*win floating window) + // so we always scan for small forms with button patterns, regardless of modalSurface state + const formButtons = {}; + [...document.querySelectorAll('a.press.pressButton')].forEach(btn => { + if (btn.offsetWidth === 0) return; + const container = btn.closest('[id$="_container"]'); + const m = container?.id?.match(/^form(\\d+)_/); + if (!m) return; + const fn = m[1]; + if (!formButtons[fn]) formButtons[fn] = []; + formButtons[fn].push(btn); + }); + + for (const [fn, buttons] of Object.entries(formButtons)) { + const p = 'form' + fn + '_'; + const elCount = document.querySelectorAll('[id^="' + p + '"]').length; + if (elCount > 100) continue; // Skip large content forms + if (buttons.length > 1) { + // Confirmation dialog (multiple buttons: Да/Нет, OK/Отмена, etc.) + const msgEl = document.getElementById(p + 'Message'); + const message = msgEl?.innerText?.trim() || ''; + const btnNames = buttons.map(el => { + const b = { name: el.innerText?.trim() || '' }; + if (el.classList.contains('pressDefault')) b.default = true; + return b; + }).filter(b => b.name); + result.confirmation = { message, buttons: btnNames.map(b => b.name), formNum: parseInt(fn) }; + break; + } + } + + // Single-button modal: error dialog with pressDefault + staticText + if (!result.confirmation) { + for (const [fn, buttons] of Object.entries(formButtons)) { + const p = 'form' + fn + '_'; + const elCount = document.querySelectorAll('[id^="' + p + '"]').length; + if (elCount > 100) continue; + if (buttons.length !== 1 || !buttons[0].classList.contains('pressDefault')) continue; + const texts = [...document.querySelectorAll('[id^="' + p + '"].staticText')] + .filter(el => el.offsetWidth > 0) + .map(el => el.innerText?.trim()) + .filter(Boolean); + if (texts.length > 0) { + result.modal = { message: texts.join(' '), formNum: parseInt(fn), button: buttons[0].innerText?.trim() || '' }; + break; + } + } + } + + return (result.balloon || result.messages || result.modal || result.confirmation) ? result : null; + })()`; +} + +/** + * Resolve field names to element IDs for Playwright page.fill(). + * Returns [{ field, inputId, name, label }] or [{ field, error, available }]. + * Supports synonym matching: internal name AND visible label. + * Fuzzy order: exact name -> exact label -> includes name -> includes label. + */ +export function resolveFieldsScript(formNum, fields) { + const p = `form${formNum}_`; + return `(() => { + const p = ${JSON.stringify(p)}; + const fieldNames = ${JSON.stringify(Object.keys(fields))}; + const results = []; + + // Build field map with name + label for synonym matching + const allFields = []; + document.querySelectorAll('input.editInput[id^="' + p + '"], textarea[id^="' + p + '"]').forEach(el => { + if (el.offsetWidth === 0) return; + const name = el.id.replace(p, '').replace(/_i\\d+$/, ''); + const titleEl = document.getElementById(p + name + '#title_text') + || document.getElementById(p + name + '#title_div'); + const label = (titleEl?.innerText?.trim() || '').replace(/\\n/g, ' ').replace(/:$/, ''); + const last = { inputId: el.id, name, label }; + if (document.getElementById(p + name + '_DLB')?.offsetWidth > 0) last.hasSelect = true; + allFields.push(last); + }); + // Checkboxes + document.querySelectorAll('[id^="' + p + '"].checkbox').forEach(el => { + if (el.offsetWidth === 0) return; + const name = el.id.replace(p, ''); + const titleEl = document.getElementById(p + name + '#title_text'); + const label = (titleEl?.innerText?.trim() || '').replace(/\\n/g, ' ').replace(/:$/, ''); + const checked = el.classList.contains('checked') || el.classList.contains('checkboxOn') || el.classList.contains('select'); + allFields.push({ inputId: el.id, name, label, isCheckbox: true, checked }); + }); + // Radio button groups — base element = option 0, others are #N#radio + const radioSeen = new Set(); + document.querySelectorAll('[id^="' + p + '"].radio').forEach(el => { + if (el.offsetWidth === 0) return; + const id = el.id.replace(p, ''); + // Skip if already processed or if it's a sub-element (#N#radio) + const m = id.match(/^(.+?)#(\\d+)#radio$/); + const groupName = m ? m[1] : (!id.includes('#') ? id : null); + if (!groupName || radioSeen.has(groupName)) return; + radioSeen.add(groupName); + const titleEl = document.getElementById(p + groupName + '#title_text'); + const label = (titleEl?.innerText?.trim() || '').replace(/\\n/g, ' ').replace(/:$/, ''); + // Collect options: option 0 is the base element, options 1+ have #N#radio + const options = []; + // Option 0: base element + const base = document.getElementById(p + groupName); + if (base && base.classList.contains('radio') && base.offsetWidth > 0) { + const textEl = document.getElementById(p + groupName + '#0#radio_text'); + options.push({ index: 0, label: textEl?.innerText?.trim() || '', selected: base.classList.contains('select') }); + } + // Options 1+ + for (let i = 1; i < 20; i++) { + const opt = document.getElementById(p + groupName + '#' + i + '#radio'); + if (!opt || opt.offsetWidth === 0) break; + const textEl = document.getElementById(p + groupName + '#' + i + '#radio_text'); + options.push({ index: i, label: textEl?.innerText?.trim() || '', selected: opt.classList.contains('select') }); + } + allFields.push({ inputId: p + groupName, name: groupName, label, isRadio: true, options }); + }); + + // Build DCS pairs: checkbox label → paired value field + const dcsPairs = {}; + for (const f of allFields) { + const m = f.name.match(/^(.+Элемент\\d+)(Использование|Значение)$/); + if (!m) continue; + if (!dcsPairs[m[1]]) dcsPairs[m[1]] = {}; + dcsPairs[m[1]][m[2]] = f; + } + + for (const fieldName of fieldNames) { + const target = fieldName.toLowerCase().replace(/\\n/g, ' ').replace(/:$/, ''); + // Fuzzy: exact name -> exact label -> includes name -> includes label + let found = allFields.find(f => f.name.toLowerCase() === target); + if (!found) found = allFields.find(f => f.label && f.label.toLowerCase() === target); + if (!found) found = allFields.find(f => f.name.toLowerCase().includes(target)); + if (!found) found = allFields.find(f => f.label && f.label.toLowerCase().includes(target)); + // DCS pair: match checkbox or value label → resolve to paired value field + if (!found) { + for (const pair of Object.values(dcsPairs)) { + const cb = pair['Использование']; + const val = pair['Значение']; + if (!cb || !val) continue; + const pairLabel = ((val.label || cb.label || '').replace(/:$/, '')).toLowerCase(); + if (pairLabel && (pairLabel === target || pairLabel.includes(target) || target.includes(pairLabel))) { + found = val; + found._dcsCheckbox = cb; + break; + } + } + } + + if (found) { + const entry = { field: fieldName, inputId: found.inputId, name: found.name, label: found.label }; + if (found.isCheckbox) { entry.isCheckbox = true; entry.checked = found.checked; } + if (found.isRadio) { entry.isRadio = true; entry.options = found.options; } + if (found.hasSelect) entry.hasSelect = true; + if (found._dcsCheckbox) { + entry.dcsCheckbox = { inputId: found._dcsCheckbox.inputId, checked: found._dcsCheckbox.checked }; + delete found._dcsCheckbox; + } + results.push(entry); + } else { + const available = allFields.map(f => f.label ? f.name + ' (' + f.label + ')' : f.name); + results.push({ field: fieldName, error: 'not_found', available }); + } + } + return results; + })()`; +} diff --git a/.claude/skills/web-test/scripts/package-lock.json b/.claude/skills/web-test/scripts/package-lock.json new file mode 100644 index 00000000..38f6d221 --- /dev/null +++ b/.claude/skills/web-test/scripts/package-lock.json @@ -0,0 +1,59 @@ +{ + "name": "1c-web-test", + "version": "0.2.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "1c-web-test", + "version": "0.2.0", + "dependencies": { + "playwright": "^1.50.0" + } + }, + "node_modules/fsevents": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz", + "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==", + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/playwright": { + "version": "1.58.2", + "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.58.2.tgz", + "integrity": "sha512-vA30H8Nvkq/cPBnNw4Q8TWz1EJyqgpuinBcHET0YVJVFldr8JDNiU9LaWAE1KqSkRYazuaBhTpB5ZzShOezQ6A==", + "license": "Apache-2.0", + "dependencies": { + "playwright-core": "1.58.2" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "fsevents": "2.3.2" + } + }, + "node_modules/playwright-core": { + "version": "1.58.2", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.58.2.tgz", + "integrity": "sha512-yZkEtftgwS8CsfYo7nm0KE8jsvm6i/PTgVtB8DL726wNf6H2IMsDuxCpJj59KDaxCtSnrWan2AeDqM7JBaultg==", + "license": "Apache-2.0", + "bin": { + "playwright-core": "cli.js" + }, + "engines": { + "node": ">=18" + } + } + } +} diff --git a/.claude/skills/web-test/scripts/package.json b/.claude/skills/web-test/scripts/package.json new file mode 100644 index 00000000..59e35992 --- /dev/null +++ b/.claude/skills/web-test/scripts/package.json @@ -0,0 +1,10 @@ +{ + "name": "1c-web-test", + "version": "0.2.0", + "private": true, + "type": "module", + "description": "Browser automation engine for 1C web client (Playwright)", + "dependencies": { + "playwright": "^1.50.0" + } +} diff --git a/.claude/skills/web-test/scripts/run.mjs b/.claude/skills/web-test/scripts/run.mjs new file mode 100644 index 00000000..cbdc51e8 --- /dev/null +++ b/.claude/skills/web-test/scripts/run.mjs @@ -0,0 +1,320 @@ +#!/usr/bin/env node +/** + * CLI runner for 1C web client automation. + * + * Architecture: `start` launches browser + HTTP server in one process. + * `exec`, `shot`, `stop` send requests to the running server. + * + * Usage: + * node src/run.mjs start — launch browser, connect to 1C, serve requests + * node src/run.mjs run — autonomous: connect, execute script, disconnect + * node src/run.mjs exec — run script against existing session + * node src/run.mjs shot [file] — take screenshot + * node src/run.mjs stop — logout + close browser + * node src/run.mjs status — check session + */ +import http from 'http'; +import * as browser from './browser.mjs'; +import { readFileSync, writeFileSync, unlinkSync, existsSync } from 'fs'; +import { resolve, dirname } from 'path'; +import { fileURLToPath } from 'url'; + +const __dirname = dirname(fileURLToPath(import.meta.url)); +const SESSION_FILE = resolve(__dirname, '..', '.browser-session.json'); + +const [,, cmd, ...args] = process.argv; + +switch (cmd) { + case 'start': await cmdStart(args[0]); break; + case 'run': await cmdRun(args[0], args[1]); break; + case 'exec': await cmdExec(args[0]); break; + case 'shot': await cmdShot(args[0]); break; + case 'stop': await cmdStop(); break; + case 'status': cmdStatus(); break; + default: usage(); +} + + +// ============================================================ +// start: launch browser + HTTP server +// ============================================================ + +async function cmdStart(url) { + if (!url) die('Usage: node src/run.mjs start '); + + // Connect to 1C + const state = await browser.connect(url); + + // Start HTTP server for exec/shot/stop + const httpServer = http.createServer(handleRequest); + httpServer.listen(0, '127.0.0.1', () => { + const port = httpServer.address().port; + const session = { + port, + url, + pid: process.pid, + startedAt: new Date().toISOString() + }; + writeFileSync(SESSION_FILE, JSON.stringify(session, null, 2)); + out({ ok: true, message: 'Browser ready', port, ...state }); + }); + + process.on('SIGINT', async () => { + await browser.disconnect(); + cleanup(); + process.exit(0); + }); +} + +async function handleRequest(req, res) { + try { + if (req.method === 'POST' && req.url === '/exec') { + const code = await readBody(req); + const result = await executeScript(code); + json(res, result); + + } else if (req.method === 'GET' && req.url === '/shot') { + const png = await browser.screenshot(); + res.writeHead(200, { 'Content-Type': 'image/png' }); + res.end(png); + + } else if (req.method === 'POST' && req.url === '/stop') { + json(res, { ok: true, message: 'Stopping' }); + await browser.disconnect(); + cleanup(); + process.exit(0); + + } else if (req.method === 'GET' && req.url === '/status') { + json(res, { ok: true, connected: browser.isConnected() }); + + } else { + res.writeHead(404); + res.end('Not found'); + } + } catch (e) { + json(res, { ok: false, error: e.message }, 500); + } +} + +async function executeScript(code) { + const output = []; + const origLog = console.log; + const origErr = console.error; + console.log = (...a) => output.push(a.map(String).join(' ')); + console.error = (...a) => output.push('[ERR] ' + a.map(String).join(' ')); + + const t0 = Date.now(); + try { + // Build sandbox: all browser.mjs exports + useful Node globals + const exports = {}; + for (const [k, v] of Object.entries(browser)) { + if (k !== 'default') exports[k] = v; + } + exports.writeFileSync = writeFileSync; + exports.readFileSync = readFileSync; + + // Wrap action functions to auto-detect 1C errors (modal, balloon) + // and stop execution immediately with diagnostic info + const ACTION_FNS = [ + 'clickElement', 'fillFields', 'selectValue', 'fillTableRow', + 'deleteTableRow', 'openCommand', 'navigateSection', 'navigateLink', + 'closeForm', 'filterList', 'unfilterList' + ]; + for (const name of ACTION_FNS) { + if (typeof exports[name] !== 'function') continue; + const orig = exports[name]; + exports[name] = async (...args) => { + const result = await orig(...args); + const errors = result?.errors; + if (errors?.modal || errors?.balloon) { + const msg = errors.modal?.message || errors.balloon?.message || 'Unknown 1C error'; + const err = new Error(msg); + err.onecError = { step: name, args, errors, formState: result }; + throw err; + } + return result; + }; + } + + const AsyncFunction = Object.getPrototypeOf(async function(){}).constructor; + const fn = new AsyncFunction(...Object.keys(exports), code); + await fn(...Object.values(exports)); + + console.log = origLog; + console.error = origErr; + return { ok: true, output: output.join('\n'), elapsed: elapsed(t0) }; + } catch (e) { + console.log = origLog; + console.error = origErr; + + // Error screenshot + let shotFile; + try { + const png = await browser.screenshot(); + shotFile = resolve(__dirname, '..', 'error-shot.png'); + writeFileSync(shotFile, png); + } catch {} + + const result = { ok: false, error: e.message, output: output.join('\n'), screenshot: shotFile, elapsed: elapsed(t0) }; + + // Enrich with 1C error context if available + if (e.onecError) { + result.step = e.onecError.step; + result.stepArgs = e.onecError.args; + result.onecErrors = e.onecError.errors; + result.formState = e.onecError.formState; + } + + return result; + } +} + + +// ============================================================ +// run: autonomous connect → execute → disconnect (no server) +// ============================================================ + +async function cmdRun(url, fileOrDash) { + if (!url || !fileOrDash) die('Usage: node src/run.mjs run '); + + const code = fileOrDash === '-' + ? await readStdin() + : readFileSync(resolve(fileOrDash), 'utf-8'); + + await browser.connect(url); + const result = await executeScript(code); + await browser.disconnect(); + + out(result); + if (!result.ok) process.exit(1); +} + + +// ============================================================ +// exec: send script to running server +// ============================================================ + +async function cmdExec(fileOrDash) { + if (!fileOrDash) die('Usage: node src/run.mjs exec '); + + const code = fileOrDash === '-' + ? await readStdin() + : readFileSync(resolve(fileOrDash), 'utf-8'); + + const sess = loadSession(); + const resp = await fetch(`http://127.0.0.1:${sess.port}/exec`, { + method: 'POST', + body: code + }); + const result = await resp.json(); + out(result); + if (!result.ok) process.exit(1); +} + + +// ============================================================ +// shot: take screenshot via server +// ============================================================ + +async function cmdShot(file) { + const sess = loadSession(); + const resp = await fetch(`http://127.0.0.1:${sess.port}/shot`); + if (!resp.ok) { + const err = await resp.text(); + die(`Screenshot failed: ${err}`); + } + const buf = Buffer.from(await resp.arrayBuffer()); + const outFile = file || 'shot.png'; + writeFileSync(outFile, buf); + out({ ok: true, file: outFile }); +} + + +// ============================================================ +// stop: send stop to server +// ============================================================ + +async function cmdStop() { + const sess = loadSession(); + try { + const resp = await fetch(`http://127.0.0.1:${sess.port}/stop`, { method: 'POST' }); + const result = await resp.json(); + out(result); + } catch { + // Server may have already exited before responding + out({ ok: true, message: 'Stopped' }); + } + cleanup(); +} + + +// ============================================================ +// status: check session +// ============================================================ + +function cmdStatus() { + if (!existsSync(SESSION_FILE)) { + out({ ok: false, message: 'No active session' }); + process.exit(1); + } + const sess = JSON.parse(readFileSync(SESSION_FILE, 'utf-8')); + out({ ok: true, ...sess }); +} + + +// ============================================================ +// helpers +// ============================================================ + +function loadSession() { + if (!existsSync(SESSION_FILE)) { + die('No active session. Run: node src/run.mjs start '); + } + return JSON.parse(readFileSync(SESSION_FILE, 'utf-8')); +} + +function cleanup() { + try { unlinkSync(SESSION_FILE); } catch {} +} + +async function readBody(req) { + const chunks = []; + for await (const chunk of req) chunks.push(chunk); + return Buffer.concat(chunks).toString('utf-8'); +} + +async function readStdin() { + const chunks = []; + for await (const chunk of process.stdin) chunks.push(chunk); + return Buffer.concat(chunks).toString('utf-8'); +} + +function elapsed(t0) { + return Math.round((Date.now() - t0) / 100) / 10; +} + +function json(res, obj, status = 200) { + res.writeHead(status, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify(obj, null, 2)); +} + +function out(obj) { + process.stdout.write(JSON.stringify(obj, null, 2) + '\n'); +} + +function die(msg) { + process.stderr.write(msg + '\n'); + process.exit(1); +} + +function usage() { + die(`Usage: node src/run.mjs [args] + +Commands: + start Launch browser and connect to 1C web client + run Autonomous: connect, execute script, disconnect + exec Execute script (file path or - for stdin) + shot [file] Take screenshot (default: shot.png) + stop Logout and close browser + status Check session status`); +} diff --git a/.gitignore b/.gitignore index 0cb587f5..7076fc4e 100644 --- a/.gitignore +++ b/.gitignore @@ -21,3 +21,10 @@ __pycache__/ # Локальный реестр баз данных 1С .v8-project.json + +# web-test: Node.js зависимости и runtime-артефакты +.claude/skills/web-test/scripts/node_modules/ +.claude/skills/web-test/.browser-session.json + +# Скриншоты (артефакты тестирования web-test) +*.png diff --git a/README.md b/README.md index bebe7a18..604cfa15 100644 --- a/README.md +++ b/README.md @@ -35,12 +35,14 @@ | Командный интерфейс (CI) | 2 навыка `/interface-*` | Редактирование и валидация CommandInterface.xml подсистем | [Подробнее](docs/subsystem-guide.md) | | Базы данных (DB) | 9 навыков `/db-*` | Создание баз, загрузка/выгрузка конфигураций, обновление БД, загрузка из Git | [Подробнее](docs/db-guide.md) | | Веб-публикация (Web) | 4 навыка `/web-*` | Публикация баз через Apache, статус, остановка, удаление публикаций | [Подробнее](docs/web-guide.md) | +| Тестирование (Web) | `/web-test` | Автоматизация 1С через браузер — навигация, формы, таблицы, отчёты, фильтры | [Подробнее](docs/web-test-guide.md) | | Утилиты | `/img-grid` | Наложение сетки на изображение для определения пропорций колонок | — | ## Требования - **Windows** с PowerShell 5.1+ (входит в Windows) — рантайм по умолчанию - **1С:Предприятие 8.3** — для сборки/разборки EPF/ERF (навыки генерации XML работают без платформы) +- **Node.js 18+** — для `/web-test` (тестирование через браузер) ### Кроссплатформенный режим (Python) @@ -148,6 +150,7 @@ python scripts/switch-to-powershell.py # вернуть на PowerShell ├── web-info/ # Статус Apache и публикаций ├── web-stop/ # Остановка Apache ├── web-unpublish/ # Удаление публикации +├── web-test/ # Тестирование через браузер (Playwright) └── img-grid/ # Сетка для анализа изображений scripts/ ├── switch-to-python.py # Переключение навыков на Python-рантайм @@ -164,6 +167,7 @@ docs/ ├── subsystem-guide.md # Гайд: подсистемы и командный интерфейс ├── db-guide.md # Гайд: базы данных 1С ├── web-guide.md # Гайд: веб-публикация через Apache +├── web-test-guide.md # Гайд: тестирование через веб-клиент ├── 1c-epf-spec.md # Спецификация XML-формата (EPF) ├── 1c-erf-spec.md # Спецификация XML-формата (ERF) ├── 1c-form-spec.md # Спецификация управляемых форм diff --git a/docs/web-test-guide.md b/docs/web-test-guide.md new file mode 100644 index 00000000..c0995102 --- /dev/null +++ b/docs/web-test-guide.md @@ -0,0 +1,148 @@ +# Тестирование через веб-клиент 1С + +Навык `/web-test` автоматизирует действия в веб-клиенте 1С через Playwright — навигация по разделам, заполнение форм, чтение таблиц и отчётов, фильтрация списков. Замыкает цикл: правка исходников → загрузка → обновление → публикация → **автоматическое тестирование**. + +## Навык + +| Навык | Скрипт | Описание | +|-------|:------:|----------| +| `/web-test` | `.mjs` (Node.js) | Автоматизация 1С через браузер — навигация, формы, таблицы, отчёты | + +## Предусловия + +- База опубликована через Apache (`/web-publish`) +- Node.js 18+ установлен +- Зависимости установлены: `cd .claude/skills/web-test/scripts && npm install` + +## Рабочий цикл + +``` +/web-publish → /web-test → результат + ↑ | + └── правки → /db-load-xml → /db-update ──┘ +``` + +### Два режима работы + +**Автономный** — одна команда запускает браузер, выполняет сценарий и закрывает: +``` +> Открой список заказов клиентов в ERP, найди заказ КП00-000018, открой и прочитай реквизиты +``` +Claude напишет `.js` файл со сценарием и запустит `node $RUN run script.js`. + +**Интерактивный** — пошаговая работа через живую сессию: +``` +> Запусти браузер на базе erp +> Перейди в раздел Продажи +> Открой Заказы клиентов +> Прочитай таблицу +``` +Claude запустит `node $RUN start `, затем выполнит каждый шаг через `node $RUN exec`. + +## Сценарии использования + +### Навигация и чтение данных + +``` +> Открой список контрагентов в ERP и покажи первые 10 записей +``` + +Claude перейдёт в нужный раздел, откроет список и прочитает таблицу. + +### Создание документа + +``` +> Создай заказ клиента: организация "Андромеда Плюс", контрагент "Торговый дом Комплексный", +> добавь строку: номенклатура "Вентилятор", количество 5 +``` + +Claude откроет форму создания, заполнит реквизиты шапки, добавит строку в табличную часть. + +### Работа с отчётами + +``` +> Открой отчёт "Остатки и доступность товаров", +> установи отбор Склад = "Склад бытовой техники", сформируй и прочитай результат +``` + +Claude заполнит фильтры отчёта (DCS-настройки) по человекочитаемым именам, нажмёт "Сформировать" и прочитает табличный документ. + +### Поиск и фильтрация + +``` +> Найди в списке номенклатуры товар "Вентилятор" и открой его карточку +``` + +Claude отфильтрует список, откроет найденный элемент двойным кликом, прочитает форму. + +### Проверка после загрузки расширения + +``` +> Загрузи расширение ТестОшибки и проверь через браузер, что при создании заказа клиента +> появляется ошибка "Тестовая ошибка из расширения" +``` + +Claude загрузит расширение через `/db-load-xml`, затем через `/web-test` откроет форму заказа и проверит ожидаемое поведение. + +## API: что умеет навык + +### Навигация + +| Функция | Что делает | +|---------|------------| +| `navigateSection(name)` | Переход в раздел (Продажи, Склад и доставка, ...) | +| `openCommand(name)` | Открытие команды из панели функций | +| `navigateLink(url)` | Открытие по пути метаданных (`Документ.ЗаказКлиента`) | +| `switchTab(name)` | Переключение между открытыми вкладками | + +### Чтение + +| Функция | Что делает | +|---------|------------| +| `getFormState()` | Структура формы: поля, кнопки, вкладки, фильтры, DCS-настройки отчёта | +| `readTable()` | Данные таблицы с пагинацией, поддержка дерева и иерархии | +| `readSpreadsheet()` | Табличный документ отчёта: заголовки, данные, итоги | +| `getSections()` | Разделы и команды | + +### Действия + +| Функция | Что делает | +|---------|------------| +| `clickElement(text)` | Клик по кнопке, ссылке, вкладке, строке таблицы | +| `fillFields({...})` | Заполнение полей формы (текст, чекбокс, радио, ссылочные) | +| `selectValue(field, search)` | Выбор из справочника через форму подбора | +| `fillTableRow({...})` | Заполнение строки табличной части | +| `filterList(text)` | Фильтрация списка (простая и расширенная) | +| `closeForm()` | Закрытие формы с управлением диалогом подтверждения | + +### DCS-отчёты (фильтры по меткам) + +Фильтры отчётов можно задавать человекочитаемыми именами вместо технических: + +```js +// Вместо: 'КомпоновщикНастроекПользовательскиеНастройкиЭлемент3Значение' +// Просто: +await fillFields({ 'Склад': 'Склад бытовой техники' }); +``` + +`getFormState()` возвращает `reportSettings` — фильтры в читаемом виде: +```json +[ + { "name": "Склад", "enabled": true, "value": "Склад бытовой техники" }, + { "name": "Номенклатура", "enabled": false, "value": "" } +] +``` + +## Особенности + +- **Headed mode** — 1С требует видимый браузер, headless не поддерживается +- **Время запуска** — первое подключение к 1С занимает 30-60 секунд +- **Fuzzy matching** — все поиски по имени: точное совпадение → начало строки → вхождение +- **Clipboard paste** — все поля заполняются через Ctrl+V (единственный способ корректно триггерить события 1С) +- **Anti-loop** — если элемент не найден после 2 попыток, навык сообщает что найдено вместо бесконечных ретраев + +## Связанные навыки + +- [Веб-публикация](web-guide.md) — `/web-publish`, `/web-info`, `/web-stop`, `/web-unpublish` +- [Базы данных](db-guide.md) — `/db-load-xml`, `/db-update`, `/db-run` +- [Расширения](cfe-guide.md) — `/cfe-init`, `/cfe-borrow`, `/cfe-patch-method`