From 75558fe46cd0cf2028778637b8663b733bd1feff Mon Sep 17 00:00:00 2001 From: Nick Shirokov Date: Sat, 28 Feb 2026 13:28:23 +0300 Subject: [PATCH] fix(web-test): detect server-side errors via waitForSelector and ancestry-based button grouping MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two problems solved: 1. Server-side exceptions (ВызватьИсключение in ПередЗаписью) produce modal dialogs AFTER the DOM stabilizes. clickElement now uses waitForSelector with MutationObserver (doesn't block JS event loop) to detect #modalSurface or .balloon appearance. 2. checkErrorsScript used button IDs to determine form ownership, but 1C modal dialog buttons often have empty IDs. Now uses closest('[id$="_container"]') ancestry to group pressButtons by form, correctly separating modal buttons from background form buttons (e.g. "Зачет оплаты" in ERP order form). Tested with ТестОшибки CFE extension on ERP — error detected in 7.7s. Co-Authored-By: Claude Opus 4.6 --- .claude/skills/web-test/scripts/browser.mjs | 18 +++++ .claude/skills/web-test/scripts/dom.mjs | 86 +++++++++++---------- 2 files changed, 62 insertions(+), 42 deletions(-) diff --git a/.claude/skills/web-test/scripts/browser.mjs b/.claude/skills/web-test/scripts/browser.mjs index 457acd23..f82c3923 100644 --- a/.claude/skills/web-test/scripts/browser.mjs +++ b/.claude/skills/web-test/scripts/browser.mjs @@ -969,6 +969,24 @@ export async function clickElement(text, { dblclick } = {}) { return state; } + // For buttons that trigger server-side operations (post, write, etc.), + // the DOM may stabilize BEFORE the server response arrives. + // Use waitForSelector to detect error modal — this doesn't block the JS event loop. + if (target.kind === 'button') { + const postForm = await page.evaluate(detectFormScript()); + if (postForm === formNum) { + // Form didn't change — server might still be processing. + // waitForSelector uses MutationObserver internally — doesn't block event loop. + try { + await page.waitForSelector( + '#modalSurface:not([style*="display: none"]), .balloon', + { state: 'visible', timeout: 10000 } + ); + } catch {} + await waitForStable(); + } + } + // Form may have changed — re-detect const state = await getFormState(); state.clicked = { kind: target.kind, name: target.name }; diff --git a/.claude/skills/web-test/scripts/dom.mjs b/.claude/skills/web-test/scripts/dom.mjs index d7c2fb2b..fa344a20 100644 --- a/.claude/skills/web-test/scripts/dom.mjs +++ b/.claude/skills/web-test/scripts/dom.mjs @@ -798,55 +798,57 @@ export function checkErrorsScript() { if (msgs.length > 0) { result.messages = msgs; break; } } - // 3. Confirmation dialog (#modalSurface + pressButton buttons) + // 3+4. Modal dialogs: confirmation (multiple buttons) or error (single pressDefault) + // Uses form container ancestry to group buttons — pressButton elements often lack form-prefixed IDs 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 }; - } - } + // Group visible pressButtons by their form container + const formButtons = {}; + [...document.querySelectorAll('a.press.pressButton')].forEach(btn => { + if (btn.offsetWidth === 0) return; + const container = btn.closest('[id$="_container"]'); + const m = container?.id?.match(/^form(\\d+)_/); + if (!m) return; + const fn = m[1]; + if (!formButtons[fn]) formButtons[fn] = []; + formButtons[fn].push(btn); + }); - // 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 + '_'; + for (const [fn, buttons] of Object.entries(formButtons)) { + const p = 'form' + fn + '_'; 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 }; + if (elCount > 100) continue; // Skip large content forms + if (buttons.length > 1) { + // Confirmation dialog (multiple buttons: Да/Нет, OK/Отмена, etc.) + const msgEl = document.getElementById(p + 'Message'); + const message = msgEl?.innerText?.trim() || ''; + const btnNames = buttons.map(el => { + const b = { name: el.innerText?.trim() || '' }; + if (el.classList.contains('pressDefault')) b.default = true; + return b; + }).filter(b => b.name); + result.confirmation = { message, buttons: btnNames.map(b => b.name), formNum: parseInt(fn) }; break; } } + + // Single-button modal: error dialog with pressDefault + staticText + if (!result.confirmation) { + for (const [fn, buttons] of Object.entries(formButtons)) { + const p = 'form' + fn + '_'; + const elCount = document.querySelectorAll('[id^="' + p + '"]').length; + if (elCount > 100) continue; + if (buttons.length !== 1 || !buttons[0].classList.contains('pressDefault')) continue; + const texts = [...document.querySelectorAll('[id^="' + p + '"].staticText')] + .filter(el => el.offsetWidth > 0) + .map(el => el.innerText?.trim()) + .filter(Boolean); + if (texts.length > 0) { + result.modal = { message: texts.join(' '), formNum: parseInt(fn), button: buttons[0].innerText?.trim() || '' }; + break; + } + } + } } return (result.balloon || result.messages || result.modal || result.confirmation) ? result : null;