mirror of
https://github.com/Nikolay-Shirokov/cc-1c-skills.git
synced 2026-07-26 06:31:02 +03:00
refactor(web-test): этап A.3 — выделить core/wait.mjs + core/errors.mjs
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) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.7
parent
398c515390
commit
4f01f01286
@@ -510,458 +510,19 @@ export async function closeContext(name) {
|
|||||||
contexts.delete(name);
|
contexts.delete(name);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
// ============================================================
|
||||||
* Close startup modals and guide tabs.
|
// Wait + error/modal handling — extracted to core/{wait,errors}.mjs
|
||||||
* Strategy: Escape → click default buttons → close extra tabs → repeat.
|
// ============================================================
|
||||||
*/
|
import {
|
||||||
async function closeModals() {
|
waitForStable, waitForCondition, startNetworkMonitor,
|
||||||
for (let attempt = 0; attempt < 5; attempt++) {
|
} from './core/wait.mjs';
|
||||||
// 1. Press Escape to dismiss any popup/modal
|
import {
|
||||||
await page.keyboard.press('Escape');
|
closeModals, checkForErrors, dismissPendingErrors, fetchErrorStack,
|
||||||
await page.waitForTimeout(1000);
|
} from './core/errors.mjs';
|
||||||
|
// Re-export only what was publicly exported before the refactor.
|
||||||
// 2. Try clicking default "Закрыть"/"OK" buttons
|
// waitForStable/waitForCondition/startNetworkMonitor/closeModals/checkForErrors/
|
||||||
const clicked = await page.evaluate(`(() => {
|
// dismissPendingErrors are internal helpers — imported above for local use only.
|
||||||
const btns = [...document.querySelectorAll('a.press.pressDefault')].filter(el => el.offsetWidth > 0);
|
export { fetchErrorStack } from './core/errors.mjs';
|
||||||
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);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* getPage moved to core/state.mjs */
|
/* getPage moved to core/state.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);
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user