From 4f01f012864be17d783030799fd4a6bb3d56cb6b Mon Sep 17 00:00:00 2001 From: Nick Shirokov Date: Mon, 25 May 2026 22:10:31 +0300 Subject: [PATCH] =?UTF-8?q?refactor(web-test):=20=D1=8D=D1=82=D0=B0=D0=BF?= =?UTF-8?q?=20A.3=20=E2=80=94=20=D0=B2=D1=8B=D0=B4=D0=B5=D0=BB=D0=B8=D1=82?= =?UTF-8?q?=D1=8C=20core/wait.mjs=20+=20core/errors.mjs?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit core/wait.mjs (123 LOC): - waitForStable: smart DOM-stability polling - waitForCondition: JS-expression polling - startNetworkMonitor: CDP network-activity monitor core/errors.mjs (336 LOC): - closeModals, dismissPendingErrors, checkForErrors, fetchErrorStack - Платформенные диалоги: _detectPlatformDialogs, _closePlatformDialogs - _parseErrorStack, _fetchStackViaReport, _fetchStackViaHamburger (приватные) browser.mjs импортирует их для внутреннего использования и re-export'ит только fetchErrorStack (исходно публичный). Остальные функции остаются приватными — публичный API не меняется (56 экспортов). Co-Authored-By: Claude Opus 4.7 (1M context) --- .claude/skills/web-test/scripts/browser.mjs | 465 +----------------- .../skills/web-test/scripts/core/errors.mjs | 341 +++++++++++++ .claude/skills/web-test/scripts/core/wait.mjs | 123 +++++ 3 files changed, 477 insertions(+), 452 deletions(-) create mode 100644 .claude/skills/web-test/scripts/core/errors.mjs create mode 100644 .claude/skills/web-test/scripts/core/wait.mjs diff --git a/.claude/skills/web-test/scripts/browser.mjs b/.claude/skills/web-test/scripts/browser.mjs index 1cb965fe..56168171 100644 --- a/.claude/skills/web-test/scripts/browser.mjs +++ b/.claude/skills/web-test/scripts/browser.mjs @@ -510,458 +510,19 @@ export async function closeContext(name) { contexts.delete(name); } -/** - * 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 -} - -/** - * Start monitoring network activity via CDP. - * Must be called BEFORE the click so it captures all server requests. - * Returns a monitor object with waitDone() and cleanup() methods. - */ -async function startNetworkMonitor() { - const client = await page.context().newCDPSession(page); - await client.send('Network.enable'); - - let pending = 0; - let total = 0; - let lastZeroTime = null; - const DEBOUNCE = 300; - - client.on('Network.requestWillBeSent', () => { - pending++; - total++; - lastZeroTime = null; - }); - client.on('Network.loadingFinished', () => { - if (--pending === 0) lastZeroTime = Date.now(); - }); - client.on('Network.loadingFailed', () => { - if (--pending === 0) lastZeroTime = Date.now(); - }); - - return { - /** Wait until all network requests complete (300ms debounce) or UI element appears. */ - async waitDone(timeout = 10000) { - const start = Date.now(); - while (Date.now() - start < timeout) { - await page.waitForTimeout(50); - - // Check for UI elements (modal, balloon, confirm) - const ui = await page.evaluate(`(() => { - const modal = document.querySelector('#modalSurface:not([style*="display: none"])'); - const balloon = document.querySelector('.balloon'); - const confirm = document.querySelector('.confirm'); - return !!(modal || balloon || confirm); - })()`); - if (ui) return; - - // CDP debounce: pending===0 held for DEBOUNCE ms - if (total > 0 && pending === 0 && lastZeroTime !== null) { - if (Date.now() - lastZeroTime >= DEBOUNCE) return; - } - } - }, - /** Detach CDP session. Always call this when done. */ - async cleanup() { - await client.send('Network.disable').catch(() => {}); - await client.detach().catch(() => {}); - } - }; -} - -/** - * Poll until a JS expression returns truthy, or timeout (ms) expires. - * Resolves early — typically within 100-300ms instead of fixed delays. - */ -async function waitForCondition(evalScript, timeout = 2000) { - const start = Date.now(); - while (Date.now() - start < timeout) { - const result = await page.evaluate(evalScript); - if (result) return result; - await page.waitForTimeout(100); - } - return null; -} - -/** - * Check for validation errors / diagnostics after an action. - * Detects: inline balloon tooltip, messages panel, modal error dialog. - * Returns { balloon, messages[], modal } or null. - */ -async function checkForErrors() { - return await page.evaluate(checkErrorsScript()); -} - -/** - * Dismiss pending error modal if present (single OK button dialog). - * Called at the start of action functions so that a leftover error modal - * from a previous operation doesn't block the next action. - * Does NOT dismiss confirmations (Да/Нет — require user decision). - * Returns the dismissed error object or null. - */ -async function dismissPendingErrors() { - // Close leftover platform dialogs first (About, Support Info, Error Report) - // These block all interaction via modalSurface and are invisible to 1C form detection - try { - const pd = await _detectPlatformDialogs(); - if (pd.length) await _closePlatformDialogs(); - } catch { /* OK */ } - const err = await checkForErrors(); - if (!err?.modal) return null; - try { - // Target pressDefault within the modal's form container specifically - const formNum = err.modal.formNum; - const sel = formNum != null - ? `#form${formNum}_container a.press.pressDefault` - : 'a.press.pressDefault'; - const btn = await page.$(sel); - if (btn) { await btn.click({ force: true }); await page.waitForTimeout(500); } - } catch { /* OK */ } - await waitForStable(); - return err; -} - -/** - * Detect open platform-level dialogs (About, Support Info, Error Report). - * Returns array of { type, title? } for each detected dialog, or empty array. - */ -async function _detectPlatformDialogs() { - return await page.evaluate(() => { - const result = []; - // "О программе" dialog - const about = document.getElementById('aboutContainer'); - if (about && about.offsetWidth > 0) result.push({ type: 'about', title: 'О программе' }); - // "Информация для технической поддержки" (inside a ps*win with errJournalInput) - const errJ = document.getElementById('errJournalInput'); - if (errJ && errJ.offsetWidth > 0) result.push({ type: 'supportInfo', title: 'Информация для технической поддержки' }); - // "Отчет об ошибке" / "Подробный текст ошибки" — ps*win cloud windows without aboutContainer - if (!result.length) { - document.querySelectorAll('[id^="ps"][id$="win"]').forEach(w => { - if (w.offsetWidth === 0 || w.offsetHeight === 0) return; - // Skip the main app window (ps*win that contains the 1C forms) - if (w.querySelector('[id^="form"][id$="_container"]')) return; - // Check title text - const titleEl = w.querySelector('[id$="headerTopLine_cmd_Title"]'); - const title = titleEl?.textContent?.trim() || ''; - if (title) result.push({ type: 'platformWindow', title }); - }); - } - return result; - }); -} - -/** - * Close any platform-level dialogs that may be left open (about, support info, error report). - * These are NOT 1C forms — they are platform UI overlays invisible to getFormState(). - * Each close is wrapped in try/catch to avoid cascading failures. - */ -async function _closePlatformDialogs() { - await page.evaluate(() => { - // "Подробный текст ошибки" OK button (inside error report detail view) - // It's a cloud window with its own OK button — look for visible pressDefault in small ps*win - const psWins = document.querySelectorAll('[id^="ps"][id$="win"]'); - for (const w of psWins) { - if (w.offsetWidth === 0) continue; - // Check if this is a small dialog (error detail, about, support info) - const closeBtn = w.querySelector('[id$="_cmd_CloseButton"]'); - if (closeBtn && closeBtn.offsetWidth > 0) { - try { closeBtn.click(); } catch {} - } - } - // "Информация для технической поддержки" — extOkBtn - const extOk = document.getElementById('extOkBtn'); - if (extOk && extOk.offsetWidth > 0) try { extOk.click(); } catch {} - // "О программе" — aboutOkButton - const aboutOk = document.getElementById('aboutOkButton'); - if (aboutOk && aboutOk.offsetWidth > 0) try { aboutOk.click(); } catch {} - }); - await page.waitForTimeout(300); -} - -/** - * Parse raw error stack text into structured entries. - * Input: raw text from errJournalInput (first block) or "Подробный текст ошибки" textarea. - * Returns { raw, timestamp?, entries: [{location, code}] } - */ -function _parseErrorStack(raw) { - if (!raw) return null; - const result = { raw, entries: [] }; - // Extract timestamp if present (format: DD.MM.YYYY HH:MM:SS) - const tsMatch = raw.match(/^(\d{2}\.\d{2}\.\d{4}\s+\d{1,2}:\d{2}:\d{2})/m); - if (tsMatch) result.timestamp = tsMatch[1]; - // Extract {Module.Path(lineNum)}: code entries - const entryRe = /\{([^}]+)\}:\s*(.+)/g; - let m; - while ((m = entryRe.exec(raw)) !== null) { - result.entries.push({ location: m[1].trim(), code: m[2].trim() }); - } - return result.entries.length > 0 ? result : null; -} - -/** - * Fetch error call stack from the 1C platform UI. - * Uses two strategies: - * Path 1 (hasReport=true): Click OpenReport link → "подробный текст ошибки" → read textarea - * Path 2 (fallback): Hamburger → "О программе" → "Информация для техподдержки" → errJournalInput - * - * Always closes the error modal and any platform dialogs it opened. - * Returns parsed stack object or null on failure. - * - * @param {number} formNum - form number of the error modal (e.g. 6 for form6_) - * @param {boolean} hasReport - true if OpenReport link is available - */ -export async function fetchErrorStack(formNum, hasReport) { - try { - // Platform exception modals are initially unstable — they redraw within ~1s. - // The initial state may lack the OpenReport link. Re-check after a short delay. - if (!hasReport) { - await page.waitForTimeout(1500); - hasReport = await page.evaluate((fn) => { - const el = document.getElementById('form' + fn + '_OpenReport#text'); - return !!(el && el.offsetWidth > 2 && el.textContent.trim()); - }, formNum); - } - if (hasReport) return await _fetchStackViaReport(formNum); - return await _fetchStackViaHamburger(formNum); - } catch { - return null; - } finally { - // Ensure all platform dialogs are closed - try { await _closePlatformDialogs(); } catch {} - // Ensure the error modal itself is closed - try { - const sel = formNum != null - ? `#form${formNum}_container a.press.pressDefault` - : 'a.press.pressDefault'; - const btn = await page.$(sel); - if (btn) await btn.click({ force: true }); - await page.waitForTimeout(300); - } catch {} - } -} - -/** - * Path 1: Fetch stack via OpenReport link (for platform exceptions). - * The error modal must still be open with a visible "Сформировать отчет об ошибке" link. - */ -async function _fetchStackViaReport(formNum) { - // 1. Get coordinates of the OpenReport link and click via mouse (modalSurface blocks JS clicks) - const coords = await page.evaluate((fn) => { - const el = document.getElementById('form' + fn + '_OpenReport#text'); - if (!el || el.offsetWidth <= 2) return null; - const rect = el.getBoundingClientRect(); - return { x: rect.x + rect.width / 2, y: rect.y + rect.height / 2 }; - }, formNum); - if (!coords) return null; - - await page.mouse.click(coords.x, coords.y); - - // 2. Wait for "Отчет об ошибке" dialog — look for "подробный текст ошибки" link - let found = false; - for (let i = 0; i < 20; i++) { - await page.waitForTimeout(500); - found = await page.evaluate(() => { - const links = document.querySelectorAll('a, [class*="hyper"], span'); - for (const el of links) { - if (el.offsetWidth > 0 && el.textContent.includes('подробный текст ошибки')) return true; - } - return false; - }); - if (found) break; - } - if (!found) return null; - - // 3. Click "подробный текст ошибки" - await page.getByText('подробный текст ошибки').click(); - await page.waitForTimeout(2000); - - // 4. Read the textarea with detailed error text (find the largest visible textarea) - const raw = await page.evaluate(() => { - let best = null; - document.querySelectorAll('textarea').forEach(ta => { - if (ta.offsetWidth > 0 && ta.value.length > 0) { - if (!best || ta.value.length > best.value.length) best = ta; - } - }); - return best?.value || null; - }); - - // 5. Close "Подробный текст ошибки" dialog (click its OK button) - try { - const okBtn = await page.evaluate(() => { - // Find the OK button in the topmost small cloud window - const psWins = [...document.querySelectorAll('[id^="ps"][id$="win"]')] - .filter(w => w.offsetWidth > 0) - .sort((a, b) => parseInt(b.style?.zIndex || '0') - parseInt(a.style?.zIndex || '0')); - for (const w of psWins) { - const ok = w.querySelector('button.webBtn, .pressDefault'); - if (ok && ok.textContent.trim() === 'OK') { ok.click(); return true; } - } - return false; - }); - await page.waitForTimeout(300); - } catch {} - - // 6. Close "Отчет об ошибке" dialog (click its × close button) - try { - await page.evaluate(() => { - const psWins = [...document.querySelectorAll('[id^="ps"][id$="win"]')] - .filter(w => w.offsetWidth > 0); - for (const w of psWins) { - const closeBtn = w.querySelector('[id$="_cmd_CloseButton"]'); - if (closeBtn && closeBtn.offsetWidth > 0) { closeBtn.click(); break; } - } - }); - await page.waitForTimeout(300); - } catch {} - - return _parseErrorStack(raw); -} - -/** - * Path 2: Fetch stack via hamburger menu → "О программе" → "Информация для техподдержки". - * Works for all error types including simple ВызватьИсключение. - * The error modal is closed first to allow access to the hamburger menu. - */ -async function _fetchStackViaHamburger(formNum) { - // 1. Close the error modal first - try { - const sel = formNum != null - ? `#form${formNum}_container a.press.pressDefault` - : 'a.press.pressDefault'; - const btn = await page.$(sel); - if (btn) await btn.click({ force: true }); - await page.waitForTimeout(500); - } catch {} - - // 2. Click hamburger menu - await page.click('#captionbarMore', { timeout: 5000 }); - await page.waitForTimeout(1000); - - // 3. Click "О программе..." - await page.getByText('О программе...', { exact: true }).click({ timeout: 5000 }); - await page.waitForTimeout(2000); - - // 4. Click "Информация для технической поддержки" - await page.click('#aboutHyperLink', { timeout: 5000 }); - - // 5. Wait for errJournalInput to appear and be filled - let raw = null; - for (let i = 0; i < 20; i++) { - await page.waitForTimeout(500); - raw = await page.evaluate(() => { - const el = document.getElementById('errJournalInput'); - return (el && el.offsetWidth > 0 && el.value.length > 50) ? el.value : null; - }); - if (raw) break; - } - if (!raw) return null; - - // 6. Parse first error block (most recent — before first separator) - const separator = / - - - - /; - const errSection = raw.indexOf('\n\n') !== -1 ? raw.substring(raw.indexOf('\n\n')) : raw; - // Find the "Ошибки:" section - const errIdx = raw.indexOf('Ошибки:'); - let errorText = errIdx !== -1 ? raw.substring(errIdx + 'Ошибки:'.length).trim() : raw; - // Take first block (before first separator line) - const lines = errorText.split('\n'); - const firstBlockLines = []; - let inBlock = false; - for (const line of lines) { - if (separator.test(line)) { - if (inBlock) break; // end of first block - inBlock = true; - continue; - } - if (inBlock) firstBlockLines.push(line); - } - const firstBlock = firstBlockLines.join('\n').trim(); - - // 7. Close support info and about dialogs (done in finally via _closePlatformDialogs) - return _parseErrorStack(firstBlock || errorText); -} +// ============================================================ +// Wait + error/modal handling — extracted to core/{wait,errors}.mjs +// ============================================================ +import { + waitForStable, waitForCondition, startNetworkMonitor, +} from './core/wait.mjs'; +import { + closeModals, checkForErrors, dismissPendingErrors, fetchErrorStack, +} from './core/errors.mjs'; +// Re-export only what was publicly exported before the refactor. +// waitForStable/waitForCondition/startNetworkMonitor/closeModals/checkForErrors/ +// dismissPendingErrors are internal helpers — imported above for local use only. +export { fetchErrorStack } from './core/errors.mjs'; /* getPage moved to core/state.mjs */ diff --git a/.claude/skills/web-test/scripts/core/errors.mjs b/.claude/skills/web-test/scripts/core/errors.mjs new file mode 100644 index 00000000..13e96233 --- /dev/null +++ b/.claude/skills/web-test/scripts/core/errors.mjs @@ -0,0 +1,341 @@ +// web-test core/errors v1.16 — Error/modal/platform-dialog handling: dismiss, detect, fetch stack from 1C UI. +// Source: https://github.com/Nikolay-Shirokov/cc-1c-skills + +import { page } from './state.mjs'; +import { checkErrorsScript } from '../dom.mjs'; +import { waitForStable } from './wait.mjs'; + +/** + * Close startup modals and guide tabs. + * Strategy: Escape → click default buttons → close extra tabs → repeat. + */ +export 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; + } +} + +/** + * Check for validation errors / diagnostics after an action. + * Detects: inline balloon tooltip, messages panel, modal error dialog. + * Returns { balloon, messages[], modal } or null. + */ +export async function checkForErrors() { + return await page.evaluate(checkErrorsScript()); +} + +/** + * Dismiss pending error modal if present (single OK button dialog). + * Called at the start of action functions so that a leftover error modal + * from a previous operation doesn't block the next action. + * Does NOT dismiss confirmations (Да/Нет — require user decision). + * Returns the dismissed error object or null. + */ +export async function dismissPendingErrors() { + // Close leftover platform dialogs first (About, Support Info, Error Report) + // These block all interaction via modalSurface and are invisible to 1C form detection + try { + const pd = await _detectPlatformDialogs(); + if (pd.length) await _closePlatformDialogs(); + } catch { /* OK */ } + const err = await checkForErrors(); + if (!err?.modal) return null; + try { + // Target pressDefault within the modal's form container specifically + const formNum = err.modal.formNum; + const sel = formNum != null + ? `#form${formNum}_container a.press.pressDefault` + : 'a.press.pressDefault'; + const btn = await page.$(sel); + if (btn) { await btn.click({ force: true }); await page.waitForTimeout(500); } + } catch { /* OK */ } + await waitForStable(); + return err; +} + +/** + * Detect open platform-level dialogs (About, Support Info, Error Report). + * Returns array of { type, title? } for each detected dialog, or empty array. + */ +async function _detectPlatformDialogs() { + return await page.evaluate(() => { + const result = []; + // "О программе" dialog + const about = document.getElementById('aboutContainer'); + if (about && about.offsetWidth > 0) result.push({ type: 'about', title: 'О программе' }); + // "Информация для технической поддержки" (inside a ps*win with errJournalInput) + const errJ = document.getElementById('errJournalInput'); + if (errJ && errJ.offsetWidth > 0) result.push({ type: 'supportInfo', title: 'Информация для технической поддержки' }); + // "Отчет об ошибке" / "Подробный текст ошибки" — ps*win cloud windows without aboutContainer + if (!result.length) { + document.querySelectorAll('[id^="ps"][id$="win"]').forEach(w => { + if (w.offsetWidth === 0 || w.offsetHeight === 0) return; + // Skip the main app window (ps*win that contains the 1C forms) + if (w.querySelector('[id^="form"][id$="_container"]')) return; + // Check title text + const titleEl = w.querySelector('[id$="headerTopLine_cmd_Title"]'); + const title = titleEl?.textContent?.trim() || ''; + if (title) result.push({ type: 'platformWindow', title }); + }); + } + return result; + }); +} + +/** + * Close any platform-level dialogs that may be left open (about, support info, error report). + * These are NOT 1C forms — they are platform UI overlays invisible to getFormState(). + * Each close is wrapped in try/catch to avoid cascading failures. + */ +async function _closePlatformDialogs() { + await page.evaluate(() => { + // "Подробный текст ошибки" OK button (inside error report detail view) + // It's a cloud window with its own OK button — look for visible pressDefault in small ps*win + const psWins = document.querySelectorAll('[id^="ps"][id$="win"]'); + for (const w of psWins) { + if (w.offsetWidth === 0) continue; + // Check if this is a small dialog (error detail, about, support info) + const closeBtn = w.querySelector('[id$="_cmd_CloseButton"]'); + if (closeBtn && closeBtn.offsetWidth > 0) { + try { closeBtn.click(); } catch {} + } + } + // "Информация для технической поддержки" — extOkBtn + const extOk = document.getElementById('extOkBtn'); + if (extOk && extOk.offsetWidth > 0) try { extOk.click(); } catch {} + // "О программе" — aboutOkButton + const aboutOk = document.getElementById('aboutOkButton'); + if (aboutOk && aboutOk.offsetWidth > 0) try { aboutOk.click(); } catch {} + }); + await page.waitForTimeout(300); +} + +/** + * Parse raw error stack text into structured entries. + * Input: raw text from errJournalInput (first block) or "Подробный текст ошибки" textarea. + * Returns { raw, timestamp?, entries: [{location, code}] } + */ +function _parseErrorStack(raw) { + if (!raw) return null; + const result = { raw, entries: [] }; + // Extract timestamp if present (format: DD.MM.YYYY HH:MM:SS) + const tsMatch = raw.match(/^(\d{2}\.\d{2}\.\d{4}\s+\d{1,2}:\d{2}:\d{2})/m); + if (tsMatch) result.timestamp = tsMatch[1]; + // Extract {Module.Path(lineNum)}: code entries + const entryRe = /\{([^}]+)\}:\s*(.+)/g; + let m; + while ((m = entryRe.exec(raw)) !== null) { + result.entries.push({ location: m[1].trim(), code: m[2].trim() }); + } + return result.entries.length > 0 ? result : null; +} + +/** + * Fetch error call stack from the 1C platform UI. + * Uses two strategies: + * Path 1 (hasReport=true): Click OpenReport link → "подробный текст ошибки" → read textarea + * Path 2 (fallback): Hamburger → "О программе" → "Информация для техподдержки" → errJournalInput + * + * Always closes the error modal and any platform dialogs it opened. + * Returns parsed stack object or null on failure. + * + * @param {number} formNum - form number of the error modal (e.g. 6 for form6_) + * @param {boolean} hasReport - true if OpenReport link is available + */ +export async function fetchErrorStack(formNum, hasReport) { + try { + // Platform exception modals are initially unstable — they redraw within ~1s. + // The initial state may lack the OpenReport link. Re-check after a short delay. + if (!hasReport) { + await page.waitForTimeout(1500); + hasReport = await page.evaluate((fn) => { + const el = document.getElementById('form' + fn + '_OpenReport#text'); + return !!(el && el.offsetWidth > 2 && el.textContent.trim()); + }, formNum); + } + if (hasReport) return await _fetchStackViaReport(formNum); + return await _fetchStackViaHamburger(formNum); + } catch { + return null; + } finally { + // Ensure all platform dialogs are closed + try { await _closePlatformDialogs(); } catch {} + // Ensure the error modal itself is closed + try { + const sel = formNum != null + ? `#form${formNum}_container a.press.pressDefault` + : 'a.press.pressDefault'; + const btn = await page.$(sel); + if (btn) await btn.click({ force: true }); + await page.waitForTimeout(300); + } catch {} + } +} + +/** + * Path 1: Fetch stack via OpenReport link (for platform exceptions). + * The error modal must still be open with a visible "Сформировать отчет об ошибке" link. + */ +async function _fetchStackViaReport(formNum) { + // 1. Get coordinates of the OpenReport link and click via mouse (modalSurface blocks JS clicks) + const coords = await page.evaluate((fn) => { + const el = document.getElementById('form' + fn + '_OpenReport#text'); + if (!el || el.offsetWidth <= 2) return null; + const rect = el.getBoundingClientRect(); + return { x: rect.x + rect.width / 2, y: rect.y + rect.height / 2 }; + }, formNum); + if (!coords) return null; + + await page.mouse.click(coords.x, coords.y); + + // 2. Wait for "Отчет об ошибке" dialog — look for "подробный текст ошибки" link + let found = false; + for (let i = 0; i < 20; i++) { + await page.waitForTimeout(500); + found = await page.evaluate(() => { + const links = document.querySelectorAll('a, [class*="hyper"], span'); + for (const el of links) { + if (el.offsetWidth > 0 && el.textContent.includes('подробный текст ошибки')) return true; + } + return false; + }); + if (found) break; + } + if (!found) return null; + + // 3. Click "подробный текст ошибки" + await page.getByText('подробный текст ошибки').click(); + await page.waitForTimeout(2000); + + // 4. Read the textarea with detailed error text (find the largest visible textarea) + const raw = await page.evaluate(() => { + let best = null; + document.querySelectorAll('textarea').forEach(ta => { + if (ta.offsetWidth > 0 && ta.value.length > 0) { + if (!best || ta.value.length > best.value.length) best = ta; + } + }); + return best?.value || null; + }); + + // 5. Close "Подробный текст ошибки" dialog (click its OK button) + try { + const okBtn = await page.evaluate(() => { + // Find the OK button in the topmost small cloud window + const psWins = [...document.querySelectorAll('[id^="ps"][id$="win"]')] + .filter(w => w.offsetWidth > 0) + .sort((a, b) => parseInt(b.style?.zIndex || '0') - parseInt(a.style?.zIndex || '0')); + for (const w of psWins) { + const ok = w.querySelector('button.webBtn, .pressDefault'); + if (ok && ok.textContent.trim() === 'OK') { ok.click(); return true; } + } + return false; + }); + await page.waitForTimeout(300); + } catch {} + + // 6. Close "Отчет об ошибке" dialog (click its × close button) + try { + await page.evaluate(() => { + const psWins = [...document.querySelectorAll('[id^="ps"][id$="win"]')] + .filter(w => w.offsetWidth > 0); + for (const w of psWins) { + const closeBtn = w.querySelector('[id$="_cmd_CloseButton"]'); + if (closeBtn && closeBtn.offsetWidth > 0) { closeBtn.click(); break; } + } + }); + await page.waitForTimeout(300); + } catch {} + + return _parseErrorStack(raw); +} + +/** + * Path 2: Fetch stack via hamburger menu → "О программе" → "Информация для техподдержки". + * Works for all error types including simple ВызватьИсключение. + * The error modal is closed first to allow access to the hamburger menu. + */ +async function _fetchStackViaHamburger(formNum) { + // 1. Close the error modal first + try { + const sel = formNum != null + ? `#form${formNum}_container a.press.pressDefault` + : 'a.press.pressDefault'; + const btn = await page.$(sel); + if (btn) await btn.click({ force: true }); + await page.waitForTimeout(500); + } catch {} + + // 2. Click hamburger menu + await page.click('#captionbarMore', { timeout: 5000 }); + await page.waitForTimeout(1000); + + // 3. Click "О программе..." + await page.getByText('О программе...', { exact: true }).click({ timeout: 5000 }); + await page.waitForTimeout(2000); + + // 4. Click "Информация для технической поддержки" + await page.click('#aboutHyperLink', { timeout: 5000 }); + + // 5. Wait for errJournalInput to appear and be filled + let raw = null; + for (let i = 0; i < 20; i++) { + await page.waitForTimeout(500); + raw = await page.evaluate(() => { + const el = document.getElementById('errJournalInput'); + return (el && el.offsetWidth > 0 && el.value.length > 50) ? el.value : null; + }); + if (raw) break; + } + if (!raw) return null; + + // 6. Parse first error block (most recent — before first separator) + const separator = / - - - - /; + const errSection = raw.indexOf('\n\n') !== -1 ? raw.substring(raw.indexOf('\n\n')) : raw; + // Find the "Ошибки:" section + const errIdx = raw.indexOf('Ошибки:'); + let errorText = errIdx !== -1 ? raw.substring(errIdx + 'Ошибки:'.length).trim() : raw; + // Take first block (before first separator line) + const lines = errorText.split('\n'); + const firstBlockLines = []; + let inBlock = false; + for (const line of lines) { + if (separator.test(line)) { + if (inBlock) break; // end of first block + inBlock = true; + continue; + } + if (inBlock) firstBlockLines.push(line); + } + const firstBlock = firstBlockLines.join('\n').trim(); + + // 7. Close support info and about dialogs (done in finally via _closePlatformDialogs) + return _parseErrorStack(firstBlock || errorText); +} diff --git a/.claude/skills/web-test/scripts/core/wait.mjs b/.claude/skills/web-test/scripts/core/wait.mjs new file mode 100644 index 00000000..8e58f41a --- /dev/null +++ b/.claude/skills/web-test/scripts/core/wait.mjs @@ -0,0 +1,123 @@ +// web-test core/wait v1.16 — Smart wait helpers: DOM stability polling, JS-expression polling, CDP network monitor. +// Source: https://github.com/Nikolay-Shirokov/cc-1c-skills + +import { page, MAX_WAIT, POLL_INTERVAL, STABLE_CYCLES } from './state.mjs'; +import { detectFormScript } from '../dom.mjs'; + +/** + * 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) + */ +export 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 +} + +/** + * Start monitoring network activity via CDP. + * Must be called BEFORE the click so it captures all server requests. + * Returns a monitor object with waitDone() and cleanup() methods. + */ +export async function startNetworkMonitor() { + const client = await page.context().newCDPSession(page); + await client.send('Network.enable'); + + let pending = 0; + let total = 0; + let lastZeroTime = null; + const DEBOUNCE = 300; + + client.on('Network.requestWillBeSent', () => { + pending++; + total++; + lastZeroTime = null; + }); + client.on('Network.loadingFinished', () => { + if (--pending === 0) lastZeroTime = Date.now(); + }); + client.on('Network.loadingFailed', () => { + if (--pending === 0) lastZeroTime = Date.now(); + }); + + return { + /** Wait until all network requests complete (300ms debounce) or UI element appears. */ + async waitDone(timeout = 10000) { + const start = Date.now(); + while (Date.now() - start < timeout) { + await page.waitForTimeout(50); + + // Check for UI elements (modal, balloon, confirm) + const ui = await page.evaluate(`(() => { + const modal = document.querySelector('#modalSurface:not([style*="display: none"])'); + const balloon = document.querySelector('.balloon'); + const confirm = document.querySelector('.confirm'); + return !!(modal || balloon || confirm); + })()`); + if (ui) return; + + // CDP debounce: pending===0 held for DEBOUNCE ms + if (total > 0 && pending === 0 && lastZeroTime !== null) { + if (Date.now() - lastZeroTime >= DEBOUNCE) return; + } + } + }, + /** Detach CDP session. Always call this when done. */ + async cleanup() { + await client.send('Network.disable').catch(() => {}); + await client.detach().catch(() => {}); + } + }; +} + +/** + * Poll until a JS expression returns truthy, or timeout (ms) expires. + * Resolves early — typically within 100-300ms instead of fixed delays. + */ +export async function waitForCondition(evalScript, timeout = 2000) { + const start = Date.now(); + while (Date.now() - start < timeout) { + const result = await page.evaluate(evalScript); + if (result) return result; + await page.waitForTimeout(100); + } + return null; +}