diff --git a/.claude/skills/web-test/SKILL.md b/.claude/skills/web-test/SKILL.md index 0572c0c7..34477ad4 100644 --- a/.claude/skills/web-test/SKILL.md +++ b/.claude/skills/web-test/SKILL.md @@ -22,19 +22,32 @@ Writes and runs automation scripts for 1C web client via Playwright. /web-test Проверь список контрагентов — прочитай таблицу ``` +## Setup (first time) + +```bash +cd .claude/skills/web-test/scripts && npm install +``` + +Requires Node.js 18+. `npm install` downloads Playwright and Chromium browser. + ## Workflow -Runner path: `C:\WS\tasks\1c-web-client-mcp\src\run.mjs` +Runner: `.claude/skills/web-test/scripts/run.mjs` + +Use `RUN` shorthand in all commands: +```bash +RUN=".claude/skills/web-test/scripts/run.mjs" +``` ### Interactive mode (step-by-step) ```bash # 1. Start browser session — blocks, prints JSON when ready # Use run_in_background=true (Bash tool), then wait for "Browser ready" -node src/run.mjs start +node $RUN start # 2. Execute scripts — each returns JSON with results -cat <<'SCRIPT' | node src/run.mjs exec - +cat <<'SCRIPT' | node $RUN exec - await navigateSection('Покупки'); const form = await openCommand('Авансовые отчеты'); console.log(JSON.stringify(form.fields, null, 2)); @@ -43,13 +56,13 @@ SCRIPT # 3. React to output, run more scripts... # 4. Screenshot anytime -node src/run.mjs shot result.png +node $RUN shot result.png # 5. Check session is alive -node src/run.mjs status +node $RUN status # 6. Stop when done (logout + close browser) -node src/run.mjs stop +node $RUN stop ``` `start` blocks forever (keeps browser alive). Run it in background, then use `exec`/`shot`/`stop` from other commands. @@ -59,9 +72,9 @@ node src/run.mjs stop Write `.mjs` script, run via exec: ```bash -node src/run.mjs start # in background -node src/run.mjs exec test-scenario.mjs -node src/run.mjs stop +node $RUN start # in background +node $RUN exec test-scenario.mjs +node $RUN stop ``` ## URL @@ -234,7 +247,7 @@ writeFileSync('result.png', png); - **Headed mode** — 1C requires visible browser, no headless - **1C loads 30-60s** on initial connect (wait is built into `start`) -- **Fuzzy match** — all name lookups use fuzzy search (exact > includes) +- **Fuzzy match** — all name lookups use fuzzy search (exact > startsWith > includes) - **errorModal** — if response contains `errorModal`, 1C showed an error dialog - **Clipboard paste** — all fields filled via Ctrl+V (triggers 1C events properly) -- **Stdin pipe for Cyrillic** — use `cat <<'SCRIPT' | node src/run.mjs exec -` to avoid bash escaping +- **Stdin pipe for Cyrillic** — use `cat <<'SCRIPT' | node $RUN exec -` to avoid bash escaping diff --git a/.claude/skills/web-test/scripts/browser.mjs b/.claude/skills/web-test/scripts/browser.mjs new file mode 100644 index 00000000..256afdf0 --- /dev/null +++ b/.claude/skills/web-test/scripts/browser.mjs @@ -0,0 +1,1985 @@ +/** + * 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 }); + 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 +} + +/** + * 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()); +} + +/** 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(); + 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(); + 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(); +} + +/** 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 })); +} + +/** + * 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 + const searchInputId = await page.evaluate(`(() => { + const p = 'form${selFormNum}_'; + const inputs = [...document.querySelectorAll('input.editInput[id^="' + p + '"]')].filter(el => el.offsetWidth > 0); + // Prefer field with "search"/"поиск" in its ID + let searchInput = inputs.find(el => /поиск|search|строкапоиска|find/i.test(el.id)); + if (!searchInput && inputs.length > 0) searchInput = inputs[0]; + 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: dismiss 1C error modal if present + async function dismissErrors() { + 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 */ } + return err; + } + return null; + } + + // 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 dismissErrors(); + + // 1. Focus (handle surface/modal overlay from previous interaction) + try { + await page.click(selector); + } catch (e) { + if (e.message.includes('intercepts pointer events')) { + await dismissErrors(); + await page.keyboard.press('Escape'); + await page.waitForTimeout(500); + try { await page.click(selector); } catch (e2) { + if (e2.message.includes('intercepts pointer events')) { + await dismissErrors(); + 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 dismissErrors(); // 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 dismissErrors(); + + // 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(); + 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 { + 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(); + + // 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 surface overlay intercepts — dismiss with Escape and retry + if (clickErr.message.includes('intercepts pointer events')) { + await page.keyboard.press('Escape'); + await page.waitForTimeout(500); + await page.click(selector, { timeout: 5000 }); + } 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; + } + + // 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. Returns new form state. If confirmation dialog appears — returns it in `confirmation` field. */ +export async function closeForm() { + ensureConnected(); + await page.keyboard.press('Escape'); + await waitForStable(); + const state = await getFormState(); + const err = await checkForErrors(); + if (err?.confirmation) { + 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(); + const formNum = await page.evaluate(detectFormScript()); + if (formNum === null) return { error: 'no_form' }; + + // 1. Find DLB button + const btn = await page.evaluate(findFieldButtonScript(formNum, fieldName, 'DLB')); + if (btn?.error) return btn; + + // 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(); + 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. 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; + + for (let iter = 0; iter < MAX_ITER; iter++) { + // Read focused element + const cell = await page.evaluate(`(() => { + const f = document.activeElement; + if (!f || f.tagName !== 'INPUT') return { tag: f?.tagName || 'none' }; + return { + tag: 'INPUT', id: f.id, + fullName: f.id.replace(/^form\\d+_/, '').replace(/_i\\d+$/, '') + }; + })()`); + + if (cell.tag !== 'INPUT') break; // exited edit mode + + // 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; +} + +/** + * 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(); + 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(); + 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 && /Строк[аи]Поиска/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(); + 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 && /Строк[аи]Поиска/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..0298fe52 --- /dev/null +++ b/.claude/skills/web-test/scripts/dom.mjs @@ -0,0 +1,939 @@ +/** + * 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] >= 2) 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; + 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; + 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 && /Строк[аи]Поиска/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; + 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 }); + }); + 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)); + 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 }; + } + return { fieldName: found.name, buttonId: btnId, buttonType: suffix }; + })()`; +} + +/** + * 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. Confirmation dialog (#modalSurface + pressButton buttons) + const modalSurface = document.getElementById('modalSurface'); + if (modalSurface && modalSurface.offsetWidth > 0) { + const pressButtons = [...document.querySelectorAll('a.press.pressButton')].filter(el => el.offsetWidth > 0); + if (pressButtons.length > 1) { + // Find the modal form: look for form{N}_Message staticText + let modalFormNum = null; + const allForms = new Set(); + document.querySelectorAll('[id^="form"]').forEach(el => { + const m = el.id.match(/^form(\\d+)_/); + if (m) allForms.add(parseInt(m[1])); + }); + const sortedForms = [...allForms].sort((a, b) => b - a); // highest first + for (const fn of sortedForms) { + const msgEl = document.getElementById('form' + fn + '_Message'); + if (msgEl && msgEl.offsetWidth > 0) { modalFormNum = fn; break; } + } + const message = modalFormNum !== null + ? (document.getElementById('form' + modalFormNum + '_Message')?.innerText?.trim() || '') + : ''; + const buttons = pressButtons.map(el => { + const btn = { name: el.innerText?.trim() || '' }; + if (el.classList.contains('pressDefault')) btn.default = true; + return btn; + }).filter(b => b.name); + result.confirmation = { message, buttons: buttons.map(b => b.name), formNum: modalFormNum }; + } + } + + // 4. Modal error dialog (high form number, pressDefault, few elements) + if (!result.confirmation) { + const defaults = [...document.querySelectorAll('a.press.pressDefault')].filter(el => el.offsetWidth > 0); + for (const btn of defaults) { + const m = btn.id.match(/^form(\\d+)_/); + if (!m) continue; + const formNum = parseInt(m[1]); + const p = 'form' + formNum + '_'; + const elCount = document.querySelectorAll('[id^="' + p + '"]').length; + if (elCount > 20) continue; + const texts = [...document.querySelectorAll('[id^="' + p + '"].staticText')] + .filter(el => el.offsetWidth > 0) + .map(el => el.innerText?.trim()) + .filter(Boolean); + if (texts.length > 0) { + const btnText = btn.innerText?.trim() || ''; + result.modal = { message: texts.join(' '), formNum, button: btnText }; + 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 }); + }); + + 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)); + + 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; + 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..ca0c7b43 --- /dev/null +++ b/.claude/skills/web-test/scripts/run.mjs @@ -0,0 +1,264 @@ +#!/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 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 '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; + + 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 — save with absolute path so model can view it + let shotFile; + try { + const png = await browser.screenshot(); + shotFile = resolve(__dirname, '..', 'error-shot.png'); + writeFileSync(shotFile, png); + } catch {} + + return { ok: false, error: e.message, output: output.join('\n'), screenshot: shotFile, elapsed: elapsed(t0) }; + } +} + + +// ============================================================ +// 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 + 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 79321f31..53ba1080 100644 --- a/.gitignore +++ b/.gitignore @@ -18,3 +18,7 @@ tools/ # Локальный реестр баз данных 1С .v8-project.json + +# web-test: Node.js зависимости и runtime +.claude/skills/web-test/scripts/node_modules/ +.claude/skills/web-test/.browser-session.json