mirror of
https://github.com/Nikolay-Shirokov/cc-1c-skills.git
synced 2026-07-26 06:31:02 +03:00
chore(repo): нормализация EOL к LF + .gitattributes
Приводим авторский контент (.ps1/.psm1/.py/.mjs/.md/.json, пин .bsl) к единому LF и закрепляем политикой в .gitattributes. Инструмент правки всегда пишет LF, поэтому единый LF убирает EOL-шум в диффах, ложные срабатывания blame и налог на ручную синхронизацию CRLF-файлов. BOM на .ps1 сохранён (git с eol=lf меняет только CR<->LF, BOM не трогает). Данные 1С (*.xml) и бинарники под нормализацию не берём. Гейт: PS-порт 459/459, Python-порт 459/459, web-test E2E 22/22 (с пересборкой стенда). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.8
parent
9eb9571e95
commit
26888a07d5
@@ -1,97 +1,97 @@
|
||||
// web-test engine/core/clipboard v1.17 — OS-clipboard preservation around trusted paste.
|
||||
// Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
|
||||
//
|
||||
// pasteText() — the only path 1C respects for autocomplete and Cyrillic input.
|
||||
// saveClipboard/restoreClipboard preserve full clipboard contents (all MIME
|
||||
// types) around the writeText+Ctrl+V pair so a user's concurrent Ctrl+C isn't
|
||||
// clobbered. Blobs are stashed on `window` to avoid CDP serialization.
|
||||
|
||||
import {
|
||||
page, preserveClipboard, clipboardWarnLogged, setClipboardWarnLogged,
|
||||
} from './state.mjs';
|
||||
|
||||
export async function saveClipboard() {
|
||||
if (!page) return;
|
||||
try {
|
||||
await page.evaluate(async () => {
|
||||
try {
|
||||
const items = await navigator.clipboard.read();
|
||||
const saved = [];
|
||||
for (const item of items) {
|
||||
const types = {};
|
||||
for (const t of item.types) types[t] = await item.getType(t);
|
||||
saved.push(types);
|
||||
}
|
||||
window.__webTestSavedClipboard = saved;
|
||||
delete window.__webTestClipboardError;
|
||||
} catch (e) {
|
||||
window.__webTestSavedClipboard = null;
|
||||
window.__webTestClipboardError = e?.name || String(e);
|
||||
}
|
||||
});
|
||||
} catch {
|
||||
// page.evaluate itself failed (closed page, navigation in flight) — skip.
|
||||
}
|
||||
}
|
||||
|
||||
export async function restoreClipboard() {
|
||||
if (!page) return;
|
||||
let err = null;
|
||||
try {
|
||||
err = await page.evaluate(async () => {
|
||||
const saved = window.__webTestSavedClipboard;
|
||||
const captured = window.__webTestClipboardError || null;
|
||||
delete window.__webTestSavedClipboard;
|
||||
delete window.__webTestClipboardError;
|
||||
try {
|
||||
if (!saved || saved.length === 0) {
|
||||
// Save failed (e.g. CF_HDROP from Explorer not readable via Clipboard API)
|
||||
// or buffer was empty. Either way, the test's writeText already destroyed
|
||||
// any prior native formats in the OS clipboard, so explicitly clear here
|
||||
// to avoid leaking the test value into the user's clipboard.
|
||||
await navigator.clipboard.writeText('');
|
||||
return captured;
|
||||
}
|
||||
const items = saved.map(types => new ClipboardItem(types));
|
||||
await navigator.clipboard.write(items);
|
||||
return null;
|
||||
} catch (e) {
|
||||
return e?.name || String(e);
|
||||
}
|
||||
});
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
if (err && !clipboardWarnLogged) {
|
||||
setClipboardWarnLogged(true);
|
||||
console.error(`[web-test] clipboard preserve skipped: ${err} (logged once per session)`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Paste `text` via OS clipboard (the only trusted-paste path that 1C respects
|
||||
* for autocomplete and Cyrillic). Wraps the writeText+confirm-key pair in a
|
||||
* narrow save/restore so a user's clipboard survives the test run — the window
|
||||
* between save and restore is microseconds.
|
||||
*
|
||||
* - `confirm` — key (string) or sequence (array) to press after writeText.
|
||||
* Defaults to 'Control+V'. Use ['Control+a', 'Control+v'] for select-all-then-paste,
|
||||
* or 'Shift+F11' for the goto-link dialog.
|
||||
* - `postDelay` — ms to wait between confirm-press and restore, for dialogs
|
||||
* that read clipboard asynchronously (e.g. Shift+F11). Default 0.
|
||||
*/
|
||||
export async function pasteText(text, { confirm = 'Control+V', postDelay = 0 } = {}) {
|
||||
if (!page) return;
|
||||
if (preserveClipboard) await saveClipboard();
|
||||
try {
|
||||
await page.evaluate(`navigator.clipboard.writeText(${JSON.stringify(String(text))})`);
|
||||
if (Array.isArray(confirm)) {
|
||||
for (const key of confirm) await page.keyboard.press(key);
|
||||
} else if (confirm) {
|
||||
await page.keyboard.press(confirm);
|
||||
}
|
||||
if (postDelay) await page.waitForTimeout(postDelay);
|
||||
} finally {
|
||||
if (preserveClipboard) await restoreClipboard();
|
||||
}
|
||||
}
|
||||
// web-test engine/core/clipboard v1.17 — OS-clipboard preservation around trusted paste.
|
||||
// Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
|
||||
//
|
||||
// pasteText() — the only path 1C respects for autocomplete and Cyrillic input.
|
||||
// saveClipboard/restoreClipboard preserve full clipboard contents (all MIME
|
||||
// types) around the writeText+Ctrl+V pair so a user's concurrent Ctrl+C isn't
|
||||
// clobbered. Blobs are stashed on `window` to avoid CDP serialization.
|
||||
|
||||
import {
|
||||
page, preserveClipboard, clipboardWarnLogged, setClipboardWarnLogged,
|
||||
} from './state.mjs';
|
||||
|
||||
export async function saveClipboard() {
|
||||
if (!page) return;
|
||||
try {
|
||||
await page.evaluate(async () => {
|
||||
try {
|
||||
const items = await navigator.clipboard.read();
|
||||
const saved = [];
|
||||
for (const item of items) {
|
||||
const types = {};
|
||||
for (const t of item.types) types[t] = await item.getType(t);
|
||||
saved.push(types);
|
||||
}
|
||||
window.__webTestSavedClipboard = saved;
|
||||
delete window.__webTestClipboardError;
|
||||
} catch (e) {
|
||||
window.__webTestSavedClipboard = null;
|
||||
window.__webTestClipboardError = e?.name || String(e);
|
||||
}
|
||||
});
|
||||
} catch {
|
||||
// page.evaluate itself failed (closed page, navigation in flight) — skip.
|
||||
}
|
||||
}
|
||||
|
||||
export async function restoreClipboard() {
|
||||
if (!page) return;
|
||||
let err = null;
|
||||
try {
|
||||
err = await page.evaluate(async () => {
|
||||
const saved = window.__webTestSavedClipboard;
|
||||
const captured = window.__webTestClipboardError || null;
|
||||
delete window.__webTestSavedClipboard;
|
||||
delete window.__webTestClipboardError;
|
||||
try {
|
||||
if (!saved || saved.length === 0) {
|
||||
// Save failed (e.g. CF_HDROP from Explorer not readable via Clipboard API)
|
||||
// or buffer was empty. Either way, the test's writeText already destroyed
|
||||
// any prior native formats in the OS clipboard, so explicitly clear here
|
||||
// to avoid leaking the test value into the user's clipboard.
|
||||
await navigator.clipboard.writeText('');
|
||||
return captured;
|
||||
}
|
||||
const items = saved.map(types => new ClipboardItem(types));
|
||||
await navigator.clipboard.write(items);
|
||||
return null;
|
||||
} catch (e) {
|
||||
return e?.name || String(e);
|
||||
}
|
||||
});
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
if (err && !clipboardWarnLogged) {
|
||||
setClipboardWarnLogged(true);
|
||||
console.error(`[web-test] clipboard preserve skipped: ${err} (logged once per session)`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Paste `text` via OS clipboard (the only trusted-paste path that 1C respects
|
||||
* for autocomplete and Cyrillic). Wraps the writeText+confirm-key pair in a
|
||||
* narrow save/restore so a user's clipboard survives the test run — the window
|
||||
* between save and restore is microseconds.
|
||||
*
|
||||
* - `confirm` — key (string) or sequence (array) to press after writeText.
|
||||
* Defaults to 'Control+V'. Use ['Control+a', 'Control+v'] for select-all-then-paste,
|
||||
* or 'Shift+F11' for the goto-link dialog.
|
||||
* - `postDelay` — ms to wait between confirm-press and restore, for dialogs
|
||||
* that read clipboard asynchronously (e.g. Shift+F11). Default 0.
|
||||
*/
|
||||
export async function pasteText(text, { confirm = 'Control+V', postDelay = 0 } = {}) {
|
||||
if (!page) return;
|
||||
if (preserveClipboard) await saveClipboard();
|
||||
try {
|
||||
await page.evaluate(`navigator.clipboard.writeText(${JSON.stringify(String(text))})`);
|
||||
if (Array.isArray(confirm)) {
|
||||
for (const key of confirm) await page.keyboard.press(key);
|
||||
} else if (confirm) {
|
||||
await page.keyboard.press(confirm);
|
||||
}
|
||||
if (postDelay) await page.waitForTimeout(postDelay);
|
||||
} finally {
|
||||
if (preserveClipboard) await restoreClipboard();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,310 +1,310 @@
|
||||
// web-test core/errors v1.18 — 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 {
|
||||
getOpenReportCoordsScript, isErrorDetailLinkVisibleScript,
|
||||
readLargestVisibleTextareaScript, clickTopCloudOkButtonScript,
|
||||
clickReportCloseButtonScript,
|
||||
} from '../../dom/errors-stack.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.
|
||||
*/
|
||||
export 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.
|
||||
*/
|
||||
export 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(getOpenReportCoordsScript(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(isErrorDetailLinkVisibleScript());
|
||||
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(readLargestVisibleTextareaScript());
|
||||
|
||||
// 5. Close "Подробный текст ошибки" dialog (click its OK button)
|
||||
try {
|
||||
await page.evaluate(clickTopCloudOkButtonScript());
|
||||
await page.waitForTimeout(300);
|
||||
} catch {}
|
||||
|
||||
// 6. Close "Отчет об ошибке" dialog (click its × close button)
|
||||
try {
|
||||
await page.evaluate(clickReportCloseButtonScript());
|
||||
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);
|
||||
}
|
||||
// web-test core/errors v1.18 — 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 {
|
||||
getOpenReportCoordsScript, isErrorDetailLinkVisibleScript,
|
||||
readLargestVisibleTextareaScript, clickTopCloudOkButtonScript,
|
||||
clickReportCloseButtonScript,
|
||||
} from '../../dom/errors-stack.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.
|
||||
*/
|
||||
export 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.
|
||||
*/
|
||||
export 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(getOpenReportCoordsScript(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(isErrorDetailLinkVisibleScript());
|
||||
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(readLargestVisibleTextareaScript());
|
||||
|
||||
// 5. Close "Подробный текст ошибки" dialog (click its OK button)
|
||||
try {
|
||||
await page.evaluate(clickTopCloudOkButtonScript());
|
||||
await page.waitForTimeout(300);
|
||||
} catch {}
|
||||
|
||||
// 6. Close "Отчет об ошибке" dialog (click its × close button)
|
||||
try {
|
||||
await page.evaluate(clickReportCloseButtonScript());
|
||||
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);
|
||||
}
|
||||
|
||||
@@ -1,178 +1,178 @@
|
||||
// web-test core/helpers v1.21 — private, cross-cutting helpers used by the
|
||||
// public action functions (clickElement/fillFields/selectValue/etc).
|
||||
// Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
|
||||
|
||||
import { page } from './state.mjs';
|
||||
import { dismissPendingErrors, checkForErrors } from './errors.mjs';
|
||||
import { getFormState } from '../forms/state.mjs';
|
||||
import {
|
||||
detectNewFormScript,
|
||||
isInputFocusedScript,
|
||||
isInputFocusedInGridScript,
|
||||
findOpenPopupScript,
|
||||
readEddScript,
|
||||
isEddVisibleScript,
|
||||
clickEddItemViaDispatchScript,
|
||||
clickShowAllInEddScript,
|
||||
} from '../../dom.mjs';
|
||||
|
||||
/**
|
||||
* page.click with the standard "intercepts pointer events" retry ladder:
|
||||
* normal → force → Escape (+ optional dismissPendingErrors) → normal.
|
||||
* Mirrors the three hand-written copies in fillReferenceField, clickElement
|
||||
* and the DLB branch of selectValue.
|
||||
*
|
||||
* @param {string} selector
|
||||
* @param {object} [opts]
|
||||
* @param {number} [opts.timeout] — passed through to page.click
|
||||
* @param {boolean} [opts.dismissErrors=false] — call dismissPendingErrors()
|
||||
* before pressing Escape on the second retry (used in fillReferenceField).
|
||||
*/
|
||||
export async function safeClick(selector, { timeout, dismissErrors = false } = {}) {
|
||||
const baseOpts = timeout != null ? { timeout } : {};
|
||||
try {
|
||||
await page.click(selector, baseOpts);
|
||||
} catch (e) {
|
||||
if (!e.message.includes('intercepts pointer events')) throw e;
|
||||
try {
|
||||
await page.click(selector, { ...baseOpts, force: true });
|
||||
} catch (e2) {
|
||||
if (!e2.message.includes('intercepts pointer events')) throw e2;
|
||||
if (dismissErrors) await dismissPendingErrors();
|
||||
await page.keyboard.press('Escape');
|
||||
await page.waitForTimeout(500);
|
||||
await page.click(selector, baseOpts);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Find a form field's input element id by name. Tries `form{N}_{name}` first,
|
||||
* then `form{N}_{name}_i0` (reference fields use the _i0 suffix). Returns the
|
||||
* element id or null. Used in selectValue's clear/composite-type/F4 fallback
|
||||
* branches.
|
||||
*
|
||||
* @param {number} formNum
|
||||
* @param {string} fieldName
|
||||
* @returns {Promise<string|null>}
|
||||
*/
|
||||
export async function findFieldInputId(formNum, fieldName) {
|
||||
return await page.evaluate(`(() => {
|
||||
const p = 'form${formNum}_';
|
||||
const name = ${JSON.stringify(fieldName)};
|
||||
const el = document.querySelector('[id="' + p + name + '"], [id="' + p + name + '_i0"]');
|
||||
return el ? el.id : null;
|
||||
})()`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Detect a new form opened above the given `prevFormNum`. Two modes:
|
||||
* `{ strict: true }` — only counts visible interactive elements
|
||||
* (`input.editInput[id], a.press[id]`); used by fillReferenceField.
|
||||
* default (broad) — any element with `id^=form{N}_` that's visible
|
||||
* in either dimension; also finds type-dialogs whose a.press buttons
|
||||
* have empty IDs. Used by selectValue / fillTableRow.
|
||||
*
|
||||
* @param {number} prevFormNum
|
||||
* @param {object} [opts]
|
||||
* @param {boolean} [opts.strict=false]
|
||||
* @returns {Promise<number|null>} new form number or null
|
||||
*/
|
||||
export async function detectNewForm(prevFormNum, { strict = false } = {}) {
|
||||
return page.evaluate(detectNewFormScript(prevFormNum, { strict }));
|
||||
}
|
||||
|
||||
/**
|
||||
* Thin wrapper: is the currently focused element an INPUT (or TEXTAREA)?
|
||||
*
|
||||
* @param {object} [opts]
|
||||
* @param {boolean} [opts.allowTextarea=false]
|
||||
*/
|
||||
export async function isInputFocused({ allowTextarea = false } = {}) {
|
||||
return page.evaluate(isInputFocusedScript({ allowTextarea }));
|
||||
}
|
||||
|
||||
/**
|
||||
* Thin wrapper: is the currently focused INPUT/TEXTAREA inside a `.grid`?
|
||||
* Used to verify grid edit-mode. Pass `{ gridSelector }` to scope the check
|
||||
* to a specific grid (when a form has multiple grids).
|
||||
*/
|
||||
export async function isInputFocusedInGrid({ gridSelector } = {}) {
|
||||
return page.evaluate(isInputFocusedInGridScript(gridSelector));
|
||||
}
|
||||
|
||||
/**
|
||||
* Thin wrapper: is calculator (`.calculate`) or calendar (`.frameCalendar`)
|
||||
* popup visible? Returns `'calculator' | 'calendar' | null`.
|
||||
*/
|
||||
export async function findOpenPopup() {
|
||||
return page.evaluate(findOpenPopupScript());
|
||||
}
|
||||
|
||||
/**
|
||||
* Read the `#editDropDown` autocomplete popup. Returns whether it's visible
|
||||
* and, when visible, an array of `.eddText` items with display name and
|
||||
* center coordinates (suitable for page.mouse.click).
|
||||
*
|
||||
* @returns {Promise<{visible: boolean, items?: Array<{name:string, x:number, y:number}>}>}
|
||||
*/
|
||||
export async function readEdd() {
|
||||
return page.evaluate(readEddScript());
|
||||
}
|
||||
|
||||
/**
|
||||
* Thin wrapper: is the EDD popup currently visible?
|
||||
* Lighter than `readEdd` when only presence matters.
|
||||
*/
|
||||
export async function isEddVisible() {
|
||||
return page.evaluate(isEddVisibleScript());
|
||||
}
|
||||
|
||||
/**
|
||||
* Click an EDD item by name via dispatchEvent (bypasses div.surface overlays).
|
||||
* Returns the clicked item's innerText, or `null` if no match.
|
||||
*/
|
||||
export async function clickEddItemViaDispatch(itemName) {
|
||||
return page.evaluate(clickEddItemViaDispatchScript(itemName));
|
||||
}
|
||||
|
||||
/**
|
||||
* Click the "Показать все" / "Show all" link in the EDD footer.
|
||||
* Returns boolean.
|
||||
*/
|
||||
export async function clickShowAllInEdd() {
|
||||
return page.evaluate(clickShowAllInEddScript());
|
||||
}
|
||||
|
||||
/**
|
||||
* Standard "tail" of action functions: fetch current form state, attach
|
||||
* caller-specified extras (e.g. `{ clicked: {...} }`) and the result of
|
||||
* `checkForErrors()` if any. Returns the flat state object.
|
||||
*
|
||||
* Unifies ~15 hand-written copies in clickElement, selectValue, closeForm,
|
||||
* navigation functions, etc. Also closes R1/R2/R3 from the refactor plan —
|
||||
* any caller using this helper gets `state.errors` for free.
|
||||
*
|
||||
* @param {object} [extras] — merged into the state object via Object.assign.
|
||||
* @returns {Promise<object>} form state (flat) with optional `errors`.
|
||||
*/
|
||||
export async function returnFormState(extras = {}) {
|
||||
const state = await getFormState();
|
||||
Object.assign(state, extras);
|
||||
const err = await checkForErrors();
|
||||
if (err) state.errors = err;
|
||||
return state;
|
||||
}
|
||||
|
||||
/**
|
||||
* Mouse click at (x, y) with an optional modifier key held down for the duration.
|
||||
* Supports `'ctrl'` / `'shift'` (used by clickElement for multi-select).
|
||||
* Pass `{ dbl: true }` for double-click.
|
||||
*/
|
||||
export async function modifierClick(x, y, modifier, { dbl = false } = {}) {
|
||||
const modKey = modifier === 'ctrl' ? 'Control' : modifier === 'shift' ? 'Shift' : null;
|
||||
if (modKey) await page.keyboard.down(modKey);
|
||||
if (dbl) await page.mouse.dblclick(x, y);
|
||||
else await page.mouse.click(x, y);
|
||||
if (modKey) await page.keyboard.up(modKey);
|
||||
}
|
||||
// web-test core/helpers v1.21 — private, cross-cutting helpers used by the
|
||||
// public action functions (clickElement/fillFields/selectValue/etc).
|
||||
// Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
|
||||
|
||||
import { page } from './state.mjs';
|
||||
import { dismissPendingErrors, checkForErrors } from './errors.mjs';
|
||||
import { getFormState } from '../forms/state.mjs';
|
||||
import {
|
||||
detectNewFormScript,
|
||||
isInputFocusedScript,
|
||||
isInputFocusedInGridScript,
|
||||
findOpenPopupScript,
|
||||
readEddScript,
|
||||
isEddVisibleScript,
|
||||
clickEddItemViaDispatchScript,
|
||||
clickShowAllInEddScript,
|
||||
} from '../../dom.mjs';
|
||||
|
||||
/**
|
||||
* page.click with the standard "intercepts pointer events" retry ladder:
|
||||
* normal → force → Escape (+ optional dismissPendingErrors) → normal.
|
||||
* Mirrors the three hand-written copies in fillReferenceField, clickElement
|
||||
* and the DLB branch of selectValue.
|
||||
*
|
||||
* @param {string} selector
|
||||
* @param {object} [opts]
|
||||
* @param {number} [opts.timeout] — passed through to page.click
|
||||
* @param {boolean} [opts.dismissErrors=false] — call dismissPendingErrors()
|
||||
* before pressing Escape on the second retry (used in fillReferenceField).
|
||||
*/
|
||||
export async function safeClick(selector, { timeout, dismissErrors = false } = {}) {
|
||||
const baseOpts = timeout != null ? { timeout } : {};
|
||||
try {
|
||||
await page.click(selector, baseOpts);
|
||||
} catch (e) {
|
||||
if (!e.message.includes('intercepts pointer events')) throw e;
|
||||
try {
|
||||
await page.click(selector, { ...baseOpts, force: true });
|
||||
} catch (e2) {
|
||||
if (!e2.message.includes('intercepts pointer events')) throw e2;
|
||||
if (dismissErrors) await dismissPendingErrors();
|
||||
await page.keyboard.press('Escape');
|
||||
await page.waitForTimeout(500);
|
||||
await page.click(selector, baseOpts);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Find a form field's input element id by name. Tries `form{N}_{name}` first,
|
||||
* then `form{N}_{name}_i0` (reference fields use the _i0 suffix). Returns the
|
||||
* element id or null. Used in selectValue's clear/composite-type/F4 fallback
|
||||
* branches.
|
||||
*
|
||||
* @param {number} formNum
|
||||
* @param {string} fieldName
|
||||
* @returns {Promise<string|null>}
|
||||
*/
|
||||
export async function findFieldInputId(formNum, fieldName) {
|
||||
return await page.evaluate(`(() => {
|
||||
const p = 'form${formNum}_';
|
||||
const name = ${JSON.stringify(fieldName)};
|
||||
const el = document.querySelector('[id="' + p + name + '"], [id="' + p + name + '_i0"]');
|
||||
return el ? el.id : null;
|
||||
})()`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Detect a new form opened above the given `prevFormNum`. Two modes:
|
||||
* `{ strict: true }` — only counts visible interactive elements
|
||||
* (`input.editInput[id], a.press[id]`); used by fillReferenceField.
|
||||
* default (broad) — any element with `id^=form{N}_` that's visible
|
||||
* in either dimension; also finds type-dialogs whose a.press buttons
|
||||
* have empty IDs. Used by selectValue / fillTableRow.
|
||||
*
|
||||
* @param {number} prevFormNum
|
||||
* @param {object} [opts]
|
||||
* @param {boolean} [opts.strict=false]
|
||||
* @returns {Promise<number|null>} new form number or null
|
||||
*/
|
||||
export async function detectNewForm(prevFormNum, { strict = false } = {}) {
|
||||
return page.evaluate(detectNewFormScript(prevFormNum, { strict }));
|
||||
}
|
||||
|
||||
/**
|
||||
* Thin wrapper: is the currently focused element an INPUT (or TEXTAREA)?
|
||||
*
|
||||
* @param {object} [opts]
|
||||
* @param {boolean} [opts.allowTextarea=false]
|
||||
*/
|
||||
export async function isInputFocused({ allowTextarea = false } = {}) {
|
||||
return page.evaluate(isInputFocusedScript({ allowTextarea }));
|
||||
}
|
||||
|
||||
/**
|
||||
* Thin wrapper: is the currently focused INPUT/TEXTAREA inside a `.grid`?
|
||||
* Used to verify grid edit-mode. Pass `{ gridSelector }` to scope the check
|
||||
* to a specific grid (when a form has multiple grids).
|
||||
*/
|
||||
export async function isInputFocusedInGrid({ gridSelector } = {}) {
|
||||
return page.evaluate(isInputFocusedInGridScript(gridSelector));
|
||||
}
|
||||
|
||||
/**
|
||||
* Thin wrapper: is calculator (`.calculate`) or calendar (`.frameCalendar`)
|
||||
* popup visible? Returns `'calculator' | 'calendar' | null`.
|
||||
*/
|
||||
export async function findOpenPopup() {
|
||||
return page.evaluate(findOpenPopupScript());
|
||||
}
|
||||
|
||||
/**
|
||||
* Read the `#editDropDown` autocomplete popup. Returns whether it's visible
|
||||
* and, when visible, an array of `.eddText` items with display name and
|
||||
* center coordinates (suitable for page.mouse.click).
|
||||
*
|
||||
* @returns {Promise<{visible: boolean, items?: Array<{name:string, x:number, y:number}>}>}
|
||||
*/
|
||||
export async function readEdd() {
|
||||
return page.evaluate(readEddScript());
|
||||
}
|
||||
|
||||
/**
|
||||
* Thin wrapper: is the EDD popup currently visible?
|
||||
* Lighter than `readEdd` when only presence matters.
|
||||
*/
|
||||
export async function isEddVisible() {
|
||||
return page.evaluate(isEddVisibleScript());
|
||||
}
|
||||
|
||||
/**
|
||||
* Click an EDD item by name via dispatchEvent (bypasses div.surface overlays).
|
||||
* Returns the clicked item's innerText, or `null` if no match.
|
||||
*/
|
||||
export async function clickEddItemViaDispatch(itemName) {
|
||||
return page.evaluate(clickEddItemViaDispatchScript(itemName));
|
||||
}
|
||||
|
||||
/**
|
||||
* Click the "Показать все" / "Show all" link in the EDD footer.
|
||||
* Returns boolean.
|
||||
*/
|
||||
export async function clickShowAllInEdd() {
|
||||
return page.evaluate(clickShowAllInEddScript());
|
||||
}
|
||||
|
||||
/**
|
||||
* Standard "tail" of action functions: fetch current form state, attach
|
||||
* caller-specified extras (e.g. `{ clicked: {...} }`) and the result of
|
||||
* `checkForErrors()` if any. Returns the flat state object.
|
||||
*
|
||||
* Unifies ~15 hand-written copies in clickElement, selectValue, closeForm,
|
||||
* navigation functions, etc. Also closes R1/R2/R3 from the refactor plan —
|
||||
* any caller using this helper gets `state.errors` for free.
|
||||
*
|
||||
* @param {object} [extras] — merged into the state object via Object.assign.
|
||||
* @returns {Promise<object>} form state (flat) with optional `errors`.
|
||||
*/
|
||||
export async function returnFormState(extras = {}) {
|
||||
const state = await getFormState();
|
||||
Object.assign(state, extras);
|
||||
const err = await checkForErrors();
|
||||
if (err) state.errors = err;
|
||||
return state;
|
||||
}
|
||||
|
||||
/**
|
||||
* Mouse click at (x, y) with an optional modifier key held down for the duration.
|
||||
* Supports `'ctrl'` / `'shift'` (used by clickElement for multi-select).
|
||||
* Pass `{ dbl: true }` for double-click.
|
||||
*/
|
||||
export async function modifierClick(x, y, modifier, { dbl = false } = {}) {
|
||||
const modKey = modifier === 'ctrl' ? 'Control' : modifier === 'shift' ? 'Shift' : null;
|
||||
if (modKey) await page.keyboard.down(modKey);
|
||||
if (dbl) await page.mouse.dblclick(x, y);
|
||||
else await page.mouse.click(x, y);
|
||||
if (modKey) await page.keyboard.up(modKey);
|
||||
}
|
||||
|
||||
@@ -1,404 +1,404 @@
|
||||
// web-test core/session v1.17 — Browser session lifecycle: connect/disconnect/attach/detach, multi-context registry.
|
||||
// Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
|
||||
|
||||
import { chromium } from 'playwright';
|
||||
import { statSync, mkdirSync, readdirSync, rmSync } from 'fs';
|
||||
import { join as pathJoin } from 'path';
|
||||
import { tmpdir } from 'os';
|
||||
import {
|
||||
browser, page, sessionPrefix, seanceId, recorder, highlightMode,
|
||||
contexts, activeContextName, activeMode, persistentUserDataDir,
|
||||
setBrowser, setPage, setSessionPrefix, setSeanceId, setHighlightMode,
|
||||
setActiveContextName, setActiveMode, setPersistentUserDataDir,
|
||||
isConnected, LOAD_TIMEOUT, INIT_TIMEOUT, EXT_ID,
|
||||
} from './state.mjs';
|
||||
import { closeModals } from './errors.mjs';
|
||||
import { stopRecording } from '../recording/capture.mjs';
|
||||
import { getPageState } from '../nav/navigation.mjs';
|
||||
|
||||
/**
|
||||
* Find the 1C browser extension in Chrome/Edge user profiles.
|
||||
* Returns the path to the latest version, or null if not found.
|
||||
* Can be overridden via extensionPath in .v8-project.json.
|
||||
*/
|
||||
function findExtension(overridePath) {
|
||||
if (overridePath) {
|
||||
try { if (statSync(overridePath).isDirectory()) return overridePath; } catch {}
|
||||
return null;
|
||||
}
|
||||
const localAppData = process.env.LOCALAPPDATA;
|
||||
if (!localAppData) return null;
|
||||
const browsers = [
|
||||
pathJoin(localAppData, 'Google', 'Chrome', 'User Data'),
|
||||
pathJoin(localAppData, 'Microsoft', 'Edge', 'User Data'),
|
||||
];
|
||||
for (const userData of browsers) {
|
||||
try { if (!statSync(userData).isDirectory()) continue; } catch { continue; }
|
||||
let profiles;
|
||||
try { profiles = readdirSync(userData).filter(d => d === 'Default' || d.startsWith('Profile ')); } catch { continue; }
|
||||
for (const profile of profiles) {
|
||||
const extDir = pathJoin(userData, profile, 'Extensions', EXT_ID);
|
||||
try { if (!statSync(extDir).isDirectory()) continue; } catch { continue; }
|
||||
let versions;
|
||||
try { versions = readdirSync(extDir).filter(d => /^\d/.test(d)).sort(); } catch { continue; }
|
||||
if (versions.length > 0) {
|
||||
const best = pathJoin(extDir, versions[versions.length - 1]);
|
||||
try { if (statSync(pathJoin(best, 'manifest.json')).isFile()) return best; } catch {}
|
||||
}
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/* isConnected moved to core/state.mjs */
|
||||
|
||||
/**
|
||||
* 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, { extensionPath } = {}) {
|
||||
if (isConnected()) {
|
||||
await page.goto(url, { waitUntil: 'domcontentloaded', timeout: LOAD_TIMEOUT });
|
||||
} else {
|
||||
const extPath = findExtension(extensionPath);
|
||||
if (extPath) {
|
||||
// Launch with 1C browser extension via persistent context
|
||||
setPersistentUserDataDir(pathJoin(tmpdir(), 'pw-1c-ext-' + Date.now()));
|
||||
mkdirSync(persistentUserDataDir, { recursive: true });
|
||||
const context = await chromium.launchPersistentContext(persistentUserDataDir, {
|
||||
headless: false,
|
||||
args: [
|
||||
'--start-maximized',
|
||||
'--disable-extensions-except=' + extPath,
|
||||
'--load-extension=' + extPath,
|
||||
],
|
||||
viewport: null,
|
||||
permissions: ['clipboard-read', 'clipboard-write'],
|
||||
});
|
||||
setBrowser(context); // persistent context IS the browser
|
||||
setPage(context.pages()[0] || await context.newPage());
|
||||
} else {
|
||||
// Fallback: launch without extension
|
||||
setBrowser(await chromium.launch({ headless: false, args: ['--start-maximized'] }));
|
||||
const context = await browser.newContext({
|
||||
viewport: null,
|
||||
permissions: ['clipboard-read', 'clipboard-write'],
|
||||
});
|
||||
setPage(await context.newPage());
|
||||
}
|
||||
|
||||
// Auto-accept native browser dialogs (confirm/alert from 1C scripts like vis.js)
|
||||
page.on('dialog', dialog => dialog.accept().catch(() => {}));
|
||||
|
||||
// Capture seanceId from network requests for graceful logout
|
||||
setSessionPrefix(null);
|
||||
setSeanceId(null);
|
||||
page.on('request', req => {
|
||||
if (seanceId) return;
|
||||
const m = req.url().match(/^(https?:\/\/[^/]+\/[^/]+\/[^/]+)\/e1cib\/.+[?&]seanceId=([^&]+)/);
|
||||
if (m) { setSessionPrefix(m[1]); setSeanceId(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();
|
||||
}
|
||||
|
||||
/**
|
||||
* Best-effort POST /e1cib/logout on a slot to release the 1C session license.
|
||||
* Silent — if page is closed or session info missing, just returns.
|
||||
* @param {object} slot { page, sessionPrefix, seanceId } from contexts Map
|
||||
* @param {number} [waitMs=500] pause after logout fetch (gives 1C time to process)
|
||||
*/
|
||||
async function logoutSlot(slot, waitMs = 500) {
|
||||
if (!slot?.page || slot.page.isClosed() || !slot.seanceId || !slot.sessionPrefix) return;
|
||||
try {
|
||||
const logoutUrl = `${slot.sessionPrefix}/e1cib/logout?seanceId=${slot.seanceId}`;
|
||||
await slot.page.evaluate(async (url) => {
|
||||
await fetch(url, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: '{"root":{}}' });
|
||||
}, logoutUrl);
|
||||
await slot.page.waitForTimeout(waitMs);
|
||||
} catch {}
|
||||
}
|
||||
|
||||
/**
|
||||
* Gracefully terminate the 1C session and close the browser.
|
||||
* Sends POST /e1cib/logout to release the license before closing.
|
||||
*/
|
||||
export async function disconnect() {
|
||||
// Multi-context path: stop recording + logout each slot before closing browser
|
||||
if (contexts.size > 0) {
|
||||
saveActiveSlot();
|
||||
// Recorder is global — one stop covers all contexts
|
||||
if (recorder) {
|
||||
try { await stopRecording(); } catch {}
|
||||
}
|
||||
for (const [, slot] of contexts.entries()) {
|
||||
await logoutSlot(slot);
|
||||
}
|
||||
contexts.clear();
|
||||
setActiveContextName(null);
|
||||
setActiveMode(null);
|
||||
}
|
||||
|
||||
// Single-session path (connect): auto-stop recording if active
|
||||
if (recorder) {
|
||||
try { await stopRecording(); } catch {}
|
||||
}
|
||||
|
||||
if (browser) {
|
||||
// Graceful logout — release the 1C license (single-session connect path)
|
||||
await logoutSlot({ page, sessionPrefix, seanceId }, 1000);
|
||||
await browser.close().catch(() => {});
|
||||
setBrowser(null);
|
||||
setPage(null);
|
||||
setSessionPrefix(null);
|
||||
setSeanceId(null);
|
||||
// Clean up persistent user data dir
|
||||
if (persistentUserDataDir) {
|
||||
try { rmSync(persistentUserDataDir, { recursive: true, force: true }); } catch {}
|
||||
setPersistentUserDataDir(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;
|
||||
setBrowser(await chromium.connect(wsEndpoint));
|
||||
const ctx = browser.contexts()[0];
|
||||
setPage(ctx?.pages()[0]);
|
||||
if (!page) throw new Error('No page found in browser');
|
||||
setSessionPrefix(session.sessionPrefix || null);
|
||||
setSeanceId(session.seanceId || null);
|
||||
}
|
||||
|
||||
/**
|
||||
* Detach from browser without closing it.
|
||||
* Returns session state for persistence.
|
||||
*/
|
||||
export function detach() {
|
||||
const session = { sessionPrefix, seanceId };
|
||||
setBrowser(null);
|
||||
setPage(null);
|
||||
setSessionPrefix(null);
|
||||
setSeanceId(null);
|
||||
return session;
|
||||
}
|
||||
|
||||
/** Get current session state (for saving between reconnections). */
|
||||
export function getSession() {
|
||||
return { sessionPrefix, seanceId };
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// Multi-context support (used by run.mjs cmdTest only)
|
||||
// ============================================================
|
||||
|
||||
/**
|
||||
* Save current module-level state into the active slot before switching.
|
||||
* No-op if no active slot.
|
||||
*/
|
||||
function saveActiveSlot() {
|
||||
if (!activeContextName) return;
|
||||
const slot = contexts.get(activeContextName);
|
||||
if (!slot) return;
|
||||
slot.page = page;
|
||||
slot.sessionPrefix = sessionPrefix;
|
||||
slot.seanceId = seanceId;
|
||||
slot.highlightMode = highlightMode;
|
||||
// Note: `recorder`, `lastCaptions`, `lastRecordingDuration` are intentionally NOT
|
||||
// mirrored per-slot. A multi-context recording produces one continuous output file —
|
||||
// the recorder follows the active page via recorder._attachPage(), not per-slot state.
|
||||
}
|
||||
|
||||
/** Load a slot's state into module-level vars and mark it active. */
|
||||
function activateSlot(name) {
|
||||
const slot = contexts.get(name);
|
||||
if (!slot) throw new Error(`Context "${name}" not found. Create it via createContext() first.`);
|
||||
setPage(slot.page);
|
||||
setSessionPrefix(slot.sessionPrefix);
|
||||
setSeanceId(slot.seanceId);
|
||||
setHighlightMode(slot.highlightMode || false);
|
||||
setActiveContextName(name);
|
||||
}
|
||||
|
||||
/** Attach 1C session listeners to a page, writing into the given slot. */
|
||||
function attachSessionListeners(pg, slot, name) {
|
||||
pg.on('dialog', dialog => dialog.accept().catch(() => {}));
|
||||
pg.on('request', req => {
|
||||
if (slot.seanceId) return;
|
||||
const m = req.url().match(/^(https?:\/\/[^/]+\/[^/]+\/[^/]+)\/e1cib\/.+[?&]seanceId=([^&]+)/);
|
||||
if (m) {
|
||||
slot.sessionPrefix = m[1];
|
||||
slot.seanceId = m[2];
|
||||
if (activeContextName === name) {
|
||||
setSessionPrefix(m[1]);
|
||||
setSeanceId(m[2]);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Create (or navigate) a named browser context.
|
||||
* First call launches Chromium via chromium.launch() (NOT launchPersistentContext) so that
|
||||
* subsequent calls can create additional isolated BrowserContexts in the same process.
|
||||
* Trade-off: 1C browser extension is loaded via --load-extension (process-level) rather than
|
||||
* persistent profile.
|
||||
*
|
||||
* Use this from run.mjs cmdTest only — exec/run/start use connect() and stay on the
|
||||
* legacy persistent-context path.
|
||||
*/
|
||||
export async function createContext(name, url, { extensionPath, isolation = 'tab' } = {}) {
|
||||
if (contexts.has(name)) {
|
||||
await setActiveContext(name);
|
||||
await page.goto(url, { waitUntil: 'domcontentloaded', timeout: LOAD_TIMEOUT });
|
||||
try { await page.waitForSelector('#themesCell_theme_0', { timeout: INIT_TIMEOUT }); }
|
||||
catch { await page.waitForTimeout(5000); }
|
||||
await closeModals();
|
||||
return await getPageState();
|
||||
}
|
||||
|
||||
if (!['tab', 'window'].includes(isolation)) {
|
||||
throw new Error(`createContext: invalid isolation "${isolation}", expected 'tab' or 'window'`);
|
||||
}
|
||||
if (activeMode && activeMode !== isolation) {
|
||||
throw new Error(`createContext: cannot mix isolation modes — first context used "${activeMode}", "${name}" requested "${isolation}". Use the same mode for all contexts in one run.`);
|
||||
}
|
||||
|
||||
// First context: launch browser. Subsequent: reuse existing.
|
||||
let isFirstContext = !browser;
|
||||
if (isFirstContext) {
|
||||
const extPath = findExtension(extensionPath);
|
||||
const launchArgs = ['--start-maximized'];
|
||||
if (extPath) {
|
||||
launchArgs.push('--disable-extensions-except=' + extPath, '--load-extension=' + extPath);
|
||||
}
|
||||
if (isolation === 'tab') {
|
||||
// Persistent context: extension loads reliably, one window with tabs per context
|
||||
setPersistentUserDataDir(pathJoin(tmpdir(), 'pw-1c-test-' + Date.now()));
|
||||
mkdirSync(persistentUserDataDir, { recursive: true });
|
||||
setBrowser(await chromium.launchPersistentContext(persistentUserDataDir, {
|
||||
headless: false,
|
||||
args: launchArgs,
|
||||
viewport: null,
|
||||
permissions: ['clipboard-read', 'clipboard-write'],
|
||||
}));
|
||||
} else {
|
||||
// Window mode: separate BrowserContext per slot, full cookie isolation
|
||||
setBrowser(await chromium.launch({ headless: false, args: launchArgs }));
|
||||
}
|
||||
setActiveMode(isolation);
|
||||
}
|
||||
|
||||
// Save current active before switching
|
||||
saveActiveSlot();
|
||||
|
||||
// Create slot — page differs by mode
|
||||
let newCtx, newPage;
|
||||
if (activeMode === 'tab') {
|
||||
// Reuse the persistent context for all slots; each slot gets its own page (tab)
|
||||
newCtx = browser;
|
||||
if (isFirstContext) {
|
||||
newPage = browser.pages()[0] || await browser.newPage();
|
||||
} else {
|
||||
newPage = await browser.newPage();
|
||||
}
|
||||
} else {
|
||||
// Window mode: each slot owns its BrowserContext + page
|
||||
newCtx = await browser.newContext({
|
||||
viewport: null,
|
||||
permissions: ['clipboard-read', 'clipboard-write'],
|
||||
});
|
||||
newPage = await newCtx.newPage();
|
||||
}
|
||||
|
||||
const slot = {
|
||||
context: newCtx,
|
||||
page: newPage,
|
||||
sessionPrefix: null,
|
||||
seanceId: null,
|
||||
highlightMode: false,
|
||||
};
|
||||
contexts.set(name, slot);
|
||||
|
||||
attachSessionListeners(newPage, slot, name);
|
||||
activateSlot(name);
|
||||
|
||||
await page.goto(url, { waitUntil: 'domcontentloaded', timeout: LOAD_TIMEOUT });
|
||||
try { await page.waitForSelector('#themesCell_theme_0', { timeout: INIT_TIMEOUT }); }
|
||||
catch { await page.waitForTimeout(5000); }
|
||||
await closeModals();
|
||||
|
||||
return await getPageState();
|
||||
}
|
||||
|
||||
/** Switch the active context. Subsequent browser API calls operate on this context's page. */
|
||||
export async function setActiveContext(name) {
|
||||
if (activeContextName === name) return;
|
||||
if (!contexts.has(name)) throw new Error(`Context "${name}" not found. Available: [${[...contexts.keys()].join(', ')}]`);
|
||||
// If a recording is active, flush the outgoing page's last frame so the gap is filled
|
||||
// up to the moment of the switch (avoids a "jump" in video time).
|
||||
if (recorder && recorder._flushFrames) recorder._flushFrames();
|
||||
saveActiveSlot();
|
||||
activateSlot(name);
|
||||
// If the recording is still alive (it lives across slots — we keep the same ffmpeg/output),
|
||||
// re-attach its screencast to the newly active page.
|
||||
if (recorder && recorder._attachPage) {
|
||||
await recorder._attachPage(page);
|
||||
}
|
||||
}
|
||||
|
||||
export function listContexts() {
|
||||
return [...contexts.keys()];
|
||||
}
|
||||
|
||||
export function getActiveContext() {
|
||||
return activeContextName;
|
||||
}
|
||||
|
||||
export function hasContext(name) {
|
||||
return contexts.has(name);
|
||||
}
|
||||
|
||||
/**
|
||||
* Close a named context: logout, close its page (tab mode) or BrowserContext
|
||||
* (window mode), remove from registry. Cannot close the currently active
|
||||
* context — caller must setActiveContext to another first. This keeps the
|
||||
* recorder/page invariants simple: recorder is always attached to the
|
||||
* active slot, which closeContext never touches.
|
||||
*
|
||||
* @throws if name is not registered or equals the active context.
|
||||
*/
|
||||
export async function closeContext(name) {
|
||||
if (!contexts.has(name)) {
|
||||
throw new Error(`Context "${name}" not found. Available: [${[...contexts.keys()].join(', ')}]`);
|
||||
}
|
||||
if (name === activeContextName) {
|
||||
throw new Error(`closeContext: cannot close the active context "${name}". setActiveContext to another context first.`);
|
||||
}
|
||||
const slot = contexts.get(name);
|
||||
await logoutSlot(slot);
|
||||
if (activeMode === 'tab') {
|
||||
try { await slot.page.close(); } catch {}
|
||||
} else {
|
||||
try { await slot.context.close(); } catch {}
|
||||
}
|
||||
contexts.delete(name);
|
||||
}
|
||||
// web-test core/session v1.17 — Browser session lifecycle: connect/disconnect/attach/detach, multi-context registry.
|
||||
// Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
|
||||
|
||||
import { chromium } from 'playwright';
|
||||
import { statSync, mkdirSync, readdirSync, rmSync } from 'fs';
|
||||
import { join as pathJoin } from 'path';
|
||||
import { tmpdir } from 'os';
|
||||
import {
|
||||
browser, page, sessionPrefix, seanceId, recorder, highlightMode,
|
||||
contexts, activeContextName, activeMode, persistentUserDataDir,
|
||||
setBrowser, setPage, setSessionPrefix, setSeanceId, setHighlightMode,
|
||||
setActiveContextName, setActiveMode, setPersistentUserDataDir,
|
||||
isConnected, LOAD_TIMEOUT, INIT_TIMEOUT, EXT_ID,
|
||||
} from './state.mjs';
|
||||
import { closeModals } from './errors.mjs';
|
||||
import { stopRecording } from '../recording/capture.mjs';
|
||||
import { getPageState } from '../nav/navigation.mjs';
|
||||
|
||||
/**
|
||||
* Find the 1C browser extension in Chrome/Edge user profiles.
|
||||
* Returns the path to the latest version, or null if not found.
|
||||
* Can be overridden via extensionPath in .v8-project.json.
|
||||
*/
|
||||
function findExtension(overridePath) {
|
||||
if (overridePath) {
|
||||
try { if (statSync(overridePath).isDirectory()) return overridePath; } catch {}
|
||||
return null;
|
||||
}
|
||||
const localAppData = process.env.LOCALAPPDATA;
|
||||
if (!localAppData) return null;
|
||||
const browsers = [
|
||||
pathJoin(localAppData, 'Google', 'Chrome', 'User Data'),
|
||||
pathJoin(localAppData, 'Microsoft', 'Edge', 'User Data'),
|
||||
];
|
||||
for (const userData of browsers) {
|
||||
try { if (!statSync(userData).isDirectory()) continue; } catch { continue; }
|
||||
let profiles;
|
||||
try { profiles = readdirSync(userData).filter(d => d === 'Default' || d.startsWith('Profile ')); } catch { continue; }
|
||||
for (const profile of profiles) {
|
||||
const extDir = pathJoin(userData, profile, 'Extensions', EXT_ID);
|
||||
try { if (!statSync(extDir).isDirectory()) continue; } catch { continue; }
|
||||
let versions;
|
||||
try { versions = readdirSync(extDir).filter(d => /^\d/.test(d)).sort(); } catch { continue; }
|
||||
if (versions.length > 0) {
|
||||
const best = pathJoin(extDir, versions[versions.length - 1]);
|
||||
try { if (statSync(pathJoin(best, 'manifest.json')).isFile()) return best; } catch {}
|
||||
}
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/* isConnected moved to core/state.mjs */
|
||||
|
||||
/**
|
||||
* 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, { extensionPath } = {}) {
|
||||
if (isConnected()) {
|
||||
await page.goto(url, { waitUntil: 'domcontentloaded', timeout: LOAD_TIMEOUT });
|
||||
} else {
|
||||
const extPath = findExtension(extensionPath);
|
||||
if (extPath) {
|
||||
// Launch with 1C browser extension via persistent context
|
||||
setPersistentUserDataDir(pathJoin(tmpdir(), 'pw-1c-ext-' + Date.now()));
|
||||
mkdirSync(persistentUserDataDir, { recursive: true });
|
||||
const context = await chromium.launchPersistentContext(persistentUserDataDir, {
|
||||
headless: false,
|
||||
args: [
|
||||
'--start-maximized',
|
||||
'--disable-extensions-except=' + extPath,
|
||||
'--load-extension=' + extPath,
|
||||
],
|
||||
viewport: null,
|
||||
permissions: ['clipboard-read', 'clipboard-write'],
|
||||
});
|
||||
setBrowser(context); // persistent context IS the browser
|
||||
setPage(context.pages()[0] || await context.newPage());
|
||||
} else {
|
||||
// Fallback: launch without extension
|
||||
setBrowser(await chromium.launch({ headless: false, args: ['--start-maximized'] }));
|
||||
const context = await browser.newContext({
|
||||
viewport: null,
|
||||
permissions: ['clipboard-read', 'clipboard-write'],
|
||||
});
|
||||
setPage(await context.newPage());
|
||||
}
|
||||
|
||||
// Auto-accept native browser dialogs (confirm/alert from 1C scripts like vis.js)
|
||||
page.on('dialog', dialog => dialog.accept().catch(() => {}));
|
||||
|
||||
// Capture seanceId from network requests for graceful logout
|
||||
setSessionPrefix(null);
|
||||
setSeanceId(null);
|
||||
page.on('request', req => {
|
||||
if (seanceId) return;
|
||||
const m = req.url().match(/^(https?:\/\/[^/]+\/[^/]+\/[^/]+)\/e1cib\/.+[?&]seanceId=([^&]+)/);
|
||||
if (m) { setSessionPrefix(m[1]); setSeanceId(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();
|
||||
}
|
||||
|
||||
/**
|
||||
* Best-effort POST /e1cib/logout on a slot to release the 1C session license.
|
||||
* Silent — if page is closed or session info missing, just returns.
|
||||
* @param {object} slot { page, sessionPrefix, seanceId } from contexts Map
|
||||
* @param {number} [waitMs=500] pause after logout fetch (gives 1C time to process)
|
||||
*/
|
||||
async function logoutSlot(slot, waitMs = 500) {
|
||||
if (!slot?.page || slot.page.isClosed() || !slot.seanceId || !slot.sessionPrefix) return;
|
||||
try {
|
||||
const logoutUrl = `${slot.sessionPrefix}/e1cib/logout?seanceId=${slot.seanceId}`;
|
||||
await slot.page.evaluate(async (url) => {
|
||||
await fetch(url, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: '{"root":{}}' });
|
||||
}, logoutUrl);
|
||||
await slot.page.waitForTimeout(waitMs);
|
||||
} catch {}
|
||||
}
|
||||
|
||||
/**
|
||||
* Gracefully terminate the 1C session and close the browser.
|
||||
* Sends POST /e1cib/logout to release the license before closing.
|
||||
*/
|
||||
export async function disconnect() {
|
||||
// Multi-context path: stop recording + logout each slot before closing browser
|
||||
if (contexts.size > 0) {
|
||||
saveActiveSlot();
|
||||
// Recorder is global — one stop covers all contexts
|
||||
if (recorder) {
|
||||
try { await stopRecording(); } catch {}
|
||||
}
|
||||
for (const [, slot] of contexts.entries()) {
|
||||
await logoutSlot(slot);
|
||||
}
|
||||
contexts.clear();
|
||||
setActiveContextName(null);
|
||||
setActiveMode(null);
|
||||
}
|
||||
|
||||
// Single-session path (connect): auto-stop recording if active
|
||||
if (recorder) {
|
||||
try { await stopRecording(); } catch {}
|
||||
}
|
||||
|
||||
if (browser) {
|
||||
// Graceful logout — release the 1C license (single-session connect path)
|
||||
await logoutSlot({ page, sessionPrefix, seanceId }, 1000);
|
||||
await browser.close().catch(() => {});
|
||||
setBrowser(null);
|
||||
setPage(null);
|
||||
setSessionPrefix(null);
|
||||
setSeanceId(null);
|
||||
// Clean up persistent user data dir
|
||||
if (persistentUserDataDir) {
|
||||
try { rmSync(persistentUserDataDir, { recursive: true, force: true }); } catch {}
|
||||
setPersistentUserDataDir(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;
|
||||
setBrowser(await chromium.connect(wsEndpoint));
|
||||
const ctx = browser.contexts()[0];
|
||||
setPage(ctx?.pages()[0]);
|
||||
if (!page) throw new Error('No page found in browser');
|
||||
setSessionPrefix(session.sessionPrefix || null);
|
||||
setSeanceId(session.seanceId || null);
|
||||
}
|
||||
|
||||
/**
|
||||
* Detach from browser without closing it.
|
||||
* Returns session state for persistence.
|
||||
*/
|
||||
export function detach() {
|
||||
const session = { sessionPrefix, seanceId };
|
||||
setBrowser(null);
|
||||
setPage(null);
|
||||
setSessionPrefix(null);
|
||||
setSeanceId(null);
|
||||
return session;
|
||||
}
|
||||
|
||||
/** Get current session state (for saving between reconnections). */
|
||||
export function getSession() {
|
||||
return { sessionPrefix, seanceId };
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// Multi-context support (used by run.mjs cmdTest only)
|
||||
// ============================================================
|
||||
|
||||
/**
|
||||
* Save current module-level state into the active slot before switching.
|
||||
* No-op if no active slot.
|
||||
*/
|
||||
function saveActiveSlot() {
|
||||
if (!activeContextName) return;
|
||||
const slot = contexts.get(activeContextName);
|
||||
if (!slot) return;
|
||||
slot.page = page;
|
||||
slot.sessionPrefix = sessionPrefix;
|
||||
slot.seanceId = seanceId;
|
||||
slot.highlightMode = highlightMode;
|
||||
// Note: `recorder`, `lastCaptions`, `lastRecordingDuration` are intentionally NOT
|
||||
// mirrored per-slot. A multi-context recording produces one continuous output file —
|
||||
// the recorder follows the active page via recorder._attachPage(), not per-slot state.
|
||||
}
|
||||
|
||||
/** Load a slot's state into module-level vars and mark it active. */
|
||||
function activateSlot(name) {
|
||||
const slot = contexts.get(name);
|
||||
if (!slot) throw new Error(`Context "${name}" not found. Create it via createContext() first.`);
|
||||
setPage(slot.page);
|
||||
setSessionPrefix(slot.sessionPrefix);
|
||||
setSeanceId(slot.seanceId);
|
||||
setHighlightMode(slot.highlightMode || false);
|
||||
setActiveContextName(name);
|
||||
}
|
||||
|
||||
/** Attach 1C session listeners to a page, writing into the given slot. */
|
||||
function attachSessionListeners(pg, slot, name) {
|
||||
pg.on('dialog', dialog => dialog.accept().catch(() => {}));
|
||||
pg.on('request', req => {
|
||||
if (slot.seanceId) return;
|
||||
const m = req.url().match(/^(https?:\/\/[^/]+\/[^/]+\/[^/]+)\/e1cib\/.+[?&]seanceId=([^&]+)/);
|
||||
if (m) {
|
||||
slot.sessionPrefix = m[1];
|
||||
slot.seanceId = m[2];
|
||||
if (activeContextName === name) {
|
||||
setSessionPrefix(m[1]);
|
||||
setSeanceId(m[2]);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Create (or navigate) a named browser context.
|
||||
* First call launches Chromium via chromium.launch() (NOT launchPersistentContext) so that
|
||||
* subsequent calls can create additional isolated BrowserContexts in the same process.
|
||||
* Trade-off: 1C browser extension is loaded via --load-extension (process-level) rather than
|
||||
* persistent profile.
|
||||
*
|
||||
* Use this from run.mjs cmdTest only — exec/run/start use connect() and stay on the
|
||||
* legacy persistent-context path.
|
||||
*/
|
||||
export async function createContext(name, url, { extensionPath, isolation = 'tab' } = {}) {
|
||||
if (contexts.has(name)) {
|
||||
await setActiveContext(name);
|
||||
await page.goto(url, { waitUntil: 'domcontentloaded', timeout: LOAD_TIMEOUT });
|
||||
try { await page.waitForSelector('#themesCell_theme_0', { timeout: INIT_TIMEOUT }); }
|
||||
catch { await page.waitForTimeout(5000); }
|
||||
await closeModals();
|
||||
return await getPageState();
|
||||
}
|
||||
|
||||
if (!['tab', 'window'].includes(isolation)) {
|
||||
throw new Error(`createContext: invalid isolation "${isolation}", expected 'tab' or 'window'`);
|
||||
}
|
||||
if (activeMode && activeMode !== isolation) {
|
||||
throw new Error(`createContext: cannot mix isolation modes — first context used "${activeMode}", "${name}" requested "${isolation}". Use the same mode for all contexts in one run.`);
|
||||
}
|
||||
|
||||
// First context: launch browser. Subsequent: reuse existing.
|
||||
let isFirstContext = !browser;
|
||||
if (isFirstContext) {
|
||||
const extPath = findExtension(extensionPath);
|
||||
const launchArgs = ['--start-maximized'];
|
||||
if (extPath) {
|
||||
launchArgs.push('--disable-extensions-except=' + extPath, '--load-extension=' + extPath);
|
||||
}
|
||||
if (isolation === 'tab') {
|
||||
// Persistent context: extension loads reliably, one window with tabs per context
|
||||
setPersistentUserDataDir(pathJoin(tmpdir(), 'pw-1c-test-' + Date.now()));
|
||||
mkdirSync(persistentUserDataDir, { recursive: true });
|
||||
setBrowser(await chromium.launchPersistentContext(persistentUserDataDir, {
|
||||
headless: false,
|
||||
args: launchArgs,
|
||||
viewport: null,
|
||||
permissions: ['clipboard-read', 'clipboard-write'],
|
||||
}));
|
||||
} else {
|
||||
// Window mode: separate BrowserContext per slot, full cookie isolation
|
||||
setBrowser(await chromium.launch({ headless: false, args: launchArgs }));
|
||||
}
|
||||
setActiveMode(isolation);
|
||||
}
|
||||
|
||||
// Save current active before switching
|
||||
saveActiveSlot();
|
||||
|
||||
// Create slot — page differs by mode
|
||||
let newCtx, newPage;
|
||||
if (activeMode === 'tab') {
|
||||
// Reuse the persistent context for all slots; each slot gets its own page (tab)
|
||||
newCtx = browser;
|
||||
if (isFirstContext) {
|
||||
newPage = browser.pages()[0] || await browser.newPage();
|
||||
} else {
|
||||
newPage = await browser.newPage();
|
||||
}
|
||||
} else {
|
||||
// Window mode: each slot owns its BrowserContext + page
|
||||
newCtx = await browser.newContext({
|
||||
viewport: null,
|
||||
permissions: ['clipboard-read', 'clipboard-write'],
|
||||
});
|
||||
newPage = await newCtx.newPage();
|
||||
}
|
||||
|
||||
const slot = {
|
||||
context: newCtx,
|
||||
page: newPage,
|
||||
sessionPrefix: null,
|
||||
seanceId: null,
|
||||
highlightMode: false,
|
||||
};
|
||||
contexts.set(name, slot);
|
||||
|
||||
attachSessionListeners(newPage, slot, name);
|
||||
activateSlot(name);
|
||||
|
||||
await page.goto(url, { waitUntil: 'domcontentloaded', timeout: LOAD_TIMEOUT });
|
||||
try { await page.waitForSelector('#themesCell_theme_0', { timeout: INIT_TIMEOUT }); }
|
||||
catch { await page.waitForTimeout(5000); }
|
||||
await closeModals();
|
||||
|
||||
return await getPageState();
|
||||
}
|
||||
|
||||
/** Switch the active context. Subsequent browser API calls operate on this context's page. */
|
||||
export async function setActiveContext(name) {
|
||||
if (activeContextName === name) return;
|
||||
if (!contexts.has(name)) throw new Error(`Context "${name}" not found. Available: [${[...contexts.keys()].join(', ')}]`);
|
||||
// If a recording is active, flush the outgoing page's last frame so the gap is filled
|
||||
// up to the moment of the switch (avoids a "jump" in video time).
|
||||
if (recorder && recorder._flushFrames) recorder._flushFrames();
|
||||
saveActiveSlot();
|
||||
activateSlot(name);
|
||||
// If the recording is still alive (it lives across slots — we keep the same ffmpeg/output),
|
||||
// re-attach its screencast to the newly active page.
|
||||
if (recorder && recorder._attachPage) {
|
||||
await recorder._attachPage(page);
|
||||
}
|
||||
}
|
||||
|
||||
export function listContexts() {
|
||||
return [...contexts.keys()];
|
||||
}
|
||||
|
||||
export function getActiveContext() {
|
||||
return activeContextName;
|
||||
}
|
||||
|
||||
export function hasContext(name) {
|
||||
return contexts.has(name);
|
||||
}
|
||||
|
||||
/**
|
||||
* Close a named context: logout, close its page (tab mode) or BrowserContext
|
||||
* (window mode), remove from registry. Cannot close the currently active
|
||||
* context — caller must setActiveContext to another first. This keeps the
|
||||
* recorder/page invariants simple: recorder is always attached to the
|
||||
* active slot, which closeContext never touches.
|
||||
*
|
||||
* @throws if name is not registered or equals the active context.
|
||||
*/
|
||||
export async function closeContext(name) {
|
||||
if (!contexts.has(name)) {
|
||||
throw new Error(`Context "${name}" not found. Available: [${[...contexts.keys()].join(', ')}]`);
|
||||
}
|
||||
if (name === activeContextName) {
|
||||
throw new Error(`closeContext: cannot close the active context "${name}". setActiveContext to another context first.`);
|
||||
}
|
||||
const slot = contexts.get(name);
|
||||
await logoutSlot(slot);
|
||||
if (activeMode === 'tab') {
|
||||
try { await slot.page.close(); } catch {}
|
||||
} else {
|
||||
try { await slot.context.close(); } catch {}
|
||||
}
|
||||
contexts.delete(name);
|
||||
}
|
||||
|
||||
@@ -1,113 +1,113 @@
|
||||
// web-test core/state v1.17 — module-level state for the web-test engine.
|
||||
// Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
|
||||
//
|
||||
// Holds the single browser/page/recorder slot plus the multi-context registry,
|
||||
// constants, and small state-only utilities (ensureConnected, getPage,
|
||||
// resolveProjectPath, normYo). Mutable values are exported as `let` bindings
|
||||
// for live read access from consumer modules; writes go through setters so
|
||||
// imported bindings stay read-only at the import site.
|
||||
|
||||
import { dirname, resolve as pathResolve } from 'path';
|
||||
import { fileURLToPath } from 'url';
|
||||
|
||||
// Project root: 6 levels up from .claude/skills/web-test/scripts/engine/core/state.mjs
|
||||
const __fn_state = fileURLToPath(import.meta.url);
|
||||
export const projectRoot = pathResolve(dirname(__fn_state), '..', '..', '..', '..', '..', '..');
|
||||
|
||||
/** Resolve a user-provided path relative to the project root (not cwd). */
|
||||
export const resolveProjectPath = (p) => pathResolve(projectRoot, p);
|
||||
|
||||
// ──────────────────────────────────────────────────────────────────────────
|
||||
// Mutable single-session state. Importers read via the live binding; writes
|
||||
// must go through the corresponding setter (ESM imports are read-only).
|
||||
// ──────────────────────────────────────────────────────────────────────────
|
||||
|
||||
export let browser = null;
|
||||
export let page = null;
|
||||
export let sessionPrefix = null; // e.g. "http://localhost:8081/bpdemo/ru_RU"
|
||||
export let seanceId = null;
|
||||
export let recorder = null; // { cdp, ffmpeg, startTime, outputPath, ffmpegError, captions }
|
||||
export let lastCaptions = []; // captions from the last completed recording (for addNarration)
|
||||
export let lastRecordingDuration = null; // wall-clock duration of the last recording (seconds)
|
||||
export let highlightMode = false;
|
||||
export let persistentUserDataDir = null; // temp dir for launchPersistentContext, cleaned on disconnect
|
||||
|
||||
// Clipboard preservation: save full clipboard contents (all MIME types) right
|
||||
// before each writeText+Ctrl+V pair, restore right after. Toggled via
|
||||
// setPreserveClipboard() from run.mjs.
|
||||
export let preserveClipboard = true;
|
||||
export let clipboardWarnLogged = false;
|
||||
|
||||
export const setBrowser = (v) => { browser = v; };
|
||||
export const setPage = (v) => { page = v; };
|
||||
export const setSessionPrefix = (v) => { sessionPrefix = v; };
|
||||
export const setSeanceId = (v) => { seanceId = v; };
|
||||
export const setRecorder = (v) => { recorder = v; };
|
||||
export const setLastCaptions = (v) => { lastCaptions = v; };
|
||||
export const setLastRecordingDuration = (v) => { lastRecordingDuration = v; };
|
||||
export const setHighlightMode = (v) => { highlightMode = !!v; };
|
||||
export const setPersistentUserDataDir = (v) => { persistentUserDataDir = v; };
|
||||
export const setPreserveClipboard = (v) => { preserveClipboard = !!v; };
|
||||
export const setClipboardWarnLogged = (v) => { clipboardWarnLogged = !!v; };
|
||||
|
||||
// ──────────────────────────────────────────────────────────────────────────
|
||||
// Multi-context registry: name → { context, page, sessionPrefix, seanceId,
|
||||
// recorder, lastCaptions, lastRecordingDuration, highlightMode }.
|
||||
// Populated by createContext(); module-level vars above mirror the active
|
||||
// slot. connect() does NOT use this Map — it preserves legacy single-session
|
||||
// behavior for exec/run/start.
|
||||
// ──────────────────────────────────────────────────────────────────────────
|
||||
|
||||
export const contexts = new Map();
|
||||
export let activeContextName = null;
|
||||
// Isolation mode for the current cmdTest session — set by the first
|
||||
// createContext call. 'tab': all contexts share one persistent context
|
||||
// (one window, multiple tabs, extension loads reliably). 'window': each
|
||||
// context gets its own BrowserContext (separate window per context, full
|
||||
// cookie isolation, extension may not load).
|
||||
export let activeMode = null;
|
||||
|
||||
export const setActiveContextName = (v) => { activeContextName = v; };
|
||||
export const setActiveMode = (v) => { activeMode = v; };
|
||||
|
||||
// ──────────────────────────────────────────────────────────────────────────
|
||||
// Constants.
|
||||
// ──────────────────────────────────────────────────────────────────────────
|
||||
|
||||
export const LOAD_TIMEOUT = 60000;
|
||||
export const INIT_TIMEOUT = 60000;
|
||||
export const ACTION_WAIT = 2000; // fallback minimum wait
|
||||
export const MAX_WAIT = 10000; // max wait for stability
|
||||
export const POLL_INTERVAL = 200; // polling interval
|
||||
export const STABLE_CYCLES = 3; // consecutive stable cycles needed
|
||||
|
||||
// 1C browser extension ID (stable across versions, defined by key in manifest.json)
|
||||
export const EXT_ID = 'pbhelknnhilelbnhfpcjlcabhmfangik';
|
||||
|
||||
// ──────────────────────────────────────────────────────────────────────────
|
||||
// Utilities that only depend on state.
|
||||
// ──────────────────────────────────────────────────────────────────────────
|
||||
|
||||
/** Normalize ё→е and →space for fuzzy matching. */
|
||||
export const normYo = (s) => s.replace(/ё/gi, 'е').replace(/ /g, ' ');
|
||||
|
||||
/** Check if browser is connected and page is usable. */
|
||||
export function isConnected() {
|
||||
if (!browser || !page || page.isClosed()) return false;
|
||||
// launchPersistentContext returns BrowserContext (no isConnected), launch returns Browser
|
||||
if (typeof browser.isConnected === 'function') return browser.isConnected();
|
||||
// For persistent context, check via context's browser()
|
||||
return browser.browser()?.isConnected() ?? false;
|
||||
}
|
||||
|
||||
export function ensureConnected() {
|
||||
if (!isConnected()) {
|
||||
throw new Error('Browser not connected. Call web_connect first.');
|
||||
}
|
||||
}
|
||||
|
||||
/** Get the raw Playwright page object (for advanced scripting in skill mode). */
|
||||
export function getPage() {
|
||||
ensureConnected();
|
||||
return page;
|
||||
}
|
||||
// web-test core/state v1.17 — module-level state for the web-test engine.
|
||||
// Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
|
||||
//
|
||||
// Holds the single browser/page/recorder slot plus the multi-context registry,
|
||||
// constants, and small state-only utilities (ensureConnected, getPage,
|
||||
// resolveProjectPath, normYo). Mutable values are exported as `let` bindings
|
||||
// for live read access from consumer modules; writes go through setters so
|
||||
// imported bindings stay read-only at the import site.
|
||||
|
||||
import { dirname, resolve as pathResolve } from 'path';
|
||||
import { fileURLToPath } from 'url';
|
||||
|
||||
// Project root: 6 levels up from .claude/skills/web-test/scripts/engine/core/state.mjs
|
||||
const __fn_state = fileURLToPath(import.meta.url);
|
||||
export const projectRoot = pathResolve(dirname(__fn_state), '..', '..', '..', '..', '..', '..');
|
||||
|
||||
/** Resolve a user-provided path relative to the project root (not cwd). */
|
||||
export const resolveProjectPath = (p) => pathResolve(projectRoot, p);
|
||||
|
||||
// ──────────────────────────────────────────────────────────────────────────
|
||||
// Mutable single-session state. Importers read via the live binding; writes
|
||||
// must go through the corresponding setter (ESM imports are read-only).
|
||||
// ──────────────────────────────────────────────────────────────────────────
|
||||
|
||||
export let browser = null;
|
||||
export let page = null;
|
||||
export let sessionPrefix = null; // e.g. "http://localhost:8081/bpdemo/ru_RU"
|
||||
export let seanceId = null;
|
||||
export let recorder = null; // { cdp, ffmpeg, startTime, outputPath, ffmpegError, captions }
|
||||
export let lastCaptions = []; // captions from the last completed recording (for addNarration)
|
||||
export let lastRecordingDuration = null; // wall-clock duration of the last recording (seconds)
|
||||
export let highlightMode = false;
|
||||
export let persistentUserDataDir = null; // temp dir for launchPersistentContext, cleaned on disconnect
|
||||
|
||||
// Clipboard preservation: save full clipboard contents (all MIME types) right
|
||||
// before each writeText+Ctrl+V pair, restore right after. Toggled via
|
||||
// setPreserveClipboard() from run.mjs.
|
||||
export let preserveClipboard = true;
|
||||
export let clipboardWarnLogged = false;
|
||||
|
||||
export const setBrowser = (v) => { browser = v; };
|
||||
export const setPage = (v) => { page = v; };
|
||||
export const setSessionPrefix = (v) => { sessionPrefix = v; };
|
||||
export const setSeanceId = (v) => { seanceId = v; };
|
||||
export const setRecorder = (v) => { recorder = v; };
|
||||
export const setLastCaptions = (v) => { lastCaptions = v; };
|
||||
export const setLastRecordingDuration = (v) => { lastRecordingDuration = v; };
|
||||
export const setHighlightMode = (v) => { highlightMode = !!v; };
|
||||
export const setPersistentUserDataDir = (v) => { persistentUserDataDir = v; };
|
||||
export const setPreserveClipboard = (v) => { preserveClipboard = !!v; };
|
||||
export const setClipboardWarnLogged = (v) => { clipboardWarnLogged = !!v; };
|
||||
|
||||
// ──────────────────────────────────────────────────────────────────────────
|
||||
// Multi-context registry: name → { context, page, sessionPrefix, seanceId,
|
||||
// recorder, lastCaptions, lastRecordingDuration, highlightMode }.
|
||||
// Populated by createContext(); module-level vars above mirror the active
|
||||
// slot. connect() does NOT use this Map — it preserves legacy single-session
|
||||
// behavior for exec/run/start.
|
||||
// ──────────────────────────────────────────────────────────────────────────
|
||||
|
||||
export const contexts = new Map();
|
||||
export let activeContextName = null;
|
||||
// Isolation mode for the current cmdTest session — set by the first
|
||||
// createContext call. 'tab': all contexts share one persistent context
|
||||
// (one window, multiple tabs, extension loads reliably). 'window': each
|
||||
// context gets its own BrowserContext (separate window per context, full
|
||||
// cookie isolation, extension may not load).
|
||||
export let activeMode = null;
|
||||
|
||||
export const setActiveContextName = (v) => { activeContextName = v; };
|
||||
export const setActiveMode = (v) => { activeMode = v; };
|
||||
|
||||
// ──────────────────────────────────────────────────────────────────────────
|
||||
// Constants.
|
||||
// ──────────────────────────────────────────────────────────────────────────
|
||||
|
||||
export const LOAD_TIMEOUT = 60000;
|
||||
export const INIT_TIMEOUT = 60000;
|
||||
export const ACTION_WAIT = 2000; // fallback minimum wait
|
||||
export const MAX_WAIT = 10000; // max wait for stability
|
||||
export const POLL_INTERVAL = 200; // polling interval
|
||||
export const STABLE_CYCLES = 3; // consecutive stable cycles needed
|
||||
|
||||
// 1C browser extension ID (stable across versions, defined by key in manifest.json)
|
||||
export const EXT_ID = 'pbhelknnhilelbnhfpcjlcabhmfangik';
|
||||
|
||||
// ──────────────────────────────────────────────────────────────────────────
|
||||
// Utilities that only depend on state.
|
||||
// ──────────────────────────────────────────────────────────────────────────
|
||||
|
||||
/** Normalize ё→е and →space for fuzzy matching. */
|
||||
export const normYo = (s) => s.replace(/ё/gi, 'е').replace(/ /g, ' ');
|
||||
|
||||
/** Check if browser is connected and page is usable. */
|
||||
export function isConnected() {
|
||||
if (!browser || !page || page.isClosed()) return false;
|
||||
// launchPersistentContext returns BrowserContext (no isConnected), launch returns Browser
|
||||
if (typeof browser.isConnected === 'function') return browser.isConnected();
|
||||
// For persistent context, check via context's browser()
|
||||
return browser.browser()?.isConnected() ?? false;
|
||||
}
|
||||
|
||||
export function ensureConnected() {
|
||||
if (!isConnected()) {
|
||||
throw new Error('Browser not connected. Call web_connect first.');
|
||||
}
|
||||
}
|
||||
|
||||
/** Get the raw Playwright page object (for advanced scripting in skill mode). */
|
||||
export function getPage() {
|
||||
ensureConnected();
|
||||
return page;
|
||||
}
|
||||
|
||||
@@ -1,123 +1,123 @@
|
||||
// web-test core/wait v1.17 — 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;
|
||||
}
|
||||
// web-test core/wait v1.17 — 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;
|
||||
}
|
||||
|
||||
@@ -1,56 +1,56 @@
|
||||
// web-test forms/close v1.18 — Close current form via Escape, handle save-changes confirmation.
|
||||
// Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
|
||||
|
||||
import { page, recorder, ensureConnected } from '../core/state.mjs';
|
||||
import { detectFormScript } from '../../dom.mjs';
|
||||
import { dismissPendingErrors, checkForErrors, detectPlatformDialogs, closePlatformDialogs } from '../core/errors.mjs';
|
||||
import { waitForStable } from '../core/wait.mjs';
|
||||
import { returnFormState } from '../core/helpers.mjs';
|
||||
import { getFormState } from './state.mjs';
|
||||
|
||||
/**
|
||||
* Close the current form/dialog via Escape.
|
||||
* @param {Object} [opts]
|
||||
* @param {boolean} [opts.save] - Handle "Save changes?" confirmation automatically:
|
||||
* true → click "Да" (save and close)
|
||||
* false → click "Нет" (discard and close)
|
||||
* undefined → return confirmation as hint for caller to decide
|
||||
*/
|
||||
export async function closeForm({ save } = {}) {
|
||||
ensureConnected();
|
||||
await dismissPendingErrors();
|
||||
// If platform dialogs are open, close them instead of pressing Escape
|
||||
const pd = await detectPlatformDialogs();
|
||||
if (pd.length) {
|
||||
await closePlatformDialogs();
|
||||
await page.waitForTimeout(300);
|
||||
return returnFormState({ closed: true, closedPlatformDialogs: pd });
|
||||
}
|
||||
const beforeForm = await page.evaluate(detectFormScript());
|
||||
await page.keyboard.press('Escape');
|
||||
await waitForStable(beforeForm);
|
||||
const state = await getFormState();
|
||||
const err = await checkForErrors();
|
||||
if (err?.confirmation) {
|
||||
if (save === true || save === false) {
|
||||
const label = save ? 'Да' : 'Нет';
|
||||
const btnSel = `#form${err.confirmation.formNum}_container a.press.pressButton`;
|
||||
const btns = await page.$$(btnSel);
|
||||
for (const b of btns) {
|
||||
const txt = (await b.textContent()).trim();
|
||||
if (txt === label) {
|
||||
if (recorder) await page.waitForTimeout(500); // show confirmation to viewer during recording
|
||||
await b.click({ force: true });
|
||||
await waitForStable(beforeForm);
|
||||
break;
|
||||
}
|
||||
}
|
||||
const afterForm = await page.evaluate(detectFormScript());
|
||||
return returnFormState({ closed: afterForm !== beforeForm });
|
||||
}
|
||||
state.confirmation = err.confirmation;
|
||||
state.hint = 'Confirmation dialog shown. Click "Да" to confirm or "Нет" to cancel';
|
||||
return state;
|
||||
}
|
||||
return returnFormState({ closed: state.form !== beforeForm });
|
||||
}
|
||||
// web-test forms/close v1.18 — Close current form via Escape, handle save-changes confirmation.
|
||||
// Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
|
||||
|
||||
import { page, recorder, ensureConnected } from '../core/state.mjs';
|
||||
import { detectFormScript } from '../../dom.mjs';
|
||||
import { dismissPendingErrors, checkForErrors, detectPlatformDialogs, closePlatformDialogs } from '../core/errors.mjs';
|
||||
import { waitForStable } from '../core/wait.mjs';
|
||||
import { returnFormState } from '../core/helpers.mjs';
|
||||
import { getFormState } from './state.mjs';
|
||||
|
||||
/**
|
||||
* Close the current form/dialog via Escape.
|
||||
* @param {Object} [opts]
|
||||
* @param {boolean} [opts.save] - Handle "Save changes?" confirmation automatically:
|
||||
* true → click "Да" (save and close)
|
||||
* false → click "Нет" (discard and close)
|
||||
* undefined → return confirmation as hint for caller to decide
|
||||
*/
|
||||
export async function closeForm({ save } = {}) {
|
||||
ensureConnected();
|
||||
await dismissPendingErrors();
|
||||
// If platform dialogs are open, close them instead of pressing Escape
|
||||
const pd = await detectPlatformDialogs();
|
||||
if (pd.length) {
|
||||
await closePlatformDialogs();
|
||||
await page.waitForTimeout(300);
|
||||
return returnFormState({ closed: true, closedPlatformDialogs: pd });
|
||||
}
|
||||
const beforeForm = await page.evaluate(detectFormScript());
|
||||
await page.keyboard.press('Escape');
|
||||
await waitForStable(beforeForm);
|
||||
const state = await getFormState();
|
||||
const err = await checkForErrors();
|
||||
if (err?.confirmation) {
|
||||
if (save === true || save === false) {
|
||||
const label = save ? 'Да' : 'Нет';
|
||||
const btnSel = `#form${err.confirmation.formNum}_container a.press.pressButton`;
|
||||
const btns = await page.$$(btnSel);
|
||||
for (const b of btns) {
|
||||
const txt = (await b.textContent()).trim();
|
||||
if (txt === label) {
|
||||
if (recorder) await page.waitForTimeout(500); // show confirmation to viewer during recording
|
||||
await b.click({ force: true });
|
||||
await waitForStable(beforeForm);
|
||||
break;
|
||||
}
|
||||
}
|
||||
const afterForm = await page.evaluate(detectFormScript());
|
||||
return returnFormState({ closed: afterForm !== beforeForm });
|
||||
}
|
||||
state.confirmation = err.confirmation;
|
||||
state.hint = 'Confirmation dialog shown. Click "Да" to confirm or "Нет" to cancel';
|
||||
return state;
|
||||
}
|
||||
return returnFormState({ closed: state.form !== beforeForm });
|
||||
}
|
||||
|
||||
@@ -1,147 +1,147 @@
|
||||
// web-test forms/fill v1.19 — Fill form fields by name (text/checkbox/date/number/dropdown/reference). Delegates references to selectValue / fillReferenceField.
|
||||
// Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
|
||||
|
||||
import {
|
||||
page, ensureConnected, ACTION_WAIT, highlightMode, normYo,
|
||||
} from '../core/state.mjs';
|
||||
import {
|
||||
detectFormScript, resolveFieldsScript,
|
||||
} from '../../dom.mjs';
|
||||
import { dismissPendingErrors, checkForErrors } from '../core/errors.mjs';
|
||||
import { waitForStable, startNetworkMonitor } from '../core/wait.mjs';
|
||||
import { highlight, unhighlight } from '../recording/highlight.mjs';
|
||||
import {
|
||||
fillReferenceField, selectValue, pickFromSelectionForm,
|
||||
isTypeDialog, pickFromTypeDialog,
|
||||
} from './select-value.mjs';
|
||||
import { pasteText } from '../core/clipboard.mjs';
|
||||
import { returnFormState } from '../core/helpers.mjs';
|
||||
|
||||
/** Fill fields on the current form via Playwright page.fill(). Returns fill results + updated form. */
|
||||
export async function fillFields(fields) {
|
||||
ensureConnected();
|
||||
await dismissPendingErrors();
|
||||
const formNum = await page.evaluate(detectFormScript());
|
||||
if (formNum === null) throw new Error('fillFields: no form found');
|
||||
|
||||
// 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;
|
||||
}
|
||||
// Auto-highlight the field input before filling
|
||||
if (highlightMode && r.inputId) {
|
||||
try {
|
||||
await page.evaluate(({ id }) => {
|
||||
const target = document.getElementById(id);
|
||||
if (!target) return;
|
||||
let div = document.getElementById('__web_test_highlight');
|
||||
if (!div) { div = document.createElement('div'); div.id = '__web_test_highlight'; document.body.appendChild(div); }
|
||||
const r = target.getBoundingClientRect();
|
||||
div.style.cssText = 'position:fixed;pointer-events:none;z-index:999998;top:' + (r.y-4) + 'px;left:' + (r.x-4) + 'px;width:' + (r.width+8) + 'px;height:' + (r.height+8) + 'px;outline:3px solid #e74c3c;border-radius:4px;box-shadow:0 0 16px #e74c3c80';
|
||||
}, { id: r.inputId });
|
||||
await page.waitForTimeout(500);
|
||||
await unhighlight();
|
||||
} catch {}
|
||||
}
|
||||
try {
|
||||
// Auto-enable DCS checkbox if resolved via label
|
||||
if (r.dcsCheckbox && !r.dcsCheckbox.checked) {
|
||||
await page.click(`[id="${r.dcsCheckbox.inputId}"]`);
|
||||
await waitForStable();
|
||||
}
|
||||
const selector = `[id="${r.inputId}"]`;
|
||||
// Clear field via Shift+F4 if value is empty (not applicable to checkbox/radio)
|
||||
const rawValue = fields[r.field];
|
||||
const isEmpty = rawValue === '' || rawValue === null || rawValue === undefined;
|
||||
if (isEmpty && !r.isCheckbox && !r.isRadio) {
|
||||
await page.click(selector);
|
||||
await page.waitForTimeout(200);
|
||||
await page.keyboard.press('Shift+F4');
|
||||
await page.waitForTimeout(300);
|
||||
await page.keyboard.press('Tab');
|
||||
await waitForStable();
|
||||
results.push({ field: r.field, ok: true, value: '', method: 'clear' });
|
||||
continue;
|
||||
}
|
||||
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 = normYo(String(fields[r.field]).toLowerCase());
|
||||
const opt = r.options.find(o => normYo(o.label.toLowerCase()) === desired)
|
||||
|| r.options.find(o => normYo(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) {
|
||||
// Combobox/reference with DLB: DLB-first, then paste fallback
|
||||
const refResult = await fillReferenceField(selector, r.field, fields[r.field], formNum);
|
||||
results.push(refResult);
|
||||
} else if (r.hasPick && (r.isDate || r.isCalc)) {
|
||||
// Date/time (calendar CB) or numeric (calculator CB) field — use paste:
|
||||
// the pick button is a calendar/calculator widget, not a selection form.
|
||||
await page.click(selector);
|
||||
await page.waitForTimeout(200);
|
||||
await page.keyboard.press('Control+A');
|
||||
await pasteText(fields[r.field]);
|
||||
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' });
|
||||
} else if (r.hasPick) {
|
||||
// Reference field with CB (non-editable or editable ref): delegate to selectValue (F4 → selection form)
|
||||
const svResult = await selectValue(r.field, String(fields[r.field]));
|
||||
if (svResult?.error) {
|
||||
results.push({ field: r.field, error: svResult.error, message: svResult.message });
|
||||
} else {
|
||||
results.push({ field: r.field, ok: true, value: svResult.value || String(fields[r.field]), method: svResult.method || 'form' });
|
||||
}
|
||||
} 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 pasteText(fields[r.field]);
|
||||
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 });
|
||||
}
|
||||
if (highlightMode) try { await unhighlight(); } catch {}
|
||||
}
|
||||
|
||||
const failed = results.filter(r => r.error);
|
||||
if (failed.length > 0) {
|
||||
const details = failed.map(f => ` ${f.field}: ${f.message || f.error}${f.available ? ' (available: ' + f.available.join(', ') + ')' : ''}`).join('\n');
|
||||
throw new Error(`fillFields: ${failed.length} of ${results.length} field(s) failed:\n${details}`);
|
||||
}
|
||||
return returnFormState({ filled: results });
|
||||
}
|
||||
|
||||
/** Convenience alias: fill a single field. Same as fillFields({ name: value }). */
|
||||
export async function fillField(name, value) {
|
||||
return fillFields({ [name]: value });
|
||||
}
|
||||
// web-test forms/fill v1.19 — Fill form fields by name (text/checkbox/date/number/dropdown/reference). Delegates references to selectValue / fillReferenceField.
|
||||
// Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
|
||||
|
||||
import {
|
||||
page, ensureConnected, ACTION_WAIT, highlightMode, normYo,
|
||||
} from '../core/state.mjs';
|
||||
import {
|
||||
detectFormScript, resolveFieldsScript,
|
||||
} from '../../dom.mjs';
|
||||
import { dismissPendingErrors, checkForErrors } from '../core/errors.mjs';
|
||||
import { waitForStable, startNetworkMonitor } from '../core/wait.mjs';
|
||||
import { highlight, unhighlight } from '../recording/highlight.mjs';
|
||||
import {
|
||||
fillReferenceField, selectValue, pickFromSelectionForm,
|
||||
isTypeDialog, pickFromTypeDialog,
|
||||
} from './select-value.mjs';
|
||||
import { pasteText } from '../core/clipboard.mjs';
|
||||
import { returnFormState } from '../core/helpers.mjs';
|
||||
|
||||
/** Fill fields on the current form via Playwright page.fill(). Returns fill results + updated form. */
|
||||
export async function fillFields(fields) {
|
||||
ensureConnected();
|
||||
await dismissPendingErrors();
|
||||
const formNum = await page.evaluate(detectFormScript());
|
||||
if (formNum === null) throw new Error('fillFields: no form found');
|
||||
|
||||
// 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;
|
||||
}
|
||||
// Auto-highlight the field input before filling
|
||||
if (highlightMode && r.inputId) {
|
||||
try {
|
||||
await page.evaluate(({ id }) => {
|
||||
const target = document.getElementById(id);
|
||||
if (!target) return;
|
||||
let div = document.getElementById('__web_test_highlight');
|
||||
if (!div) { div = document.createElement('div'); div.id = '__web_test_highlight'; document.body.appendChild(div); }
|
||||
const r = target.getBoundingClientRect();
|
||||
div.style.cssText = 'position:fixed;pointer-events:none;z-index:999998;top:' + (r.y-4) + 'px;left:' + (r.x-4) + 'px;width:' + (r.width+8) + 'px;height:' + (r.height+8) + 'px;outline:3px solid #e74c3c;border-radius:4px;box-shadow:0 0 16px #e74c3c80';
|
||||
}, { id: r.inputId });
|
||||
await page.waitForTimeout(500);
|
||||
await unhighlight();
|
||||
} catch {}
|
||||
}
|
||||
try {
|
||||
// Auto-enable DCS checkbox if resolved via label
|
||||
if (r.dcsCheckbox && !r.dcsCheckbox.checked) {
|
||||
await page.click(`[id="${r.dcsCheckbox.inputId}"]`);
|
||||
await waitForStable();
|
||||
}
|
||||
const selector = `[id="${r.inputId}"]`;
|
||||
// Clear field via Shift+F4 if value is empty (not applicable to checkbox/radio)
|
||||
const rawValue = fields[r.field];
|
||||
const isEmpty = rawValue === '' || rawValue === null || rawValue === undefined;
|
||||
if (isEmpty && !r.isCheckbox && !r.isRadio) {
|
||||
await page.click(selector);
|
||||
await page.waitForTimeout(200);
|
||||
await page.keyboard.press('Shift+F4');
|
||||
await page.waitForTimeout(300);
|
||||
await page.keyboard.press('Tab');
|
||||
await waitForStable();
|
||||
results.push({ field: r.field, ok: true, value: '', method: 'clear' });
|
||||
continue;
|
||||
}
|
||||
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 = normYo(String(fields[r.field]).toLowerCase());
|
||||
const opt = r.options.find(o => normYo(o.label.toLowerCase()) === desired)
|
||||
|| r.options.find(o => normYo(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) {
|
||||
// Combobox/reference with DLB: DLB-first, then paste fallback
|
||||
const refResult = await fillReferenceField(selector, r.field, fields[r.field], formNum);
|
||||
results.push(refResult);
|
||||
} else if (r.hasPick && (r.isDate || r.isCalc)) {
|
||||
// Date/time (calendar CB) or numeric (calculator CB) field — use paste:
|
||||
// the pick button is a calendar/calculator widget, not a selection form.
|
||||
await page.click(selector);
|
||||
await page.waitForTimeout(200);
|
||||
await page.keyboard.press('Control+A');
|
||||
await pasteText(fields[r.field]);
|
||||
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' });
|
||||
} else if (r.hasPick) {
|
||||
// Reference field with CB (non-editable or editable ref): delegate to selectValue (F4 → selection form)
|
||||
const svResult = await selectValue(r.field, String(fields[r.field]));
|
||||
if (svResult?.error) {
|
||||
results.push({ field: r.field, error: svResult.error, message: svResult.message });
|
||||
} else {
|
||||
results.push({ field: r.field, ok: true, value: svResult.value || String(fields[r.field]), method: svResult.method || 'form' });
|
||||
}
|
||||
} 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 pasteText(fields[r.field]);
|
||||
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 });
|
||||
}
|
||||
if (highlightMode) try { await unhighlight(); } catch {}
|
||||
}
|
||||
|
||||
const failed = results.filter(r => r.error);
|
||||
if (failed.length > 0) {
|
||||
const details = failed.map(f => ` ${f.field}: ${f.message || f.error}${f.available ? ' (available: ' + f.available.join(', ') + ')' : ''}`).join('\n');
|
||||
throw new Error(`fillFields: ${failed.length} of ${results.length} field(s) failed:\n${details}`);
|
||||
}
|
||||
return returnFormState({ filled: results });
|
||||
}
|
||||
|
||||
/** Convenience alias: fill a single field. Same as fillFields({ name: value }). */
|
||||
export async function fillField(name, value) {
|
||||
return fillFields({ [name]: value });
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,32 +1,32 @@
|
||||
// web-test engine/forms/state v1.17 — central form-state reader.
|
||||
// Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
|
||||
//
|
||||
// getFormState — the canonical "what's on the screen right now" call. Combines:
|
||||
// 1. DOM script (getFormStateScript) → form structure (fields, buttons, tables, openForms, ...)
|
||||
// 2. checkForErrors → state.errors + state.confirmation hint
|
||||
// 3. detectPlatformDialogs → state.platformDialogs (About / Support Info / Error Report)
|
||||
//
|
||||
// Returned by virtually every action-function as the "after" snapshot.
|
||||
|
||||
import { page, ensureConnected } from '../core/state.mjs';
|
||||
import { getFormStateScript } from '../../dom.mjs';
|
||||
import { checkForErrors, detectPlatformDialogs } from '../core/errors.mjs';
|
||||
|
||||
/** 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';
|
||||
}
|
||||
}
|
||||
// Detect platform-level dialogs (About, Support Info, Error Report)
|
||||
// These are NOT 1C forms — invisible to detectForms() and not closeable via Escape.
|
||||
const pd = await detectPlatformDialogs();
|
||||
if (pd.length) state.platformDialogs = pd;
|
||||
return state;
|
||||
}
|
||||
// web-test engine/forms/state v1.17 — central form-state reader.
|
||||
// Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
|
||||
//
|
||||
// getFormState — the canonical "what's on the screen right now" call. Combines:
|
||||
// 1. DOM script (getFormStateScript) → form structure (fields, buttons, tables, openForms, ...)
|
||||
// 2. checkForErrors → state.errors + state.confirmation hint
|
||||
// 3. detectPlatformDialogs → state.platformDialogs (About / Support Info / Error Report)
|
||||
//
|
||||
// Returned by virtually every action-function as the "after" snapshot.
|
||||
|
||||
import { page, ensureConnected } from '../core/state.mjs';
|
||||
import { getFormStateScript } from '../../dom.mjs';
|
||||
import { checkForErrors, detectPlatformDialogs } from '../core/errors.mjs';
|
||||
|
||||
/** 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';
|
||||
}
|
||||
}
|
||||
// Detect platform-level dialogs (About, Support Info, Error Report)
|
||||
// These are NOT 1C forms — invisible to detectForms() and not closeable via Escape.
|
||||
const pd = await detectPlatformDialogs();
|
||||
if (pd.length) state.platformDialogs = pd;
|
||||
return state;
|
||||
}
|
||||
|
||||
@@ -1,253 +1,253 @@
|
||||
// web-test nav/navigation v1.17 — Section navigation, openCommand, switchTab, navigateLink (Shift+F11), openFile.
|
||||
// Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
|
||||
|
||||
import {
|
||||
page, ensureConnected, highlightMode, resolveProjectPath,
|
||||
} from '../core/state.mjs';
|
||||
import {
|
||||
readSectionsScript, readTabsScript, readCommandsScript,
|
||||
navigateSectionScript, openCommandScript, switchTabScript,
|
||||
detectFormScript,
|
||||
} from '../../dom.mjs';
|
||||
import { dismissPendingErrors, checkForErrors } from '../core/errors.mjs';
|
||||
import { waitForStable, waitForCondition } from '../core/wait.mjs';
|
||||
import { highlight, unhighlight } from '../recording/highlight.mjs';
|
||||
import { returnFormState } from '../core/helpers.mjs';
|
||||
// Static import — ESM cycle that resolves at call time.
|
||||
import { pasteText } from '../core/clipboard.mjs';
|
||||
import { getFormState } from '../forms/state.mjs';
|
||||
|
||||
/**
|
||||
* 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();
|
||||
await dismissPendingErrors();
|
||||
if (highlightMode) try { await highlight(name); await page.waitForTimeout(500); await unhighlight(); } catch {}
|
||||
const result = await page.evaluate(navigateSectionScript(name));
|
||||
if (result?.error) {
|
||||
const avail = result.available?.filter(Boolean);
|
||||
if (avail?.length === 0) throw new Error(`navigateSection: "${name}" not found. Section panel is in icon-only mode — text labels are hidden. Switch to "Text" or "Picture and text" display mode in 1C settings (View → Section Panel → Display Mode)`);
|
||||
throw new Error(`navigateSection: "${name}" not found. Available: ${avail?.join(', ') || 'none'}`);
|
||||
}
|
||||
|
||||
await waitForStable();
|
||||
const { sections, commands } = await page.evaluate(`({
|
||||
sections: ${readSectionsScript()},
|
||||
commands: ${readCommandsScript()}
|
||||
})`);
|
||||
return returnFormState({ 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();
|
||||
await dismissPendingErrors();
|
||||
if (highlightMode) try { await highlight(name); await page.waitForTimeout(500); await unhighlight(); } catch {}
|
||||
const formBefore = await page.evaluate(detectFormScript());
|
||||
const result = await page.evaluate(openCommandScript(name));
|
||||
if (result?.error) throw new Error(`openCommand: "${name}" not found. Available: ${result.available?.join(', ') || 'none'}`);
|
||||
|
||||
await waitForStable(formBefore);
|
||||
return await returnFormState();
|
||||
}
|
||||
|
||||
/** 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) throw new Error(`switchTab: "${name}" not found. Available: ${result.available?.join(', ') || 'none'}`);
|
||||
await waitForStable();
|
||||
return returnFormState();
|
||||
}
|
||||
|
||||
// English → Russian metadata type mapping for e1cib navigation links
|
||||
const E1CIB_TYPE_MAP = {
|
||||
'catalog': 'Справочник', 'catalogs': 'Справочник',
|
||||
'document': 'Документ', 'documents': 'Документ',
|
||||
'commonmodule': 'ОбщийМодуль',
|
||||
'enum': 'Перечисление', 'enums': 'Перечисление',
|
||||
'dataprocessor': 'Обработка', 'dataprocessors': 'Обработка',
|
||||
'report': 'Отчет', 'reports': 'Отчет',
|
||||
'accumulationregister': 'РегистрНакопления',
|
||||
'informationregister': 'РегистрСведений',
|
||||
'accountingregister': 'РегистрБухгалтерии',
|
||||
'calculationregister': 'РегистрРасчета',
|
||||
'chartofaccounts': 'ПланСчетов',
|
||||
'chartofcharacteristictypes': 'ПланВидовХарактеристик',
|
||||
'chartofcalculationtypes': 'ПланВидовРасчета',
|
||||
'businessprocess': 'БизнесПроцесс',
|
||||
'task': 'Задача',
|
||||
'exchangeplan': 'ПланОбмена',
|
||||
'constant': 'Константа',
|
||||
};
|
||||
|
||||
// Types that open via e1cib/app/ (reports and data processors have their own app forms)
|
||||
const E1CIB_APP_TYPES = new Set(['Отчет', 'Обработка']);
|
||||
|
||||
function normalizeE1cibUrl(url) {
|
||||
// Already a full e1cib link
|
||||
if (url.startsWith('e1cib/')) return url;
|
||||
// "ТипОбъекта.Имя" or "EnglishType.Имя" — translate type, pick list/ or app/ prefix
|
||||
const dot = url.indexOf('.');
|
||||
if (dot > 0) {
|
||||
const typePart = url.substring(0, dot);
|
||||
const namePart = url.substring(dot + 1);
|
||||
const ruType = E1CIB_TYPE_MAP[typePart.toLowerCase()] || typePart;
|
||||
const prefix = E1CIB_APP_TYPES.has(ruType) ? 'e1cib/app' : 'e1cib/list';
|
||||
return `${prefix}/${ruType}.${namePart}`;
|
||||
}
|
||||
return `e1cib/list/${url}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Open an external data processor or report (EPF/ERF) via File → Open menu.
|
||||
* Handles the security confirmation dialog on first open.
|
||||
* @param {string} filePath - path to EPF/ERF file (absolute or relative to cwd)
|
||||
* @returns {Promise<object>} form state of the opened processor/report
|
||||
*/
|
||||
export async function openFile(filePath) {
|
||||
ensureConnected();
|
||||
await dismissPendingErrors();
|
||||
const absPath = resolveProjectPath(filePath.replace(/\\/g, '/'));
|
||||
|
||||
const MAX_ATTEMPTS = 2; // 1st may trigger security dialog, 2nd is the real open
|
||||
for (let attempt = 0; attempt < MAX_ATTEMPTS; attempt++) {
|
||||
const formBefore = await page.evaluate(detectFormScript());
|
||||
|
||||
// 1. Ctrl+O opens 1C's "Выбор файлов" dialog
|
||||
await page.keyboard.press('Control+o');
|
||||
|
||||
// 2. Wait for the file selection dialog
|
||||
const dialogOk = await waitForCondition(`(() => {
|
||||
const ok = document.querySelector('#fileSelectDialogOk');
|
||||
return ok && ok.offsetWidth > 0 ? true : false;
|
||||
})()`, 3000);
|
||||
if (!dialogOk) throw new Error("File selection dialog did not open (Ctrl+O)");
|
||||
|
||||
// 3. Click "выберите с диска" to trigger the native OS file picker
|
||||
let fileChooser;
|
||||
try {
|
||||
[fileChooser] = await Promise.all([
|
||||
page.waitForEvent('filechooser', { timeout: 5000 }),
|
||||
page.click('a.underline.pointer'),
|
||||
]);
|
||||
} catch (e) {
|
||||
// Try closing the dialog before throwing
|
||||
await page.keyboard.press('Escape');
|
||||
throw new Error(`File chooser did not appear: ${e.message}`);
|
||||
}
|
||||
|
||||
// 4. Set the file path and click OK
|
||||
await fileChooser.setFiles(absPath);
|
||||
await page.waitForTimeout(500);
|
||||
await page.click('#fileSelectDialogOk');
|
||||
await waitForStable(formBefore);
|
||||
|
||||
// 5. Check for security dialog
|
||||
const err = await checkForErrors();
|
||||
if (err?.confirmation) {
|
||||
// Security confirmation — click the positive button (Продолжить/Да/OK)
|
||||
const positiveBtn = err.confirmation.buttons.find(b =>
|
||||
/продолжить|да|ok|yes|открыть/i.test(b)
|
||||
) || err.confirmation.buttons[0];
|
||||
if (positiveBtn) {
|
||||
const btns = await page.$$(`#form${err.confirmation.formNum}_container a.press.pressButton`);
|
||||
for (const b of btns) {
|
||||
const txt = (await b.textContent())?.trim();
|
||||
if (txt === positiveBtn) { await b.click(); break; }
|
||||
}
|
||||
await waitForStable(formBefore);
|
||||
}
|
||||
// After confirmation, check if EPF form appeared or a follow-up dialog showed.
|
||||
// Check form change FIRST — avoids confusing a small EPF form with a modal dialog.
|
||||
const formAfter = await page.evaluate(detectFormScript());
|
||||
if (formAfter != null && formAfter !== formBefore) {
|
||||
// New form appeared — but is it the EPF or an informational dialog?
|
||||
// Informational "re-open" dialogs are tiny (< 20 elements).
|
||||
const elCount = await page.evaluate(`document.querySelectorAll('[id^="form${formAfter}_"]').length`);
|
||||
if (elCount < 20) {
|
||||
// Likely an info dialog — check and dismiss
|
||||
const err2 = await checkForErrors();
|
||||
if (err2?.modal) {
|
||||
await dismissPendingErrors();
|
||||
await waitForStable(formBefore);
|
||||
continue; // retry open cycle
|
||||
}
|
||||
}
|
||||
// It's the real EPF form
|
||||
return returnFormState({ opened: { file: absPath, attempt: attempt + 1 } });
|
||||
}
|
||||
// Form didn't appear — retry
|
||||
continue;
|
||||
}
|
||||
|
||||
// No security dialog — check if form appeared
|
||||
if (err?.modal) {
|
||||
throw new Error(`Error opening file: ${err.modal.message}`);
|
||||
}
|
||||
const formAfter = await page.evaluate(detectFormScript());
|
||||
if (formAfter != null && formAfter !== formBefore) {
|
||||
const state = await getFormState();
|
||||
state.opened = { file: absPath, attempt: attempt + 1 };
|
||||
return state;
|
||||
}
|
||||
}
|
||||
|
||||
throw new Error(`Form did not open after ${MAX_ATTEMPTS} attempts for: ${absPath}`);
|
||||
}
|
||||
|
||||
/** Navigate to a 1C navigation link via Shift+F11 dialog. Returns new form state. */
|
||||
export async function navigateLink(url) {
|
||||
ensureConnected();
|
||||
await dismissPendingErrors();
|
||||
const link = normalizeE1cibUrl(url);
|
||||
const formBefore = await page.evaluate(detectFormScript());
|
||||
|
||||
// Copy link to clipboard, press Shift+F11 (opens "Go to link" dialog with clipboard content)
|
||||
await pasteText(link, { confirm: 'Shift+F11', postDelay: 200 });
|
||||
await waitForStable();
|
||||
|
||||
// Click "Перейти" in the navigation dialog
|
||||
const dialog = await page.evaluate(detectFormScript());
|
||||
if (dialog != null && dialog !== formBefore) {
|
||||
const btns = await page.$$(`#form${dialog}_container a.press`);
|
||||
for (const b of btns) {
|
||||
const txt = (await b.textContent())?.trim();
|
||||
if (txt === 'Перейти') { await b.click(); break; }
|
||||
}
|
||||
}
|
||||
|
||||
await waitForStable(formBefore);
|
||||
return await returnFormState();
|
||||
}
|
||||
// web-test nav/navigation v1.17 — Section navigation, openCommand, switchTab, navigateLink (Shift+F11), openFile.
|
||||
// Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
|
||||
|
||||
import {
|
||||
page, ensureConnected, highlightMode, resolveProjectPath,
|
||||
} from '../core/state.mjs';
|
||||
import {
|
||||
readSectionsScript, readTabsScript, readCommandsScript,
|
||||
navigateSectionScript, openCommandScript, switchTabScript,
|
||||
detectFormScript,
|
||||
} from '../../dom.mjs';
|
||||
import { dismissPendingErrors, checkForErrors } from '../core/errors.mjs';
|
||||
import { waitForStable, waitForCondition } from '../core/wait.mjs';
|
||||
import { highlight, unhighlight } from '../recording/highlight.mjs';
|
||||
import { returnFormState } from '../core/helpers.mjs';
|
||||
// Static import — ESM cycle that resolves at call time.
|
||||
import { pasteText } from '../core/clipboard.mjs';
|
||||
import { getFormState } from '../forms/state.mjs';
|
||||
|
||||
/**
|
||||
* 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();
|
||||
await dismissPendingErrors();
|
||||
if (highlightMode) try { await highlight(name); await page.waitForTimeout(500); await unhighlight(); } catch {}
|
||||
const result = await page.evaluate(navigateSectionScript(name));
|
||||
if (result?.error) {
|
||||
const avail = result.available?.filter(Boolean);
|
||||
if (avail?.length === 0) throw new Error(`navigateSection: "${name}" not found. Section panel is in icon-only mode — text labels are hidden. Switch to "Text" or "Picture and text" display mode in 1C settings (View → Section Panel → Display Mode)`);
|
||||
throw new Error(`navigateSection: "${name}" not found. Available: ${avail?.join(', ') || 'none'}`);
|
||||
}
|
||||
|
||||
await waitForStable();
|
||||
const { sections, commands } = await page.evaluate(`({
|
||||
sections: ${readSectionsScript()},
|
||||
commands: ${readCommandsScript()}
|
||||
})`);
|
||||
return returnFormState({ 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();
|
||||
await dismissPendingErrors();
|
||||
if (highlightMode) try { await highlight(name); await page.waitForTimeout(500); await unhighlight(); } catch {}
|
||||
const formBefore = await page.evaluate(detectFormScript());
|
||||
const result = await page.evaluate(openCommandScript(name));
|
||||
if (result?.error) throw new Error(`openCommand: "${name}" not found. Available: ${result.available?.join(', ') || 'none'}`);
|
||||
|
||||
await waitForStable(formBefore);
|
||||
return await returnFormState();
|
||||
}
|
||||
|
||||
/** 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) throw new Error(`switchTab: "${name}" not found. Available: ${result.available?.join(', ') || 'none'}`);
|
||||
await waitForStable();
|
||||
return returnFormState();
|
||||
}
|
||||
|
||||
// English → Russian metadata type mapping for e1cib navigation links
|
||||
const E1CIB_TYPE_MAP = {
|
||||
'catalog': 'Справочник', 'catalogs': 'Справочник',
|
||||
'document': 'Документ', 'documents': 'Документ',
|
||||
'commonmodule': 'ОбщийМодуль',
|
||||
'enum': 'Перечисление', 'enums': 'Перечисление',
|
||||
'dataprocessor': 'Обработка', 'dataprocessors': 'Обработка',
|
||||
'report': 'Отчет', 'reports': 'Отчет',
|
||||
'accumulationregister': 'РегистрНакопления',
|
||||
'informationregister': 'РегистрСведений',
|
||||
'accountingregister': 'РегистрБухгалтерии',
|
||||
'calculationregister': 'РегистрРасчета',
|
||||
'chartofaccounts': 'ПланСчетов',
|
||||
'chartofcharacteristictypes': 'ПланВидовХарактеристик',
|
||||
'chartofcalculationtypes': 'ПланВидовРасчета',
|
||||
'businessprocess': 'БизнесПроцесс',
|
||||
'task': 'Задача',
|
||||
'exchangeplan': 'ПланОбмена',
|
||||
'constant': 'Константа',
|
||||
};
|
||||
|
||||
// Types that open via e1cib/app/ (reports and data processors have their own app forms)
|
||||
const E1CIB_APP_TYPES = new Set(['Отчет', 'Обработка']);
|
||||
|
||||
function normalizeE1cibUrl(url) {
|
||||
// Already a full e1cib link
|
||||
if (url.startsWith('e1cib/')) return url;
|
||||
// "ТипОбъекта.Имя" or "EnglishType.Имя" — translate type, pick list/ or app/ prefix
|
||||
const dot = url.indexOf('.');
|
||||
if (dot > 0) {
|
||||
const typePart = url.substring(0, dot);
|
||||
const namePart = url.substring(dot + 1);
|
||||
const ruType = E1CIB_TYPE_MAP[typePart.toLowerCase()] || typePart;
|
||||
const prefix = E1CIB_APP_TYPES.has(ruType) ? 'e1cib/app' : 'e1cib/list';
|
||||
return `${prefix}/${ruType}.${namePart}`;
|
||||
}
|
||||
return `e1cib/list/${url}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Open an external data processor or report (EPF/ERF) via File → Open menu.
|
||||
* Handles the security confirmation dialog on first open.
|
||||
* @param {string} filePath - path to EPF/ERF file (absolute or relative to cwd)
|
||||
* @returns {Promise<object>} form state of the opened processor/report
|
||||
*/
|
||||
export async function openFile(filePath) {
|
||||
ensureConnected();
|
||||
await dismissPendingErrors();
|
||||
const absPath = resolveProjectPath(filePath.replace(/\\/g, '/'));
|
||||
|
||||
const MAX_ATTEMPTS = 2; // 1st may trigger security dialog, 2nd is the real open
|
||||
for (let attempt = 0; attempt < MAX_ATTEMPTS; attempt++) {
|
||||
const formBefore = await page.evaluate(detectFormScript());
|
||||
|
||||
// 1. Ctrl+O opens 1C's "Выбор файлов" dialog
|
||||
await page.keyboard.press('Control+o');
|
||||
|
||||
// 2. Wait for the file selection dialog
|
||||
const dialogOk = await waitForCondition(`(() => {
|
||||
const ok = document.querySelector('#fileSelectDialogOk');
|
||||
return ok && ok.offsetWidth > 0 ? true : false;
|
||||
})()`, 3000);
|
||||
if (!dialogOk) throw new Error("File selection dialog did not open (Ctrl+O)");
|
||||
|
||||
// 3. Click "выберите с диска" to trigger the native OS file picker
|
||||
let fileChooser;
|
||||
try {
|
||||
[fileChooser] = await Promise.all([
|
||||
page.waitForEvent('filechooser', { timeout: 5000 }),
|
||||
page.click('a.underline.pointer'),
|
||||
]);
|
||||
} catch (e) {
|
||||
// Try closing the dialog before throwing
|
||||
await page.keyboard.press('Escape');
|
||||
throw new Error(`File chooser did not appear: ${e.message}`);
|
||||
}
|
||||
|
||||
// 4. Set the file path and click OK
|
||||
await fileChooser.setFiles(absPath);
|
||||
await page.waitForTimeout(500);
|
||||
await page.click('#fileSelectDialogOk');
|
||||
await waitForStable(formBefore);
|
||||
|
||||
// 5. Check for security dialog
|
||||
const err = await checkForErrors();
|
||||
if (err?.confirmation) {
|
||||
// Security confirmation — click the positive button (Продолжить/Да/OK)
|
||||
const positiveBtn = err.confirmation.buttons.find(b =>
|
||||
/продолжить|да|ok|yes|открыть/i.test(b)
|
||||
) || err.confirmation.buttons[0];
|
||||
if (positiveBtn) {
|
||||
const btns = await page.$$(`#form${err.confirmation.formNum}_container a.press.pressButton`);
|
||||
for (const b of btns) {
|
||||
const txt = (await b.textContent())?.trim();
|
||||
if (txt === positiveBtn) { await b.click(); break; }
|
||||
}
|
||||
await waitForStable(formBefore);
|
||||
}
|
||||
// After confirmation, check if EPF form appeared or a follow-up dialog showed.
|
||||
// Check form change FIRST — avoids confusing a small EPF form with a modal dialog.
|
||||
const formAfter = await page.evaluate(detectFormScript());
|
||||
if (formAfter != null && formAfter !== formBefore) {
|
||||
// New form appeared — but is it the EPF or an informational dialog?
|
||||
// Informational "re-open" dialogs are tiny (< 20 elements).
|
||||
const elCount = await page.evaluate(`document.querySelectorAll('[id^="form${formAfter}_"]').length`);
|
||||
if (elCount < 20) {
|
||||
// Likely an info dialog — check and dismiss
|
||||
const err2 = await checkForErrors();
|
||||
if (err2?.modal) {
|
||||
await dismissPendingErrors();
|
||||
await waitForStable(formBefore);
|
||||
continue; // retry open cycle
|
||||
}
|
||||
}
|
||||
// It's the real EPF form
|
||||
return returnFormState({ opened: { file: absPath, attempt: attempt + 1 } });
|
||||
}
|
||||
// Form didn't appear — retry
|
||||
continue;
|
||||
}
|
||||
|
||||
// No security dialog — check if form appeared
|
||||
if (err?.modal) {
|
||||
throw new Error(`Error opening file: ${err.modal.message}`);
|
||||
}
|
||||
const formAfter = await page.evaluate(detectFormScript());
|
||||
if (formAfter != null && formAfter !== formBefore) {
|
||||
const state = await getFormState();
|
||||
state.opened = { file: absPath, attempt: attempt + 1 };
|
||||
return state;
|
||||
}
|
||||
}
|
||||
|
||||
throw new Error(`Form did not open after ${MAX_ATTEMPTS} attempts for: ${absPath}`);
|
||||
}
|
||||
|
||||
/** Navigate to a 1C navigation link via Shift+F11 dialog. Returns new form state. */
|
||||
export async function navigateLink(url) {
|
||||
ensureConnected();
|
||||
await dismissPendingErrors();
|
||||
const link = normalizeE1cibUrl(url);
|
||||
const formBefore = await page.evaluate(detectFormScript());
|
||||
|
||||
// Copy link to clipboard, press Shift+F11 (opens "Go to link" dialog with clipboard content)
|
||||
await pasteText(link, { confirm: 'Shift+F11', postDelay: 200 });
|
||||
await waitForStable();
|
||||
|
||||
// Click "Перейти" in the navigation dialog
|
||||
const dialog = await page.evaluate(detectFormScript());
|
||||
if (dialog != null && dialog !== formBefore) {
|
||||
const btns = await page.$$(`#form${dialog}_container a.press`);
|
||||
for (const b of btns) {
|
||||
const txt = (await b.textContent())?.trim();
|
||||
if (txt === 'Перейти') { await b.click(); break; }
|
||||
}
|
||||
}
|
||||
|
||||
await waitForStable(formBefore);
|
||||
return await returnFormState();
|
||||
}
|
||||
|
||||
@@ -1,292 +1,292 @@
|
||||
// web-test recording/captions v1.17 — Overlay primitives: captions, title slides, image overlays.
|
||||
// Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
|
||||
|
||||
import { existsSync as fsExistsSync, readFileSync } from 'fs';
|
||||
import { extname } from 'path';
|
||||
import {
|
||||
page, recorder, lastCaptions, ensureConnected, resolveProjectPath,
|
||||
} from '../core/state.mjs';
|
||||
|
||||
/**
|
||||
* Show a text caption overlay on the page (visible in recording).
|
||||
* Calling again updates the text without creating a new element.
|
||||
* @param {string} text — caption text
|
||||
* @param {object} [opts]
|
||||
* @param {'top'|'bottom'} [opts.position='bottom'] — vertical position
|
||||
* @param {number} [opts.fontSize=24] — font size in pixels
|
||||
* @param {string} [opts.background='rgba(0,0,0,0.7)'] — background color
|
||||
* @param {string} [opts.color='#fff'] — text color
|
||||
* @param {string|false} [opts.speech] — TTS narration text. Omit to use displayed text,
|
||||
* pass a string for custom narration, or false to skip narration for this caption.
|
||||
*/
|
||||
export async function showCaption(text, opts = {}) {
|
||||
ensureConnected();
|
||||
|
||||
// Collect caption for TTS narration if recording
|
||||
let smartWaitMs = 0;
|
||||
if (recorder && (text.trim() || typeof opts.speech === 'string') && opts.speech !== false) {
|
||||
const speech = typeof opts.speech === 'string' ? opts.speech : text;
|
||||
// Use video timeline position (accounts for frame duplication) instead of wall-clock
|
||||
recorder.captions.push({ text: text || speech, speech, time: Math.round(recorder.videoTimeMs), ...(opts.voice ? { voice: opts.voice } : {}) });
|
||||
// Estimate TTS duration and wait so the video has enough screen time for voiceover
|
||||
smartWaitMs = Math.max(2000, speech.length * (recorder.speechRate || 70));
|
||||
}
|
||||
const position = opts.position || 'bottom';
|
||||
const fontSize = opts.fontSize || 24;
|
||||
const bg = opts.background || 'rgba(0,0,0,0.7)';
|
||||
const color = opts.color || '#fff';
|
||||
|
||||
await page.evaluate(({ text, position, fontSize, bg, color }) => {
|
||||
let el = document.getElementById('__web_test_caption');
|
||||
if (!el) {
|
||||
el = document.createElement('div');
|
||||
el.id = '__web_test_caption';
|
||||
el.style.cssText = `
|
||||
position: fixed; left: 0; right: 0; z-index: 99999;
|
||||
text-align: center; padding: 12px 24px;
|
||||
font-family: Arial, sans-serif; pointer-events: none;
|
||||
`;
|
||||
document.body.appendChild(el);
|
||||
}
|
||||
el.style[position === 'top' ? 'top' : 'bottom'] = '20px';
|
||||
el.style[position === 'top' ? 'bottom' : 'top'] = 'auto';
|
||||
el.style.fontSize = fontSize + 'px';
|
||||
el.style.background = bg;
|
||||
el.style.color = color;
|
||||
el.textContent = text;
|
||||
}, { text, position, fontSize, bg, color });
|
||||
|
||||
// Smart TTS wait: pause for estimated speech duration so video has enough screen time.
|
||||
// Split into chunks and flush frames periodically — CDP doesn't send screencast frames
|
||||
// for static pages, so we must write duplicate frames to keep video timeline in sync.
|
||||
if (smartWaitMs > 0) {
|
||||
let remaining = smartWaitMs;
|
||||
while (remaining > 0) {
|
||||
const chunk = Math.min(remaining, 1000);
|
||||
await page.waitForTimeout(chunk);
|
||||
remaining -= chunk;
|
||||
if (recorder?._flushFrames) recorder._flushFrames();
|
||||
}
|
||||
recorder.captionCredit = { waitedMs: smartWaitMs, at: Date.now() };
|
||||
}
|
||||
}
|
||||
|
||||
/** Remove the caption overlay from the page. */
|
||||
export async function hideCaption() {
|
||||
ensureConnected();
|
||||
await page.evaluate(() => {
|
||||
const el = document.getElementById('__web_test_caption');
|
||||
if (el) el.remove();
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Get captions collected during the current or last recording.
|
||||
* @returns {Array<{text: string, speech: string, time: number}>}
|
||||
*/
|
||||
export function getCaptions() {
|
||||
if (recorder) return [...recorder.captions];
|
||||
return [...lastCaptions];
|
||||
}
|
||||
|
||||
/**
|
||||
* Show a full-screen title slide overlay (for video recordings).
|
||||
* Repeated calls update the content. Use hideTitleSlide() to remove.
|
||||
* @param {string} text Title text (\n → line break)
|
||||
* @param {object} [opts]
|
||||
* @param {string} [opts.subtitle] Smaller text below the title
|
||||
* @param {string} [opts.background] CSS background (default: dark gradient)
|
||||
* @param {string} [opts.color] Text color (default: '#fff')
|
||||
* @param {number} [opts.fontSize] Title font size in px (default: 36)
|
||||
*/
|
||||
export async function showTitleSlide(text, opts = {}) {
|
||||
ensureConnected();
|
||||
const {
|
||||
subtitle = '',
|
||||
background = 'linear-gradient(135deg, #1a1a2e 0%, #16213e 50%, #0f3460 100%)',
|
||||
color = '#fff',
|
||||
fontSize = 36,
|
||||
speech,
|
||||
} = opts;
|
||||
|
||||
// Collect caption for TTS narration if recording
|
||||
let smartWaitMs = 0;
|
||||
if (recorder && speech && speech !== false) {
|
||||
const captionText = typeof speech === 'string' ? speech : text.replace(/\n/g, ' ');
|
||||
if (captionText) {
|
||||
recorder.captions.push({ text: captionText, speech: captionText, time: Math.round(recorder.videoTimeMs), ...(opts.voice ? { voice: opts.voice } : {}) });
|
||||
smartWaitMs = Math.max(2000, captionText.length * (recorder.speechRate || 70));
|
||||
}
|
||||
}
|
||||
|
||||
await page.evaluate(({ text, subtitle, background, color, fontSize }) => {
|
||||
let div = document.getElementById('__web_test_title');
|
||||
if (!div) {
|
||||
div = document.createElement('div');
|
||||
div.id = '__web_test_title';
|
||||
document.body.appendChild(div);
|
||||
}
|
||||
div.style.cssText = [
|
||||
'position:fixed', 'top:0', 'left:0', 'width:100%', 'height:100%',
|
||||
`background:${background}`,
|
||||
'display:flex', 'align-items:center', 'justify-content:center',
|
||||
'z-index:999999', 'pointer-events:none',
|
||||
].join(';');
|
||||
// Remove other overlays to prevent flash between slides
|
||||
const img = document.getElementById('__web_test_image');
|
||||
if (img) img.remove();
|
||||
const esc = s => s.replace(/&/g, '&').replace(/</g, '<').replace(/\n/g, '<br>');
|
||||
let html = `<div style="font-size:${fontSize}px;font-weight:600;line-height:1.4;">${esc(text)}</div>`;
|
||||
if (subtitle) {
|
||||
html += `<div style="font-size:${Math.round(fontSize * 0.5)}px;margin-top:16px;opacity:0.7;">${esc(subtitle)}</div>`;
|
||||
}
|
||||
div.innerHTML = `<div style="text-align:center;max-width:70%;color:${color};font-family:'Segoe UI',Arial,sans-serif;">${html}</div>`;
|
||||
}, { text, subtitle, background, color, fontSize });
|
||||
|
||||
// Smart TTS wait (same pattern as showCaption/showImage)
|
||||
if (smartWaitMs > 0) {
|
||||
let remaining = smartWaitMs;
|
||||
while (remaining > 0) {
|
||||
const chunk = Math.min(remaining, 1000);
|
||||
await page.waitForTimeout(chunk);
|
||||
remaining -= chunk;
|
||||
if (recorder?._flushFrames) recorder._flushFrames();
|
||||
}
|
||||
recorder.captionCredit = { waitedMs: smartWaitMs, at: Date.now() };
|
||||
}
|
||||
}
|
||||
|
||||
/** Remove the title slide overlay. */
|
||||
export async function hideTitleSlide() {
|
||||
ensureConnected();
|
||||
await page.evaluate(() => {
|
||||
const el = document.getElementById('__web_test_title');
|
||||
if (el) el.remove();
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Show a full-screen image overlay (e.g. presentation slide screenshot).
|
||||
* Reads the image file, base64-encodes it, and renders as a fixed overlay
|
||||
* on the page — captured by CDP screencast automatically.
|
||||
*
|
||||
* Style presets:
|
||||
* - 'blur' (default) — blurred+dimmed copy as background, image centered with shadow
|
||||
* - 'dark' — dark background (#2a2a2a) with shadow
|
||||
* - 'light' — white background with shadow
|
||||
* - 'full' — image covers entire screen, no padding/shadow
|
||||
*
|
||||
* Custom background overrides the preset (e.g. background: '#003366').
|
||||
*
|
||||
* @param {string} imagePath — path to the image file (PNG, JPG, etc.)
|
||||
* @param {object} [opts]
|
||||
* @param {'blur'|'dark'|'light'|'full'} [opts.style='blur'] — display style preset
|
||||
* @param {string} [opts.background] — custom background color/gradient (overrides style preset)
|
||||
* @param {boolean} [opts.shadow] — show drop shadow (default: true for blur/dark/light, false for full)
|
||||
* @param {string|false} [opts.speech] — TTS narration text while image is shown.
|
||||
* Pass a string for narration, or false to skip. Omit to skip (no auto-text for images).
|
||||
*/
|
||||
export async function showImage(imagePath, opts = {}) {
|
||||
ensureConnected();
|
||||
const style = opts.style || 'blur';
|
||||
const speech = opts.speech;
|
||||
|
||||
// Style presets
|
||||
const presets = {
|
||||
blur: { bg: '#222', fit: 'contain', shadow: true, blur: true },
|
||||
dark: { bg: '#2a2a2a', fit: 'contain', shadow: true, blur: false },
|
||||
light: { bg: '#ffffff', fit: 'contain', shadow: true, blur: false },
|
||||
full: { bg: '#000', fit: 'contain', shadow: false, blur: false },
|
||||
};
|
||||
const preset = presets[style] || presets.blur;
|
||||
|
||||
const bg = opts.background || preset.bg;
|
||||
const fit = preset.fit;
|
||||
const shadow = opts.shadow !== undefined ? opts.shadow : preset.shadow;
|
||||
const useBlur = opts.background ? false : preset.blur;
|
||||
|
||||
// Read image and base64-encode
|
||||
const absPath = resolveProjectPath(imagePath);
|
||||
if (!fsExistsSync(absPath)) {
|
||||
throw new Error(`showImage: file not found: ${absPath}`);
|
||||
}
|
||||
const buf = readFileSync(absPath);
|
||||
const ext = extname(absPath).toLowerCase().replace('.', '');
|
||||
const mime = ext === 'jpg' || ext === 'jpeg' ? 'image/jpeg'
|
||||
: ext === 'png' ? 'image/png'
|
||||
: ext === 'gif' ? 'image/gif'
|
||||
: ext === 'webp' ? 'image/webp'
|
||||
: ext === 'svg' ? 'image/svg+xml'
|
||||
: 'image/png';
|
||||
const dataUrl = `data:${mime};base64,${buf.toString('base64')}`;
|
||||
|
||||
// Collect caption for TTS narration if recording
|
||||
let smartWaitMs = 0;
|
||||
if (recorder && speech && speech !== false) {
|
||||
const captionText = typeof speech === 'string' ? speech : '';
|
||||
if (captionText) {
|
||||
recorder.captions.push({ text: captionText, speech: captionText, time: Math.round(recorder.videoTimeMs), ...(opts.voice ? { voice: opts.voice } : {}) });
|
||||
smartWaitMs = Math.max(2000, captionText.length * (recorder.speechRate || 70));
|
||||
}
|
||||
}
|
||||
|
||||
// Padding: full style uses 100%, others use 92% for breathing room
|
||||
const isFull = style === 'full';
|
||||
const maxSize = isFull ? '100%' : '92%';
|
||||
|
||||
await page.evaluate(({ dataUrl, fit, bg, useBlur, shadow, maxSize, isFull }) => {
|
||||
let div = document.getElementById('__web_test_image');
|
||||
if (!div) {
|
||||
div = document.createElement('div');
|
||||
div.id = '__web_test_image';
|
||||
document.body.appendChild(div);
|
||||
}
|
||||
// Remove other overlays to prevent flash between slides
|
||||
const title = document.getElementById('__web_test_title');
|
||||
if (title) title.remove();
|
||||
|
||||
div.style.cssText = [
|
||||
'position:fixed', 'top:0', 'left:0', 'width:100%', 'height:100%',
|
||||
`background:${bg}`,
|
||||
'display:flex', 'align-items:center', 'justify-content:center',
|
||||
'z-index:999999', 'pointer-events:none', 'overflow:hidden'
|
||||
].join(';');
|
||||
|
||||
let html = '';
|
||||
|
||||
// Blurred background layer: the same image stretched to cover, blurred and dimmed
|
||||
if (useBlur) {
|
||||
html += `<img src="${dataUrl}" style="position:absolute;top:0;left:0;width:100%;height:100%;object-fit:cover;filter:blur(30px) brightness(0.5);transform:scale(1.1);" />`;
|
||||
}
|
||||
|
||||
// Main image
|
||||
const shadowCss = shadow ? 'box-shadow:0 4px 40px rgba(0,0,0,0.5);' : '';
|
||||
const sizeCss = isFull
|
||||
? `width:100%;height:100%;object-fit:${fit};`
|
||||
: `max-width:${maxSize};max-height:${maxSize};min-width:50%;min-height:50%;object-fit:${fit};`;
|
||||
html += `<img src="${dataUrl}" style="position:relative;${sizeCss}${shadowCss}" />`;
|
||||
|
||||
div.innerHTML = html;
|
||||
}, { dataUrl, fit, bg, useBlur, shadow, maxSize, isFull });
|
||||
|
||||
// Smart TTS wait (same pattern as showCaption)
|
||||
if (smartWaitMs > 0) {
|
||||
let remaining = smartWaitMs;
|
||||
while (remaining > 0) {
|
||||
const chunk = Math.min(remaining, 1000);
|
||||
await page.waitForTimeout(chunk);
|
||||
remaining -= chunk;
|
||||
if (recorder?._flushFrames) recorder._flushFrames();
|
||||
}
|
||||
recorder.captionCredit = { waitedMs: smartWaitMs, at: Date.now() };
|
||||
}
|
||||
}
|
||||
|
||||
/** Remove the image overlay. */
|
||||
export async function hideImage() {
|
||||
ensureConnected();
|
||||
await page.evaluate(() => {
|
||||
const el = document.getElementById('__web_test_image');
|
||||
if (el) el.remove();
|
||||
});
|
||||
}
|
||||
// web-test recording/captions v1.17 — Overlay primitives: captions, title slides, image overlays.
|
||||
// Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
|
||||
|
||||
import { existsSync as fsExistsSync, readFileSync } from 'fs';
|
||||
import { extname } from 'path';
|
||||
import {
|
||||
page, recorder, lastCaptions, ensureConnected, resolveProjectPath,
|
||||
} from '../core/state.mjs';
|
||||
|
||||
/**
|
||||
* Show a text caption overlay on the page (visible in recording).
|
||||
* Calling again updates the text without creating a new element.
|
||||
* @param {string} text — caption text
|
||||
* @param {object} [opts]
|
||||
* @param {'top'|'bottom'} [opts.position='bottom'] — vertical position
|
||||
* @param {number} [opts.fontSize=24] — font size in pixels
|
||||
* @param {string} [opts.background='rgba(0,0,0,0.7)'] — background color
|
||||
* @param {string} [opts.color='#fff'] — text color
|
||||
* @param {string|false} [opts.speech] — TTS narration text. Omit to use displayed text,
|
||||
* pass a string for custom narration, or false to skip narration for this caption.
|
||||
*/
|
||||
export async function showCaption(text, opts = {}) {
|
||||
ensureConnected();
|
||||
|
||||
// Collect caption for TTS narration if recording
|
||||
let smartWaitMs = 0;
|
||||
if (recorder && (text.trim() || typeof opts.speech === 'string') && opts.speech !== false) {
|
||||
const speech = typeof opts.speech === 'string' ? opts.speech : text;
|
||||
// Use video timeline position (accounts for frame duplication) instead of wall-clock
|
||||
recorder.captions.push({ text: text || speech, speech, time: Math.round(recorder.videoTimeMs), ...(opts.voice ? { voice: opts.voice } : {}) });
|
||||
// Estimate TTS duration and wait so the video has enough screen time for voiceover
|
||||
smartWaitMs = Math.max(2000, speech.length * (recorder.speechRate || 70));
|
||||
}
|
||||
const position = opts.position || 'bottom';
|
||||
const fontSize = opts.fontSize || 24;
|
||||
const bg = opts.background || 'rgba(0,0,0,0.7)';
|
||||
const color = opts.color || '#fff';
|
||||
|
||||
await page.evaluate(({ text, position, fontSize, bg, color }) => {
|
||||
let el = document.getElementById('__web_test_caption');
|
||||
if (!el) {
|
||||
el = document.createElement('div');
|
||||
el.id = '__web_test_caption';
|
||||
el.style.cssText = `
|
||||
position: fixed; left: 0; right: 0; z-index: 99999;
|
||||
text-align: center; padding: 12px 24px;
|
||||
font-family: Arial, sans-serif; pointer-events: none;
|
||||
`;
|
||||
document.body.appendChild(el);
|
||||
}
|
||||
el.style[position === 'top' ? 'top' : 'bottom'] = '20px';
|
||||
el.style[position === 'top' ? 'bottom' : 'top'] = 'auto';
|
||||
el.style.fontSize = fontSize + 'px';
|
||||
el.style.background = bg;
|
||||
el.style.color = color;
|
||||
el.textContent = text;
|
||||
}, { text, position, fontSize, bg, color });
|
||||
|
||||
// Smart TTS wait: pause for estimated speech duration so video has enough screen time.
|
||||
// Split into chunks and flush frames periodically — CDP doesn't send screencast frames
|
||||
// for static pages, so we must write duplicate frames to keep video timeline in sync.
|
||||
if (smartWaitMs > 0) {
|
||||
let remaining = smartWaitMs;
|
||||
while (remaining > 0) {
|
||||
const chunk = Math.min(remaining, 1000);
|
||||
await page.waitForTimeout(chunk);
|
||||
remaining -= chunk;
|
||||
if (recorder?._flushFrames) recorder._flushFrames();
|
||||
}
|
||||
recorder.captionCredit = { waitedMs: smartWaitMs, at: Date.now() };
|
||||
}
|
||||
}
|
||||
|
||||
/** Remove the caption overlay from the page. */
|
||||
export async function hideCaption() {
|
||||
ensureConnected();
|
||||
await page.evaluate(() => {
|
||||
const el = document.getElementById('__web_test_caption');
|
||||
if (el) el.remove();
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Get captions collected during the current or last recording.
|
||||
* @returns {Array<{text: string, speech: string, time: number}>}
|
||||
*/
|
||||
export function getCaptions() {
|
||||
if (recorder) return [...recorder.captions];
|
||||
return [...lastCaptions];
|
||||
}
|
||||
|
||||
/**
|
||||
* Show a full-screen title slide overlay (for video recordings).
|
||||
* Repeated calls update the content. Use hideTitleSlide() to remove.
|
||||
* @param {string} text Title text (\n → line break)
|
||||
* @param {object} [opts]
|
||||
* @param {string} [opts.subtitle] Smaller text below the title
|
||||
* @param {string} [opts.background] CSS background (default: dark gradient)
|
||||
* @param {string} [opts.color] Text color (default: '#fff')
|
||||
* @param {number} [opts.fontSize] Title font size in px (default: 36)
|
||||
*/
|
||||
export async function showTitleSlide(text, opts = {}) {
|
||||
ensureConnected();
|
||||
const {
|
||||
subtitle = '',
|
||||
background = 'linear-gradient(135deg, #1a1a2e 0%, #16213e 50%, #0f3460 100%)',
|
||||
color = '#fff',
|
||||
fontSize = 36,
|
||||
speech,
|
||||
} = opts;
|
||||
|
||||
// Collect caption for TTS narration if recording
|
||||
let smartWaitMs = 0;
|
||||
if (recorder && speech && speech !== false) {
|
||||
const captionText = typeof speech === 'string' ? speech : text.replace(/\n/g, ' ');
|
||||
if (captionText) {
|
||||
recorder.captions.push({ text: captionText, speech: captionText, time: Math.round(recorder.videoTimeMs), ...(opts.voice ? { voice: opts.voice } : {}) });
|
||||
smartWaitMs = Math.max(2000, captionText.length * (recorder.speechRate || 70));
|
||||
}
|
||||
}
|
||||
|
||||
await page.evaluate(({ text, subtitle, background, color, fontSize }) => {
|
||||
let div = document.getElementById('__web_test_title');
|
||||
if (!div) {
|
||||
div = document.createElement('div');
|
||||
div.id = '__web_test_title';
|
||||
document.body.appendChild(div);
|
||||
}
|
||||
div.style.cssText = [
|
||||
'position:fixed', 'top:0', 'left:0', 'width:100%', 'height:100%',
|
||||
`background:${background}`,
|
||||
'display:flex', 'align-items:center', 'justify-content:center',
|
||||
'z-index:999999', 'pointer-events:none',
|
||||
].join(';');
|
||||
// Remove other overlays to prevent flash between slides
|
||||
const img = document.getElementById('__web_test_image');
|
||||
if (img) img.remove();
|
||||
const esc = s => s.replace(/&/g, '&').replace(/</g, '<').replace(/\n/g, '<br>');
|
||||
let html = `<div style="font-size:${fontSize}px;font-weight:600;line-height:1.4;">${esc(text)}</div>`;
|
||||
if (subtitle) {
|
||||
html += `<div style="font-size:${Math.round(fontSize * 0.5)}px;margin-top:16px;opacity:0.7;">${esc(subtitle)}</div>`;
|
||||
}
|
||||
div.innerHTML = `<div style="text-align:center;max-width:70%;color:${color};font-family:'Segoe UI',Arial,sans-serif;">${html}</div>`;
|
||||
}, { text, subtitle, background, color, fontSize });
|
||||
|
||||
// Smart TTS wait (same pattern as showCaption/showImage)
|
||||
if (smartWaitMs > 0) {
|
||||
let remaining = smartWaitMs;
|
||||
while (remaining > 0) {
|
||||
const chunk = Math.min(remaining, 1000);
|
||||
await page.waitForTimeout(chunk);
|
||||
remaining -= chunk;
|
||||
if (recorder?._flushFrames) recorder._flushFrames();
|
||||
}
|
||||
recorder.captionCredit = { waitedMs: smartWaitMs, at: Date.now() };
|
||||
}
|
||||
}
|
||||
|
||||
/** Remove the title slide overlay. */
|
||||
export async function hideTitleSlide() {
|
||||
ensureConnected();
|
||||
await page.evaluate(() => {
|
||||
const el = document.getElementById('__web_test_title');
|
||||
if (el) el.remove();
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Show a full-screen image overlay (e.g. presentation slide screenshot).
|
||||
* Reads the image file, base64-encodes it, and renders as a fixed overlay
|
||||
* on the page — captured by CDP screencast automatically.
|
||||
*
|
||||
* Style presets:
|
||||
* - 'blur' (default) — blurred+dimmed copy as background, image centered with shadow
|
||||
* - 'dark' — dark background (#2a2a2a) with shadow
|
||||
* - 'light' — white background with shadow
|
||||
* - 'full' — image covers entire screen, no padding/shadow
|
||||
*
|
||||
* Custom background overrides the preset (e.g. background: '#003366').
|
||||
*
|
||||
* @param {string} imagePath — path to the image file (PNG, JPG, etc.)
|
||||
* @param {object} [opts]
|
||||
* @param {'blur'|'dark'|'light'|'full'} [opts.style='blur'] — display style preset
|
||||
* @param {string} [opts.background] — custom background color/gradient (overrides style preset)
|
||||
* @param {boolean} [opts.shadow] — show drop shadow (default: true for blur/dark/light, false for full)
|
||||
* @param {string|false} [opts.speech] — TTS narration text while image is shown.
|
||||
* Pass a string for narration, or false to skip. Omit to skip (no auto-text for images).
|
||||
*/
|
||||
export async function showImage(imagePath, opts = {}) {
|
||||
ensureConnected();
|
||||
const style = opts.style || 'blur';
|
||||
const speech = opts.speech;
|
||||
|
||||
// Style presets
|
||||
const presets = {
|
||||
blur: { bg: '#222', fit: 'contain', shadow: true, blur: true },
|
||||
dark: { bg: '#2a2a2a', fit: 'contain', shadow: true, blur: false },
|
||||
light: { bg: '#ffffff', fit: 'contain', shadow: true, blur: false },
|
||||
full: { bg: '#000', fit: 'contain', shadow: false, blur: false },
|
||||
};
|
||||
const preset = presets[style] || presets.blur;
|
||||
|
||||
const bg = opts.background || preset.bg;
|
||||
const fit = preset.fit;
|
||||
const shadow = opts.shadow !== undefined ? opts.shadow : preset.shadow;
|
||||
const useBlur = opts.background ? false : preset.blur;
|
||||
|
||||
// Read image and base64-encode
|
||||
const absPath = resolveProjectPath(imagePath);
|
||||
if (!fsExistsSync(absPath)) {
|
||||
throw new Error(`showImage: file not found: ${absPath}`);
|
||||
}
|
||||
const buf = readFileSync(absPath);
|
||||
const ext = extname(absPath).toLowerCase().replace('.', '');
|
||||
const mime = ext === 'jpg' || ext === 'jpeg' ? 'image/jpeg'
|
||||
: ext === 'png' ? 'image/png'
|
||||
: ext === 'gif' ? 'image/gif'
|
||||
: ext === 'webp' ? 'image/webp'
|
||||
: ext === 'svg' ? 'image/svg+xml'
|
||||
: 'image/png';
|
||||
const dataUrl = `data:${mime};base64,${buf.toString('base64')}`;
|
||||
|
||||
// Collect caption for TTS narration if recording
|
||||
let smartWaitMs = 0;
|
||||
if (recorder && speech && speech !== false) {
|
||||
const captionText = typeof speech === 'string' ? speech : '';
|
||||
if (captionText) {
|
||||
recorder.captions.push({ text: captionText, speech: captionText, time: Math.round(recorder.videoTimeMs), ...(opts.voice ? { voice: opts.voice } : {}) });
|
||||
smartWaitMs = Math.max(2000, captionText.length * (recorder.speechRate || 70));
|
||||
}
|
||||
}
|
||||
|
||||
// Padding: full style uses 100%, others use 92% for breathing room
|
||||
const isFull = style === 'full';
|
||||
const maxSize = isFull ? '100%' : '92%';
|
||||
|
||||
await page.evaluate(({ dataUrl, fit, bg, useBlur, shadow, maxSize, isFull }) => {
|
||||
let div = document.getElementById('__web_test_image');
|
||||
if (!div) {
|
||||
div = document.createElement('div');
|
||||
div.id = '__web_test_image';
|
||||
document.body.appendChild(div);
|
||||
}
|
||||
// Remove other overlays to prevent flash between slides
|
||||
const title = document.getElementById('__web_test_title');
|
||||
if (title) title.remove();
|
||||
|
||||
div.style.cssText = [
|
||||
'position:fixed', 'top:0', 'left:0', 'width:100%', 'height:100%',
|
||||
`background:${bg}`,
|
||||
'display:flex', 'align-items:center', 'justify-content:center',
|
||||
'z-index:999999', 'pointer-events:none', 'overflow:hidden'
|
||||
].join(';');
|
||||
|
||||
let html = '';
|
||||
|
||||
// Blurred background layer: the same image stretched to cover, blurred and dimmed
|
||||
if (useBlur) {
|
||||
html += `<img src="${dataUrl}" style="position:absolute;top:0;left:0;width:100%;height:100%;object-fit:cover;filter:blur(30px) brightness(0.5);transform:scale(1.1);" />`;
|
||||
}
|
||||
|
||||
// Main image
|
||||
const shadowCss = shadow ? 'box-shadow:0 4px 40px rgba(0,0,0,0.5);' : '';
|
||||
const sizeCss = isFull
|
||||
? `width:100%;height:100%;object-fit:${fit};`
|
||||
: `max-width:${maxSize};max-height:${maxSize};min-width:50%;min-height:50%;object-fit:${fit};`;
|
||||
html += `<img src="${dataUrl}" style="position:relative;${sizeCss}${shadowCss}" />`;
|
||||
|
||||
div.innerHTML = html;
|
||||
}, { dataUrl, fit, bg, useBlur, shadow, maxSize, isFull });
|
||||
|
||||
// Smart TTS wait (same pattern as showCaption)
|
||||
if (smartWaitMs > 0) {
|
||||
let remaining = smartWaitMs;
|
||||
while (remaining > 0) {
|
||||
const chunk = Math.min(remaining, 1000);
|
||||
await page.waitForTimeout(chunk);
|
||||
remaining -= chunk;
|
||||
if (recorder?._flushFrames) recorder._flushFrames();
|
||||
}
|
||||
recorder.captionCredit = { waitedMs: smartWaitMs, at: Date.now() };
|
||||
}
|
||||
}
|
||||
|
||||
/** Remove the image overlay. */
|
||||
export async function hideImage() {
|
||||
ensureConnected();
|
||||
await page.evaluate(() => {
|
||||
const el = document.getElementById('__web_test_image');
|
||||
if (el) el.remove();
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,243 +1,243 @@
|
||||
// web-test recording/capture v1.17 — Recording lifecycle (CDP screencast + ffmpeg pipe), screenshot, wait helpers.
|
||||
// Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
|
||||
|
||||
import { spawn } from 'child_process';
|
||||
import { mkdirSync, statSync, writeFileSync } from 'fs';
|
||||
import { dirname } from 'path';
|
||||
import {
|
||||
page, recorder, lastCaptions,
|
||||
setRecorder, setLastCaptions, setLastRecordingDuration,
|
||||
resolveProjectPath, ensureConnected,
|
||||
} from '../core/state.mjs';
|
||||
import { resolveFfmpeg } from './tts.mjs';
|
||||
|
||||
// Imported lazily inside wait() to avoid initialization-time circular deps.
|
||||
|
||||
/** 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();
|
||||
let ms = seconds * 1000;
|
||||
// Credit system: if showCaption already waited for TTS, subtract that time
|
||||
if (recorder && recorder.captionCredit) {
|
||||
const elapsed = Date.now() - recorder.captionCredit.at;
|
||||
const credit = Math.max(0, recorder.captionCredit.waitedMs - elapsed);
|
||||
ms = Math.max(0, ms - credit);
|
||||
recorder.captionCredit = null;
|
||||
}
|
||||
if (ms > 0) {
|
||||
// During recording, split long waits into chunks and flush frames
|
||||
// to keep video timeline in sync (CDP may not send frames for static pages)
|
||||
if (recorder?._flushFrames && ms > 1000) {
|
||||
let remaining = ms;
|
||||
while (remaining > 0) {
|
||||
const chunk = Math.min(remaining, 1000);
|
||||
await page.waitForTimeout(chunk);
|
||||
remaining -= chunk;
|
||||
recorder._flushFrames();
|
||||
}
|
||||
} else {
|
||||
await page.waitForTimeout(ms);
|
||||
}
|
||||
}
|
||||
const { getFormState } = await import('../forms/state.mjs');
|
||||
return await getFormState();
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// Video recording — CDP screencast + ffmpeg
|
||||
// ============================================================
|
||||
|
||||
/** Check if video recording is active. */
|
||||
export function isRecording() {
|
||||
return recorder !== null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Start video recording via CDP screencast + ffmpeg.
|
||||
* Frames are captured as JPEG and piped to ffmpeg for MP4 encoding.
|
||||
* @param {string} outputPath — output .mp4 file path
|
||||
* @param {object} [opts]
|
||||
* @param {number} [opts.fps=25] — target framerate
|
||||
* @param {number} [opts.quality=80] — JPEG quality (1-100)
|
||||
* @param {string} [opts.ffmpegPath] — explicit path to ffmpeg binary
|
||||
*/
|
||||
export async function startRecording(outputPath, opts = {}) {
|
||||
ensureConnected();
|
||||
if (recorder) {
|
||||
if (opts.force) {
|
||||
try { await stopRecording(); } catch {}
|
||||
} else {
|
||||
throw new Error('Already recording. Call stopRecording() first, or use { force: true }.');
|
||||
}
|
||||
}
|
||||
setLastCaptions([]);
|
||||
setLastRecordingDuration(null);
|
||||
|
||||
const fps = opts.fps || 25;
|
||||
const quality = opts.quality || 80;
|
||||
const ffmpegPath = resolveFfmpeg(opts.ffmpegPath);
|
||||
|
||||
// Ensure output directory exists
|
||||
const resolvedPath = resolveProjectPath(outputPath);
|
||||
mkdirSync(dirname(resolvedPath), { recursive: true });
|
||||
|
||||
// Spawn ffmpeg process — single output file across context switches
|
||||
const ffmpeg = spawn(ffmpegPath, [
|
||||
'-y', // overwrite output
|
||||
'-f', 'image2pipe', // input: piped images
|
||||
'-framerate', String(fps), // input framerate
|
||||
'-i', '-', // read from stdin
|
||||
'-c:v', 'libx264', // H.264 codec
|
||||
'-preset', 'fast', // good quality/speed balance
|
||||
'-crf', '23', // default quality (good for screen content)
|
||||
'-vf', 'scale=in_range=full:out_range=limited', // JPEG full→H.264 limited range
|
||||
'-pix_fmt', 'yuv420p', // broad compatibility
|
||||
'-color_range', 'tv', // limited range (16-235) — standard for H.264 players
|
||||
'-movflags', '+faststart', // web-friendly MP4
|
||||
resolvedPath
|
||||
], { stdio: ['pipe', 'ignore', 'pipe'] });
|
||||
|
||||
ffmpeg.on('error', err => { if (recorder) recorder.ffmpegError += err.message; });
|
||||
|
||||
const frameDuration = 1000 / fps;
|
||||
const speechRate = opts.speechRate || 70; // ms per character for smart TTS wait
|
||||
|
||||
// Frame handler shared across CDP sessions (lives in recorder, not closure):
|
||||
// when the active context switches, we attach a new CDP session and route its
|
||||
// frames to the same ffmpeg pipe — preserving a single continuous timeline.
|
||||
const frameHandler = async ({ data, sessionId }, cdp) => {
|
||||
if (!recorder) return;
|
||||
const buf = Buffer.from(data, 'base64');
|
||||
const now = Date.now();
|
||||
if (!ffmpeg.stdin.destroyed) {
|
||||
let framesWritten = 0;
|
||||
if (recorder.lastFrameTime && recorder.lastFrameBuf) {
|
||||
const gap = now - recorder.lastFrameTime;
|
||||
const dupes = Math.round(gap / frameDuration) - 1;
|
||||
for (let i = 0; i < dupes && i < fps * 30; i++) {
|
||||
ffmpeg.stdin.write(recorder.lastFrameBuf);
|
||||
framesWritten++;
|
||||
}
|
||||
}
|
||||
ffmpeg.stdin.write(buf);
|
||||
framesWritten++;
|
||||
recorder.videoTimeMs += framesWritten * frameDuration;
|
||||
}
|
||||
recorder.lastFrameTime = now;
|
||||
recorder.lastFrameBuf = buf;
|
||||
try { await cdp.send('Page.screencastFrameAck', { sessionId }); } catch {}
|
||||
};
|
||||
|
||||
// Duplicate the last frame to fill wall-clock gaps (static periods, context switches).
|
||||
const _flushFrames = () => {
|
||||
if (!recorder || !recorder.lastFrameBuf || !recorder.lastFrameTime || ffmpeg.stdin.destroyed) return;
|
||||
const now = Date.now();
|
||||
const gap = now - recorder.lastFrameTime;
|
||||
const dupes = Math.round(gap / frameDuration);
|
||||
for (let i = 0; i < dupes; i++) {
|
||||
ffmpeg.stdin.write(recorder.lastFrameBuf);
|
||||
recorder.videoTimeMs += frameDuration;
|
||||
}
|
||||
if (dupes > 0) recorder.lastFrameTime = now;
|
||||
};
|
||||
|
||||
// Attach screencast to a specific page. Stops the old CDP first (if any).
|
||||
// Called by startRecording for the initial page, and by setActiveContext when
|
||||
// the active context changes mid-recording.
|
||||
const _attachPage = async (targetPage) => {
|
||||
if (recorder.cdp) {
|
||||
_flushFrames(); // freeze the last frame of the outgoing page up to "now"
|
||||
try { await recorder.cdp.send('Page.stopScreencast'); } catch {}
|
||||
try { await recorder.cdp.detach(); } catch {}
|
||||
recorder.cdp = null;
|
||||
}
|
||||
const cdp = await targetPage.context().newCDPSession(targetPage);
|
||||
cdp.on('Page.screencastFrame', (ev) => frameHandler(ev, cdp));
|
||||
await cdp.send('Page.startScreencast', { format: 'jpeg', quality, everyNthFrame: 1 });
|
||||
recorder.cdp = cdp;
|
||||
recorder.activePage = targetPage;
|
||||
};
|
||||
|
||||
setRecorder({
|
||||
cdp: null,
|
||||
activePage: null,
|
||||
ffmpeg,
|
||||
startTime: Date.now(),
|
||||
outputPath: resolvedPath,
|
||||
ffmpegError: '',
|
||||
captions: [],
|
||||
videoTimeMs: 0,
|
||||
frameDuration,
|
||||
lastFrameTime: null,
|
||||
lastFrameBuf: null,
|
||||
_flushFrames,
|
||||
_attachPage,
|
||||
speechRate,
|
||||
});
|
||||
ffmpeg.stderr.on('data', d => { recorder.ffmpegError += d.toString(); });
|
||||
|
||||
await _attachPage(page);
|
||||
}
|
||||
|
||||
/**
|
||||
* Stop video recording. Finalizes the MP4 file.
|
||||
* @returns {{ file: string, duration: number, size: number }}
|
||||
*/
|
||||
export async function stopRecording() {
|
||||
if (!recorder) return { file: null, duration: 0, size: 0 };
|
||||
|
||||
const { cdp, ffmpeg, startTime, outputPath } = recorder;
|
||||
|
||||
// Final frame flush: write remaining frames to cover the gap since the last screencast frame
|
||||
if (recorder._flushFrames) recorder._flushFrames();
|
||||
|
||||
// Stop CDP screencast
|
||||
try { await cdp.send('Page.stopScreencast'); } catch {}
|
||||
try { await cdp.detach(); } catch {}
|
||||
|
||||
// Close ffmpeg stdin and wait for encoding to finish
|
||||
await new Promise((resolve, reject) => {
|
||||
const timeout = setTimeout(() => {
|
||||
ffmpeg.kill('SIGKILL');
|
||||
reject(new Error('ffmpeg timed out after 30s'));
|
||||
}, 30000);
|
||||
|
||||
ffmpeg.on('close', (code) => {
|
||||
clearTimeout(timeout);
|
||||
if (code === 0) resolve();
|
||||
else reject(new Error(`ffmpeg exited with code ${code}: ${recorder?.ffmpegError || ''}`));
|
||||
});
|
||||
ffmpeg.on('error', (err) => {
|
||||
clearTimeout(timeout);
|
||||
reject(err);
|
||||
});
|
||||
|
||||
ffmpeg.stdin.end();
|
||||
});
|
||||
|
||||
const duration = (Date.now() - startTime) / 1000;
|
||||
const stats = statSync(outputPath);
|
||||
|
||||
// Preserve captions for addNarration()
|
||||
setLastCaptions(recorder.captions || []);
|
||||
setLastRecordingDuration(duration);
|
||||
if (lastCaptions.length) {
|
||||
const captionsPath = outputPath.replace(/\.[^.]+$/, '.captions.json');
|
||||
const captionsData = { recordingDuration: duration, videoTimestamps: true, captions: lastCaptions };
|
||||
writeFileSync(captionsPath, JSON.stringify(captionsData, null, 2), 'utf-8');
|
||||
}
|
||||
|
||||
setRecorder(null);
|
||||
|
||||
return {
|
||||
file: outputPath,
|
||||
duration: Math.round(duration * 10) / 10,
|
||||
size: stats.size,
|
||||
captions: lastCaptions.length
|
||||
};
|
||||
// web-test recording/capture v1.17 — Recording lifecycle (CDP screencast + ffmpeg pipe), screenshot, wait helpers.
|
||||
// Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
|
||||
|
||||
import { spawn } from 'child_process';
|
||||
import { mkdirSync, statSync, writeFileSync } from 'fs';
|
||||
import { dirname } from 'path';
|
||||
import {
|
||||
page, recorder, lastCaptions,
|
||||
setRecorder, setLastCaptions, setLastRecordingDuration,
|
||||
resolveProjectPath, ensureConnected,
|
||||
} from '../core/state.mjs';
|
||||
import { resolveFfmpeg } from './tts.mjs';
|
||||
// Imported lazily inside wait() to avoid initialization-time circular deps.
|
||||
|
||||
/** 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();
|
||||
let ms = seconds * 1000;
|
||||
// Credit system: if showCaption already waited for TTS, subtract that time
|
||||
if (recorder && recorder.captionCredit) {
|
||||
const elapsed = Date.now() - recorder.captionCredit.at;
|
||||
const credit = Math.max(0, recorder.captionCredit.waitedMs - elapsed);
|
||||
ms = Math.max(0, ms - credit);
|
||||
recorder.captionCredit = null;
|
||||
}
|
||||
if (ms > 0) {
|
||||
// During recording, split long waits into chunks and flush frames
|
||||
// to keep video timeline in sync (CDP may not send frames for static pages)
|
||||
if (recorder?._flushFrames && ms > 1000) {
|
||||
let remaining = ms;
|
||||
while (remaining > 0) {
|
||||
const chunk = Math.min(remaining, 1000);
|
||||
await page.waitForTimeout(chunk);
|
||||
remaining -= chunk;
|
||||
recorder._flushFrames();
|
||||
}
|
||||
} else {
|
||||
await page.waitForTimeout(ms);
|
||||
}
|
||||
}
|
||||
const { getFormState } = await import('../forms/state.mjs');
|
||||
return await getFormState();
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// Video recording — CDP screencast + ffmpeg
|
||||
// ============================================================
|
||||
|
||||
/** Check if video recording is active. */
|
||||
export function isRecording() {
|
||||
return recorder !== null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Start video recording via CDP screencast + ffmpeg.
|
||||
* Frames are captured as JPEG and piped to ffmpeg for MP4 encoding.
|
||||
* @param {string} outputPath — output .mp4 file path
|
||||
* @param {object} [opts]
|
||||
* @param {number} [opts.fps=25] — target framerate
|
||||
* @param {number} [opts.quality=80] — JPEG quality (1-100)
|
||||
* @param {string} [opts.ffmpegPath] — explicit path to ffmpeg binary
|
||||
*/
|
||||
export async function startRecording(outputPath, opts = {}) {
|
||||
ensureConnected();
|
||||
if (recorder) {
|
||||
if (opts.force) {
|
||||
try { await stopRecording(); } catch {}
|
||||
} else {
|
||||
throw new Error('Already recording. Call stopRecording() first, or use { force: true }.');
|
||||
}
|
||||
}
|
||||
setLastCaptions([]);
|
||||
setLastRecordingDuration(null);
|
||||
|
||||
const fps = opts.fps || 25;
|
||||
const quality = opts.quality || 80;
|
||||
const ffmpegPath = resolveFfmpeg(opts.ffmpegPath);
|
||||
|
||||
// Ensure output directory exists
|
||||
const resolvedPath = resolveProjectPath(outputPath);
|
||||
mkdirSync(dirname(resolvedPath), { recursive: true });
|
||||
|
||||
// Spawn ffmpeg process — single output file across context switches
|
||||
const ffmpeg = spawn(ffmpegPath, [
|
||||
'-y', // overwrite output
|
||||
'-f', 'image2pipe', // input: piped images
|
||||
'-framerate', String(fps), // input framerate
|
||||
'-i', '-', // read from stdin
|
||||
'-c:v', 'libx264', // H.264 codec
|
||||
'-preset', 'fast', // good quality/speed balance
|
||||
'-crf', '23', // default quality (good for screen content)
|
||||
'-vf', 'scale=in_range=full:out_range=limited', // JPEG full→H.264 limited range
|
||||
'-pix_fmt', 'yuv420p', // broad compatibility
|
||||
'-color_range', 'tv', // limited range (16-235) — standard for H.264 players
|
||||
'-movflags', '+faststart', // web-friendly MP4
|
||||
resolvedPath
|
||||
], { stdio: ['pipe', 'ignore', 'pipe'] });
|
||||
|
||||
ffmpeg.on('error', err => { if (recorder) recorder.ffmpegError += err.message; });
|
||||
|
||||
const frameDuration = 1000 / fps;
|
||||
const speechRate = opts.speechRate || 70; // ms per character for smart TTS wait
|
||||
|
||||
// Frame handler shared across CDP sessions (lives in recorder, not closure):
|
||||
// when the active context switches, we attach a new CDP session and route its
|
||||
// frames to the same ffmpeg pipe — preserving a single continuous timeline.
|
||||
const frameHandler = async ({ data, sessionId }, cdp) => {
|
||||
if (!recorder) return;
|
||||
const buf = Buffer.from(data, 'base64');
|
||||
const now = Date.now();
|
||||
if (!ffmpeg.stdin.destroyed) {
|
||||
let framesWritten = 0;
|
||||
if (recorder.lastFrameTime && recorder.lastFrameBuf) {
|
||||
const gap = now - recorder.lastFrameTime;
|
||||
const dupes = Math.round(gap / frameDuration) - 1;
|
||||
for (let i = 0; i < dupes && i < fps * 30; i++) {
|
||||
ffmpeg.stdin.write(recorder.lastFrameBuf);
|
||||
framesWritten++;
|
||||
}
|
||||
}
|
||||
ffmpeg.stdin.write(buf);
|
||||
framesWritten++;
|
||||
recorder.videoTimeMs += framesWritten * frameDuration;
|
||||
}
|
||||
recorder.lastFrameTime = now;
|
||||
recorder.lastFrameBuf = buf;
|
||||
try { await cdp.send('Page.screencastFrameAck', { sessionId }); } catch {}
|
||||
};
|
||||
|
||||
// Duplicate the last frame to fill wall-clock gaps (static periods, context switches).
|
||||
const _flushFrames = () => {
|
||||
if (!recorder || !recorder.lastFrameBuf || !recorder.lastFrameTime || ffmpeg.stdin.destroyed) return;
|
||||
const now = Date.now();
|
||||
const gap = now - recorder.lastFrameTime;
|
||||
const dupes = Math.round(gap / frameDuration);
|
||||
for (let i = 0; i < dupes; i++) {
|
||||
ffmpeg.stdin.write(recorder.lastFrameBuf);
|
||||
recorder.videoTimeMs += frameDuration;
|
||||
}
|
||||
if (dupes > 0) recorder.lastFrameTime = now;
|
||||
};
|
||||
|
||||
// Attach screencast to a specific page. Stops the old CDP first (if any).
|
||||
// Called by startRecording for the initial page, and by setActiveContext when
|
||||
// the active context changes mid-recording.
|
||||
const _attachPage = async (targetPage) => {
|
||||
if (recorder.cdp) {
|
||||
_flushFrames(); // freeze the last frame of the outgoing page up to "now"
|
||||
try { await recorder.cdp.send('Page.stopScreencast'); } catch {}
|
||||
try { await recorder.cdp.detach(); } catch {}
|
||||
recorder.cdp = null;
|
||||
}
|
||||
const cdp = await targetPage.context().newCDPSession(targetPage);
|
||||
cdp.on('Page.screencastFrame', (ev) => frameHandler(ev, cdp));
|
||||
await cdp.send('Page.startScreencast', { format: 'jpeg', quality, everyNthFrame: 1 });
|
||||
recorder.cdp = cdp;
|
||||
recorder.activePage = targetPage;
|
||||
};
|
||||
|
||||
setRecorder({
|
||||
cdp: null,
|
||||
activePage: null,
|
||||
ffmpeg,
|
||||
startTime: Date.now(),
|
||||
outputPath: resolvedPath,
|
||||
ffmpegError: '',
|
||||
captions: [],
|
||||
videoTimeMs: 0,
|
||||
frameDuration,
|
||||
lastFrameTime: null,
|
||||
lastFrameBuf: null,
|
||||
_flushFrames,
|
||||
_attachPage,
|
||||
speechRate,
|
||||
});
|
||||
ffmpeg.stderr.on('data', d => { recorder.ffmpegError += d.toString(); });
|
||||
|
||||
await _attachPage(page);
|
||||
}
|
||||
|
||||
/**
|
||||
* Stop video recording. Finalizes the MP4 file.
|
||||
* @returns {{ file: string, duration: number, size: number }}
|
||||
*/
|
||||
export async function stopRecording() {
|
||||
if (!recorder) return { file: null, duration: 0, size: 0 };
|
||||
|
||||
const { cdp, ffmpeg, startTime, outputPath } = recorder;
|
||||
|
||||
// Final frame flush: write remaining frames to cover the gap since the last screencast frame
|
||||
if (recorder._flushFrames) recorder._flushFrames();
|
||||
|
||||
// Stop CDP screencast
|
||||
try { await cdp.send('Page.stopScreencast'); } catch {}
|
||||
try { await cdp.detach(); } catch {}
|
||||
|
||||
// Close ffmpeg stdin and wait for encoding to finish
|
||||
await new Promise((resolve, reject) => {
|
||||
const timeout = setTimeout(() => {
|
||||
ffmpeg.kill('SIGKILL');
|
||||
reject(new Error('ffmpeg timed out after 30s'));
|
||||
}, 30000);
|
||||
|
||||
ffmpeg.on('close', (code) => {
|
||||
clearTimeout(timeout);
|
||||
if (code === 0) resolve();
|
||||
else reject(new Error(`ffmpeg exited with code ${code}: ${recorder?.ffmpegError || ''}`));
|
||||
});
|
||||
ffmpeg.on('error', (err) => {
|
||||
clearTimeout(timeout);
|
||||
reject(err);
|
||||
});
|
||||
|
||||
ffmpeg.stdin.end();
|
||||
});
|
||||
|
||||
const duration = (Date.now() - startTime) / 1000;
|
||||
const stats = statSync(outputPath);
|
||||
|
||||
// Preserve captions for addNarration()
|
||||
setLastCaptions(recorder.captions || []);
|
||||
setLastRecordingDuration(duration);
|
||||
if (lastCaptions.length) {
|
||||
const captionsPath = outputPath.replace(/\.[^.]+$/, '.captions.json');
|
||||
const captionsData = { recordingDuration: duration, videoTimestamps: true, captions: lastCaptions };
|
||||
writeFileSync(captionsPath, JSON.stringify(captionsData, null, 2), 'utf-8');
|
||||
}
|
||||
|
||||
setRecorder(null);
|
||||
|
||||
return {
|
||||
file: outputPath,
|
||||
duration: Math.round(duration * 10) / 10,
|
||||
size: stats.size,
|
||||
captions: lastCaptions.length
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,340 +1,340 @@
|
||||
// web-test recording/highlight v1.17 — Visual highlight overlay (single + auto-mode for clickElement/fillFields/selectValue).
|
||||
// Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
|
||||
|
||||
import {
|
||||
page, highlightMode, ensureConnected, normYo,
|
||||
setHighlightMode,
|
||||
} from '../core/state.mjs';
|
||||
import {
|
||||
readSubmenuScript, detectFormScript, resolveGridScript,
|
||||
findClickTargetScript, resolveFieldsScript,
|
||||
} from '../../dom.mjs';
|
||||
|
||||
/**
|
||||
* Highlight an element on the page (visual accent for video recordings).
|
||||
* Uses overlay div for visibility (not clipped by overflow:hidden), with
|
||||
* requestAnimationFrame tracking so it follows layout shifts (async banners etc).
|
||||
* @param {string} text Element text/label (fuzzy match, same as clickElement/fillFields)
|
||||
* @param {object} [opts]
|
||||
* @param {string} [opts.color] Outline color (default: '#e74c3c')
|
||||
* @param {number} [opts.padding] Extra padding around element (default: 4)
|
||||
*/
|
||||
export async function highlight(text, opts = {}) {
|
||||
ensureConnected();
|
||||
const { color = '#e74c3c', padding = 4, table } = opts;
|
||||
|
||||
// Remove previous highlight first
|
||||
await unhighlight();
|
||||
|
||||
let elId = null;
|
||||
|
||||
// 0. Open submenu/popup — highest priority (submenu overlays the form,
|
||||
// so form search would match grid rows behind the popup)
|
||||
const popupItems = await page.evaluate(readSubmenuScript());
|
||||
if (Array.isArray(popupItems) && popupItems.length > 0) {
|
||||
const target = normYo(text.toLowerCase());
|
||||
let found = popupItems.find(i => normYo(i.name.toLowerCase()) === target);
|
||||
if (!found) found = popupItems.find(i => normYo(i.name.toLowerCase()).startsWith(target));
|
||||
if (!found) found = popupItems.find(i => normYo(i.name.toLowerCase()).includes(target));
|
||||
if (found) {
|
||||
// 1C duplicates IDs in clouds — getElementById returns the hidden copy.
|
||||
// Use elementFromPoint to find the visible element and get its actual rect.
|
||||
await page.evaluate(({ x, y, color, padding }) => {
|
||||
const el = document.elementFromPoint(x, y);
|
||||
if (!el) return;
|
||||
const block = el.closest('.submenuBlock') || el.closest('a.press') || el;
|
||||
const r = block.getBoundingClientRect();
|
||||
let div = document.getElementById('__web_test_highlight');
|
||||
if (!div) {
|
||||
div = document.createElement('div');
|
||||
div.id = '__web_test_highlight';
|
||||
document.body.appendChild(div);
|
||||
}
|
||||
div.style.cssText = [
|
||||
'position:fixed', 'pointer-events:none', 'z-index:999998',
|
||||
`top:${r.y - padding}px`, `left:${r.x - padding}px`,
|
||||
`width:${r.width + padding * 2}px`, `height:${r.height + padding * 2}px`,
|
||||
`outline:3px solid ${color}`, 'border-radius:4px',
|
||||
`box-shadow:0 0 16px ${color}80`,
|
||||
].join(';');
|
||||
}, { x: found.x, y: found.y, color, padding });
|
||||
return; // overlay placed, done
|
||||
}
|
||||
}
|
||||
|
||||
// 1. Visible commands on the function panel (cmd_XXX_txt elements)
|
||||
// Must be checked BEFORE form search: when the section content panel
|
||||
// is showing, the form behind it is hidden but detectFormScript still
|
||||
// finds it, and form buttons match before commands.
|
||||
if (!elId) {
|
||||
elId = await page.evaluate(`(() => {
|
||||
const norm = s => (s?.trim().replace(/\\u00a0/g, ' ') || '').replace(/ё/gi, 'е');
|
||||
const target = ${JSON.stringify(normYo(text.toLowerCase()))};
|
||||
const cmds = [...document.querySelectorAll('[id^="cmd_"][id$="_txt"]')].filter(e => e.offsetWidth > 0);
|
||||
if (cmds.length === 0) return null;
|
||||
let el = cmds.find(e => norm(e.innerText).toLowerCase() === target);
|
||||
if (!el) el = cmds.find(e => norm(e.innerText).toLowerCase().startsWith(target));
|
||||
if (!el) el = cmds.find(e => norm(e.innerText).toLowerCase().includes(target));
|
||||
return el ? el.id : null;
|
||||
})()`);
|
||||
}
|
||||
|
||||
// 1b. Command group headers on the function panel (eAccentColor labels).
|
||||
// Match header text, then highlight the header + commands below it
|
||||
// until the next spacer/header/end.
|
||||
if (!elId) {
|
||||
const groupDone = await page.evaluate(({ target, color, padding }) => {
|
||||
const container = document.querySelector('#funcPanel_container');
|
||||
if (!container) return false;
|
||||
const norm = s => (s?.trim().replace(/\u00a0/g, ' ') || '').replace(/ё/gi, 'е').toLowerCase();
|
||||
const headers = [...container.querySelectorAll('.eAccentColor')].filter(e => e.offsetWidth > 0);
|
||||
if (!headers.length) return false;
|
||||
|
||||
let headerEl = headers.find(h => norm(h.textContent) === target);
|
||||
if (!headerEl) headerEl = headers.find(h => norm(h.textContent).startsWith(target));
|
||||
if (!headerEl) headerEl = headers.find(h => norm(h.textContent).includes(target));
|
||||
if (!headerEl) return false;
|
||||
|
||||
// Collect header + following cmd siblings until next spacer/header
|
||||
const parent = headerEl.parentElement;
|
||||
const children = [...parent.children];
|
||||
const startIdx = children.indexOf(headerEl);
|
||||
const groupEls = [headerEl];
|
||||
for (let i = startIdx + 1; i < children.length; i++) {
|
||||
const el = children[i];
|
||||
if (el.classList.contains('eAccentColor')) break;
|
||||
if (!el.id && !el.classList.contains('functionItem') && el.getBoundingClientRect().width < 10) break;
|
||||
groupEls.push(el);
|
||||
}
|
||||
|
||||
// Bounding box
|
||||
let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity;
|
||||
for (const el of groupEls) {
|
||||
const r = el.getBoundingClientRect();
|
||||
if (r.width === 0 && r.height === 0) continue;
|
||||
minX = Math.min(minX, r.left); minY = Math.min(minY, r.top);
|
||||
maxX = Math.max(maxX, r.right); maxY = Math.max(maxY, r.bottom);
|
||||
}
|
||||
if (minX === Infinity) return false;
|
||||
|
||||
let div = document.getElementById('__web_test_highlight');
|
||||
if (!div) { div = document.createElement('div'); div.id = '__web_test_highlight'; document.body.appendChild(div); }
|
||||
div.style.cssText = [
|
||||
'position:fixed', 'pointer-events:none', 'z-index:999998',
|
||||
`top:${minY - padding}px`, `left:${minX - padding}px`,
|
||||
`width:${maxX - minX + padding * 2}px`, `height:${maxY - minY + padding * 2}px`,
|
||||
`outline:3px solid ${color}`, 'border-radius:4px',
|
||||
`box-shadow:0 0 16px ${color}80`,
|
||||
].join(';');
|
||||
return true;
|
||||
}, { target: normYo(text.toLowerCase()), color, padding });
|
||||
if (groupDone) return;
|
||||
}
|
||||
|
||||
// 2. Form groups/panels — checked BEFORE buttons/fields because group names
|
||||
// often collide with command bar buttons (e.g. "БизнесПроцессы" is both a
|
||||
// panel and a command bar element). Includes _container and _div elements
|
||||
// but skips logicGroupContainer (Representation=None, height=0).
|
||||
if (!elId) {
|
||||
const formNum = await page.evaluate(detectFormScript());
|
||||
if (formNum !== null) {
|
||||
elId = await page.evaluate(`(() => {
|
||||
const norm = s => (s?.trim().replace(/\\u00a0/g, ' ') || '').replace(/ё/gi, 'е');
|
||||
const target = ${JSON.stringify(normYo(text.toLowerCase()))};
|
||||
const p = 'form' + ${formNum} + '_';
|
||||
// Group containers: _container or _div, but skip logicGroupContainer (invisible groups)
|
||||
const groups = [...document.querySelectorAll('[id^="' + p + '"][id$="_container"], [id^="' + p + '"][id$="_div"]')]
|
||||
.filter(el => el.offsetWidth > 0 && el.offsetHeight > 0 && !el.classList.contains('logicGroupContainer'));
|
||||
const items = groups.map(el => {
|
||||
const idName = el.id.replace(p, '').replace(/_(container|div)$/, '');
|
||||
const titleEl = document.getElementById(p + idName + '#title_text')
|
||||
|| document.getElementById(p + idName + '_title_text');
|
||||
const label = norm(titleEl?.innerText || '').toLowerCase();
|
||||
const name = norm(idName).toLowerCase();
|
||||
const big = el.offsetWidth >= 100 && el.offsetHeight >= 50;
|
||||
return { id: el.id, name, label, big };
|
||||
});
|
||||
let found = items.find(i => i.label === target);
|
||||
if (!found) found = items.find(i => i.name === target);
|
||||
// Fuzzy match: only large groups (min 100x50) to avoid matching command bars
|
||||
if (!found) found = items.filter(i => i.big).find(i => i.label.startsWith(target) || i.name.startsWith(target));
|
||||
if (!found && target.length >= 4) found = items.filter(i => i.big).find(i => i.label.includes(target) || i.name.includes(target));
|
||||
return found ? found.id : null;
|
||||
})()`);
|
||||
}
|
||||
}
|
||||
|
||||
// 3. Form-scoped search (buttons, links, fields, grid rows)
|
||||
if (!elId) {
|
||||
const formNum = await page.evaluate(detectFormScript());
|
||||
if (formNum !== null) {
|
||||
// 3a. Try button/link/tab/gridRow via findClickTargetScript
|
||||
let gridSelector;
|
||||
if (table) {
|
||||
const resolved = await page.evaluate(resolveGridScript(formNum, table));
|
||||
if (!resolved.error) gridSelector = resolved.gridSelector;
|
||||
}
|
||||
const target = await page.evaluate(findClickTargetScript(formNum, text, table ? { tableName: table, gridSelector } : undefined));
|
||||
if (target && !target.error) {
|
||||
if (target.id) {
|
||||
elId = target.id;
|
||||
} else if (target.x && target.y) {
|
||||
// Grid row — find the gridLine element and tag it
|
||||
elId = await page.evaluate(`(() => {
|
||||
const p = ${JSON.stringify(`form${formNum}_`)};
|
||||
const grid = document.querySelector('[id^="' + p + '"].grid');
|
||||
if (!grid) return null;
|
||||
const body = grid.querySelector('.gridBody');
|
||||
if (!body) return null;
|
||||
const norm = s => (s?.trim().replace(/\\u00a0/g, ' ') || '').replace(/ё/gi, 'е');
|
||||
const target = ${JSON.stringify(normYo(text.toLowerCase()))};
|
||||
for (const line of body.querySelectorAll('.gridLine')) {
|
||||
const cells = [...line.querySelectorAll('.gridBoxText')].filter(b => b.offsetWidth > 0);
|
||||
const rowText = cells.map(b => b.innerText?.trim() || '').join(' ').toLowerCase().replace(/ё/gi, 'е');
|
||||
if (rowText.includes(target)) {
|
||||
if (!line.id) line.id = '__wt_hl_tmp';
|
||||
return line.id;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
})()`);
|
||||
}
|
||||
}
|
||||
|
||||
// 3b. If not found as button — try as field via resolveFieldsScript
|
||||
if (!elId) {
|
||||
const dummyFields = { [text]: '' };
|
||||
const resolved = await page.evaluate(resolveFieldsScript(formNum, dummyFields));
|
||||
if (resolved?.length > 0 && !resolved[0].error && resolved[0].inputId) {
|
||||
elId = resolved[0].inputId;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 4. Fallback: sections (sidebar navigation)
|
||||
if (!elId) {
|
||||
elId = await page.evaluate(`(() => {
|
||||
const norm = s => (s?.trim().replace(/\\u00a0/g, ' ') || '').replace(/ё/gi, 'е');
|
||||
const target = ${JSON.stringify(normYo(text.toLowerCase()))};
|
||||
const secs = [...document.querySelectorAll('[id^="themesCell_theme_"]')];
|
||||
let el = secs.find(e => norm(e.innerText).toLowerCase() === target);
|
||||
if (!el) el = secs.find(e => norm(e.innerText).toLowerCase().startsWith(target));
|
||||
if (!el) el = secs.find(e => norm(e.innerText).toLowerCase().includes(target));
|
||||
return el ? el.id : null;
|
||||
})()`);
|
||||
}
|
||||
|
||||
if (!elId) {
|
||||
// Collect available elements to help the caller fix the name
|
||||
const available = await page.evaluate(`(() => {
|
||||
const norm = s => (s?.trim().replace(/\\u00a0/g, ' ') || '').replace(/ё/gi, 'е');
|
||||
const result = {};
|
||||
// Commands
|
||||
const cmds = [...document.querySelectorAll('[id^="cmd_"][id$="_txt"]')].filter(e => e.offsetWidth > 0).map(e => norm(e.innerText));
|
||||
if (cmds.length) result.commands = cmds;
|
||||
// Command group headers
|
||||
const fp = document.querySelector('#funcPanel_container');
|
||||
if (fp) {
|
||||
const gh = [...fp.querySelectorAll('.eAccentColor')].filter(e => e.offsetWidth > 0).map(e => norm(e.textContent));
|
||||
if (gh.length) result.commandGroups = gh;
|
||||
}
|
||||
// Sections
|
||||
const secs = [...document.querySelectorAll('[id^="themesCell_theme_"]')].map(e => norm(e.innerText)).filter(Boolean);
|
||||
if (secs.length) result.sections = secs;
|
||||
// Form elements
|
||||
${(() => {
|
||||
// Detect form inline to avoid extra evaluate round-trip
|
||||
return `
|
||||
const forms = {};
|
||||
document.querySelectorAll('[id^="form"]').forEach(el => {
|
||||
const m = el.id.match(/^form(\\d+)_/);
|
||||
if (m) forms[m[1]] = (forms[m[1]] || 0) + 1;
|
||||
});
|
||||
let formNum = null, maxCount = 0;
|
||||
for (const [n, c] of Object.entries(forms)) {
|
||||
if (parseInt(n) > 0 && c > maxCount) { maxCount = c; formNum = n; }
|
||||
}
|
||||
if (formNum !== null) {
|
||||
const p = 'form' + formNum + '_';
|
||||
// Groups (_container or _div, skip logicGroupContainer, min 100x50)
|
||||
const groups = [...document.querySelectorAll('[id^="' + p + '"][id$="_container"], [id^="' + p + '"][id$="_div"]')]
|
||||
.filter(el => el.offsetWidth >= 100 && el.offsetHeight >= 50 && !el.classList.contains('logicGroupContainer'))
|
||||
.map(el => {
|
||||
const idName = el.id.replace(p, '').replace(/_(container|div)$/, '');
|
||||
const titleEl = document.getElementById(p + idName + '#title_text') || document.getElementById(p + idName + '_title_text');
|
||||
return norm(titleEl?.innerText || '') || idName;
|
||||
}).filter(Boolean);
|
||||
if (groups.length) result.groups = groups;
|
||||
// Buttons/links
|
||||
const btns = [...document.querySelectorAll('[id^="' + p + '"].btnText, [id^="' + p + '"] .btnText, [id^="' + p + '"].hplnk')]
|
||||
.filter(el => el.offsetWidth > 0).map(el => norm(el.innerText)).filter(Boolean);
|
||||
if (btns.length) result.buttons = [...new Set(btns)];
|
||||
}`;
|
||||
})()}
|
||||
return result;
|
||||
})()`);
|
||||
const parts = [];
|
||||
for (const [cat, items] of Object.entries(available)) {
|
||||
parts.push(` ${cat}: ${items.join(', ')}`);
|
||||
}
|
||||
const hint = parts.length ? `\nAvailable:\n${parts.join('\n')}` : '';
|
||||
throw new Error(`highlight: "${text}" not found${hint}`);
|
||||
}
|
||||
|
||||
// Overlay div + rAF tracking loop (not clipped by overflow:hidden, follows layout shifts)
|
||||
await page.evaluate(({ elId, color, padding }) => {
|
||||
const target = document.getElementById(elId);
|
||||
if (!target) return;
|
||||
let div = document.getElementById('__web_test_highlight');
|
||||
if (!div) {
|
||||
div = document.createElement('div');
|
||||
div.id = '__web_test_highlight';
|
||||
document.body.appendChild(div);
|
||||
}
|
||||
function sync() {
|
||||
const r = target.getBoundingClientRect();
|
||||
div.style.cssText = [
|
||||
'position:fixed', 'pointer-events:none', 'z-index:999998',
|
||||
`top:${r.y - padding}px`, `left:${r.x - padding}px`,
|
||||
`width:${r.width + padding * 2}px`, `height:${r.height + padding * 2}px`,
|
||||
`outline:3px solid ${color}`, 'border-radius:4px',
|
||||
`box-shadow:0 0 16px ${color}80`,
|
||||
].join(';');
|
||||
}
|
||||
sync();
|
||||
// Track position changes via rAF
|
||||
function tick() {
|
||||
if (!document.getElementById('__web_test_highlight')) return; // stopped
|
||||
sync();
|
||||
requestAnimationFrame(tick);
|
||||
}
|
||||
requestAnimationFrame(tick);
|
||||
}, { elId, color, padding });
|
||||
}
|
||||
|
||||
/** Remove the highlight overlay. */
|
||||
export async function unhighlight() {
|
||||
ensureConnected();
|
||||
await page.evaluate(() => {
|
||||
const el = document.getElementById('__web_test_highlight');
|
||||
if (el) el.remove(); // also stops rAF loop (id check)
|
||||
// Clean up temp ID from grid rows
|
||||
const tmp = document.getElementById('__wt_hl_tmp');
|
||||
if (tmp) tmp.removeAttribute('id');
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Toggle auto-highlight mode. When enabled, clickElement/fillFields/selectValue
|
||||
* automatically highlight the target element before acting.
|
||||
* @param {boolean} on true to enable, false to disable
|
||||
*/
|
||||
export function setHighlight(on) {
|
||||
setHighlightMode(!!on);
|
||||
}
|
||||
|
||||
/** @returns {boolean} Whether auto-highlight mode is active. */
|
||||
export function isHighlightMode() {
|
||||
return highlightMode;
|
||||
}
|
||||
// web-test recording/highlight v1.17 — Visual highlight overlay (single + auto-mode for clickElement/fillFields/selectValue).
|
||||
// Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
|
||||
|
||||
import {
|
||||
page, highlightMode, ensureConnected, normYo,
|
||||
setHighlightMode,
|
||||
} from '../core/state.mjs';
|
||||
import {
|
||||
readSubmenuScript, detectFormScript, resolveGridScript,
|
||||
findClickTargetScript, resolveFieldsScript,
|
||||
} from '../../dom.mjs';
|
||||
|
||||
/**
|
||||
* Highlight an element on the page (visual accent for video recordings).
|
||||
* Uses overlay div for visibility (not clipped by overflow:hidden), with
|
||||
* requestAnimationFrame tracking so it follows layout shifts (async banners etc).
|
||||
* @param {string} text Element text/label (fuzzy match, same as clickElement/fillFields)
|
||||
* @param {object} [opts]
|
||||
* @param {string} [opts.color] Outline color (default: '#e74c3c')
|
||||
* @param {number} [opts.padding] Extra padding around element (default: 4)
|
||||
*/
|
||||
export async function highlight(text, opts = {}) {
|
||||
ensureConnected();
|
||||
const { color = '#e74c3c', padding = 4, table } = opts;
|
||||
|
||||
// Remove previous highlight first
|
||||
await unhighlight();
|
||||
|
||||
let elId = null;
|
||||
|
||||
// 0. Open submenu/popup — highest priority (submenu overlays the form,
|
||||
// so form search would match grid rows behind the popup)
|
||||
const popupItems = await page.evaluate(readSubmenuScript());
|
||||
if (Array.isArray(popupItems) && popupItems.length > 0) {
|
||||
const target = normYo(text.toLowerCase());
|
||||
let found = popupItems.find(i => normYo(i.name.toLowerCase()) === target);
|
||||
if (!found) found = popupItems.find(i => normYo(i.name.toLowerCase()).startsWith(target));
|
||||
if (!found) found = popupItems.find(i => normYo(i.name.toLowerCase()).includes(target));
|
||||
if (found) {
|
||||
// 1C duplicates IDs in clouds — getElementById returns the hidden copy.
|
||||
// Use elementFromPoint to find the visible element and get its actual rect.
|
||||
await page.evaluate(({ x, y, color, padding }) => {
|
||||
const el = document.elementFromPoint(x, y);
|
||||
if (!el) return;
|
||||
const block = el.closest('.submenuBlock') || el.closest('a.press') || el;
|
||||
const r = block.getBoundingClientRect();
|
||||
let div = document.getElementById('__web_test_highlight');
|
||||
if (!div) {
|
||||
div = document.createElement('div');
|
||||
div.id = '__web_test_highlight';
|
||||
document.body.appendChild(div);
|
||||
}
|
||||
div.style.cssText = [
|
||||
'position:fixed', 'pointer-events:none', 'z-index:999998',
|
||||
`top:${r.y - padding}px`, `left:${r.x - padding}px`,
|
||||
`width:${r.width + padding * 2}px`, `height:${r.height + padding * 2}px`,
|
||||
`outline:3px solid ${color}`, 'border-radius:4px',
|
||||
`box-shadow:0 0 16px ${color}80`,
|
||||
].join(';');
|
||||
}, { x: found.x, y: found.y, color, padding });
|
||||
return; // overlay placed, done
|
||||
}
|
||||
}
|
||||
|
||||
// 1. Visible commands on the function panel (cmd_XXX_txt elements)
|
||||
// Must be checked BEFORE form search: when the section content panel
|
||||
// is showing, the form behind it is hidden but detectFormScript still
|
||||
// finds it, and form buttons match before commands.
|
||||
if (!elId) {
|
||||
elId = await page.evaluate(`(() => {
|
||||
const norm = s => (s?.trim().replace(/\\u00a0/g, ' ') || '').replace(/ё/gi, 'е');
|
||||
const target = ${JSON.stringify(normYo(text.toLowerCase()))};
|
||||
const cmds = [...document.querySelectorAll('[id^="cmd_"][id$="_txt"]')].filter(e => e.offsetWidth > 0);
|
||||
if (cmds.length === 0) return null;
|
||||
let el = cmds.find(e => norm(e.innerText).toLowerCase() === target);
|
||||
if (!el) el = cmds.find(e => norm(e.innerText).toLowerCase().startsWith(target));
|
||||
if (!el) el = cmds.find(e => norm(e.innerText).toLowerCase().includes(target));
|
||||
return el ? el.id : null;
|
||||
})()`);
|
||||
}
|
||||
|
||||
// 1b. Command group headers on the function panel (eAccentColor labels).
|
||||
// Match header text, then highlight the header + commands below it
|
||||
// until the next spacer/header/end.
|
||||
if (!elId) {
|
||||
const groupDone = await page.evaluate(({ target, color, padding }) => {
|
||||
const container = document.querySelector('#funcPanel_container');
|
||||
if (!container) return false;
|
||||
const norm = s => (s?.trim().replace(/\u00a0/g, ' ') || '').replace(/ё/gi, 'е').toLowerCase();
|
||||
const headers = [...container.querySelectorAll('.eAccentColor')].filter(e => e.offsetWidth > 0);
|
||||
if (!headers.length) return false;
|
||||
|
||||
let headerEl = headers.find(h => norm(h.textContent) === target);
|
||||
if (!headerEl) headerEl = headers.find(h => norm(h.textContent).startsWith(target));
|
||||
if (!headerEl) headerEl = headers.find(h => norm(h.textContent).includes(target));
|
||||
if (!headerEl) return false;
|
||||
|
||||
// Collect header + following cmd siblings until next spacer/header
|
||||
const parent = headerEl.parentElement;
|
||||
const children = [...parent.children];
|
||||
const startIdx = children.indexOf(headerEl);
|
||||
const groupEls = [headerEl];
|
||||
for (let i = startIdx + 1; i < children.length; i++) {
|
||||
const el = children[i];
|
||||
if (el.classList.contains('eAccentColor')) break;
|
||||
if (!el.id && !el.classList.contains('functionItem') && el.getBoundingClientRect().width < 10) break;
|
||||
groupEls.push(el);
|
||||
}
|
||||
|
||||
// Bounding box
|
||||
let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity;
|
||||
for (const el of groupEls) {
|
||||
const r = el.getBoundingClientRect();
|
||||
if (r.width === 0 && r.height === 0) continue;
|
||||
minX = Math.min(minX, r.left); minY = Math.min(minY, r.top);
|
||||
maxX = Math.max(maxX, r.right); maxY = Math.max(maxY, r.bottom);
|
||||
}
|
||||
if (minX === Infinity) return false;
|
||||
|
||||
let div = document.getElementById('__web_test_highlight');
|
||||
if (!div) { div = document.createElement('div'); div.id = '__web_test_highlight'; document.body.appendChild(div); }
|
||||
div.style.cssText = [
|
||||
'position:fixed', 'pointer-events:none', 'z-index:999998',
|
||||
`top:${minY - padding}px`, `left:${minX - padding}px`,
|
||||
`width:${maxX - minX + padding * 2}px`, `height:${maxY - minY + padding * 2}px`,
|
||||
`outline:3px solid ${color}`, 'border-radius:4px',
|
||||
`box-shadow:0 0 16px ${color}80`,
|
||||
].join(';');
|
||||
return true;
|
||||
}, { target: normYo(text.toLowerCase()), color, padding });
|
||||
if (groupDone) return;
|
||||
}
|
||||
|
||||
// 2. Form groups/panels — checked BEFORE buttons/fields because group names
|
||||
// often collide with command bar buttons (e.g. "БизнесПроцессы" is both a
|
||||
// panel and a command bar element). Includes _container and _div elements
|
||||
// but skips logicGroupContainer (Representation=None, height=0).
|
||||
if (!elId) {
|
||||
const formNum = await page.evaluate(detectFormScript());
|
||||
if (formNum !== null) {
|
||||
elId = await page.evaluate(`(() => {
|
||||
const norm = s => (s?.trim().replace(/\\u00a0/g, ' ') || '').replace(/ё/gi, 'е');
|
||||
const target = ${JSON.stringify(normYo(text.toLowerCase()))};
|
||||
const p = 'form' + ${formNum} + '_';
|
||||
// Group containers: _container or _div, but skip logicGroupContainer (invisible groups)
|
||||
const groups = [...document.querySelectorAll('[id^="' + p + '"][id$="_container"], [id^="' + p + '"][id$="_div"]')]
|
||||
.filter(el => el.offsetWidth > 0 && el.offsetHeight > 0 && !el.classList.contains('logicGroupContainer'));
|
||||
const items = groups.map(el => {
|
||||
const idName = el.id.replace(p, '').replace(/_(container|div)$/, '');
|
||||
const titleEl = document.getElementById(p + idName + '#title_text')
|
||||
|| document.getElementById(p + idName + '_title_text');
|
||||
const label = norm(titleEl?.innerText || '').toLowerCase();
|
||||
const name = norm(idName).toLowerCase();
|
||||
const big = el.offsetWidth >= 100 && el.offsetHeight >= 50;
|
||||
return { id: el.id, name, label, big };
|
||||
});
|
||||
let found = items.find(i => i.label === target);
|
||||
if (!found) found = items.find(i => i.name === target);
|
||||
// Fuzzy match: only large groups (min 100x50) to avoid matching command bars
|
||||
if (!found) found = items.filter(i => i.big).find(i => i.label.startsWith(target) || i.name.startsWith(target));
|
||||
if (!found && target.length >= 4) found = items.filter(i => i.big).find(i => i.label.includes(target) || i.name.includes(target));
|
||||
return found ? found.id : null;
|
||||
})()`);
|
||||
}
|
||||
}
|
||||
|
||||
// 3. Form-scoped search (buttons, links, fields, grid rows)
|
||||
if (!elId) {
|
||||
const formNum = await page.evaluate(detectFormScript());
|
||||
if (formNum !== null) {
|
||||
// 3a. Try button/link/tab/gridRow via findClickTargetScript
|
||||
let gridSelector;
|
||||
if (table) {
|
||||
const resolved = await page.evaluate(resolveGridScript(formNum, table));
|
||||
if (!resolved.error) gridSelector = resolved.gridSelector;
|
||||
}
|
||||
const target = await page.evaluate(findClickTargetScript(formNum, text, table ? { tableName: table, gridSelector } : undefined));
|
||||
if (target && !target.error) {
|
||||
if (target.id) {
|
||||
elId = target.id;
|
||||
} else if (target.x && target.y) {
|
||||
// Grid row — find the gridLine element and tag it
|
||||
elId = await page.evaluate(`(() => {
|
||||
const p = ${JSON.stringify(`form${formNum}_`)};
|
||||
const grid = document.querySelector('[id^="' + p + '"].grid');
|
||||
if (!grid) return null;
|
||||
const body = grid.querySelector('.gridBody');
|
||||
if (!body) return null;
|
||||
const norm = s => (s?.trim().replace(/\\u00a0/g, ' ') || '').replace(/ё/gi, 'е');
|
||||
const target = ${JSON.stringify(normYo(text.toLowerCase()))};
|
||||
for (const line of body.querySelectorAll('.gridLine')) {
|
||||
const cells = [...line.querySelectorAll('.gridBoxText')].filter(b => b.offsetWidth > 0);
|
||||
const rowText = cells.map(b => b.innerText?.trim() || '').join(' ').toLowerCase().replace(/ё/gi, 'е');
|
||||
if (rowText.includes(target)) {
|
||||
if (!line.id) line.id = '__wt_hl_tmp';
|
||||
return line.id;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
})()`);
|
||||
}
|
||||
}
|
||||
|
||||
// 3b. If not found as button — try as field via resolveFieldsScript
|
||||
if (!elId) {
|
||||
const dummyFields = { [text]: '' };
|
||||
const resolved = await page.evaluate(resolveFieldsScript(formNum, dummyFields));
|
||||
if (resolved?.length > 0 && !resolved[0].error && resolved[0].inputId) {
|
||||
elId = resolved[0].inputId;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 4. Fallback: sections (sidebar navigation)
|
||||
if (!elId) {
|
||||
elId = await page.evaluate(`(() => {
|
||||
const norm = s => (s?.trim().replace(/\\u00a0/g, ' ') || '').replace(/ё/gi, 'е');
|
||||
const target = ${JSON.stringify(normYo(text.toLowerCase()))};
|
||||
const secs = [...document.querySelectorAll('[id^="themesCell_theme_"]')];
|
||||
let el = secs.find(e => norm(e.innerText).toLowerCase() === target);
|
||||
if (!el) el = secs.find(e => norm(e.innerText).toLowerCase().startsWith(target));
|
||||
if (!el) el = secs.find(e => norm(e.innerText).toLowerCase().includes(target));
|
||||
return el ? el.id : null;
|
||||
})()`);
|
||||
}
|
||||
|
||||
if (!elId) {
|
||||
// Collect available elements to help the caller fix the name
|
||||
const available = await page.evaluate(`(() => {
|
||||
const norm = s => (s?.trim().replace(/\\u00a0/g, ' ') || '').replace(/ё/gi, 'е');
|
||||
const result = {};
|
||||
// Commands
|
||||
const cmds = [...document.querySelectorAll('[id^="cmd_"][id$="_txt"]')].filter(e => e.offsetWidth > 0).map(e => norm(e.innerText));
|
||||
if (cmds.length) result.commands = cmds;
|
||||
// Command group headers
|
||||
const fp = document.querySelector('#funcPanel_container');
|
||||
if (fp) {
|
||||
const gh = [...fp.querySelectorAll('.eAccentColor')].filter(e => e.offsetWidth > 0).map(e => norm(e.textContent));
|
||||
if (gh.length) result.commandGroups = gh;
|
||||
}
|
||||
// Sections
|
||||
const secs = [...document.querySelectorAll('[id^="themesCell_theme_"]')].map(e => norm(e.innerText)).filter(Boolean);
|
||||
if (secs.length) result.sections = secs;
|
||||
// Form elements
|
||||
${(() => {
|
||||
// Detect form inline to avoid extra evaluate round-trip
|
||||
return `
|
||||
const forms = {};
|
||||
document.querySelectorAll('[id^="form"]').forEach(el => {
|
||||
const m = el.id.match(/^form(\\d+)_/);
|
||||
if (m) forms[m[1]] = (forms[m[1]] || 0) + 1;
|
||||
});
|
||||
let formNum = null, maxCount = 0;
|
||||
for (const [n, c] of Object.entries(forms)) {
|
||||
if (parseInt(n) > 0 && c > maxCount) { maxCount = c; formNum = n; }
|
||||
}
|
||||
if (formNum !== null) {
|
||||
const p = 'form' + formNum + '_';
|
||||
// Groups (_container or _div, skip logicGroupContainer, min 100x50)
|
||||
const groups = [...document.querySelectorAll('[id^="' + p + '"][id$="_container"], [id^="' + p + '"][id$="_div"]')]
|
||||
.filter(el => el.offsetWidth >= 100 && el.offsetHeight >= 50 && !el.classList.contains('logicGroupContainer'))
|
||||
.map(el => {
|
||||
const idName = el.id.replace(p, '').replace(/_(container|div)$/, '');
|
||||
const titleEl = document.getElementById(p + idName + '#title_text') || document.getElementById(p + idName + '_title_text');
|
||||
return norm(titleEl?.innerText || '') || idName;
|
||||
}).filter(Boolean);
|
||||
if (groups.length) result.groups = groups;
|
||||
// Buttons/links
|
||||
const btns = [...document.querySelectorAll('[id^="' + p + '"].btnText, [id^="' + p + '"] .btnText, [id^="' + p + '"].hplnk')]
|
||||
.filter(el => el.offsetWidth > 0).map(el => norm(el.innerText)).filter(Boolean);
|
||||
if (btns.length) result.buttons = [...new Set(btns)];
|
||||
}`;
|
||||
})()}
|
||||
return result;
|
||||
})()`);
|
||||
const parts = [];
|
||||
for (const [cat, items] of Object.entries(available)) {
|
||||
parts.push(` ${cat}: ${items.join(', ')}`);
|
||||
}
|
||||
const hint = parts.length ? `\nAvailable:\n${parts.join('\n')}` : '';
|
||||
throw new Error(`highlight: "${text}" not found${hint}`);
|
||||
}
|
||||
|
||||
// Overlay div + rAF tracking loop (not clipped by overflow:hidden, follows layout shifts)
|
||||
await page.evaluate(({ elId, color, padding }) => {
|
||||
const target = document.getElementById(elId);
|
||||
if (!target) return;
|
||||
let div = document.getElementById('__web_test_highlight');
|
||||
if (!div) {
|
||||
div = document.createElement('div');
|
||||
div.id = '__web_test_highlight';
|
||||
document.body.appendChild(div);
|
||||
}
|
||||
function sync() {
|
||||
const r = target.getBoundingClientRect();
|
||||
div.style.cssText = [
|
||||
'position:fixed', 'pointer-events:none', 'z-index:999998',
|
||||
`top:${r.y - padding}px`, `left:${r.x - padding}px`,
|
||||
`width:${r.width + padding * 2}px`, `height:${r.height + padding * 2}px`,
|
||||
`outline:3px solid ${color}`, 'border-radius:4px',
|
||||
`box-shadow:0 0 16px ${color}80`,
|
||||
].join(';');
|
||||
}
|
||||
sync();
|
||||
// Track position changes via rAF
|
||||
function tick() {
|
||||
if (!document.getElementById('__web_test_highlight')) return; // stopped
|
||||
sync();
|
||||
requestAnimationFrame(tick);
|
||||
}
|
||||
requestAnimationFrame(tick);
|
||||
}, { elId, color, padding });
|
||||
}
|
||||
|
||||
/** Remove the highlight overlay. */
|
||||
export async function unhighlight() {
|
||||
ensureConnected();
|
||||
await page.evaluate(() => {
|
||||
const el = document.getElementById('__web_test_highlight');
|
||||
if (el) el.remove(); // also stops rAF loop (id check)
|
||||
// Clean up temp ID from grid rows
|
||||
const tmp = document.getElementById('__wt_hl_tmp');
|
||||
if (tmp) tmp.removeAttribute('id');
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Toggle auto-highlight mode. When enabled, clickElement/fillFields/selectValue
|
||||
* automatically highlight the target element before acting.
|
||||
* @param {boolean} on true to enable, false to disable
|
||||
*/
|
||||
export function setHighlight(on) {
|
||||
setHighlightMode(!!on);
|
||||
}
|
||||
|
||||
/** @returns {boolean} Whether auto-highlight mode is active. */
|
||||
export function isHighlightMode() {
|
||||
return highlightMode;
|
||||
}
|
||||
|
||||
@@ -1,196 +1,196 @@
|
||||
// web-test recording/narration v1.17 — Post-process: generate TTS audio for captions and merge with recorded video.
|
||||
// Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
|
||||
|
||||
import { execFileSync } from 'child_process';
|
||||
import { existsSync as fsExistsSync, mkdirSync, readFileSync, rmSync, statSync } from 'fs';
|
||||
import { extname, join as pathJoin } from 'path';
|
||||
import { tmpdir } from 'os';
|
||||
import {
|
||||
lastCaptions, lastRecordingDuration, resolveProjectPath,
|
||||
} from '../core/state.mjs';
|
||||
import {
|
||||
resolveFfmpeg, getTtsProvider, getAudioDuration, generateSilence,
|
||||
} from './tts.mjs';
|
||||
|
||||
/**
|
||||
* Add TTS narration to a recorded video.
|
||||
* Generates speech from captions and merges audio with the video.
|
||||
* @param {string} videoPath — path to the recorded MP4 file
|
||||
* @param {object} [opts]
|
||||
* @param {Array<{text: string, speech: string, time: number, voice?: string}>} [opts.captions] — explicit captions (default: from last recording or .captions.json). Each caption may include a `voice` field to override the global voice for that segment
|
||||
* @param {string} [opts.provider='edge'] — TTS provider: 'edge' or 'openai'
|
||||
* @param {string} [opts.voice] — voice name (provider-specific)
|
||||
* @param {string} [opts.apiKey] — API key (for openai provider)
|
||||
* @param {string} [opts.apiUrl] — API endpoint (for openai provider)
|
||||
* @param {string} [opts.model] — model name (for openai provider, default: 'tts-1')
|
||||
* @param {string} [opts.ffmpegPath] — path to ffmpeg binary
|
||||
* @param {string} [opts.outputPath] — output file path (default: video-narrated.mp4)
|
||||
* @returns {{ file: string, duration: number, size: number, captions: number, warnings?: string[] }}
|
||||
*/
|
||||
export async function addNarration(videoPath, opts = {}) {
|
||||
if (!videoPath) return { file: null, duration: 0, size: 0, captions: 0 };
|
||||
videoPath = resolveProjectPath(videoPath);
|
||||
const ffmpegPath = resolveFfmpeg(opts.ffmpegPath);
|
||||
const ttsProvider = getTtsProvider(opts.provider || 'edge');
|
||||
const ttsOpts = { voice: opts.voice, apiKey: opts.apiKey, apiUrl: opts.apiUrl, model: opts.model };
|
||||
|
||||
// Resolve captions: explicit > lastCaptions > .captions.json
|
||||
let captions = opts.captions;
|
||||
let videoTimestamps = true; // new recordings use video-time timestamps (no scaling needed)
|
||||
let recordingDuration = null; // wall-clock duration (for legacy scaling fallback)
|
||||
if (!captions || !captions.length) {
|
||||
if (lastCaptions.length) {
|
||||
captions = [...lastCaptions];
|
||||
recordingDuration = lastRecordingDuration;
|
||||
// Runtime captions always use video timestamps (set in showCaption)
|
||||
}
|
||||
}
|
||||
if (!captions || !captions.length) {
|
||||
const captionsJsonPath = videoPath.replace(/\.[^.]+$/, '.captions.json');
|
||||
if (fsExistsSync(captionsJsonPath)) {
|
||||
const raw = JSON.parse(readFileSync(captionsJsonPath, 'utf-8'));
|
||||
// Support formats: array (old), { recordingDuration, captions } (v2), { videoTimestamps, captions } (v3)
|
||||
if (Array.isArray(raw)) {
|
||||
captions = raw;
|
||||
videoTimestamps = false;
|
||||
} else {
|
||||
captions = raw.captions;
|
||||
videoTimestamps = !!raw.videoTimestamps;
|
||||
recordingDuration = raw.recordingDuration || null;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (!captions || !captions.length) {
|
||||
throw new Error('No captions available. Record with showCaption() first, or pass opts.captions.');
|
||||
}
|
||||
|
||||
const videoDuration = getAudioDuration(videoPath, ffmpegPath);
|
||||
|
||||
// Legacy fallback: scale wall-clock timestamps to video duration
|
||||
// (only for old captions without videoTimestamps flag)
|
||||
if (!videoTimestamps && recordingDuration && recordingDuration > 0) {
|
||||
const timeScale = videoDuration / recordingDuration;
|
||||
if (Math.abs(timeScale - 1) > 0.005) {
|
||||
captions = captions.map(c => ({ ...c, time: Math.round(c.time * timeScale) }));
|
||||
}
|
||||
}
|
||||
|
||||
// Output path
|
||||
const ext = extname(videoPath);
|
||||
const base = videoPath.slice(0, -ext.length);
|
||||
const outputPath = opts.outputPath || `${base}-narrated${ext}`;
|
||||
|
||||
// Temp directory
|
||||
const tempDir = pathJoin(tmpdir(), `web-test-tts-${Date.now()}`);
|
||||
mkdirSync(tempDir, { recursive: true });
|
||||
|
||||
const warnings = [];
|
||||
|
||||
try {
|
||||
// Phase 1: Generate TTS audio for each caption
|
||||
const ttsFiles = [];
|
||||
const BATCH_SIZE = (opts.provider === 'elevenlabs') ? 2 : 5;
|
||||
for (let batchStart = 0; batchStart < captions.length; batchStart += BATCH_SIZE) {
|
||||
const batch = captions.slice(batchStart, batchStart + BATCH_SIZE);
|
||||
const promises = batch.map(async (cap, batchIdx) => {
|
||||
const idx = batchStart + batchIdx;
|
||||
const ttsFile = pathJoin(tempDir, `tts_${idx}.mp3`);
|
||||
const capTtsOpts = cap.voice ? { ...ttsOpts, voice: cap.voice } : ttsOpts;
|
||||
try {
|
||||
await ttsProvider(cap.speech, ttsFile, capTtsOpts);
|
||||
} catch (err) {
|
||||
// Retry once
|
||||
try {
|
||||
await ttsProvider(cap.speech, ttsFile, capTtsOpts);
|
||||
} catch (retryErr) {
|
||||
warnings.push(`TTS failed for caption ${idx}: ${retryErr.message || retryErr.cause?.message || String(retryErr)}`);
|
||||
// Generate 1s silence as placeholder
|
||||
generateSilence(ttsFile, 1, ffmpegPath);
|
||||
}
|
||||
}
|
||||
return ttsFile;
|
||||
});
|
||||
const results = await Promise.all(promises);
|
||||
ttsFiles.push(...results);
|
||||
}
|
||||
|
||||
// Phase 2+3: Place each TTS at its exact timestamp using adelay + amix
|
||||
// This avoids MP3 frame quantization drift from silence-file concatenation
|
||||
const ffmpegInputs = [];
|
||||
const filterParts = [];
|
||||
const mixLabels = [];
|
||||
|
||||
for (let i = 0; i < captions.length; i++) {
|
||||
const captionTimeMs = Math.round(captions[i].time);
|
||||
const ttsFile = ttsFiles[i];
|
||||
const ttsDuration = getAudioDuration(ttsFile, ffmpegPath);
|
||||
|
||||
ffmpegInputs.push('-i', ttsFile);
|
||||
const filters = [];
|
||||
|
||||
// Speed up TTS slightly if it's longer than gap to next caption (max 1.3x)
|
||||
if (i < captions.length - 1) {
|
||||
const maxDuration = (captions[i + 1].time - captions[i].time) / 1000;
|
||||
if (ttsDuration > maxDuration && maxDuration > 0.1) {
|
||||
const tempo = ttsDuration / maxDuration;
|
||||
if (tempo <= 1.3) {
|
||||
filters.push(`atempo=${tempo.toFixed(4)}`);
|
||||
} else {
|
||||
// Too fast — let audio overlap instead of distorting
|
||||
warnings.push(`Caption ${i + 1}/${captions.length}: TTS ${ttsDuration.toFixed(1)}s > gap ${maxDuration.toFixed(1)}s (need ${Math.round(ttsDuration - maxDuration)}s more pause)`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Delay to exact caption timestamp (milliseconds)
|
||||
if (captionTimeMs > 0) {
|
||||
filters.push(`adelay=${captionTimeMs}|${captionTimeMs}`);
|
||||
}
|
||||
|
||||
const label = `a${i}`;
|
||||
mixLabels.push(`[${label}]`);
|
||||
// Input indices are shifted by 1 because silence reference is input [0]
|
||||
filterParts.push(`[${i + 1}]${filters.length ? filters.join(',') : 'acopy'}[${label}]`);
|
||||
}
|
||||
|
||||
// Generate a silence reference track as input [0] so amix runs for full video duration
|
||||
const silencePath = pathJoin(tempDir, 'silence.mp3');
|
||||
generateSilence(silencePath, Math.ceil(videoDuration), ffmpegPath);
|
||||
|
||||
const filterComplex = filterParts.join(';') + ';' +
|
||||
`[0]${mixLabels.join('')}amix=inputs=${captions.length + 1}:normalize=0:duration=first`;
|
||||
|
||||
const narrationPath = pathJoin(tempDir, 'narration.mp3');
|
||||
execFileSync(ffmpegPath, [
|
||||
'-y', '-i', silencePath, ...ffmpegInputs,
|
||||
'-filter_complex', filterComplex,
|
||||
'-t', String(Math.ceil(videoDuration)),
|
||||
'-c:a', 'libmp3lame', '-b:a', '128k', narrationPath,
|
||||
], { stdio: 'pipe', timeout: 120000 });
|
||||
|
||||
// Phase 4: Merge video + narration audio
|
||||
execFileSync(ffmpegPath, [
|
||||
'-y', '-i', videoPath, '-i', narrationPath,
|
||||
'-c:v', 'copy', '-c:a', 'aac', '-b:a', '128k',
|
||||
'-map', '0:v:0', '-map', '1:a:0',
|
||||
'-t', String(Math.ceil(videoDuration)),
|
||||
'-movflags', '+faststart', outputPath,
|
||||
], { stdio: 'pipe', timeout: 120000 });
|
||||
|
||||
const stats = statSync(outputPath);
|
||||
const duration = getAudioDuration(outputPath, ffmpegPath);
|
||||
|
||||
const result = {
|
||||
file: outputPath,
|
||||
duration: Math.round(duration * 10) / 10,
|
||||
size: stats.size,
|
||||
captions: captions.length,
|
||||
};
|
||||
if (warnings.length) result.warnings = warnings;
|
||||
return result;
|
||||
|
||||
} finally {
|
||||
// Cleanup temp directory
|
||||
try { rmSync(tempDir, { recursive: true, force: true }); } catch {}
|
||||
}
|
||||
}
|
||||
// web-test recording/narration v1.17 — Post-process: generate TTS audio for captions and merge with recorded video.
|
||||
// Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
|
||||
|
||||
import { execFileSync } from 'child_process';
|
||||
import { existsSync as fsExistsSync, mkdirSync, readFileSync, rmSync, statSync } from 'fs';
|
||||
import { extname, join as pathJoin } from 'path';
|
||||
import { tmpdir } from 'os';
|
||||
import {
|
||||
lastCaptions, lastRecordingDuration, resolveProjectPath,
|
||||
} from '../core/state.mjs';
|
||||
import {
|
||||
resolveFfmpeg, getTtsProvider, getAudioDuration, generateSilence,
|
||||
} from './tts.mjs';
|
||||
|
||||
/**
|
||||
* Add TTS narration to a recorded video.
|
||||
* Generates speech from captions and merges audio with the video.
|
||||
* @param {string} videoPath — path to the recorded MP4 file
|
||||
* @param {object} [opts]
|
||||
* @param {Array<{text: string, speech: string, time: number, voice?: string}>} [opts.captions] — explicit captions (default: from last recording or .captions.json). Each caption may include a `voice` field to override the global voice for that segment
|
||||
* @param {string} [opts.provider='edge'] — TTS provider: 'edge' or 'openai'
|
||||
* @param {string} [opts.voice] — voice name (provider-specific)
|
||||
* @param {string} [opts.apiKey] — API key (for openai provider)
|
||||
* @param {string} [opts.apiUrl] — API endpoint (for openai provider)
|
||||
* @param {string} [opts.model] — model name (for openai provider, default: 'tts-1')
|
||||
* @param {string} [opts.ffmpegPath] — path to ffmpeg binary
|
||||
* @param {string} [opts.outputPath] — output file path (default: video-narrated.mp4)
|
||||
* @returns {{ file: string, duration: number, size: number, captions: number, warnings?: string[] }}
|
||||
*/
|
||||
export async function addNarration(videoPath, opts = {}) {
|
||||
if (!videoPath) return { file: null, duration: 0, size: 0, captions: 0 };
|
||||
videoPath = resolveProjectPath(videoPath);
|
||||
const ffmpegPath = resolveFfmpeg(opts.ffmpegPath);
|
||||
const ttsProvider = getTtsProvider(opts.provider || 'edge');
|
||||
const ttsOpts = { voice: opts.voice, apiKey: opts.apiKey, apiUrl: opts.apiUrl, model: opts.model };
|
||||
|
||||
// Resolve captions: explicit > lastCaptions > .captions.json
|
||||
let captions = opts.captions;
|
||||
let videoTimestamps = true; // new recordings use video-time timestamps (no scaling needed)
|
||||
let recordingDuration = null; // wall-clock duration (for legacy scaling fallback)
|
||||
if (!captions || !captions.length) {
|
||||
if (lastCaptions.length) {
|
||||
captions = [...lastCaptions];
|
||||
recordingDuration = lastRecordingDuration;
|
||||
// Runtime captions always use video timestamps (set in showCaption)
|
||||
}
|
||||
}
|
||||
if (!captions || !captions.length) {
|
||||
const captionsJsonPath = videoPath.replace(/\.[^.]+$/, '.captions.json');
|
||||
if (fsExistsSync(captionsJsonPath)) {
|
||||
const raw = JSON.parse(readFileSync(captionsJsonPath, 'utf-8'));
|
||||
// Support formats: array (old), { recordingDuration, captions } (v2), { videoTimestamps, captions } (v3)
|
||||
if (Array.isArray(raw)) {
|
||||
captions = raw;
|
||||
videoTimestamps = false;
|
||||
} else {
|
||||
captions = raw.captions;
|
||||
videoTimestamps = !!raw.videoTimestamps;
|
||||
recordingDuration = raw.recordingDuration || null;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (!captions || !captions.length) {
|
||||
throw new Error('No captions available. Record with showCaption() first, or pass opts.captions.');
|
||||
}
|
||||
|
||||
const videoDuration = getAudioDuration(videoPath, ffmpegPath);
|
||||
|
||||
// Legacy fallback: scale wall-clock timestamps to video duration
|
||||
// (only for old captions without videoTimestamps flag)
|
||||
if (!videoTimestamps && recordingDuration && recordingDuration > 0) {
|
||||
const timeScale = videoDuration / recordingDuration;
|
||||
if (Math.abs(timeScale - 1) > 0.005) {
|
||||
captions = captions.map(c => ({ ...c, time: Math.round(c.time * timeScale) }));
|
||||
}
|
||||
}
|
||||
|
||||
// Output path
|
||||
const ext = extname(videoPath);
|
||||
const base = videoPath.slice(0, -ext.length);
|
||||
const outputPath = opts.outputPath || `${base}-narrated${ext}`;
|
||||
|
||||
// Temp directory
|
||||
const tempDir = pathJoin(tmpdir(), `web-test-tts-${Date.now()}`);
|
||||
mkdirSync(tempDir, { recursive: true });
|
||||
|
||||
const warnings = [];
|
||||
|
||||
try {
|
||||
// Phase 1: Generate TTS audio for each caption
|
||||
const ttsFiles = [];
|
||||
const BATCH_SIZE = (opts.provider === 'elevenlabs') ? 2 : 5;
|
||||
for (let batchStart = 0; batchStart < captions.length; batchStart += BATCH_SIZE) {
|
||||
const batch = captions.slice(batchStart, batchStart + BATCH_SIZE);
|
||||
const promises = batch.map(async (cap, batchIdx) => {
|
||||
const idx = batchStart + batchIdx;
|
||||
const ttsFile = pathJoin(tempDir, `tts_${idx}.mp3`);
|
||||
const capTtsOpts = cap.voice ? { ...ttsOpts, voice: cap.voice } : ttsOpts;
|
||||
try {
|
||||
await ttsProvider(cap.speech, ttsFile, capTtsOpts);
|
||||
} catch (err) {
|
||||
// Retry once
|
||||
try {
|
||||
await ttsProvider(cap.speech, ttsFile, capTtsOpts);
|
||||
} catch (retryErr) {
|
||||
warnings.push(`TTS failed for caption ${idx}: ${retryErr.message || retryErr.cause?.message || String(retryErr)}`);
|
||||
// Generate 1s silence as placeholder
|
||||
generateSilence(ttsFile, 1, ffmpegPath);
|
||||
}
|
||||
}
|
||||
return ttsFile;
|
||||
});
|
||||
const results = await Promise.all(promises);
|
||||
ttsFiles.push(...results);
|
||||
}
|
||||
|
||||
// Phase 2+3: Place each TTS at its exact timestamp using adelay + amix
|
||||
// This avoids MP3 frame quantization drift from silence-file concatenation
|
||||
const ffmpegInputs = [];
|
||||
const filterParts = [];
|
||||
const mixLabels = [];
|
||||
|
||||
for (let i = 0; i < captions.length; i++) {
|
||||
const captionTimeMs = Math.round(captions[i].time);
|
||||
const ttsFile = ttsFiles[i];
|
||||
const ttsDuration = getAudioDuration(ttsFile, ffmpegPath);
|
||||
|
||||
ffmpegInputs.push('-i', ttsFile);
|
||||
const filters = [];
|
||||
|
||||
// Speed up TTS slightly if it's longer than gap to next caption (max 1.3x)
|
||||
if (i < captions.length - 1) {
|
||||
const maxDuration = (captions[i + 1].time - captions[i].time) / 1000;
|
||||
if (ttsDuration > maxDuration && maxDuration > 0.1) {
|
||||
const tempo = ttsDuration / maxDuration;
|
||||
if (tempo <= 1.3) {
|
||||
filters.push(`atempo=${tempo.toFixed(4)}`);
|
||||
} else {
|
||||
// Too fast — let audio overlap instead of distorting
|
||||
warnings.push(`Caption ${i + 1}/${captions.length}: TTS ${ttsDuration.toFixed(1)}s > gap ${maxDuration.toFixed(1)}s (need ${Math.round(ttsDuration - maxDuration)}s more pause)`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Delay to exact caption timestamp (milliseconds)
|
||||
if (captionTimeMs > 0) {
|
||||
filters.push(`adelay=${captionTimeMs}|${captionTimeMs}`);
|
||||
}
|
||||
|
||||
const label = `a${i}`;
|
||||
mixLabels.push(`[${label}]`);
|
||||
// Input indices are shifted by 1 because silence reference is input [0]
|
||||
filterParts.push(`[${i + 1}]${filters.length ? filters.join(',') : 'acopy'}[${label}]`);
|
||||
}
|
||||
|
||||
// Generate a silence reference track as input [0] so amix runs for full video duration
|
||||
const silencePath = pathJoin(tempDir, 'silence.mp3');
|
||||
generateSilence(silencePath, Math.ceil(videoDuration), ffmpegPath);
|
||||
|
||||
const filterComplex = filterParts.join(';') + ';' +
|
||||
`[0]${mixLabels.join('')}amix=inputs=${captions.length + 1}:normalize=0:duration=first`;
|
||||
|
||||
const narrationPath = pathJoin(tempDir, 'narration.mp3');
|
||||
execFileSync(ffmpegPath, [
|
||||
'-y', '-i', silencePath, ...ffmpegInputs,
|
||||
'-filter_complex', filterComplex,
|
||||
'-t', String(Math.ceil(videoDuration)),
|
||||
'-c:a', 'libmp3lame', '-b:a', '128k', narrationPath,
|
||||
], { stdio: 'pipe', timeout: 120000 });
|
||||
|
||||
// Phase 4: Merge video + narration audio
|
||||
execFileSync(ffmpegPath, [
|
||||
'-y', '-i', videoPath, '-i', narrationPath,
|
||||
'-c:v', 'copy', '-c:a', 'aac', '-b:a', '128k',
|
||||
'-map', '0:v:0', '-map', '1:a:0',
|
||||
'-t', String(Math.ceil(videoDuration)),
|
||||
'-movflags', '+faststart', outputPath,
|
||||
], { stdio: 'pipe', timeout: 120000 });
|
||||
|
||||
const stats = statSync(outputPath);
|
||||
const duration = getAudioDuration(outputPath, ffmpegPath);
|
||||
|
||||
const result = {
|
||||
file: outputPath,
|
||||
duration: Math.round(duration * 10) / 10,
|
||||
size: stats.size,
|
||||
captions: captions.length,
|
||||
};
|
||||
if (warnings.length) result.warnings = warnings;
|
||||
return result;
|
||||
|
||||
} finally {
|
||||
// Cleanup temp directory
|
||||
try { rmSync(tempDir, { recursive: true, force: true }); } catch {}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,175 +1,175 @@
|
||||
// web-test recording/tts v1.17 — TTS providers (edge/openai/elevenlabs) and ffmpeg/ffprobe helpers.
|
||||
// Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
|
||||
|
||||
import { execFileSync, spawn } from 'child_process';
|
||||
import { existsSync as fsExistsSync, writeFileSync } from 'fs';
|
||||
import { resolve as pathResolve } from 'path';
|
||||
import { pathToFileURL } from 'url';
|
||||
import { projectRoot } from '../core/state.mjs';
|
||||
|
||||
/** Resolve ffmpeg binary path. */
|
||||
export function resolveFfmpeg(explicit) {
|
||||
// 1. Explicit path
|
||||
if (explicit) {
|
||||
try { execFileSync(explicit, ['-version'], { stdio: 'ignore', timeout: 5000 }); return explicit; }
|
||||
catch { throw new Error(`ffmpeg not found at: ${explicit}`); }
|
||||
}
|
||||
|
||||
// 2. FFMPEG_PATH env var
|
||||
const envPath = process.env.FFMPEG_PATH;
|
||||
if (envPath) {
|
||||
try { execFileSync(envPath, ['-version'], { stdio: 'ignore', timeout: 5000 }); return envPath; }
|
||||
catch { /* fall through */ }
|
||||
}
|
||||
|
||||
// 3. System PATH
|
||||
try { execFileSync('ffmpeg', ['-version'], { stdio: 'ignore', timeout: 5000 }); return 'ffmpeg'; }
|
||||
catch { /* fall through */ }
|
||||
|
||||
// 4. tools/ffmpeg/bin/ffmpeg.exe relative to project root
|
||||
const localPath = pathResolve(projectRoot, 'tools', 'ffmpeg', 'bin', 'ffmpeg.exe');
|
||||
if (fsExistsSync(localPath)) {
|
||||
try { execFileSync(localPath, ['-version'], { stdio: 'ignore', timeout: 5000 }); return localPath; }
|
||||
catch { /* fall through */ }
|
||||
}
|
||||
|
||||
// 5. Error with instructions
|
||||
throw new Error(
|
||||
'ffmpeg not found. Install it:\n' +
|
||||
' - Download from https://www.gyan.dev/ffmpeg/builds/ (essentials build)\n' +
|
||||
' - Add to PATH, or set FFMPEG_PATH env var, or place in tools/ffmpeg/bin/\n' +
|
||||
' - Or pass ffmpegPath option to startRecording()'
|
||||
);
|
||||
}
|
||||
|
||||
// ── TTS providers ──────────────────────────────────────────────────────────
|
||||
|
||||
/** Resolve node-edge-tts module: global install → tools/tts/ → error with instructions. */
|
||||
let _edgeTtsModule = null;
|
||||
export async function resolveEdgeTts() {
|
||||
if (_edgeTtsModule) return _edgeTtsModule;
|
||||
|
||||
// 1. Global/project-level install (standard Node resolution)
|
||||
try {
|
||||
_edgeTtsModule = await import('node-edge-tts');
|
||||
return _edgeTtsModule;
|
||||
} catch { /* fall through */ }
|
||||
|
||||
// 2. tools/tts/ relative to project root
|
||||
const localPath = pathResolve(projectRoot, 'tools', 'tts', 'node_modules', 'node-edge-tts', 'dist', 'edge-tts.js');
|
||||
if (fsExistsSync(localPath)) {
|
||||
try {
|
||||
_edgeTtsModule = await import(pathToFileURL(localPath).href);
|
||||
return _edgeTtsModule;
|
||||
} catch { /* fall through */ }
|
||||
}
|
||||
|
||||
// 3. Error with instructions
|
||||
throw new Error(
|
||||
'node-edge-tts not found. Install it:\n' +
|
||||
' - npm install --prefix tools/tts node-edge-tts\n' +
|
||||
' - or: npm install node-edge-tts (global/project-level)'
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Edge TTS provider (free, no API key). Uses node-edge-tts package.
|
||||
* @param {string} text — text to synthesize
|
||||
* @param {string} outputPath — path for the output mp3 file
|
||||
* @param {object} opts — { voice }
|
||||
*/
|
||||
export async function edgeTtsProvider(text, outputPath, opts = {}) {
|
||||
const { EdgeTTS } = await resolveEdgeTts();
|
||||
const voice = opts.voice || 'ru-RU-DmitryNeural';
|
||||
const tts = new EdgeTTS({ voice });
|
||||
await Promise.race([
|
||||
tts.ttsPromise(text, outputPath),
|
||||
new Promise((_, reject) => setTimeout(() => reject(new Error('Edge TTS timeout (30s)')), 30000)),
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* OpenAI-compatible TTS provider. Requires apiKey.
|
||||
* @param {string} text — text to synthesize
|
||||
* @param {string} outputPath — path for the output mp3 file
|
||||
* @param {object} opts — { apiKey, apiUrl, voice, model }
|
||||
*/
|
||||
export async function openaiTtsProvider(text, outputPath, opts = {}) {
|
||||
const apiUrl = opts.apiUrl || 'https://api.openai.com/v1/audio/speech';
|
||||
if (!opts.apiKey) throw new Error('OpenAI TTS requires apiKey');
|
||||
const resp = await fetch(apiUrl, {
|
||||
method: 'POST',
|
||||
headers: { 'Authorization': `Bearer ${opts.apiKey}`, 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
model: opts.model || 'tts-1',
|
||||
input: text,
|
||||
voice: opts.voice || 'alloy',
|
||||
response_format: 'mp3',
|
||||
}),
|
||||
});
|
||||
if (!resp.ok) throw new Error(`OpenAI TTS error ${resp.status}: ${await resp.text()}`);
|
||||
const buf = Buffer.from(await resp.arrayBuffer());
|
||||
writeFileSync(outputPath, buf);
|
||||
}
|
||||
|
||||
/**
|
||||
* ElevenLabs TTS provider. Requires apiKey.
|
||||
* @param {string} text — text to synthesize
|
||||
* @param {string} outputPath — path for the output mp3 file
|
||||
* @param {object} opts — { apiKey, apiUrl, voice, model }
|
||||
*/
|
||||
export async function elevenlabsTtsProvider(text, outputPath, opts = {}) {
|
||||
const voiceId = opts.voice || 'JBFqnCBsd6RMkjVDRZzb'; // George
|
||||
const apiUrl = opts.apiUrl || `https://api.elevenlabs.io/v1/text-to-speech/${voiceId}`;
|
||||
if (!opts.apiKey) throw new Error('ElevenLabs TTS requires apiKey');
|
||||
const resp = await fetch(apiUrl, {
|
||||
method: 'POST',
|
||||
headers: { 'xi-api-key': opts.apiKey, 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
text,
|
||||
model_id: opts.model || 'eleven_multilingual_v2',
|
||||
}),
|
||||
});
|
||||
if (!resp.ok) throw new Error(`ElevenLabs TTS error ${resp.status}: ${await resp.text()}`);
|
||||
const buf = Buffer.from(await resp.arrayBuffer());
|
||||
writeFileSync(outputPath, buf);
|
||||
}
|
||||
|
||||
/** Get TTS provider function by name. */
|
||||
export function getTtsProvider(name) {
|
||||
switch (name) {
|
||||
case 'openai': return openaiTtsProvider;
|
||||
case 'elevenlabs': return elevenlabsTtsProvider;
|
||||
case 'edge': default: return edgeTtsProvider;
|
||||
}
|
||||
}
|
||||
|
||||
// ── TTS audio helpers ──────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Get audio duration in seconds using ffprobe.
|
||||
* @param {string} filePath — path to audio file
|
||||
* @param {string} ffmpegPath — path to ffmpeg binary (ffprobe is found next to it)
|
||||
* @returns {number} duration in seconds
|
||||
*/
|
||||
export function getAudioDuration(filePath, ffmpegPath) {
|
||||
const ffprobePath = ffmpegPath.replace(/ffmpeg(\.exe)?$/i, 'ffprobe$1');
|
||||
const out = execFileSync(ffprobePath, [
|
||||
'-v', 'error', '-show_entries', 'format=duration',
|
||||
'-of', 'default=noprint_wrappers=1:nokey=1', filePath,
|
||||
], { encoding: 'utf8', timeout: 10000 }).trim();
|
||||
return parseFloat(out) || 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a silence mp3 file of given duration.
|
||||
* @param {string} outputPath — path for the output mp3 file
|
||||
* @param {number} seconds — duration in seconds
|
||||
* @param {string} ffmpegPath — path to ffmpeg binary
|
||||
*/
|
||||
export function generateSilence(outputPath, seconds, ffmpegPath) {
|
||||
execFileSync(ffmpegPath, [
|
||||
'-y', '-f', 'lavfi', '-i', `anullsrc=r=24000:cl=mono`,
|
||||
'-t', String(seconds), '-c:a', 'libmp3lame', '-b:a', '32k', outputPath,
|
||||
], { stdio: 'pipe', timeout: 10000 });
|
||||
}
|
||||
// web-test recording/tts v1.17 — TTS providers (edge/openai/elevenlabs) and ffmpeg/ffprobe helpers.
|
||||
// Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
|
||||
|
||||
import { execFileSync, spawn } from 'child_process';
|
||||
import { existsSync as fsExistsSync, writeFileSync } from 'fs';
|
||||
import { resolve as pathResolve } from 'path';
|
||||
import { pathToFileURL } from 'url';
|
||||
import { projectRoot } from '../core/state.mjs';
|
||||
|
||||
/** Resolve ffmpeg binary path. */
|
||||
export function resolveFfmpeg(explicit) {
|
||||
// 1. Explicit path
|
||||
if (explicit) {
|
||||
try { execFileSync(explicit, ['-version'], { stdio: 'ignore', timeout: 5000 }); return explicit; }
|
||||
catch { throw new Error(`ffmpeg not found at: ${explicit}`); }
|
||||
}
|
||||
|
||||
// 2. FFMPEG_PATH env var
|
||||
const envPath = process.env.FFMPEG_PATH;
|
||||
if (envPath) {
|
||||
try { execFileSync(envPath, ['-version'], { stdio: 'ignore', timeout: 5000 }); return envPath; }
|
||||
catch { /* fall through */ }
|
||||
}
|
||||
|
||||
// 3. System PATH
|
||||
try { execFileSync('ffmpeg', ['-version'], { stdio: 'ignore', timeout: 5000 }); return 'ffmpeg'; }
|
||||
catch { /* fall through */ }
|
||||
|
||||
// 4. tools/ffmpeg/bin/ffmpeg.exe relative to project root
|
||||
const localPath = pathResolve(projectRoot, 'tools', 'ffmpeg', 'bin', 'ffmpeg.exe');
|
||||
if (fsExistsSync(localPath)) {
|
||||
try { execFileSync(localPath, ['-version'], { stdio: 'ignore', timeout: 5000 }); return localPath; }
|
||||
catch { /* fall through */ }
|
||||
}
|
||||
|
||||
// 5. Error with instructions
|
||||
throw new Error(
|
||||
'ffmpeg not found. Install it:\n' +
|
||||
' - Download from https://www.gyan.dev/ffmpeg/builds/ (essentials build)\n' +
|
||||
' - Add to PATH, or set FFMPEG_PATH env var, or place in tools/ffmpeg/bin/\n' +
|
||||
' - Or pass ffmpegPath option to startRecording()'
|
||||
);
|
||||
}
|
||||
|
||||
// ── TTS providers ──────────────────────────────────────────────────────────
|
||||
|
||||
/** Resolve node-edge-tts module: global install → tools/tts/ → error with instructions. */
|
||||
let _edgeTtsModule = null;
|
||||
export async function resolveEdgeTts() {
|
||||
if (_edgeTtsModule) return _edgeTtsModule;
|
||||
|
||||
// 1. Global/project-level install (standard Node resolution)
|
||||
try {
|
||||
_edgeTtsModule = await import('node-edge-tts');
|
||||
return _edgeTtsModule;
|
||||
} catch { /* fall through */ }
|
||||
|
||||
// 2. tools/tts/ relative to project root
|
||||
const localPath = pathResolve(projectRoot, 'tools', 'tts', 'node_modules', 'node-edge-tts', 'dist', 'edge-tts.js');
|
||||
if (fsExistsSync(localPath)) {
|
||||
try {
|
||||
_edgeTtsModule = await import(pathToFileURL(localPath).href);
|
||||
return _edgeTtsModule;
|
||||
} catch { /* fall through */ }
|
||||
}
|
||||
|
||||
// 3. Error with instructions
|
||||
throw new Error(
|
||||
'node-edge-tts not found. Install it:\n' +
|
||||
' - npm install --prefix tools/tts node-edge-tts\n' +
|
||||
' - or: npm install node-edge-tts (global/project-level)'
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Edge TTS provider (free, no API key). Uses node-edge-tts package.
|
||||
* @param {string} text — text to synthesize
|
||||
* @param {string} outputPath — path for the output mp3 file
|
||||
* @param {object} opts — { voice }
|
||||
*/
|
||||
export async function edgeTtsProvider(text, outputPath, opts = {}) {
|
||||
const { EdgeTTS } = await resolveEdgeTts();
|
||||
const voice = opts.voice || 'ru-RU-DmitryNeural';
|
||||
const tts = new EdgeTTS({ voice });
|
||||
await Promise.race([
|
||||
tts.ttsPromise(text, outputPath),
|
||||
new Promise((_, reject) => setTimeout(() => reject(new Error('Edge TTS timeout (30s)')), 30000)),
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* OpenAI-compatible TTS provider. Requires apiKey.
|
||||
* @param {string} text — text to synthesize
|
||||
* @param {string} outputPath — path for the output mp3 file
|
||||
* @param {object} opts — { apiKey, apiUrl, voice, model }
|
||||
*/
|
||||
export async function openaiTtsProvider(text, outputPath, opts = {}) {
|
||||
const apiUrl = opts.apiUrl || 'https://api.openai.com/v1/audio/speech';
|
||||
if (!opts.apiKey) throw new Error('OpenAI TTS requires apiKey');
|
||||
const resp = await fetch(apiUrl, {
|
||||
method: 'POST',
|
||||
headers: { 'Authorization': `Bearer ${opts.apiKey}`, 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
model: opts.model || 'tts-1',
|
||||
input: text,
|
||||
voice: opts.voice || 'alloy',
|
||||
response_format: 'mp3',
|
||||
}),
|
||||
});
|
||||
if (!resp.ok) throw new Error(`OpenAI TTS error ${resp.status}: ${await resp.text()}`);
|
||||
const buf = Buffer.from(await resp.arrayBuffer());
|
||||
writeFileSync(outputPath, buf);
|
||||
}
|
||||
|
||||
/**
|
||||
* ElevenLabs TTS provider. Requires apiKey.
|
||||
* @param {string} text — text to synthesize
|
||||
* @param {string} outputPath — path for the output mp3 file
|
||||
* @param {object} opts — { apiKey, apiUrl, voice, model }
|
||||
*/
|
||||
export async function elevenlabsTtsProvider(text, outputPath, opts = {}) {
|
||||
const voiceId = opts.voice || 'JBFqnCBsd6RMkjVDRZzb'; // George
|
||||
const apiUrl = opts.apiUrl || `https://api.elevenlabs.io/v1/text-to-speech/${voiceId}`;
|
||||
if (!opts.apiKey) throw new Error('ElevenLabs TTS requires apiKey');
|
||||
const resp = await fetch(apiUrl, {
|
||||
method: 'POST',
|
||||
headers: { 'xi-api-key': opts.apiKey, 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
text,
|
||||
model_id: opts.model || 'eleven_multilingual_v2',
|
||||
}),
|
||||
});
|
||||
if (!resp.ok) throw new Error(`ElevenLabs TTS error ${resp.status}: ${await resp.text()}`);
|
||||
const buf = Buffer.from(await resp.arrayBuffer());
|
||||
writeFileSync(outputPath, buf);
|
||||
}
|
||||
|
||||
/** Get TTS provider function by name. */
|
||||
export function getTtsProvider(name) {
|
||||
switch (name) {
|
||||
case 'openai': return openaiTtsProvider;
|
||||
case 'elevenlabs': return elevenlabsTtsProvider;
|
||||
case 'edge': default: return edgeTtsProvider;
|
||||
}
|
||||
}
|
||||
|
||||
// ── TTS audio helpers ──────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Get audio duration in seconds using ffprobe.
|
||||
* @param {string} filePath — path to audio file
|
||||
* @param {string} ffmpegPath — path to ffmpeg binary (ffprobe is found next to it)
|
||||
* @returns {number} duration in seconds
|
||||
*/
|
||||
export function getAudioDuration(filePath, ffmpegPath) {
|
||||
const ffprobePath = ffmpegPath.replace(/ffmpeg(\.exe)?$/i, 'ffprobe$1');
|
||||
const out = execFileSync(ffprobePath, [
|
||||
'-v', 'error', '-show_entries', 'format=duration',
|
||||
'-of', 'default=noprint_wrappers=1:nokey=1', filePath,
|
||||
], { encoding: 'utf8', timeout: 10000 }).trim();
|
||||
return parseFloat(out) || 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a silence mp3 file of given duration.
|
||||
* @param {string} outputPath — path for the output mp3 file
|
||||
* @param {number} seconds — duration in seconds
|
||||
* @param {string} ffmpegPath — path to ffmpeg binary
|
||||
*/
|
||||
export function generateSilence(outputPath, seconds, ffmpegPath) {
|
||||
execFileSync(ffmpegPath, [
|
||||
'-y', '-f', 'lavfi', '-i', `anullsrc=r=24000:cl=mono`,
|
||||
'-t', String(seconds), '-c:a', 'libmp3lame', '-b:a', '32k', outputPath,
|
||||
], { stdio: 'pipe', timeout: 10000 });
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,64 +1,64 @@
|
||||
// web-test table/grid-toggle v1.17 — shared icon-detection for grid expand/
|
||||
// collapse toggles. Used by clickElement's gridGroup/gridParent and
|
||||
// gridTreeNode branches; the actual mouse click stays in the caller because
|
||||
// it depends on the caller-local modifier-key handling.
|
||||
// Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
|
||||
|
||||
import { page } from '../core/state.mjs';
|
||||
|
||||
/**
|
||||
* Locate the toggle icon for the grid row at `target.y`. Inspects the row
|
||||
* under that Y-coordinate inside the resolved grid, returns the icon's
|
||||
* center coordinates and current expanded state — or `null` if no toggle
|
||||
* icon is present (e.g. leaf node or detached row).
|
||||
*
|
||||
* @param {{y:number, gridId?:string}} target
|
||||
* @param {number} formNum
|
||||
* @param {object} opts
|
||||
* @param {string} opts.iconSelector — CSS selector inside .gridLine
|
||||
* (e.g. '.gridListH, .gridListV' for groups, '.gridBoxImg [tree="true"]' for tree nodes)
|
||||
* @param {string} opts.isExpandedExpr — JS expression evaluated in browser
|
||||
* context where `icon` is the matched element; must yield a boolean
|
||||
* (e.g. "icon.classList.contains('gridListV')" or "(icon.style.backgroundImage || '').includes('gx=0')")
|
||||
* @returns {Promise<{x:number, y:number, isExpanded:boolean}|null>}
|
||||
*/
|
||||
export async function getGridToggleIcon(target, formNum, { iconSelector, isExpandedExpr }) {
|
||||
return await page.evaluate(`(() => {
|
||||
const p = ${JSON.stringify(`form${formNum}_`)};
|
||||
const gridSel = ${JSON.stringify(target.gridId ? '#' + target.gridId : null)};
|
||||
const grid = gridSel ? document.querySelector(gridSel) : document.querySelector('[id^="' + p + '"].grid');
|
||||
const body = grid?.querySelector('.gridBody');
|
||||
if (!body) return null;
|
||||
const targetY = ${target.y};
|
||||
const lines = [...body.querySelectorAll('.gridLine')];
|
||||
for (const line of lines) {
|
||||
const lr = line.getBoundingClientRect();
|
||||
if (targetY < lr.top || targetY > lr.bottom) continue;
|
||||
const icon = line.querySelector(${JSON.stringify(iconSelector)});
|
||||
if (icon) {
|
||||
const r = icon.getBoundingClientRect();
|
||||
const isExpanded = ${isExpandedExpr};
|
||||
return { x: Math.round(r.x + r.width / 2), y: Math.round(r.y + r.height / 2), isExpanded };
|
||||
}
|
||||
}
|
||||
return null;
|
||||
})()`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Standard expand/toggle decision: should we click the toggle icon?
|
||||
* - `toggle:true` → always click.
|
||||
* - `expand:true` → click only if not already expanded.
|
||||
* - `expand:false` → click only if currently expanded.
|
||||
* - If no icon found (`iconInfo` is null) → click anyway (caller falls back to dblclick).
|
||||
*
|
||||
* @param {{isExpanded:boolean}|null} iconInfo
|
||||
* @param {boolean|undefined} expand
|
||||
* @param {boolean|undefined} toggle
|
||||
* @returns {boolean}
|
||||
*/
|
||||
export function shouldClickToggle(iconInfo, expand, toggle) {
|
||||
return toggle || !iconInfo
|
||||
|| (expand === true && !iconInfo.isExpanded)
|
||||
|| (expand === false && iconInfo.isExpanded);
|
||||
}
|
||||
// web-test table/grid-toggle v1.17 — shared icon-detection for grid expand/
|
||||
// collapse toggles. Used by clickElement's gridGroup/gridParent and
|
||||
// gridTreeNode branches; the actual mouse click stays in the caller because
|
||||
// it depends on the caller-local modifier-key handling.
|
||||
// Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
|
||||
|
||||
import { page } from '../core/state.mjs';
|
||||
|
||||
/**
|
||||
* Locate the toggle icon for the grid row at `target.y`. Inspects the row
|
||||
* under that Y-coordinate inside the resolved grid, returns the icon's
|
||||
* center coordinates and current expanded state — or `null` if no toggle
|
||||
* icon is present (e.g. leaf node or detached row).
|
||||
*
|
||||
* @param {{y:number, gridId?:string}} target
|
||||
* @param {number} formNum
|
||||
* @param {object} opts
|
||||
* @param {string} opts.iconSelector — CSS selector inside .gridLine
|
||||
* (e.g. '.gridListH, .gridListV' for groups, '.gridBoxImg [tree="true"]' for tree nodes)
|
||||
* @param {string} opts.isExpandedExpr — JS expression evaluated in browser
|
||||
* context where `icon` is the matched element; must yield a boolean
|
||||
* (e.g. "icon.classList.contains('gridListV')" or "(icon.style.backgroundImage || '').includes('gx=0')")
|
||||
* @returns {Promise<{x:number, y:number, isExpanded:boolean}|null>}
|
||||
*/
|
||||
export async function getGridToggleIcon(target, formNum, { iconSelector, isExpandedExpr }) {
|
||||
return await page.evaluate(`(() => {
|
||||
const p = ${JSON.stringify(`form${formNum}_`)};
|
||||
const gridSel = ${JSON.stringify(target.gridId ? '#' + target.gridId : null)};
|
||||
const grid = gridSel ? document.querySelector(gridSel) : document.querySelector('[id^="' + p + '"].grid');
|
||||
const body = grid?.querySelector('.gridBody');
|
||||
if (!body) return null;
|
||||
const targetY = ${target.y};
|
||||
const lines = [...body.querySelectorAll('.gridLine')];
|
||||
for (const line of lines) {
|
||||
const lr = line.getBoundingClientRect();
|
||||
if (targetY < lr.top || targetY > lr.bottom) continue;
|
||||
const icon = line.querySelector(${JSON.stringify(iconSelector)});
|
||||
if (icon) {
|
||||
const r = icon.getBoundingClientRect();
|
||||
const isExpanded = ${isExpandedExpr};
|
||||
return { x: Math.round(r.x + r.width / 2), y: Math.round(r.y + r.height / 2), isExpanded };
|
||||
}
|
||||
}
|
||||
return null;
|
||||
})()`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Standard expand/toggle decision: should we click the toggle icon?
|
||||
* - `toggle:true` → always click.
|
||||
* - `expand:true` → click only if not already expanded.
|
||||
* - `expand:false` → click only if currently expanded.
|
||||
* - If no icon found (`iconInfo` is null) → click anyway (caller falls back to dblclick).
|
||||
*
|
||||
* @param {{isExpanded:boolean}|null} iconInfo
|
||||
* @param {boolean|undefined} expand
|
||||
* @param {boolean|undefined} toggle
|
||||
* @returns {boolean}
|
||||
*/
|
||||
export function shouldClickToggle(iconInfo, expand, toggle) {
|
||||
return toggle || !iconInfo
|
||||
|| (expand === true && !iconInfo.isExpanded)
|
||||
|| (expand === false && iconInfo.isExpanded);
|
||||
}
|
||||
|
||||
@@ -1,95 +1,95 @@
|
||||
// web-test table/grid v1.20 — Form-grid operations: read table rows, fill rows, delete rows.
|
||||
// Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
|
||||
//
|
||||
// "Grid" в терминах 1С — таблица на форме (.gridLine/.gridBody/.grid в DOM):
|
||||
// табличные части документов, формы списков, ТЧ настроек и т.п.
|
||||
// Отдельно от SpreadsheetDocument (engine/spreadsheet/spreadsheet.mjs).
|
||||
|
||||
import { page, ensureConnected } from '../core/state.mjs';
|
||||
import { detectFormScript, readTableScript, resolveGridScript } from '../../dom.mjs';
|
||||
import { findDeleteRowCoordsScript, countGridRowsScript } from '../../dom/grid.mjs';
|
||||
import { isInputFocusedInGrid } from '../core/helpers.mjs';
|
||||
import { dismissPendingErrors } from '../core/errors.mjs';
|
||||
import { waitForStable } from '../core/wait.mjs';
|
||||
import { clickElement } from '../core/click.mjs';
|
||||
import { returnFormState } from '../core/helpers.mjs';
|
||||
|
||||
/** Read structured table data with pagination. Returns columns, rows, total count. */
|
||||
export async function readTable({ maxRows = 20, offset = 0, table } = {}) {
|
||||
ensureConnected();
|
||||
const formNum = await page.evaluate(detectFormScript());
|
||||
if (formNum === null) throw new Error('readTable: no form found');
|
||||
let gridSelector;
|
||||
if (table) {
|
||||
const resolved = await page.evaluate(resolveGridScript(formNum, table));
|
||||
if (resolved.error) throw new Error(`readTable: ${resolved.message || resolved.error}. Available: ${resolved.available?.map(a => a.name).join(', ') || 'none'}`);
|
||||
gridSelector = resolved.gridSelector;
|
||||
}
|
||||
return await page.evaluate(readTableScript(formNum, { maxRows, offset, gridSelector }));
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 {object} form state with { deleted, rowsBefore, rowsAfter }
|
||||
*/
|
||||
export async function deleteTableRow(row, { tab, table } = {}) {
|
||||
ensureConnected();
|
||||
await dismissPendingErrors();
|
||||
const formNum = await page.evaluate(detectFormScript());
|
||||
if (formNum === null) throw new Error('deleteTableRow: no form found');
|
||||
|
||||
// Pre-resolve grid when table is specified
|
||||
let gridSelector;
|
||||
if (table) {
|
||||
const resolved = await page.evaluate(resolveGridScript(formNum, table));
|
||||
if (resolved.error) throw new Error(`deleteTableRow: table "${table}" not found. Available: ${resolved.available?.map(a => a.name).join(', ') || 'none'}`);
|
||||
gridSelector = resolved.gridSelector;
|
||||
}
|
||||
|
||||
// 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(findDeleteRowCoordsScript(gridSelector, row));
|
||||
|
||||
if (cellCoords.error) throw new Error(`deleteTableRow: ${cellCoords.error}${cellCoords.total ? ' (total rows: ' + cellCoords.total + ')' : ''}`);
|
||||
|
||||
const rowsBefore = cellCoords.total;
|
||||
|
||||
// Pre-click Escape: leftover edit-mode from a prior fillTableRow Tab-navigation.
|
||||
// Without it the next mouse click may not select the row reliably (the active
|
||||
// edit input intercepts the event timing).
|
||||
if (await isInputFocusedInGrid({ gridSelector })) {
|
||||
await page.keyboard.press('Escape');
|
||||
await page.waitForTimeout(150);
|
||||
}
|
||||
|
||||
// Single click to select the row
|
||||
await page.mouse.click(cellCoords.x, cellCoords.y);
|
||||
await page.waitForTimeout(300);
|
||||
|
||||
// Post-click Escape: clicking a Number/Date cell auto-enters edit mode in 1С.
|
||||
// Delete in edit mode clears the cell buffer instead of deleting the row, so
|
||||
// we exit edit first. The row remains selected after Escape — Delete acts on it.
|
||||
if (await isInputFocusedInGrid({ gridSelector })) {
|
||||
await page.keyboard.press('Escape');
|
||||
await page.waitForTimeout(150);
|
||||
}
|
||||
|
||||
// 3. Press Delete to remove the row
|
||||
await page.keyboard.press('Delete');
|
||||
await waitForStable();
|
||||
|
||||
// 4. Count rows after deletion
|
||||
const rowsAfter = await page.evaluate(countGridRowsScript(gridSelector));
|
||||
|
||||
return returnFormState({ deleted: row, rowsBefore, rowsAfter });
|
||||
}
|
||||
// web-test table/grid v1.20 — Form-grid operations: read table rows, fill rows, delete rows.
|
||||
// Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
|
||||
//
|
||||
// "Grid" в терминах 1С — таблица на форме (.gridLine/.gridBody/.grid в DOM):
|
||||
// табличные части документов, формы списков, ТЧ настроек и т.п.
|
||||
// Отдельно от SpreadsheetDocument (engine/spreadsheet/spreadsheet.mjs).
|
||||
|
||||
import { page, ensureConnected } from '../core/state.mjs';
|
||||
import { detectFormScript, readTableScript, resolveGridScript } from '../../dom.mjs';
|
||||
import { findDeleteRowCoordsScript, countGridRowsScript } from '../../dom/grid.mjs';
|
||||
import { isInputFocusedInGrid } from '../core/helpers.mjs';
|
||||
import { dismissPendingErrors } from '../core/errors.mjs';
|
||||
import { waitForStable } from '../core/wait.mjs';
|
||||
import { clickElement } from '../core/click.mjs';
|
||||
import { returnFormState } from '../core/helpers.mjs';
|
||||
|
||||
/** Read structured table data with pagination. Returns columns, rows, total count. */
|
||||
export async function readTable({ maxRows = 20, offset = 0, table } = {}) {
|
||||
ensureConnected();
|
||||
const formNum = await page.evaluate(detectFormScript());
|
||||
if (formNum === null) throw new Error('readTable: no form found');
|
||||
let gridSelector;
|
||||
if (table) {
|
||||
const resolved = await page.evaluate(resolveGridScript(formNum, table));
|
||||
if (resolved.error) throw new Error(`readTable: ${resolved.message || resolved.error}. Available: ${resolved.available?.map(a => a.name).join(', ') || 'none'}`);
|
||||
gridSelector = resolved.gridSelector;
|
||||
}
|
||||
return await page.evaluate(readTableScript(formNum, { maxRows, offset, gridSelector }));
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 {object} form state with { deleted, rowsBefore, rowsAfter }
|
||||
*/
|
||||
export async function deleteTableRow(row, { tab, table } = {}) {
|
||||
ensureConnected();
|
||||
await dismissPendingErrors();
|
||||
const formNum = await page.evaluate(detectFormScript());
|
||||
if (formNum === null) throw new Error('deleteTableRow: no form found');
|
||||
|
||||
// Pre-resolve grid when table is specified
|
||||
let gridSelector;
|
||||
if (table) {
|
||||
const resolved = await page.evaluate(resolveGridScript(formNum, table));
|
||||
if (resolved.error) throw new Error(`deleteTableRow: table "${table}" not found. Available: ${resolved.available?.map(a => a.name).join(', ') || 'none'}`);
|
||||
gridSelector = resolved.gridSelector;
|
||||
}
|
||||
|
||||
// 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(findDeleteRowCoordsScript(gridSelector, row));
|
||||
|
||||
if (cellCoords.error) throw new Error(`deleteTableRow: ${cellCoords.error}${cellCoords.total ? ' (total rows: ' + cellCoords.total + ')' : ''}`);
|
||||
|
||||
const rowsBefore = cellCoords.total;
|
||||
|
||||
// Pre-click Escape: leftover edit-mode from a prior fillTableRow Tab-navigation.
|
||||
// Without it the next mouse click may not select the row reliably (the active
|
||||
// edit input intercepts the event timing).
|
||||
if (await isInputFocusedInGrid({ gridSelector })) {
|
||||
await page.keyboard.press('Escape');
|
||||
await page.waitForTimeout(150);
|
||||
}
|
||||
|
||||
// Single click to select the row
|
||||
await page.mouse.click(cellCoords.x, cellCoords.y);
|
||||
await page.waitForTimeout(300);
|
||||
|
||||
// Post-click Escape: clicking a Number/Date cell auto-enters edit mode in 1С.
|
||||
// Delete in edit mode clears the cell buffer instead of deleting the row, so
|
||||
// we exit edit first. The row remains selected after Escape — Delete acts on it.
|
||||
if (await isInputFocusedInGrid({ gridSelector })) {
|
||||
await page.keyboard.press('Escape');
|
||||
await page.waitForTimeout(150);
|
||||
}
|
||||
|
||||
// 3. Press Delete to remove the row
|
||||
await page.keyboard.press('Delete');
|
||||
await waitForStable();
|
||||
|
||||
// 4. Count rows after deletion
|
||||
const rowsAfter = await page.evaluate(countGridRowsScript(gridSelector));
|
||||
|
||||
return returnFormState({ deleted: row, rowsBefore, rowsAfter });
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user