fix(web-test): detect server-side errors via waitForSelector and ancestry-based button grouping

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 <noreply@anthropic.com>
This commit is contained in:
Nick Shirokov
2026-02-28 13:28:23 +03:00
parent 90ca2a7c4a
commit 75558fe46c
2 changed files with 62 additions and 42 deletions
@@ -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 };
+44 -42
View File
@@ -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;