Auto-build: opencode (powershell) from 6d119eb

This commit is contained in:
github-actions[bot]
2026-06-04 09:28:00 +00:00
commit 1350759977
263 changed files with 110444 additions and 0 deletions
@@ -0,0 +1,391 @@
// web-test dom shared v1.0 — embedded JS function constants
// Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
/**
* Shared function strings embedded into page.evaluate() generators.
* Не экспортируются наружу через dom.mjs facade — внутренняя кухня.
*/
/** Find visible #modalSurface. 1C may leave multiple #modalSurface in DOM (duplicate id),
* e.g. when a second form (drill-down) creates its own alongside a stale one from the first
* form. getElementById returns the FIRST in document order, which may be hidden. Scan all. */
export const HAS_VISIBLE_MODAL_FN = `function hasVisibleModal() {
const all = document.querySelectorAll('#modalSurface');
for (const el of all) { if (el.offsetWidth > 0) return true; }
return false;
}`;
/** Detect active form number. Picks form with most visible elements, skipping form0.
* When modalSurface is visible — prefer the highest-numbered form (modal dialog). */
export const DETECT_FORM_FN = HAS_VISIBLE_MODAL_FN + `
function detectForm() {
const counts = {};
document.querySelectorAll('input.editInput[id], textarea[id], a.press[id]').forEach(el => {
if (el.offsetWidth === 0) return;
const m = el.id.match(/^form(\\d+)_/);
if (m) counts[m[1]] = (counts[m[1]] || 0) + 1;
});
const nums = Object.keys(counts).map(Number);
if (!nums.length) return null;
const candidates = nums.filter(n => n > 0);
if (!candidates.length) return nums[0];
// When modal surface is visible, prefer the highest-numbered form (modal dialog)
if (hasVisibleModal()) {
const maxForm = Math.max(...candidates);
if (counts[maxForm] >= 1) return maxForm;
}
return candidates.reduce((best, n) => counts[n] > counts[best] ? n : best);
}`;
/** Detect all open forms + modal state. Returns { activeForm, allForms, formCount, modal }.
* Works even when the open-windows tab bar is hidden. */
export const DETECT_FORMS_FN = HAS_VISIBLE_MODAL_FN + `
function detectForms() {
const counts = {};
document.querySelectorAll('input.editInput[id], textarea[id], a.press[id]').forEach(el => {
if (el.offsetWidth === 0) return;
const m = el.id.match(/^form(\\d+)_/);
if (m) counts[m[1]] = (counts[m[1]] || 0) + 1;
});
const nums = Object.keys(counts).map(Number);
return { allForms: nums.sort((a, b) => a - b), formCount: nums.length, modal: hasVisibleModal() };
}`;
/** Read form state given prefix p. Returns { fields, buttons, tabs, texts, hyperlinks, table, iframes }. */
export const READ_FORM_FN = `function readForm(p) {
const result = {};
const fields = [];
const buttons = [];
const formTabs = [];
const texts = [];
const hyperlinks = [];
// Normalize non-breaking spaces to regular spaces
const nbsp = s => (s || '').replace(/\\u00a0/g, ' ');
// Fields (inputs)
document.querySelectorAll('input.editInput[id^="' + p + '"]').forEach(el => {
if (el.offsetWidth === 0) return;
const name = el.id.replace(p, '').replace(/_i\\d+$/, '');
const titleEl = document.getElementById(p + name + '#title_text')
|| document.getElementById(p + name + '#title_div');
const label = nbsp((titleEl?.innerText?.trim() || '').replace(/\\n/g, ' '));
const actions = [];
if (document.getElementById(p + name + '_DLB')?.offsetWidth > 0) actions.push('select');
if (document.getElementById(p + name + '_OB')?.offsetWidth > 0) actions.push('open');
if (document.getElementById(p + name + '_CLR')?.offsetWidth > 0) actions.push('clear');
if (document.getElementById(p + name + '_CB')?.offsetWidth > 0) actions.push('pick');
const field = { name, value: el.value || '' };
// Multi-value reference fields keep their value in .chipsItem chips, not in input.value
if (!field.value) {
const labelEl = document.getElementById(p + name);
if (labelEl) {
const chipTexts = [...labelEl.querySelectorAll('.chipsItem .chipsTitle')]
.map(c => nbsp(c.innerText?.trim() || ''))
.filter(Boolean);
if (chipTexts.length) field.value = chipTexts.join(', ');
}
}
if (label && label !== name) field.label = label;
if (el.readOnly) field.readonly = true;
if (el.disabled) field.disabled = true;
if (el.type && el.type !== 'text') field.type = el.type;
if (document.activeElement === el) field.focused = true;
if (actions.length) field.actions = actions;
if (el.closest('.inputsBox')?.classList.contains('markIncomplete')) field.required = true;
fields.push(field);
});
// Textareas
document.querySelectorAll('textarea[id^="' + p + '"]').forEach(el => {
if (el.offsetWidth === 0) return;
const name = el.id.replace(p, '').replace(/_i\\d+$/, '');
const titleEl = document.getElementById(p + name + '#title_text')
|| document.getElementById(p + name + '#title_div');
const label = nbsp((titleEl?.innerText?.trim() || '').replace(/\\n/g, ' '));
const field = { name, value: el.value || '', type: 'textarea' };
if (label && label !== name) field.label = label;
if (el.readOnly) field.readonly = true;
if (el.disabled) field.disabled = true;
if (document.activeElement === el) field.focused = true;
if (el.closest('.inputsBox')?.classList.contains('markIncomplete')) field.required = true;
fields.push(field);
});
// Checkboxes
document.querySelectorAll('[id^="' + p + '"].checkbox').forEach(el => {
if (el.offsetWidth === 0) return;
const name = el.id.replace(p, '');
const titleEl = document.getElementById(p + name + '#title_text');
const label = nbsp(titleEl?.innerText?.trim() || '');
const field = {
name,
value: el.classList.contains('checked') || el.classList.contains('checkboxOn') || el.classList.contains('select'),
type: 'checkbox'
};
if (label && label !== name) field.label = label;
fields.push(field);
});
// Radio buttons — base element is option 0, others are #N#radio (N >= 1)
const radioGroups = {};
document.querySelectorAll('[id^="' + p + '"].radio').forEach(el => {
if (el.offsetWidth === 0) return;
const id = el.id.replace(p, '');
const m = id.match(/^(.+?)#(\\d+)#radio$/);
if (m) {
// Options 1, 2, ... have explicit #N#radio suffix
const [, groupName, idx] = m;
if (!radioGroups[groupName]) radioGroups[groupName] = [];
const labelEl = document.getElementById(p + groupName + '#' + idx + '#radio_text');
const label = nbsp(labelEl?.innerText?.trim() || 'option' + idx);
radioGroups[groupName].push({ index: parseInt(idx), label, selected: el.classList.contains('select') });
} else if (!id.includes('#')) {
// Base element = option 0 (no #0#radio suffix)
if (!radioGroups[id]) radioGroups[id] = [];
const labelEl = document.getElementById(p + id + '#0#radio_text');
const label = nbsp(labelEl?.innerText?.trim() || 'option0');
radioGroups[id].unshift({ index: 0, label, selected: el.classList.contains('select') });
}
});
for (const [name, options] of Object.entries(radioGroups)) {
const titleEl = document.getElementById(p + name + '#title_text');
const label = titleEl?.innerText?.trim() || '';
const selected = options.find(o => o.selected);
const field = {
name,
value: selected?.label || '',
type: 'radio',
options: options.map(o => o.label)
};
if (label && label !== name) field.label = label;
fields.push(field);
}
// Buttons (a.press)
document.querySelectorAll('a.press[id^="' + p + '"]').forEach(el => {
if (el.offsetWidth === 0) return;
const idName = el.id.replace(p, '');
if (/_(?:DLB|CLR|OB|CB)$/.test(idName)) return;
const span = el.querySelector('.submenuText') || el.querySelector('span');
const text = nbsp(span?.textContent?.trim() || el.innerText?.trim() || '');
if (!text && !el.classList.contains('pressCommand')) return;
const btn = { name: text || idName };
if (el.classList.contains('pressDefault')) btn.default = true;
if (el.classList.contains('pressDisabled')) btn.disabled = true;
// Icon-only buttons: expose tooltip from DOM title attribute (1C puts title on parent .framePress)
if (!text) {
const tip = nbsp(el.title || el.parentElement?.title || '');
if (tip) btn.tooltip = tip;
}
buttons.push(btn);
});
// Frame buttons
document.querySelectorAll('[id^="' + p + '"].frameButton, [id^="' + p + '"] .frameButton').forEach(el => {
if (el.offsetWidth === 0) return;
const text = nbsp(el.innerText?.trim() || '');
const idName = el.id?.replace(p, '') || '';
if (!text && !idName) return;
buttons.push({ name: text || idName, frame: true });
});
// Tumbler items
document.querySelectorAll('[id^="' + p + '"].tumblerItem').forEach(el => {
if (el.offsetWidth === 0) return;
const text = el.innerText?.trim();
const idName = el.id?.replace(p, '') || '';
buttons.push({ name: text || idName, tumbler: true });
});
// Tabs — scoped to form by checking ancestor IDs
document.querySelectorAll('[data-content]').forEach(el => {
if (el.offsetWidth === 0) return;
let node = el.parentElement;
let inForm = false;
while (node) {
if (node.id && node.id.startsWith(p)) { inForm = true; break; }
node = node.parentElement;
}
if (!inForm) return;
const tab = { name: el.dataset.content };
if (el.classList.contains('select')) tab.active = true;
formTabs.push(tab);
});
// Static texts and hyperlinks
document.querySelectorAll('[id^="' + p + '"].staticText').forEach(el => {
if (el.offsetWidth === 0) return;
const name = el.id.replace(p, '');
if (name.endsWith('_div') || name.includes('#title')) return;
const text = el.innerText?.trim();
if (!text) return;
if (el.classList.contains('staticTextHyper')) {
hyperlinks.push({ name: text });
} else {
const titleEl = document.getElementById(p + name + '#title_text');
const label = titleEl?.innerText?.trim() || '';
const entry = { name, value: text };
if (label) entry.label = label;
texts.push(entry);
}
});
// Tables/grids — collect ALL visible grids
const allGrids = [...document.querySelectorAll('[id^="' + p + '"].grid, [id^="' + p + '"] .grid')]
.filter(g => g.offsetWidth > 0 && g.offsetHeight > 0);
if (allGrids.length > 0) {
const tables = allGrids.map(grid => {
const name = grid.id ? grid.id.replace(p, '') : '';
const head = grid.querySelector('.gridHead');
const body = grid.querySelector('.gridBody');
const columns = [];
if (head) {
const headLine = head.querySelector('.gridLine') || head;
[...headLine.children].forEach(box => {
if (box.offsetWidth === 0) return;
const textEl = box.querySelector('.gridBoxText');
const text = (textEl || box).innerText?.trim().replace(/\\n/g, ' ') || '';
if (text) {
const r = box.getBoundingClientRect();
columns.push({ text, x: r.x, right: r.x + r.width, y: r.y, h: r.height });
} else {
// Unnamed column — check if data cells contain checkboxes
const firstLine = body?.querySelector('.gridLine');
if (firstLine) {
const visibleHeaders = [...headLine.children].filter(c => c.offsetWidth > 0);
const idx = visibleHeaders.indexOf(box);
const cells = [...firstLine.children].filter(c => c.offsetWidth > 0);
if (cells[idx]?.querySelector('.checkbox')) {
columns.push({ text: '(checkbox)', x: 0, right: 0, y: 0, h: 0 });
}
}
}
});
// Expand single merged headers with multiple data sub-rows (e.g. "Субконто Дт" → 1/2/3)
const firstLine = body?.querySelector('.gridLine');
if (firstLine && columns.length > 0) {
const xGrp = new Map();
columns.forEach(c => {
const k = Math.round(c.x) + ':' + Math.round(c.right);
if (!xGrp.has(k)) xGrp.set(k, []);
xGrp.get(k).push(c);
});
for (const [k, hdrs] of xGrp) {
if (hdrs.length !== 1) continue;
let cnt = 0;
[...firstLine.children].forEach(box => {
if (box.offsetWidth === 0) return;
const r = box.getBoundingClientRect();
const cx = r.x + r.width / 2;
if (cx >= hdrs[0].x && cx < hdrs[0].right) cnt++;
});
if (cnt > 1) {
const base = hdrs[0];
const baseIdx = columns.indexOf(base);
columns.splice(baseIdx, 1);
for (let si = 0; si < cnt; si++) {
columns.splice(baseIdx + si, 0, { text: base.text + ' ' + (si + 1), x: base.x, right: base.right, y: 0, h: 0 });
}
}
}
}
}
const colNames = columns.map(c => c.text);
const rowCount = body ? body.querySelectorAll('.gridLine').length : 0;
// Visual label from group title (e.g. "Входящие:" for grid "Входящие")
const titleEl = document.getElementById(p + name + '#title_div')
|| document.getElementById(p + 'Группа' + name + '#title_div');
const label = titleEl ? (titleEl.innerText?.trim().replace(/:\\s*$/, '').replace(/\\u00a0/g, ' ') || null) : null;
return { name, columns: colNames, rowCount, ...(label ? { label } : {}) };
});
result.tables = tables;
// Backward compat: table = first grid summary
const first = tables[0];
result.table = { present: true, columns: first.columns, rowCount: first.rowCount };
}
// Active filters (train badges above grid: *СостояниеПросмотра)
const filters = [];
document.querySelectorAll('[id^="' + p + '"].trainItem').forEach(el => {
if (el.offsetWidth === 0) return;
const titleEl = el.querySelector('.trainName');
const valueEl = el.querySelector('.trainTitle');
if (!titleEl && !valueEl) return;
const field = (titleEl?.innerText?.trim() || '').replace(/\\n/g, ' ').replace(/\\s*:$/, '').trim();
const value = valueEl?.innerText?.trim()?.replace(/\\n/g, ' ') || '';
if (field || value) filters.push({ field, value });
});
// Also check search field value
const searchInput = [...document.querySelectorAll('input.editInput[id^="' + p + '"]')]
.find(el => el.offsetWidth > 0 && /Строк[аи]Поиска|SearchString/i.test(el.id));
if (searchInput?.value) {
filters.push({ type: 'search', value: searchInput.value });
}
if (filters.length) result.filters = filters;
// Navigation panel (FormNavigationPanel) — lives in parent page{N} container
const navigation = [];
const formEl = document.querySelector('[id^="' + p + '"]');
if (formEl) {
let pageEl = formEl.parentElement;
while (pageEl && !(pageEl.id && /^page\\d+$/.test(pageEl.id))) pageEl = pageEl.parentElement;
if (pageEl) {
pageEl.querySelectorAll('.navigationItem').forEach(el => {
if (el.offsetWidth === 0) return;
const nameEl = el.querySelector('.navigationItemName');
const text = (nameEl?.innerText?.trim() || '').replace(/\\u00a0/g, ' ');
if (!text) return;
const nav = { name: text };
if (el.classList.contains('select')) nav.active = true;
navigation.push(nav);
});
}
}
// Iframes
let iframeCount = 0;
document.querySelectorAll('[id^="' + p + '"] iframe, iframe[id^="' + p + '"]').forEach(el => {
if (el.offsetWidth > 0 && el.offsetHeight > 0) iframeCount++;
});
if (iframeCount) result.iframes = iframeCount;
if (fields.length) result.fields = fields;
if (buttons.length) result.buttons = buttons;
if (formTabs.length) result.tabs = formTabs;
if (navigation.length) result.navigation = navigation;
if (texts.length) result.texts = texts;
if (hyperlinks.length) result.hyperlinks = hyperlinks;
// Group DCS report settings into readable format
if (result.fields) {
const dcsRe = /^(.+Элемент(\\d+))(Использование|Значение|ВидСравнения)$/;
const dcsGroups = {};
const dcsNames = new Set();
for (const f of result.fields) {
const m = f.name.match(dcsRe);
if (!m) continue;
if (!dcsGroups[m[1]]) dcsGroups[m[1]] = { _n: parseInt(m[2]) };
dcsGroups[m[1]][m[3]] = f;
dcsNames.add(f.name);
}
const dcsEntries = Object.entries(dcsGroups).sort((a, b) => a[1]._n - b[1]._n);
if (dcsEntries.length) {
result.reportSettings = dcsEntries.map(([, g]) => {
const cb = g['Использование'];
const val = g['Значение'];
if (!cb && !val) return null;
// No checkbox present (class="staticText" instead of .checkbox) — setting is always enabled
const label = (val?.label || cb?.label || val?.name || cb?.name || '').replace(/:$/, '').trim();
const s = { name: label, enabled: cb ? !!cb.value : true };
if (val) {
s.value = val.value || '';
if (val.actions && val.actions.length) s.actions = val.actions;
}
return s;
}).filter(Boolean);
result.fields = result.fields.filter(f => !dcsNames.has(f.name));
if (!result.fields.length) delete result.fields;
}
}
return result;
}`;
@@ -0,0 +1,108 @@
// web-test dom/edd v1.0 — DOM scripts for the #editDropDown autocomplete popup
// Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
/**
* Read the `#editDropDown` autocomplete popup.
*
* Returns `{ visible: false }` when EDD is absent/hidden, or
* `{ visible: true, items: [{ name, x, y }] }` with center coords suitable
* for `page.mouse.click(x, y)`.
*
* Note: `page.mouse.click` is often intercepted by `div.surface` overlays
* from DLB — prefer `clickEddItemViaDispatchScript` for those cases.
*/
export function readEddScript() {
return `(() => {
const edd = document.getElementById('editDropDown');
if (!edd || edd.offsetWidth === 0) return { visible: false };
const eddTexts = [...edd.querySelectorAll('.eddText')].filter(el => el.offsetWidth > 0);
return {
visible: true,
items: eddTexts.map(el => {
const r = el.getBoundingClientRect();
return { name: el.innerText?.trim() || '', x: r.x + r.width / 2, y: r.y + r.height / 2 };
})
};
})()`;
}
/**
* Is the EDD popup currently visible? Returns boolean.
* Lighter than `readEddScript` when only presence matters.
*/
export function isEddVisibleScript() {
return `(() => {
const edd = document.getElementById('editDropDown');
return !!(edd && edd.offsetWidth > 0);
})()`;
}
/**
* Click an EDD item by name via `dispatchEvent` — bypasses `div.surface`
* overlays from DLB that intercept `page.mouse.click`.
*
* Matching is fuzzy: exact (with optional `(suffix)` strip) → includes,
* normalizes ё/е and NBSP.
*
* Returns the clicked item's innerText (trimmed), or `null` when no match.
*/
export function clickEddItemViaDispatchScript(itemName) {
return `(() => {
const edd = document.getElementById('editDropDown');
if (!edd || edd.offsetWidth === 0) return null;
const ny = s => s.replace(/ё/gi, 'е').replace(/\\u00a0/g, ' ');
const target = ny(${JSON.stringify(itemName.toLowerCase())});
const items = [...edd.querySelectorAll('.eddText')].filter(el => el.offsetWidth > 0);
function clickEl(el) {
const r = el.getBoundingClientRect();
const opts = { bubbles: true, cancelable: true, clientX: r.x + r.width/2, clientY: r.y + r.height/2 };
el.dispatchEvent(new MouseEvent('mousedown', opts));
el.dispatchEvent(new MouseEvent('mouseup', opts));
el.dispatchEvent(new MouseEvent('click', opts));
return el.innerText.trim();
}
// Pass 1: exact match (prefer over partial)
for (const el of items) {
const t = ny((el.innerText?.trim() || '').toLowerCase());
if (t === target) return clickEl(el);
const stripped = t.replace(/\\s*\\([^)]*\\)\\s*$/, '');
if (stripped === target) return clickEl(el);
}
// Pass 2: partial match
for (const el of items) {
const t = ny((el.innerText?.trim() || '').toLowerCase());
if (t.includes(target) || target.includes(t.replace(/\\s*\\([^)]*\\)\\s*$/, ''))) return clickEl(el);
}
return null;
})()`;
}
/**
* Click the "Показать все" / "Show all" link in the EDD footer via
* `dispatchEvent`. Tries `.eddBottom .hyperlink` first, then falls back
* to scanning for span/div/a with the literal text.
*
* Returns boolean — whether the link was found and clicked.
*/
export function clickShowAllInEddScript() {
return `(() => {
const edd = document.getElementById('editDropDown');
if (!edd || edd.offsetWidth === 0) return false;
let el = edd.querySelector('.eddBottom .hyperlink');
if (!el || el.offsetWidth === 0) {
const candidates = [...edd.querySelectorAll('span, div, a')]
.filter(e => e.offsetWidth > 0 && e.children.length === 0);
el = candidates.find(e => {
const t = (e.innerText?.trim() || '').toLowerCase();
return t === 'показать все' || t === 'show all';
});
}
if (!el) return false;
const r = el.getBoundingClientRect();
const opts = { bubbles: true, cancelable: true, clientX: r.x + r.width/2, clientY: r.y + r.height/2 };
el.dispatchEvent(new MouseEvent('mousedown', opts));
el.dispatchEvent(new MouseEvent('mouseup', opts));
el.dispatchEvent(new MouseEvent('click', opts));
return true;
})()`;
}
@@ -0,0 +1,63 @@
// web-test dom/edit-state v1.1 — focus and popup detection inside the 1C web client
// Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
/**
* Is the currently focused element an INPUT (optionally TEXTAREA too)?
* Returns boolean.
*
* @param {object} [opts]
* @param {boolean} [opts.allowTextarea=false] — also return true for TEXTAREA.
*/
export function isInputFocusedScript({ allowTextarea = false } = {}) {
const cond = allowTextarea
? `f.tagName === 'INPUT' || f.tagName === 'TEXTAREA'`
: `f.tagName === 'INPUT'`;
return `(() => {
const f = document.activeElement;
return !!(f && (${cond}));
})()`;
}
/**
* Is the currently focused INPUT/TEXTAREA inside a `.grid` ancestor?
* Used to verify grid edit-mode (active cell editor).
*
* @param {string} [gridSelector] — when given, only `true` if the focused input
* is inside that specific grid. Without it — any `.grid` ancestor counts.
*
* Returns boolean.
*/
export function isInputFocusedInGridScript(gridSelector) {
const sel = gridSelector ? JSON.stringify(gridSelector) : 'null';
return `(() => {
const f = document.activeElement;
if (!f || (f.tagName !== 'INPUT' && f.tagName !== 'TEXTAREA')) return false;
const sel = ${sel};
if (sel) {
const grid = document.querySelector(sel);
return !!(grid && grid.contains(f));
}
let n = f;
while (n) {
if (n.classList?.contains('grid')) return true;
n = n.parentElement;
}
return false;
})()`;
}
/**
* Is a calculator (`.calculate`) or calendar (`.frameCalendar`) popup visible?
* Returns `'calculator' | 'calendar' | null`.
*
* For the "popup gone" check, callers use: `!await findOpenPopup()`.
*/
export function findOpenPopupScript() {
return `(() => {
const calc = document.querySelector('.calculate');
if (calc && calc.offsetWidth > 0) return 'calculator';
const cal = document.querySelector('.frameCalendar');
if (cal && cal.offsetWidth > 0) return 'calendar';
return null;
})()`;
}
@@ -0,0 +1,65 @@
// web-test dom/errors-stack v1.0 — DOM scripts for fetching error stack via OpenReport link.
// Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
//
// Path-1 flow for platform exceptions: click "Сформировать отчет об ошибке" link,
// open detailed error dialog, read textarea, close cleanup dialogs.
/** Find OpenReport link coordinates on the error modal for given formNum. */
export function getOpenReportCoordsScript(formNum) {
return `(() => {
const el = document.getElementById('form${formNum}_OpenReport#text');
if (!el || el.offsetWidth <= 2) return null;
const rect = el.getBoundingClientRect();
return { x: rect.x + rect.width / 2, y: rect.y + rect.height / 2 };
})()`;
}
/** Check whether the "подробный текст ошибки" link is visible (signals report dialog ready). */
export function isErrorDetailLinkVisibleScript() {
return `(() => {
const links = document.querySelectorAll('a, [class*="hyper"], span');
for (const el of links) {
if (el.offsetWidth > 0 && el.textContent.includes('подробный текст ошибки')) return true;
}
return false;
})()`;
}
/** Read the largest visible non-empty textarea — contains the detailed error stack. */
export function readLargestVisibleTextareaScript() {
return `(() => {
let best = null;
document.querySelectorAll('textarea').forEach(ta => {
if (ta.offsetWidth > 0 && ta.value.length > 0) {
if (!best || ta.value.length > best.value.length) best = ta;
}
});
return best?.value || null;
})()`;
}
/** Click the OK button in the topmost cloud window (closes "Подробный текст ошибки"). */
export function clickTopCloudOkButtonScript() {
return `(() => {
const psWins = [...document.querySelectorAll('[id^="ps"][id$="win"]')]
.filter(w => w.offsetWidth > 0)
.sort((a, b) => parseInt(b.style?.zIndex || '0') - parseInt(a.style?.zIndex || '0'));
for (const w of psWins) {
const ok = w.querySelector('button.webBtn, .pressDefault');
if (ok && ok.textContent.trim() === 'OK') { ok.click(); return true; }
}
return false;
})()`;
}
/** Click the × CloseButton in the topmost visible cloud window (closes "Отчет об ошибке"). */
export function clickReportCloseButtonScript() {
return `(() => {
const psWins = [...document.querySelectorAll('[id^="ps"][id$="win"]')]
.filter(w => w.offsetWidth > 0);
for (const w of psWins) {
const closeBtn = w.querySelector('[id$="_cmd_CloseButton"]');
if (closeBtn && closeBtn.offsetWidth > 0) { closeBtn.click(); break; }
}
})()`;
}
@@ -0,0 +1,127 @@
// web-test dom/errors v1.0 — error/diagnostic detection (balloon, messages, modal, stateWindow)
// Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
/**
* Check for validation errors / diagnostics after an action.
* Detects three patterns:
* 1. Inline balloon tooltip (div.balloon with .balloonMessage)
* 2. Messages panel (div.messages with msg0, msg1... grid rows)
* 3. Modal error dialog (high-numbered form with pressDefault + static texts)
* Returns { balloon, messages[], modal } or null if no errors.
*/
export function checkErrorsScript() {
return `(() => {
const result = {};
// 1. Inline balloon tooltip
const balloon = document.querySelector('.balloon');
if (balloon && balloon.offsetWidth > 0) {
const msg = balloon.querySelector('.balloonMessage');
const title = balloon.querySelector('.balloonTitle');
if (msg) {
result.balloon = {
title: title?.innerText?.trim() || 'Ошибка',
message: msg.innerText?.trim() || ''
};
// Count navigation arrows to indicate total errors
const fwd = balloon.querySelector('.balloonJumpFwd');
const back = balloon.querySelector('.balloonJumpBack');
const fwdDisabled = fwd?.classList.contains('disabled');
const backDisabled = back?.classList.contains('disabled');
if (fwd && !fwdDisabled) result.balloon.hasNext = true;
if (back && !backDisabled) result.balloon.hasPrev = true;
}
}
// 2. Messages panel (div.messages — pick visible one, multiple may exist across tabs)
const msgPanels = [...document.querySelectorAll('.messages')].filter(el => el.offsetWidth > 0);
for (const msgPanel of msgPanels) {
const msgs = [];
msgPanel.querySelectorAll('[id^="msg"]').forEach(line => {
if (line.offsetWidth === 0) return;
const textEl = line.querySelector('.gridBoxText');
const text = (textEl || line).innerText?.trim();
if (text) msgs.push(text);
});
if (msgs.length > 0) { result.messages = msgs; break; }
}
// 3+4. Modal dialogs: confirmation (multiple buttons) or error (single pressDefault)
// Uses form container ancestry to group buttons — pressButton elements often lack form-prefixed IDs
// Note: 1C shows some modals WITHOUT #modalSurface (e.g. "Не удалось записать" uses ps*win floating window)
// so we always scan for small forms with button patterns, regardless of modalSurface state
const formButtons = {};
[...document.querySelectorAll('a.press.pressButton')].forEach(btn => {
if (btn.offsetWidth === 0) return;
const container = btn.closest('[id$="_container"]');
const m = container?.id?.match(/^form(\\d+)_/);
if (!m) return;
const fn = m[1];
if (!formButtons[fn]) formButtons[fn] = [];
formButtons[fn].push(btn);
});
for (const [fn, buttons] of Object.entries(formButtons)) {
const p = 'form' + fn + '_';
const elCount = document.querySelectorAll('[id^="' + p + '"]').length;
if (elCount > 100) continue; // Skip large content forms
if (buttons.length > 1) {
// Confirmation dialog (multiple buttons: Да/Нет, OK/Отмена, etc.)
// Must have a Message element — real 1C confirmations always have form{N}_Message.
// Without it, this is just a regular form with multiple buttons (e.g. EPF form).
const msgEl = document.getElementById(p + 'Message');
if (!msgEl || msgEl.offsetWidth === 0) continue;
const message = msgEl.innerText?.trim() || '';
const btnNames = buttons.map(el => {
const b = { name: el.innerText?.trim() || '' };
if (el.classList.contains('pressDefault')) b.default = true;
return b;
}).filter(b => b.name);
result.confirmation = { message, buttons: btnNames.map(b => b.name), formNum: parseInt(fn) };
break;
}
}
// Single-button modal: error dialog with pressDefault + staticText
// Skip forms with input fields — those are data entry forms (e.g. register record),
// not error dialogs. Real error modals only have staticText + buttons.
if (!result.confirmation) {
for (const [fn, buttons] of Object.entries(formButtons)) {
const p = 'form' + fn + '_';
const elCount = document.querySelectorAll('[id^="' + p + '"]').length;
if (elCount > 100) continue;
if (buttons.length !== 1 || !buttons[0].classList.contains('pressDefault')) continue;
const hasInputs = document.querySelectorAll('input.editInput[id^="' + p + '"], textarea[id^="' + p + '"]').length > 0;
if (hasInputs) continue;
const texts = [...document.querySelectorAll('[id^="' + p + '"].staticText')]
.filter(el => el.offsetWidth > 0)
.map(el => el.innerText?.trim())
.filter(Boolean);
if (texts.length > 0) {
result.modal = { message: texts.join(' '), formNum: parseInt(fn), button: buttons[0].innerText?.trim() || '' };
// Check if OpenReport link is available (platform exceptions have visible link text)
const reportLink = document.getElementById(p + 'OpenReport#text');
if (reportLink && reportLink.offsetWidth > 2 && reportLink.textContent.trim()) {
result.modal.hasReport = true;
}
// Grab AdditionalInfo/ServerText if filled (may contain extra error details)
const addInfo = document.getElementById(p + 'AdditionalInfo');
if (addInfo && addInfo.textContent && addInfo.textContent.trim()) result.modal.additionalInfo = addInfo.textContent.trim();
const srvText = document.getElementById(p + 'ServerText');
if (srvText && srvText.textContent && srvText.textContent.trim()) result.modal.serverText = srvText.textContent.trim();
break;
}
}
}
// 5. SpreadsheetDocument state window (info bar inside moxelContainer)
// Shows messages like "Не установлено значение параметра X" or "Отчет не сформирован"
const stateWins = [...document.querySelectorAll('.stateWindowSupportSurface')].filter(el => el.offsetWidth > 0);
if (stateWins.length) {
const texts = stateWins.map(el => el.innerText?.trim()).filter(Boolean);
if (texts.length) result.stateText = texts;
}
return (result.balloon || result.messages || result.modal || result.confirmation || result.stateText) ? result : null;
})()`;
}
@@ -0,0 +1,187 @@
// web-test dom/filter v1.0 — DOM scripts for filterList / unfilterList
// Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
/**
* Find the first grid cell on the form and return its center coords.
* Used as a fallback target for Alt+F when there's no search input.
*
* Returns `{ x, y } | null`.
*/
export function findFirstGridCellCoordsScript(formNum) {
return `(() => {
const p = 'form${formNum}_';
const grid = [...document.querySelectorAll('[id^="' + p + '"].grid, [id^="' + p + '"] .grid')]
.find(g => g.offsetWidth > 0);
if (!grid) return null;
const rows = [...grid.querySelectorAll('.gridBody .gridLine')];
if (!rows.length) return null;
const cells = [...rows[0].querySelectorAll('.gridBox')];
if (!cells.length) return null;
const r = cells[0].getBoundingClientRect();
return { x: Math.round(r.x + r.width / 2), y: Math.round(r.y + r.height / 2) };
})()`;
}
/**
* Find the grid cell of the first row in the column whose header text matches `field`
* (fuzzy: exact → startsWith → includes; normalizes ё/е and NBSP).
*
* If the column isn't in the grid, returns coords of the first cell + `needDlb: true`
* so the caller can use DLB to switch FieldSelector after opening the dialog.
*
* Returns:
* - `{ x, y, needDlb? } ` — coords to click (advanced search target)
* - `{ error }` — `'no_grid' | 'no_rows' | 'no_cells' | 'cell_not_found'`
*/
export function findColumnFirstCellCoordsScript(formNum, field) {
return `(() => {
const p = 'form${formNum}_';
const grid = [...document.querySelectorAll('[id^="' + p + '"].grid, [id^="' + p + '"] .grid')]
.find(g => g.offsetWidth > 0);
if (!grid) return { error: 'no_grid' };
const targetField = ${JSON.stringify(field)};
const headers = [...grid.querySelectorAll('.gridHead .gridBox')];
let colIndex = -1;
let startsWithIdx = -1;
let includesIdx = -1;
for (let i = 0; i < headers.length; i++) {
const t = headers[i].innerText?.trim().replace(/\\u00a0/g, ' ');
if (!t) continue;
const ny = s => s.replace(/ё/gi, 'е').replace(/\\u00a0/g, ' ');
const tl = ny(t.toLowerCase()), fl = ny(targetField.toLowerCase());
if (tl === fl) { colIndex = i; break; }
if (startsWithIdx < 0 && tl.startsWith(fl)) { startsWithIdx = i; }
else if (includesIdx < 0 && tl.includes(fl)) { includesIdx = i; }
}
if (colIndex < 0) colIndex = startsWithIdx >= 0 ? startsWithIdx : includesIdx;
const rows = [...grid.querySelectorAll('.gridBody .gridLine')];
if (!rows.length) return { error: 'no_rows' };
if (colIndex < 0) {
const cells = [...rows[0].querySelectorAll('.gridBox')];
if (!cells.length) return { error: 'no_cells' };
const r = cells[0].getBoundingClientRect();
return { x: Math.round(r.x + r.width / 2), y: Math.round(r.y + r.height / 2), needDlb: true };
}
const cells = [...rows[0].querySelectorAll('.gridBox')];
if (colIndex >= cells.length) return { error: 'cell_not_found' };
const r = cells[colIndex].getBoundingClientRect();
return { x: Math.round(r.x + r.width / 2), y: Math.round(r.y + r.height / 2) };
})()`;
}
/**
* Read FieldSelector input + its DLB button coords on the advanced search dialog.
* Returns `{ current, dlbX, dlbY }` (zero coords if DLB not visible).
*/
export function readFieldSelectorInfoScript(dialogForm) {
return `(() => {
const p = 'form' + ${JSON.stringify(String(dialogForm))} + '_';
const fsInput = [...document.querySelectorAll('input.editInput[id^="' + p + '"]')]
.find(el => el.offsetWidth > 0 && /FieldSelector/i.test(el.id));
const dlb = document.getElementById(p + 'FieldSelector_DLB');
return {
current: fsInput?.value?.trim() || '',
dlbX: dlb && dlb.offsetWidth > 0 ? Math.round(dlb.getBoundingClientRect().x + dlb.getBoundingClientRect().width / 2) : 0,
dlbY: dlb && dlb.offsetWidth > 0 ? Math.round(dlb.getBoundingClientRect().y + dlb.getBoundingClientRect().height / 2) : 0
};
})()`;
}
/**
* Pick a field name in the FieldSelector EDD dropdown (fuzzy: exact → includes,
* normalizes ё/е and NBSP).
*
* Returns:
* - `{ x, y, name }` — coords + matched name to click
* - `{ error, available? }` — `'no_dropdown'` or `'field_not_found'` with list of available names
*/
export function pickFieldInSelectorDropdownScript(field) {
return `(() => {
const edd = document.getElementById('editDropDown');
if (!edd || edd.offsetWidth === 0) return { error: 'no_dropdown' };
const ny = s => s.replace(/ё/gi, 'е').replace(/\\u00a0/g, ' ');
const target = ny(${JSON.stringify(field.toLowerCase())});
const items = [...edd.querySelectorAll('div')].filter(el =>
el.offsetWidth > 0 && el.innerText?.trim() && !el.innerText.includes('\\n'));
const match = items.find(el => ny(el.innerText.trim().toLowerCase()) === target)
|| items.find(el => ny(el.innerText.trim().toLowerCase()).includes(target));
if (!match) return { error: 'field_not_found', available: items.map(el => el.innerText.trim()) };
const r = match.getBoundingClientRect();
return { x: Math.round(r.x + r.width / 2), y: Math.round(r.y + r.height / 2), name: match.innerText.trim() };
})()`;
}
/**
* Read advanced search dialog state — FieldSelector value, Pattern input id+value,
* and field type flags (isDate via iCalendB button, isRef via iDLB button on Pattern).
*
* Returns `{ fieldSelector, patternValue, patternId, isDate, isRef }`.
*/
export function readFilterDialogInfoScript(dialogForm) {
return `(() => {
const p = 'form' + ${JSON.stringify(String(dialogForm))} + '_';
const fsInput = [...document.querySelectorAll('input.editInput[id^="' + p + '"]')]
.find(el => el.offsetWidth > 0 && /FieldSelector/i.test(el.id));
const ptInput = [...document.querySelectorAll('input.editInput[id^="' + p + '"]')]
.find(el => el.offsetWidth > 0 && /Pattern/i.test(el.id));
const ptLabel = ptInput?.closest('label');
const btns = ptLabel ? [...ptLabel.querySelectorAll('span.btn')].map(b => b.className) : [];
const isDate = btns.some(c => c.includes('iCalendB'));
const isRef = !isDate && btns.some(c => c.includes('iDLB'));
return {
fieldSelector: fsInput?.value?.trim() || '',
patternValue: ptInput?.value?.trim() || '',
patternId: ptInput?.id || '',
isDate,
isRef
};
})()`;
}
/**
* Find the × close button on the filter badge whose title matches `field`
* (exact → includes; normalizes ё/е and NBSP).
*
* Returns:
* - `{ x, y, field }` — coords + actual field title from the badge
* - `{ error, available }` — `'not_found'` with list of available badge titles
*/
export function findFilterBadgeCloseScript(formNum, field) {
return `(() => {
const p = 'form${formNum}_';
const norm = s => s?.trim().replace(/\\u00a0/g, ' ').replace(/:$/, '').replace(/\\n/g, ' ') || '';
const ny = s => s.replace(/ё/gi, 'е').replace(/\\u00a0/g, ' ');
const target = ny(${JSON.stringify(field.toLowerCase())});
const items = [...document.querySelectorAll('[id^="' + p + '"].trainItem')].filter(el => el.offsetWidth > 0);
for (const item of items) {
const titleEl = item.querySelector('.trainName');
const title = ny(norm(titleEl?.innerText).toLowerCase());
if (title === target || title.includes(target)) {
const close = item.querySelector('.trainClose');
if (close) {
const r = close.getBoundingClientRect();
return { x: Math.round(r.x + r.width / 2), y: Math.round(r.y + r.height / 2), field: norm(titleEl?.innerText) };
}
}
}
const available = items.map(item => norm(item.querySelector('.trainName')?.innerText));
return { error: 'not_found', available };
})()`;
}
/**
* Find the × close button on the FIRST visible filter badge (for clear-all loop).
* Returns `{ x, y } | null`.
*/
export function findFirstFilterBadgeCloseScript(formNum) {
return `(() => {
const p = 'form${formNum}_';
const item = [...document.querySelectorAll('[id^="' + p + '"].trainItem')]
.find(el => el.offsetWidth > 0);
if (!item) return null;
const close = item.querySelector('.trainClose');
if (!close) return null;
const r = close.getBoundingClientRect();
return { x: Math.round(r.x + r.width / 2), y: Math.round(r.y + r.height / 2) };
})()`;
}
@@ -0,0 +1,34 @@
// web-test dom/form-state v1.0 — combined detectForm + readForm + open tabs
// Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
import { DETECT_FORM_FN, DETECT_FORMS_FN, READ_FORM_FN } from './_shared.mjs';
/**
* Combined: detect form + read form + read open tabs.
* Single evaluate call instead of 3. Used by browser.getFormState().
*/
export function getFormStateScript() {
return `(() => {
${DETECT_FORM_FN}
${DETECT_FORMS_FN}
${READ_FORM_FN}
const formNum = detectForm();
const meta = detectForms();
if (formNum === null) return { form: null, formCount: 0, message: 'No form detected' };
const p = 'form' + formNum + '_';
const formData = readForm(p);
// Open tabs bar (present only when tab panel is enabled in 1C settings)
const openTabs = [];
document.querySelectorAll('[id^="openedCell_cmd_"]').forEach(el => {
const text = el.innerText?.trim();
if (!text) return;
const entry = { name: text };
if (el.classList.contains('select')) entry.active = true;
openTabs.push(entry);
});
const activeTab = openTabs.find(t => t.active)?.name || null;
const result = { form: formNum, activeTab, openForms: meta.allForms, formCount: meta.formCount, ...formData };
if (meta.modal) result.modal = true;
if (openTabs.length) result.openTabs = openTabs;
return result;
})()`;
}
@@ -0,0 +1,647 @@
// web-test dom/forms v1.6 — form detection, content read, click-target/field-button resolution
// Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
import { DETECT_FORM_FN, READ_FORM_FN } from './_shared.mjs';
/**
* Detect the active form number.
* Picks the form with the most visible elements (excluding form0 = home page).
*/
export function detectFormScript() {
return `(() => {
${DETECT_FORM_FN}
return detectForm();
})()`;
}
/**
* Read full form state for a given form number.
* Uses shared READ_FORM_FN.
*/
export function readFormScript(formNum) {
const p = `form${formNum}_`;
return `(() => {
${READ_FORM_FN}
return readForm(${JSON.stringify(p)});
})()`;
}
/**
* Find a clickable element on the current form (button, hyperlink, tab, frame button).
* Returns { id, kind, name } for Playwright page.click(), or { error, available }.
* Supports synonym matching: visible text AND internal name from DOM ID.
* Fuzzy order: exact name -> exact label -> includes name -> includes label.
*/
export function findClickTargetScript(formNum, text, { tableName, gridSelector } = {}) {
const p = `form${formNum}_`;
return `(() => {
const norm = s => (s?.trim().replace(/\\u00a0/g, ' ') || '').replace(/ё/gi, 'е');
const target = ${JSON.stringify(text.toLowerCase().replace(/ё/g, 'е'))};
const p = ${JSON.stringify(p)};
const tableName = ${JSON.stringify(tableName || '')};
const gridSelector = ${JSON.stringify(gridSelector || '')};
const items = [];
// Buttons (a.press)
[...document.querySelectorAll('a.press[id^="' + p + '"]')].filter(el => el.offsetWidth > 0).forEach(el => {
const idName = el.id.replace(p, '');
if (/_(?:DLB|CLR|OB|CB)$/.test(idName)) return;
const span = el.querySelector('.submenuText') || el.querySelector('span');
const text = norm(span?.textContent) || norm(el.innerText);
if (!text && !el.classList.contains('pressCommand')) return;
const isSubmenu = /^(?:Подменю|allActions)/i.test(idName);
const item = { id: el.id, name: text || idName, label: idName, kind: isSubmenu ? 'submenu' : 'button' };
// Icon-only buttons: use tooltip for fuzzy match (1C puts title on parent .framePress)
if (!text) { const tip = norm(el.title || el.parentElement?.title || ''); if (tip) item.tooltip = tip; }
items.push(item);
});
// Hyperlinks (staticTextHyper)
[...document.querySelectorAll('[id^="' + p + '"].staticTextHyper')].filter(el => el.offsetWidth > 0).forEach(el => {
const idName = el.id.replace(p, '');
const text = norm(el.innerText);
items.push({ id: el.id, name: text, label: idName, kind: 'hyperlink' });
});
// Frame buttons
[...document.querySelectorAll('[id^="' + p + '"] .frameButton, [id^="' + p + '"].frameButton')].filter(el => el.offsetWidth > 0).forEach(el => {
const text = norm(el.innerText);
const idName = el.id.replace(p, '');
if (!text && !idName) return;
items.push({ id: el.id, name: text || idName, label: text ? '' : idName, kind: 'frameButton' });
});
// Tumbler items (toggle switch segments)
[...document.querySelectorAll('[id^="' + p + '"].tumblerItem')].filter(el => el.offsetWidth > 0).forEach(el => {
const idName = el.id.replace(p, '');
const text = norm(el.innerText);
items.push({ id: el.id, name: text || idName, label: idName, kind: 'tumbler' });
});
// Checkboxes (div.checkbox) — match by label or internal name
[...document.querySelectorAll('[id^="' + p + '"].checkbox')].filter(el => el.offsetWidth > 0).forEach(el => {
const idName = el.id.replace(p, '');
const titleEl = document.getElementById(p + idName + '#title_text');
const label = norm(titleEl?.innerText || '').replace(/:/g, '').trim();
items.push({ id: el.id, name: label || idName, label: idName, kind: 'checkbox' });
});
// Tabs (scoped to form)
[...document.querySelectorAll('[data-content]')].filter(el => {
if (el.offsetWidth === 0) return false;
let node = el.parentElement;
while (node) {
if (node.id && node.id.startsWith(p)) return true;
node = node.parentElement;
}
return false;
}).forEach(el => {
const r = el.getBoundingClientRect();
items.push({ id: el.id, name: el.dataset.content, label: '', kind: 'tab',
x: Math.round(r.x + r.width / 2), y: Math.round(r.y + r.height / 2) });
});
// Navigation panel items (FormNavigationPanel) — in parent page{N}
const formEl = document.querySelector('[id^="' + p + '"]');
if (formEl) {
let pageEl = formEl.parentElement;
while (pageEl && !(pageEl.id && /^page\\d+$/.test(pageEl.id))) pageEl = pageEl.parentElement;
if (pageEl) {
pageEl.querySelectorAll('.navigationItem').forEach(el => {
if (el.offsetWidth === 0) return;
const nameEl = el.querySelector('.navigationItemName');
const text = norm(nameEl?.innerText || '');
if (!text) return;
items.push({ id: el.id, name: text, label: '', kind: 'navigation' });
});
}
}
// When table is specified, scope button search to grid's parent container
if (gridSelector) {
const gridEl = document.querySelector(gridSelector);
if (gridEl) {
// Find parent container that has id with formPrefix and contains the grid
let container = gridEl.parentElement;
while (container && container !== document.body) {
if (container.id && container.id.startsWith(p)) break;
container = container.parentElement;
}
// Filter items to those inside the container
const containerItems = container && container !== document.body
? items.filter(i => { const el = document.getElementById(i.id); return el && container.contains(el); })
: [];
// Try fuzzy match within container first
let cf = containerItems.find(i => i.name.toLowerCase() === target);
if (!cf) cf = containerItems.find(i => i.label && i.label.toLowerCase() === target);
if (!cf && target.length >= 4) cf = containerItems.find(i => i.name.toLowerCase().includes(target));
if (!cf && target.length >= 4) cf = containerItems.find(i => i.label && i.label.toLowerCase().includes(target));
if (cf) { const res = { id: cf.id, kind: cf.kind, name: cf.name }; if (cf.x != null) { res.x = cf.x; res.y = cf.y; } return res; }
// Fallback: filter by gridName id-prefix (e.g. ИсходящиеКоманднаяПанель_Добавить)
const gridName = gridEl.id ? gridEl.id.replace(p, '') : '';
if (gridName) {
const prefixItems = items.filter(i => i.label && i.label.includes(gridName));
let pf = prefixItems.find(i => i.name.toLowerCase() === target);
if (!pf && target.length >= 4) pf = prefixItems.find(i => i.label && i.label.toLowerCase().includes(target));
if (!pf && target.length >= 4) pf = prefixItems.find(i => i.name.toLowerCase().includes(target));
if (pf) { const res = { id: pf.id, kind: pf.kind, name: pf.name }; if (pf.x != null) { res.x = pf.x; res.y = pf.y; } return res; }
}
}
// Fall through to unscoped search
}
// Fuzzy match: exact name -> exact label -> exact tooltip -> startsWith name -> startsWith label -> includes name -> includes label -> includes tooltip
// Skip includes() for short strings (< 4 chars) to avoid false positives
// e.g. "Да" matching "КомандаУстановитьВсе"
let found = items.find(i => i.name.toLowerCase() === target);
if (!found) found = items.find(i => i.label && i.label.toLowerCase() === target);
if (!found) found = items.find(i => i.tooltip && i.tooltip.toLowerCase() === target);
if (!found) found = items.find(i => i.name.toLowerCase().startsWith(target));
if (!found) found = items.find(i => i.label && i.label.toLowerCase().startsWith(target));
if (!found && target.length >= 4) found = items.find(i => i.name.toLowerCase().includes(target));
if (!found && target.length >= 4) found = items.find(i => i.label && i.label.toLowerCase().includes(target));
if (!found && target.length >= 4) found = items.find(i => i.tooltip && i.tooltip.toLowerCase().includes(target));
if (found) {
const res = { id: found.id, kind: found.kind, name: found.name };
if (found.x != null) { res.x = found.x; res.y = found.y; }
return res;
}
// Grid rows — fallback: search in table rows (for hierarchical/tree navigation)
// Search ALL visible grids (or specific grid when table parameter is set)
let grids;
if (gridSelector) {
const g = document.querySelector(gridSelector);
grids = g ? [g] : [];
} else {
grids = [...document.querySelectorAll('[id^="' + p + '"].grid')].filter(g => g.offsetWidth > 0);
}
for (const grid of grids) {
const body = grid.querySelector('.gridBody');
if (!body) continue;
const lines = [...body.querySelectorAll('.gridLine')];
for (const line of lines) {
const textBoxes = [...line.querySelectorAll('.gridBoxText')].filter(b => b.offsetWidth > 0);
const rowTexts = textBoxes.map(b => norm(b.innerText) || '').filter(Boolean);
const firstCell = rowTexts[0]?.toLowerCase() || '';
const rowText = rowTexts.join(' ').toLowerCase();
if (firstCell === target || rowText === target || (target.length >= 4 && (firstCell.includes(target) || rowText.includes(target)))) {
const imgBox = line.querySelector('.gridBoxImg');
const isGroup = imgBox?.querySelector('.gridListH') !== null;
const isParent = imgBox?.querySelector('.gridListV') !== null;
const isTreeNode = line.querySelector('.gridBoxTree') !== null;
const hasChildren = line.querySelector('[tree="true"]') !== null;
let kind;
if (isGroup) kind = 'gridGroup';
else if (isParent) kind = 'gridParent';
else if (isTreeNode && hasChildren) kind = 'gridTreeNode';
else kind = 'gridRow';
const r = line.getBoundingClientRect();
return { id: '', kind, name: rowTexts[0] || '', gridId: grid.id,
x: Math.round(r.x + r.width / 2), y: Math.round(r.y + r.height / 2) };
}
}
}
// Form input fields — LAST resort: focus a field by name/label without changing its value.
// Only when no table scope is given ("если нет уточнения таблицы"): grid cells are handled elsewhere.
// Reached only after every clickable target (button/link/tab/nav/grid row) failed to match,
// so collisions between a field name and a real control are unlikely.
const fields = [];
if (!tableName) {
[...document.querySelectorAll('input.editInput[id^="' + p + '"], textarea[id^="' + p + '"]')].forEach(el => {
if (el.offsetWidth === 0) return;
// Skip inputs inside a grid — those are table cells, not form fields.
let n = el.parentElement; let inGrid = false;
while (n) { if (n.classList && n.classList.contains('grid')) { inGrid = true; break; } n = n.parentElement; }
if (inGrid) return;
const idName = el.id.replace(p, '').replace(/_i\\d+$/, '');
const titleEl = document.getElementById(p + idName + '#title_text') || document.getElementById(p + idName + '#title_div');
const label = norm(titleEl?.innerText || '').replace(/:/g, '').trim();
fields.push({ id: el.id, name: idName, label });
});
let ff = fields.find(f => f.label && f.label.toLowerCase() === target);
if (!ff) ff = fields.find(f => f.name.toLowerCase() === target);
if (!ff) ff = fields.find(f => f.label && f.label.toLowerCase().startsWith(target));
if (!ff) ff = fields.find(f => f.name.toLowerCase().startsWith(target));
if (!ff && target.length >= 4) ff = fields.find(f => f.label && f.label.toLowerCase().includes(target));
if (!ff && target.length >= 4) ff = fields.find(f => f.name.toLowerCase().includes(target));
if (ff) return { id: ff.id, kind: 'field', name: ff.label || ff.name };
}
const available = items.map(i => i.tooltip ? i.name + ' [' + i.tooltip + ']' : i.name).filter(Boolean);
for (const f of fields) { const nm = f.label || f.name; if (nm && !available.includes(nm)) available.push(nm); }
return { error: 'not_found', available };
})()`;
}
/**
* Find a field's action button (DLB, OB, CLR, CB) by fuzzy field name.
* Returns { fieldName, buttonId, buttonType } or { error, available }.
*/
export function findFieldButtonScript(formNum, fieldName, buttonSuffix = 'DLB') {
const p = `form${formNum}_`;
return `(() => {
const p = ${JSON.stringify(p)};
const target = ${JSON.stringify(fieldName.toLowerCase().replace(/ё/g, 'е'))};
const suffix = ${JSON.stringify(buttonSuffix)};
const allFields = [];
document.querySelectorAll('input.editInput[id^="' + p + '"], textarea[id^="' + p + '"]').forEach(el => {
if (el.offsetWidth === 0) return;
const name = el.id.replace(p, '').replace(/_i\\d+$/, '');
const titleEl = document.getElementById(p + name + '#title_text')
|| document.getElementById(p + name + '#title_div');
const label = (titleEl?.innerText?.trim() || '').replace(/\\n/g, ' ').replace(/:$/, '');
allFields.push({ name, label });
});
// Also collect checkboxes for DCS pair matching
const allCheckboxes = [];
document.querySelectorAll('[id^="' + p + '"].checkbox').forEach(el => {
if (el.offsetWidth === 0) return;
const name = el.id.replace(p, '');
const titleEl = document.getElementById(p + name + '#title_text');
const label = (titleEl?.innerText?.trim() || '').replace(/\\n/g, ' ').replace(/:$/, '');
allCheckboxes.push({ inputId: el.id, name, label });
});
// Build DCS pairs: checkbox label → paired value field
const dcsPairs = {};
for (const f of [...allFields, ...allCheckboxes]) {
const m = f.name.match(/^(.+Элемент\\d+)(Использование|Значение)$/);
if (!m) continue;
if (!dcsPairs[m[1]]) dcsPairs[m[1]] = {};
dcsPairs[m[1]][m[2]] = f;
}
let found = allFields.find(f => f.name.toLowerCase() === target);
if (!found) found = allFields.find(f => f.label && f.label.toLowerCase() === target);
if (!found) found = allFields.find(f => f.name.toLowerCase().includes(target));
if (!found) found = allFields.find(f => f.label && f.label.toLowerCase().includes(target));
// DCS pair: match checkbox or value label → resolve to paired value field
let dcsCheckbox = null;
if (!found) {
for (const pair of Object.values(dcsPairs)) {
const cb = pair['Использование'];
const val = pair['Значение'];
if (!cb || !val) continue;
const pairLabel = ((val.label || cb.label || '').replace(/:$/, '')).toLowerCase();
if (pairLabel && (pairLabel === target || pairLabel.includes(target) || target.includes(pairLabel))) {
found = val;
dcsCheckbox = cb;
break;
}
}
}
if (!found) {
return { error: 'field_not_found', available: allFields.map(f => f.label ? f.name + ' (' + f.label + ')' : f.name) };
}
const btnId = p + found.name + '_' + suffix;
const btn = document.getElementById(btnId);
if (!btn || btn.offsetWidth === 0) {
return { error: 'button_not_found', fieldName: found.name, message: suffix + ' button not visible for field ' + found.name };
}
const result = { fieldName: found.name, buttonId: btnId, buttonType: suffix };
if (dcsCheckbox) result.dcsCheckbox = { inputId: dcsCheckbox.inputId };
return result;
})()`;
}
/**
* Resolve field names to element IDs for Playwright page.fill().
* Returns [{ field, inputId, name, label }] or [{ field, error, available }].
* Supports synonym matching: internal name AND visible label.
* Fuzzy order: exact name -> exact label -> includes name -> includes label.
*/
export function resolveFieldsScript(formNum, fields) {
const p = `form${formNum}_`;
return `(() => {
const p = ${JSON.stringify(p)};
const fieldNames = ${JSON.stringify(Object.keys(fields))};
const results = [];
// Build field map with name + label for synonym matching
const allFields = [];
document.querySelectorAll('input.editInput[id^="' + p + '"], textarea[id^="' + p + '"]').forEach(el => {
if (el.offsetWidth === 0) return;
const name = el.id.replace(p, '').replace(/_i\\d+$/, '');
const titleEl = document.getElementById(p + name + '#title_text')
|| document.getElementById(p + name + '#title_div');
const label = (titleEl?.innerText?.trim() || '').replace(/\\n/g, ' ').replace(/:$/, '');
const last = { inputId: el.id, name, label };
if (document.getElementById(p + name + '_DLB')?.offsetWidth > 0) last.hasSelect = true;
const cbEl = document.getElementById(p + name + '_CB');
if (cbEl?.offsetWidth > 0) {
last.hasPick = true;
if (cbEl.classList.contains('iCalendB')) last.isDate = true;
else if (cbEl.classList.contains('iCalcB')) last.isCalc = true;
}
allFields.push(last);
});
// Checkboxes
document.querySelectorAll('[id^="' + p + '"].checkbox').forEach(el => {
if (el.offsetWidth === 0) return;
const name = el.id.replace(p, '');
const titleEl = document.getElementById(p + name + '#title_text');
const label = (titleEl?.innerText?.trim() || '').replace(/\\n/g, ' ').replace(/:$/, '');
const checked = el.classList.contains('checked') || el.classList.contains('checkboxOn') || el.classList.contains('select');
allFields.push({ inputId: el.id, name, label, isCheckbox: true, checked });
});
// Radio button groups — base element = option 0, others are #N#radio
const radioSeen = new Set();
document.querySelectorAll('[id^="' + p + '"].radio').forEach(el => {
if (el.offsetWidth === 0) return;
const id = el.id.replace(p, '');
// Skip if already processed or if it's a sub-element (#N#radio)
const m = id.match(/^(.+?)#(\\d+)#radio$/);
const groupName = m ? m[1] : (!id.includes('#') ? id : null);
if (!groupName || radioSeen.has(groupName)) return;
radioSeen.add(groupName);
const titleEl = document.getElementById(p + groupName + '#title_text');
const label = (titleEl?.innerText?.trim() || '').replace(/\\n/g, ' ').replace(/:$/, '');
// Collect options: option 0 is the base element, options 1+ have #N#radio
const options = [];
// Option 0: base element
const base = document.getElementById(p + groupName);
if (base && base.classList.contains('radio') && base.offsetWidth > 0) {
const textEl = document.getElementById(p + groupName + '#0#radio_text');
options.push({ index: 0, label: textEl?.innerText?.trim() || '', selected: base.classList.contains('select') });
}
// Options 1+
for (let i = 1; i < 20; i++) {
const opt = document.getElementById(p + groupName + '#' + i + '#radio');
if (!opt || opt.offsetWidth === 0) break;
const textEl = document.getElementById(p + groupName + '#' + i + '#radio_text');
options.push({ index: i, label: textEl?.innerText?.trim() || '', selected: opt.classList.contains('select') });
}
allFields.push({ inputId: p + groupName, name: groupName, label, isRadio: true, options });
});
// Build DCS pairs: checkbox label → paired value field
const dcsPairs = {};
for (const f of allFields) {
const m = f.name.match(/^(.+Элемент\\d+)(Использование|Значение)$/);
if (!m) continue;
if (!dcsPairs[m[1]]) dcsPairs[m[1]] = {};
dcsPairs[m[1]][m[2]] = f;
}
for (const fieldName of fieldNames) {
const target = fieldName.toLowerCase().replace(/\\n/g, ' ').replace(/:$/, '');
// Fuzzy: exact name -> exact label -> includes name -> includes label
let found = allFields.find(f => f.name.toLowerCase() === target);
if (!found) found = allFields.find(f => f.label && f.label.toLowerCase() === target);
if (!found) found = allFields.find(f => f.name.toLowerCase().includes(target));
if (!found) found = allFields.find(f => f.label && f.label.toLowerCase().includes(target));
// DCS pair: match checkbox or value label → resolve to paired value field
if (!found) {
for (const pair of Object.values(dcsPairs)) {
const cb = pair['Использование'];
const val = pair['Значение'];
if (!cb || !val) continue;
const pairLabel = ((val.label || cb.label || '').replace(/:$/, '')).toLowerCase();
if (pairLabel && (pairLabel === target || pairLabel.includes(target) || target.includes(pairLabel))) {
found = val;
found._dcsCheckbox = cb;
break;
}
}
}
if (found) {
const entry = { field: fieldName, inputId: found.inputId, name: found.name, label: found.label };
if (found.isCheckbox) { entry.isCheckbox = true; entry.checked = found.checked; }
if (found.isRadio) { entry.isRadio = true; entry.options = found.options; }
if (found.hasSelect) entry.hasSelect = true;
if (found.hasPick) entry.hasPick = true;
if (found.isDate) entry.isDate = true;
if (found.isCalc) entry.isCalc = true;
if (found._dcsCheckbox) {
entry.dcsCheckbox = { inputId: found._dcsCheckbox.inputId, checked: found._dcsCheckbox.checked };
delete found._dcsCheckbox;
}
results.push(entry);
} else {
const available = allFields.map(f => f.label ? f.name + ' (' + f.label + ')' : f.name);
results.push({ field: fieldName, error: 'not_found', available });
}
}
return results;
})()`;
}
/**
* Detect a new form opened above `prevFormNum`. Two modes:
* default (broad) — counts any visible `[id]` element; finds dialogs whose
* `a.press` buttons have empty IDs. Used by selectValue / fillTableRow.
* `{ strict: true }` — only counts visible interactive elements
* (`input.editInput[id], a.press[id]`); used by fillReferenceField.
*
* Returns the highest new form number or `null`.
*/
export function detectNewFormScript(prevFormNum, { strict = false } = {}) {
const selector = strict ? 'input.editInput[id], a.press[id]' : '[id]';
const visibleCheck = strict
? 'el.offsetWidth === 0'
: 'el.offsetWidth === 0 && el.offsetHeight === 0';
return `(() => {
const forms = {};
document.querySelectorAll(${JSON.stringify(selector)}).forEach(el => {
if (${visibleCheck}) return;
const m = el.id.match(/^form(\\d+)_/);
if (m) forms[m[1]] = true;
});
const nums = Object.keys(forms).map(Number).filter(n => n > ${prevFormNum});
return nums.length > 0 ? Math.max(...nums) : null;
})()`;
}
/**
* Find the search input on a list form (matches `SearchString` / `ПоискаСтроки` id).
* Returns `{ id, value } | null`.
*/
export function findSearchInputScript(formNum) {
return `(() => {
const p = 'form${formNum}_';
const el = [...document.querySelectorAll('input.editInput[id^="' + p + '"]')]
.find(el => el.offsetWidth > 0 && /Строк[аи]Поиска|SearchString/i.test(el.id));
return el ? { id: el.id, value: el.value || '' } : null;
})()`;
}
/**
* Find a visible `a.press` button by its exact innerText (after trim).
* Returns `{ x, y } | null` for `page.mouse.click(x, y)`.
*
* Used for modal dialog buttons (Найти, OK) where page.click may be blocked.
*/
export function findNamedButtonScript(buttonText) {
return `(() => {
const btns = [...document.querySelectorAll('a.press')].filter(el => el.offsetWidth > 0);
const btn = btns.find(el => el.innerText?.trim() === ${JSON.stringify(buttonText)});
if (!btn) return null;
const r = btn.getBoundingClientRect();
return { x: Math.round(r.x + r.width / 2), y: Math.round(r.y + r.height / 2) };
})()`;
}
/**
* Find a CompareType radio button by index (1 = "contains", 2 = "exact", etc.)
* on a search/filter dialog.
*
* Returns:
* - `{ already: true }` — the group is disabled OR the radio is already selected
* - `{ x, y } | null` — coords to click, or null if radio not present
*/
export function findCompareTypeRadioScript(dialogForm, radioIndex) {
return `(() => {
const p = 'form' + ${JSON.stringify(String(dialogForm))} + '_';
const group = document.getElementById(p + 'CompareType');
if (group && group.classList.contains('disabled')) return { already: true };
const el = document.getElementById(p + 'CompareType#' + ${JSON.stringify(String(radioIndex))} + '#radio');
if (!el || el.offsetWidth === 0) return null;
if (el.classList.contains('select')) return { already: true };
const r = el.getBoundingClientRect();
return { x: Math.round(r.x + r.width / 2), y: Math.round(r.y + r.height / 2) };
})()`;
}
/**
* Is any element of `form{dialogForm}_` currently visible?
* Used to poll dialog dismissal after Escape.
*/
export function isFormVisibleScript(dialogForm) {
return `(() => {
const p = 'form${dialogForm}_';
return [...document.querySelectorAll('[id^="' + p + '"]')].some(el => el.offsetWidth > 0);
})()`;
}
/**
* Find the Pattern input id on a search/filter dialog. Returns `id | null`.
*/
export function findPatternInputIdScript(dialogForm) {
return `(() => {
const p = 'form${dialogForm}_';
const el = [...document.querySelectorAll('input.editInput[id^="' + p + '"]')]
.find(el => el.offsetWidth > 0 && /Pattern/i.test(el.id));
return el ? el.id : null;
})()`;
}
/**
* Is the given form a type selection dialog ("Выбор типа данных")?
*
* Detection signals (any one is sufficient):
* - `form{N}_OK` element exists (selection forms use "Выбрать", not "OK")
* - `form{N}_ValueList` grid exists (specific to type/value list dialogs)
* - window title contains "Выбор типа" on a visible `.toplineBoxTitle`
*
* Returns boolean.
*/
export function isTypeDialogScript(formNum) {
return `(() => {
const p = 'form' + ${formNum} + '_';
const hasOK = !!document.getElementById(p + 'OK');
const hasValueList = !!document.getElementById(p + 'ValueList');
const hasTitle = [...document.querySelectorAll('.toplineBoxTitle')]
.some(el => el.offsetWidth > 0 && /выбор типа/i.test(el.getAttribute('title') || ''));
return hasOK || hasValueList || hasTitle;
})()`;
}
/**
* Click the "Показать все" / "Show all" link inside the "нет в списке"
* cloud popup via `dispatchEvent`. Returns boolean — whether clicked.
*/
export function clickShowAllInNotInListCloudScript() {
return `(() => {
for (const el of document.querySelectorAll('div')) {
if (el.offsetWidth === 0 || el.offsetHeight === 0) continue;
const s = getComputedStyle(el);
if (s.position !== 'absolute' && s.position !== 'fixed') continue;
if ((parseInt(s.zIndex) || 0) < 100) continue;
if (!(el.innerText || '').includes('нет в списке')) continue;
const links = [...el.querySelectorAll('a, span, div')]
.filter(e => e.offsetWidth > 0 && e.children.length === 0);
const showAll = links.find(e => {
const t = (e.innerText?.trim() || '').toLowerCase();
return t === 'показать все' || t === 'show all';
});
if (showAll) {
const r = showAll.getBoundingClientRect();
const opts = { bubbles:true, cancelable:true,
clientX: r.x + r.width/2, clientY: r.y + r.height/2 };
showAll.dispatchEvent(new MouseEvent('mousedown', opts));
showAll.dispatchEvent(new MouseEvent('mouseup', opts));
showAll.dispatchEvent(new MouseEvent('click', opts));
return true;
}
return false;
}
return false;
})()`;
}
/**
* Is the "нет в списке" cloud popup visible? 1C shows it as a positioned div
* (absolute/fixed, high z-index) whose text contains "нет в списке".
* Returns boolean.
*/
export function isNotInListCloudVisibleScript() {
return `(() => {
const divs = document.querySelectorAll('div');
for (const el of divs) {
if (el.offsetWidth === 0 || el.offsetHeight === 0) continue;
const style = getComputedStyle(el);
if (style.position !== 'absolute' && style.position !== 'fixed') continue;
const z = parseInt(style.zIndex) || 0;
if (z < 100) continue;
if ((el.innerText || '').includes('нет в списке')) return true;
}
return false;
})()`;
}
/**
* Find a child form opened above `prevFormNum` whose `form{N}_{buttonName}` button is visible.
* Used by type-dialog Ctrl+F flow to locate the "Найти" sub-dialog form number.
* Returns the form number or `null`.
*/
export function findChildFormByButtonScript(prevFormNum, buttonName, range = 20) {
return `(() => {
for (let n = ${prevFormNum} + 1; n < ${prevFormNum} + ${range}; n++) {
const btn = document.getElementById('form' + n + '_' + ${JSON.stringify(buttonName)});
if (btn && btn.offsetWidth > 0) return n;
}
return null;
})()`;
}
/**
* Read visible rows of a type-dialog ValueList grid and return rows that fuzzy-match `typeNorm`.
*
* `typeNorm` should already be lowercased, NBSP-normalized, ё→е normalized (use `normYo`).
*
* Returns `{ visible: string[], matches: Array<{ text, x, y }> }`.
*/
export function readTypeDialogVisibleRowsScript(formNum, typeNorm) {
return `(() => {
const grid = document.getElementById('form${formNum}_ValueList');
if (!grid) return { visible: [], matches: [] };
const body = grid.querySelector('.gridBody');
if (!body) return { visible: [], matches: [] };
const lines = body.querySelectorAll('.gridLine');
const norm = s => (s || '').replace(/\\u00a0/g, ' ').trim();
const typeNorm = ${JSON.stringify(typeNorm)};
const visible = [];
const matches = [];
for (const line of lines) {
const text = norm(line.innerText);
if (!text) continue;
visible.push(text);
if (text.toLowerCase().replace(/ё/gi, 'е').includes(typeNorm)) {
const r = line.getBoundingClientRect();
matches.push({ text, x: Math.round(r.x + r.width / 2), y: Math.round(r.y + r.height / 2) });
}
}
return { visible, matches };
})()`;
}
@@ -0,0 +1,292 @@
// web-test dom/grid-edit v1.0 — DOM scripts for row-fill (grid edit-time operations)
// Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
//
// All helpers below accept an optional `gridSelector`. When passed, they target
// that exact grid; when null/undefined they pick the LAST visible `.grid` on
// the page (this matches the implicit "current grid" used by row-fill).
/** Inline JS fragment that resolves the target grid into `const grid`. */
function gridResolver(gridSelector) {
return gridSelector
? `document.querySelector(${JSON.stringify(gridSelector)})`
: `(() => { const grids = [...document.querySelectorAll('.grid')].filter(el => el.offsetWidth > 0); return grids[grids.length - 1]; })()`;
}
/**
* Read the grid's column header texts paired with their `colindex` attribute,
* fuzzy-match `fieldKeys` (lowercased) against them, and return the keys in
* left-to-right colindex order.
*
* Keys that don't match a column get colindex `999` (pushed to the end);
* caller is expected to preserve their original relative order.
*
* Returns `string[] | null` (null when no grid or no head).
*/
export function sortFieldKeysByColindexScript(gridSelector, fieldKeys) {
return `(() => {
const grid = ${gridResolver(gridSelector)};
if (!grid) return null;
const head = grid.querySelector('.gridHead');
if (!head) return null;
const headLine = head.querySelector('.gridLine') || head;
const cols = [];
[...headLine.children].forEach(box => {
if (box.offsetWidth === 0) return;
const t = ((box.querySelector('.gridBoxText') || box).innerText?.trim() || '').toLowerCase();
const ci = parseInt(box.getAttribute('colindex') || '-1');
if (t) cols.push({ text: t, colindex: ci });
});
const keys = ${JSON.stringify(fieldKeys)};
const mapped = keys.map(k => {
const exact = cols.find(c => c.text === k);
if (exact) return { key: k, colindex: exact.colindex };
const inc = cols.find(c => c.text.includes(k) || k.includes(c.text));
return { key: k, colindex: inc ? inc.colindex : 999 };
});
mapped.sort((a, b) => a.colindex - b.colindex);
return mapped.map(m => m.key);
})()`;
}
/**
* Resolve cell coords for row `row` by matching the first column whose header
* fuzzy-matches any of `fieldKeys` (lowercased). Falls back to the second
* visible (non-`.gridBoxComp`) box when no header matches.
*
* Returns one of:
* - `{ x, y, currentText }` — coords + cell text
* - `{ error: 'no_grid' | 'no_grid_body' | 'no_cell' }`
* - `{ error: 'row_out_of_range', total }`
*/
export function findCellCoordsByFieldsScript(gridSelector, row, fieldKeys) {
return `(() => {
const grid = ${gridResolver(gridSelector)};
if (!grid) return { error: 'no_grid' };
const head = grid.querySelector('.gridHead');
const body = grid.querySelector('.gridBody');
if (!head || !body) return { error: 'no_grid_body' };
// Read column headers to find target colindex
const headLine = head.querySelector('.gridLine') || head;
const cols = [];
[...headLine.children].forEach(box => {
if (box.offsetWidth === 0) return;
const t = box.querySelector('.gridBoxText');
const ci = box.getAttribute('colindex');
cols.push({ colindex: ci, text: ((t || box).innerText?.trim() || '').toLowerCase() });
});
const keys = ${JSON.stringify(fieldKeys)};
let targetColindex = null;
for (const key of keys) {
const exact = cols.find(c => c.text === key);
if (exact) { targetColindex = exact.colindex; break; }
const inc = cols.find(c => c.text.includes(key) || key.includes(c.text));
if (inc) { targetColindex = inc.colindex; break; }
}
const rows = [...body.querySelectorAll('.gridLine')];
if (${row} >= rows.length) return { error: 'row_out_of_range', total: rows.length };
const line = rows[${row}];
// Find body cell by colindex (reliable across merged headers)
let box = null;
if (targetColindex != null) {
box = [...line.children].find(b => b.offsetWidth > 0 && b.getAttribute('colindex') === targetColindex);
}
// Fallback: second visible box (skip checkbox/N column)
if (!box) {
const boxes = [...line.children].filter(b => b.offsetWidth > 0 && !b.classList.contains('gridBoxComp'));
box = boxes.length > 1 ? boxes[1] : boxes[0];
}
if (!box) return { error: 'no_cell' };
box.scrollIntoView({ block: 'nearest', inline: 'nearest' });
const cell = box.querySelector('.gridBoxText') || box;
const r = cell.getBoundingClientRect();
const currentText = (cell.innerText?.trim() || '').replace(/\\u00a0/g, ' ');
return { x: Math.round(r.x + r.width / 2), y: Math.round(r.y + r.height / 2), currentText };
})()`;
}
/**
* Like `findCellCoordsByFieldsScript` but for a SINGLE key, with extra
* "no-space/no-dash" fuzzy fallback (e.g. "Группа Контрагентов" header matches
* key "ГруппаКонтрагентов").
*
* Returns `{ x, y, currentText } | null`.
*/
export function findNextCellCoordsByKeyScript(gridSelector, row, key) {
return `(() => {
const grid = ${gridResolver(gridSelector)};
if (!grid) return null;
const head = grid.querySelector('.gridHead');
const body = grid.querySelector('.gridBody');
if (!head || !body) return null;
const headLine = head.querySelector('.gridLine') || head;
const cols = [];
[...headLine.children].forEach(box => {
if (box.offsetWidth === 0) return;
const t = box.querySelector('.gridBoxText');
const ci = box.getAttribute('colindex');
cols.push({ colindex: ci, text: ((t || box).innerText?.trim() || '').toLowerCase() });
});
const kl = ${JSON.stringify(key.toLowerCase())};
const klNoSpace = kl.replace(/[\\s\\-]+/g, '');
let targetColindex = null;
const exact = cols.find(c => c.text === kl);
if (exact) targetColindex = exact.colindex;
else {
const inc = cols.find(c => c.text.includes(kl) || kl.includes(c.text)
|| c.text.includes(klNoSpace) || klNoSpace.includes(c.text));
if (inc) targetColindex = inc.colindex;
}
if (targetColindex == null) return null;
const rows = [...body.querySelectorAll('.gridLine')];
if (${row} >= rows.length) return null;
const line = rows[${row}];
const box = [...line.children].find(b => b.offsetWidth > 0 && b.getAttribute('colindex') === targetColindex);
if (!box) return null;
box.scrollIntoView({ block: 'nearest', inline: 'nearest' });
const cell = box.querySelector('.gridBoxText') || box;
const r = cell.getBoundingClientRect();
const currentText = (cell.innerText?.trim() || '').replace(/\\u00a0/g, ' ');
return { x: Math.round(r.x + r.width / 2), y: Math.round(r.y + r.height / 2), currentText };
})()`;
}
/**
* Inspect the element at point `(x, y)`. If it's inside a `.gridBox` containing
* a `.checkbox`, return `{ checked, x, y }` (coords of the checkbox center for
* direct click).
*
* Returns `null` when there's no cell, or the cell isn't a checkbox cell.
*/
export function findCheckboxAtPointScript(x, y) {
return `(() => {
const el = document.elementFromPoint(${x}, ${y});
const cell = el?.closest('.gridBox');
if (!cell) return null;
const chk = cell.querySelector('.checkbox');
if (!chk) return null;
const r = chk.getBoundingClientRect();
return { checked: chk.classList.contains('select'), x: Math.round(r.x + r.width/2), y: Math.round(r.y + r.height/2) };
})()`;
}
/**
* Find center coords of the first VISIBLE non-`.gridBoxComp` cell on a row
* OTHER than `row` (used to commit an edit by clicking off the edited row —
* Escape would cancel in tree grids).
*
* For `row === 0`, targets row 1; otherwise targets row 0.
*
* Returns `{ x, y } | null` (null when there's no other row).
*/
export function findRowCommitClickCoordsScript(gridSelector, row) {
return `(() => {
const grid = ${gridResolver(gridSelector)};
if (!grid) return null;
const body = grid.querySelector('.gridBody');
if (!body) return null;
const rows = [...body.querySelectorAll('.gridLine')];
const otherIdx = ${row} === 0 ? 1 : 0;
const other = rows[otherIdx];
if (!other) return null;
const visBoxes = [...other.children].filter(b => b.offsetWidth > 0 && !b.classList.contains('gridBoxComp'));
const box = visBoxes.length > 1 ? visBoxes[1] : visBoxes[0];
if (!box) return null;
const r = box.getBoundingClientRect();
return { x: Math.round(r.x + r.width / 2), y: Math.round(r.y + r.height / 2) };
})()`;
}
/**
* Diagnostic: are we in grid edit mode (active INPUT inside `.grid` or
* `.gridContent`)? Returns an OBJECT (not a boolean) suitable for diagnostics:
* - `{ inEdit: true }` — good
* - `{ inEdit: false, tag: 'DIV' }` — active element wasn't INPUT
* - `{ inEdit: false, hint: 'input not inside grid' }` — input but no grid ancestor
*/
export function getGridEditCheckScript() {
return `(() => {
const f = document.activeElement;
if (!f || f.tagName !== 'INPUT') return { inEdit: false, tag: f?.tagName };
let node = f;
while (node) {
if (node.classList?.contains('grid') || node.classList?.contains('gridContent')) return { inEdit: true };
node = node.parentElement;
}
return { inEdit: false, hint: 'input not inside grid' };
})()`;
}
/**
* Read the currently focused element if it's an editable grid cell (INPUT or
* TEXTAREA inside `.grid` / `.gridContent`). Resolves the header text by
* matching x-overlap of the input's bounding rect against header boxes.
*
* Returns one of:
* - `{ tag: 'INPUT', id, fullName, headerText }` — editable cell
* - `{ tag: 'DIV' | 'BODY' | ... }` — focused but not an editable cell
* - `{ tag: 'none' }` — nothing focused
*
* `fullName` strips both `form{N}_` prefix and `_i{M}` suffix.
*/
export function readActiveGridCellScript() {
return `(() => {
const f = document.activeElement;
if (!f) return { tag: 'none' };
if (f.tagName === 'INPUT' || f.tagName === 'TEXTAREA') {
const inGrid = (() => { let n = f; while (n) { if (n.classList?.contains('grid') || n.classList?.contains('gridContent')) return true; n = n.parentElement; } return false; })();
if (inGrid) {
let headerText = '';
let grid = f; while (grid && !grid.classList?.contains('grid')) grid = grid.parentElement;
if (grid) {
const fr = f.getBoundingClientRect();
const head = grid.querySelector('.gridHead');
const hl = head?.querySelector('.gridLine') || head;
if (hl) for (const h of hl.children) {
if (h.offsetWidth === 0) continue;
const hr = h.getBoundingClientRect();
if (fr.x >= hr.x && fr.x < hr.x + hr.width) {
const t = h.querySelector('.gridBoxText');
headerText = (t || h).innerText?.trim() || '';
break;
}
}
}
// Classify the cell's choice button (if any): ref (_DLB), calc/date (_CB iCalcB/iCalendB),
// or bare 'choice' (_CB iCB — value picked from a programmatic list, e.g. НачалоВыбора).
let buttonKind = null;
const base = f.id.replace(/_i\\d+$/, '');
const dlb = document.getElementById(base + '_DLB');
const cb = document.getElementById(base + '_CB');
if (dlb && dlb.offsetWidth > 0) buttonKind = 'ref';
else if (cb && cb.offsetWidth > 0) {
if (cb.classList.contains('iCalcB')) buttonKind = 'calc';
else if (cb.classList.contains('iCalendB')) buttonKind = 'date';
else buttonKind = 'choice';
}
return {
tag: 'INPUT', id: f.id,
fullName: f.id.replace(/^form\\d+_/, '').replace(/_i\\d+$/, ''),
headerText, buttonKind
};
}
}
return { tag: f.tagName || 'none' };
})()`;
}
/**
* Return center coords of the element with the given id.
* Returns `{ x, y } | null`.
*/
export function getElementCenterCoordsByIdScript(elementId) {
return `(() => {
const el = document.getElementById(${JSON.stringify(elementId)});
if (!el) return null;
const r = el.getBoundingClientRect();
return { x: r.x + r.width / 2, y: r.y + r.height / 2 };
})()`;
}
@@ -0,0 +1,755 @@
// web-test dom/grid v1.9 — grid resolution + table reading + edit-time helpers
// Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
/**
* Resolve a specific grid by semantic name (table parameter).
* Cascade: exact gridName match → gridName contains → column contains.
* Returns { gridSelector, gridId, gridName, gridIndex, columns } or { error, available }.
*/
export function resolveGridScript(formNum, tableName) {
const p = `form${formNum}_`;
return `(() => {
const p = ${JSON.stringify(p)};
const target = ${JSON.stringify(tableName.toLowerCase().replace(/ё/g, 'е'))};
const norm = s => (s || '').replace(/ё/gi, 'е');
const allGrids = [...document.querySelectorAll('[id^="' + p + '"].grid, [id^="' + p + '"] .grid')]
.filter(g => g.offsetWidth > 0 && g.offsetHeight > 0);
if (!allGrids.length) return { error: 'no_grids', message: 'No grids found on form' };
const infos = allGrids.map((g, idx) => {
const gridId = g.id || '';
const gridName = gridId.replace(p, '');
const head = g.querySelector('.gridHead');
const columns = [];
if (head) {
const headLine = head.querySelector('.gridLine') || head;
[...headLine.children].forEach(box => {
if (box.offsetWidth === 0) return;
const textEl = box.querySelector('.gridBoxText');
const text = (textEl || box).innerText?.trim().replace(/\\n/g, ' ') || '';
if (text) columns.push(text);
});
}
// Visual label from group title element
const titleEl = document.getElementById(p + gridName + '#title_div')
|| document.getElementById(p + 'Группа' + gridName + '#title_div');
const label = titleEl ? (titleEl.innerText?.trim().replace(/:\s*$/, '').replace(/ /g, ' ') || '') : '';
return { idx, gridId, gridName, label, columns, el: g };
});
// 1. Exact gridName match (case-insensitive)
let found = infos.find(i => norm(i.gridName).toLowerCase() === target);
// 2. Exact label match
if (!found) found = infos.find(i => i.label && norm(i.label).toLowerCase() === target);
// 3. gridName contains target
if (!found) found = infos.find(i => norm(i.gridName).toLowerCase().includes(target));
// 4. Label contains target
if (!found) found = infos.find(i => i.label && norm(i.label).toLowerCase().includes(target));
// 5. Any column contains target
if (!found) found = infos.find(i => i.columns.some(c => norm(c).toLowerCase().includes(target)));
if (found) {
return {
gridSelector: found.gridId ? '#' + CSS.escape(found.gridId) : null,
gridId: found.gridId,
gridName: found.gridName,
gridIndex: found.idx,
columns: found.columns
};
}
return {
error: 'not_found',
message: 'Table "' + ${JSON.stringify(tableName)} + '" not found',
available: infos.map(i => ({ name: i.gridName, ...(i.label ? { label: i.label } : {}), columns: i.columns }))
};
})()`;
}
/**
* Read table/grid data with pagination.
* Parses grid.innerText — \n separates rows, \t separates cells.
* First row = column headers.
* Returns { name, columns[], rows[{col:val}], total, offset, shown }.
*/
export function readTableScript(formNum, { maxRows = 20, offset = 0, gridSelector } = {}) {
const p = `form${formNum}_`;
return `(() => {
const p = ${JSON.stringify(p)};
const grid = ${gridSelector
? `document.querySelector(${JSON.stringify(gridSelector)})`
: `[...document.querySelectorAll('[id^="' + p + '"].grid, [id^="' + p + '"] .grid')]
.find(g => g.offsetWidth > 0 && g.offsetHeight > 0)`};
if (!grid) return { error: 'no_table', message: 'No table found on form ${formNum}' };
const name = grid.id ? grid.id.replace(p, '') : '';
// Detect a "picture value" cell: a sprite from a picture collection
// (.gridBoxImg .dIB with background-image .../pictureCollection/picture/<id>?...&gx=<N>).
// Excludes decorative tree/group markers (gridListH/gridListV/[tree]/gridBoxTree).
// Returns { gx } — the sprite frame index that encodes the cell state, or null.
function picInfo(cell) {
if (!cell) return null;
if (cell.querySelector('.gridListH, .gridListV, [tree="true"], .gridBoxTree')) return null;
const dib = cell.querySelector('.gridBoxImg .dIB');
if (!dib) return null;
const bg = dib.style.backgroundImage || '';
if (!bg.includes('pictureCollection/picture/')) return null;
const m = bg.match(/[?&]gx=(\\d+)/);
return { gx: m ? m[1] : '0' };
}
// DOM-based parsing: gridHead → columns, gridBody → gridLine rows → gridBox cells
const head = grid.querySelector('.gridHead');
const body = grid.querySelector('.gridBody');
if (!head || !body) {
// Fallback: innerText-based (for non-standard grids)
const gText = grid.innerText?.trim() || '';
const lines = gText.split('\\n').filter(Boolean);
return { name, columns: [], rows: [], total: lines.length, offset: 0, shown: 0,
hint: 'Grid has no gridHead/gridBody structure' };
}
// Extract column headers with X-coordinates for alignment
const columns = [];
const headLine = head.querySelector('.gridLine') || head;
[...headLine.children].forEach(box => {
if (box.offsetWidth === 0) return;
const textEl = box.querySelector('.gridBoxText');
const text = (textEl || box).innerText?.trim().replace(/\\n/g, ' ') || '';
if (!text) {
// Unnamed column — check if data cells contain checkboxes or pictures.
// Picture columns have no header text (only an icon + a title tooltip); 1С
// doesn't expose the technical column name in the DOM, so we name them by
// the header's title attribute, falling back to '(picture)'.
const firstLine = body?.querySelector('.gridLine');
const visibleHeaders = [...headLine.children].filter(c => c.offsetWidth > 0);
const idx = visibleHeaders.indexOf(box);
const cells = firstLine ? [...firstLine.children].filter(c => c.offsetWidth > 0) : [];
const r = box.getBoundingClientRect();
if (cells[idx]?.querySelector('.checkbox')) {
columns.push({ text: '(checkbox)', x: r.x, w: r.width, right: r.x + r.width, y: r.y, h: r.height });
} else if (picInfo(box) || picInfo(cells[idx])) {
let title = (box.getAttribute('title') || '').trim() || '(picture)';
// Disambiguate duplicate picture-column names with a numeric suffix.
if (columns.some(c => c.text === title)) {
let n = 2;
while (columns.some(c => c.text === title + ' ' + n)) n++;
title = title + ' ' + n;
}
columns.push({ text: title, x: r.x, w: r.width, right: r.x + r.width, y: r.y, h: r.height });
}
return;
}
const r = box.getBoundingClientRect();
columns.push({ text, x: r.x, w: r.width, right: r.x + r.width, y: r.y, h: r.height });
});
// Multi-row grid support: detect stacked/merged headers.
// Group headers by X-range. For each group, count data sub-rows from first line.
// - Stacked headers (2+ headers at same X) with multiple data rows → match by Y-order
// - Single merged header with multiple data rows → expand to numbered columns (e.g. "Субконто Дт 1")
const xGroups = new Map();
columns.forEach(c => {
const key = Math.round(c.x) + ':' + Math.round(c.right);
if (!xGroups.has(key)) xGroups.set(key, []);
xGroups.get(key).push(c);
});
for (const [, hdrs] of xGroups) hdrs.sort((a, b) => a.y - b.y);
const firstDataLine = body?.querySelector('.gridLine');
const subRowMap = new Map();
if (firstDataLine) {
[...firstDataLine.children].forEach(box => {
if (box.offsetWidth === 0) return;
const r = box.getBoundingClientRect();
const cx = r.x + r.width / 2;
for (const [key, hdrs] of xGroups) {
const h0 = hdrs[0];
if (cx >= h0.x && cx < h0.right) {
if (!subRowMap.has(key)) subRowMap.set(key, []);
subRowMap.get(key).push({ y: r.y });
break;
}
}
});
for (const [, subs] of subRowMap) subs.sort((a, b) => a.y - b.y);
}
const multiRowGroups = new Map();
for (const [key, hdrs] of xGroups) {
const subs = subRowMap.get(key);
if (!subs || subs.length <= 1) continue;
if (hdrs.length >= 2) {
multiRowGroups.set(key, hdrs);
} else if (hdrs.length === 1 && subs.length > 1) {
const base = hdrs[0];
const baseIdx = columns.indexOf(base);
columns.splice(baseIdx, 1);
const expanded = [];
for (let si = 0; si < subs.length; si++) {
const numbered = {
text: base.text + ' ' + (si + 1),
x: base.x, w: base.w, right: base.right,
y: base.y + si, h: base.h / subs.length, _subIdx: si
};
columns.splice(baseIdx + si, 0, numbered);
expanded.push(numbered);
}
multiRowGroups.set(key, expanded);
}
}
function matchColumn(cellX, cellW, cellY) {
const cx = cellX + cellW / 2;
for (const [key, hdrs] of multiRowGroups) {
const h0 = hdrs[0];
if (cx >= h0.x && cx < h0.right) {
const subs = subRowMap.get(key);
if (subs) {
const subIdx = subs.findIndex(s => Math.abs(s.y - cellY) < 5);
if (subIdx >= 0 && subIdx < hdrs.length) return hdrs[subIdx];
}
let best = hdrs[0], bestDist = Infinity;
for (const h of hdrs) {
const dist = Math.abs(cellY - h.y);
if (dist < bestDist) { bestDist = dist; best = h; }
}
return best;
}
}
return columns.find(c => cx >= c.x && cx < c.right);
}
// Extract data rows from gridBody
const allLines = body.querySelectorAll('.gridLine');
const total = allLines.length;
const rows = [];
const end = Math.min(${offset} + ${maxRows}, total);
for (let i = ${offset}; i < end; i++) {
const line = allLines[i];
if (!line) break;
const row = {};
columns.forEach(c => { row[c.text] = ''; });
[...line.children].forEach(box => {
if (box.offsetWidth === 0) return;
const textEl = box.querySelector('.gridBoxText');
const chk = box.querySelector('.checkbox');
let val;
if (chk) {
val = chk.classList.contains('select') ? 'true' : 'false';
} else {
val = (textEl || box).innerText?.trim().replace(/\\n/g, ' ') || '';
if (!val) {
// Empty text → maybe a picture cell. 'pic:<gx>' encodes the sprite frame
// (state). Absent picture stays '' (truthy check distinguishes presence).
const pic = picInfo(box);
if (pic) val = 'pic:' + pic.gx;
else return;
}
}
// Match cell to column by X+Y overlap (multi-row aware)
const r = box.getBoundingClientRect();
const col = matchColumn(r.x, r.width, r.y);
if (col) {
row[col.text] = row[col.text] ? row[col.text] + ' / ' + val : val;
}
});
// Detect row kind: group (gridListH), parent/up (gridListV), or element
const imgBox = line.querySelector('.gridBoxImg');
if (imgBox) {
if (imgBox.querySelector('.gridListH')) row._kind = 'group';
else if (imgBox.querySelector('.gridListV')) row._kind = 'parent';
}
// Tree mode: detect expand/collapse state and indent level
const treeBox = line.querySelector('.gridBoxTree');
if (treeBox) {
const treeIcon = imgBox?.querySelector('[tree="true"]');
if (treeIcon) {
const bg = treeIcon.style.backgroundImage || '';
row._tree = bg.includes('gx=0') ? 'expanded' : 'collapsed';
}
row._level = imgBox ? imgBox.querySelectorAll('.dIB').length - 1 : 0;
}
// Selection state: selRow = selected row in grid
if (line.classList.contains('selRow') || line.classList.contains('select')) row._selected = true;
rows.push(row);
}
const isTree = !!body.querySelector('.gridBoxTree');
const hasGroups = rows.some(r => r._kind === 'group');
// Virtualization-aware hasMore signal. Three sources in priority order:
// 1. Dynamic-list turn buttons (#vertButtonScroll_<gridId>, sibling of grid).
// Buttons carry data-home/data-up (above) and data-down/data-end (below);
// class "disabled" on a direction means nothing to show there.
// 2. Tabular-section scrollbar (#vertScroll_<gridId>, class scrollV) —
// track-back/track-next pixel heights tell us above/below precisely.
// 3. Fallback: scrollHeight>clientHeight for "below"; "above" unknown.
let hasMore;
const turnsBox = document.getElementById('vertButtonScroll_' + grid.id);
if (turnsBox && turnsBox.offsetHeight > 0) {
const upBtns = turnsBox.querySelectorAll('[data-home], [data-up]');
const dnBtns = turnsBox.querySelectorAll('[data-down], [data-end]');
hasMore = {
above: [...upBtns].some(b => !b.classList.contains('disabled')),
below: [...dnBtns].some(b => !b.classList.contains('disabled')),
};
} else {
const vsId = 'vertScroll_' + grid.id;
const vs = document.getElementById(vsId);
if (vs && vs.classList.contains('scrollV') && vs.offsetWidth > 0) {
const back = vs.querySelector('[data-track-back]')?.offsetHeight ?? 0;
const next = vs.querySelector('[data-track-next]')?.offsetHeight ?? 0;
hasMore = { above: back > 0, below: next > 0 };
} else {
hasMore = { below: body.scrollHeight > body.clientHeight };
}
}
const result = { name, columns: columns.map(c => c.text), rows, total, offset: ${offset}, shown: rows.length, hasMore };
if (isTree) result.viewMode = 'tree';
if (hasGroups) result.hierarchical = true;
return result;
})()`;
}
// ─── Edit-time grid helpers (for fillTableRow / row-fill) ────────────────────
//
// All helpers below accept an optional `gridSelector`. When passed, they target
// that exact grid; when null/undefined they pick the LAST visible `.grid` on
// the page (this matches the implicit "current grid" used by row-fill).
/** Inline JS fragment that resolves the target grid into `const grid`. */
function gridResolver(gridSelector) {
return gridSelector
? `document.querySelector(${JSON.stringify(gridSelector)})`
: `(() => { const grids = [...document.querySelectorAll('.grid')].filter(el => el.offsetWidth > 0); return grids[grids.length - 1]; })()`;
}
/**
* Find center coords of a target row for click-select (used by deleteTableRow).
* Picks the second visible gridBox container in the row (skips row-number/checkbox col).
*
* Returns `{ x, y, total } | { error: 'no_grid'|'no_grid_body'|'row_out_of_range'|'no_cell', total? }`.
*/
export function findDeleteRowCoordsScript(gridSelector, row) {
return `(() => {
const grid = ${gridResolver(gridSelector)};
if (!grid) return { error: 'no_grid' };
const body = grid.querySelector('.gridBody');
if (!body) return { error: 'no_grid_body' };
const rows = [...body.querySelectorAll('.gridLine')];
if (${row} >= rows.length) return { error: 'row_out_of_range', total: rows.length };
const line = rows[${row}];
// Use visible gridBox containers (not gridBoxText) to avoid clicking checkboxes
const boxes = [...line.children].filter(b => b.offsetWidth > 0 && !b.classList.contains('gridBoxComp'));
// Skip first column (row number / checkbox) — pick second visible box
const box = boxes.length > 1 ? boxes[1] : boxes[0];
if (!box) return { error: 'no_cell' };
const cell = box.querySelector('.gridBoxText') || box;
const r = cell.getBoundingClientRect();
return { x: Math.round(r.x + r.width / 2), y: Math.round(r.y + r.height / 2), total: rows.length };
})()`;
}
/**
* Count `.gridLine` rows in the body of the target grid.
* Returns the row count, or `0` when grid/body absent.
*/
export function countGridRowsScript(gridSelector) {
return `(() => {
const grid = ${gridResolver(gridSelector)};
const body = grid?.querySelector('.gridBody');
return body ? body.querySelectorAll('.gridLine').length : 0;
})()`;
}
/**
* Is the target grid a tree grid? (presence of `.gridBoxTree`)
* Returns boolean.
*/
export function isTreeGridScript(gridSelector) {
return `(() => {
const grid = ${gridResolver(gridSelector)};
return grid ? !!grid.querySelector('.gridBoxTree') : false;
})()`;
}
/**
* Return center coords of the grid's `.gridHead` element.
* Used as a click target to commit a pending cell edit (clicking the header
* defocuses the input without selecting another row).
*
* Returns `{ x, y } | null`.
*/
export function findGridHeadCenterCoordsScript(gridSelector) {
return `(() => {
const grid = ${gridResolver(gridSelector)};
if (!grid) return null;
const head = grid.querySelector('.gridHead');
if (!head) return null;
const r = head.getBoundingClientRect();
return { x: Math.round(r.x + r.width / 2), y: Math.round(r.y + r.height / 2) };
})()`;
}
/**
* Return the index of the currently selected row in the target grid, or
* fall back to the last row when nothing is selected.
*
* Returns row index, or `-1` when no rows.
*/
export function getSelectedOrLastRowIndexScript(gridSelector) {
return `(() => {
const grid = ${gridResolver(gridSelector)};
if (!grid) return -1;
const body = grid.querySelector('.gridBody');
if (!body) return -1;
const lines = [...body.querySelectorAll('.gridLine')];
const sel = lines.findIndex(l => l.classList.contains('selected'));
return sel >= 0 ? sel : lines.length - 1;
})()`;
}
/**
* Scan a form's grid for a row matching `searchLower` (case- and ё-insensitive,
* NBSP-normalised). Match order: exact → startsWith → includes.
*
* When `searchLower` is empty, returns coords of the first row (fallback).
*
* Returns `{ rowCount, x, y, isGroup } | { rowCount: 0 } | null`.
*/
export function scanGridRowsScript(formNum, searchLower) {
return `(() => {
const p = 'form${formNum}_';
const grid = document.querySelector('[id^="' + p + '"].grid, [id^="' + p + '"] .grid');
if (!grid) return null;
const body = grid.querySelector('.gridBody');
if (!body) return null;
const lines = [...body.querySelectorAll('.gridLine')];
if (!lines.length) return { rowCount: 0 };
const searchLower = ${JSON.stringify(searchLower || '')};
let sel = null;
if (searchLower) {
const norm = s => (s || '').replace(/\\u00a0/g, ' ').trim().toLowerCase().replace(/ё/gi, 'е');
const rowData = lines.map(l => ({ el: l, text: norm(l.innerText) }));
sel = rowData.find(r => r.text === searchLower)?.el
|| rowData.find(r => r.text.startsWith(searchLower))?.el
|| rowData.find(r => r.text.includes(searchLower))?.el;
} else {
sel = lines[0]; // empty search → first row
}
if (!sel) return null;
const imgBox = sel.querySelector('.gridBoxImg');
const isGroup = imgBox ? !!imgBox.querySelector('.gridListH') : false;
const r = sel.getBoundingClientRect();
return { rowCount: lines.length, x: Math.round(r.x + r.width / 2), y: Math.round(r.y + r.height / 2), isGroup };
})()`;
}
// ─── Cell-click DOM scripts (for clickElement({row, column}) on grids) ───────
/**
* Resolve a target cell in a grid by (row, column).
* - `column` matched: exact (case+ё-insensitive) → endsWith ' / X' → includes.
* - `row`: number = index in current DOM window; object = {col: value, ...} filter
* (matches first non-group/parent row where every column condition passes).
*
* Returns `{ x, y, cellX, cellRight, gridX, gridRight, columnText, rowIdx, cellText, visible } | { error, ... }`.
*
* Visibility (`visible`) is true when the cell is fully within the grid's horizontal viewport.
* Callers should horizontally scroll first if `visible === false`.
*/
export function findGridCellScript(formNum, gridSelector, { row, column }) {
const p = `form${formNum}_`;
return `(() => {
const norm = s => (s || '').replace(/\\u00a0/g, ' ').replace(/ё/gi, 'е').trim();
const lo = s => norm(s).toLowerCase();
const p = ${JSON.stringify(p)};
const grid = ${gridSelector
? `document.querySelector(${JSON.stringify(gridSelector)})`
: `[...document.querySelectorAll('[id^="' + p + '"].grid, [id^="' + p + '"] .grid')]
.find(g => g.offsetWidth > 0 && g.offsetHeight > 0)`};
if (!grid) return { error: 'no_grid' };
const head = grid.querySelector('.gridHead');
const body = grid.querySelector('.gridBody');
if (!head || !body) return { error: 'no_grid_structure' };
// Header X-ranges (mirror of readTableScript logic, simplified). We also
// remember whether each header is frozen (gridBoxFix) — frozen and scrollable
// columns can share X coordinates after horizontal scroll, so cell matching
// must respect the frozen/scrollable partition.
const headLine = head.querySelector('.gridLine') || head;
const headers = [...headLine.children]
.filter(c => c.offsetWidth > 0)
.map(c => {
const textEl = c.querySelector('.gridBoxText');
const text = (textEl || c).innerText?.trim().replace(/\\n/g, ' ') || '';
// Picture/icon columns have no header text — fall back to the title tooltip
// (mirrors readTable naming) so they can still be targeted for clicking.
const title = (c.getAttribute('title') || '').trim();
const r = c.getBoundingClientRect();
return { text, title, name: text || title, x: r.x, right: r.x + r.width, fixed: c.classList.contains('gridBoxFix') };
})
.filter(h => h.name);
const resolveCol = (name) => {
const suffix = ' / ' + name;
const cand = h => [h.text, h.title].filter(Boolean);
return headers.find(h => cand(h).some(t => lo(t) === lo(name)))
|| headers.find(h => cand(h).some(t => t.endsWith(suffix)))
|| headers.find(h => cand(h).some(t => lo(t).includes(lo(name))));
};
const targetCol = ${JSON.stringify(column)};
const col = resolveCol(targetCol);
if (!col) return { error: 'column_not_found', column: targetCol, available: headers.map(h => h.name) };
const lines = [...body.querySelectorAll('.gridLine')];
if (lines.length === 0) return { error: 'empty_grid' };
// Match cell to column by X overlap, but only among cells with the same
// fixed/scrollable kind as the header. After horizontal scroll a scrollable
// cell may have the same x as a frozen one — without this guard cellAtColX
// would silently return the frozen cell for a scrollable header.
const cellAtColX = (line, c) => [...line.children]
.filter(b => b.offsetWidth > 0 && b.classList.contains('gridBoxFix') === c.fixed)
.find(b => {
const r = b.getBoundingClientRect();
const cx = r.x + r.width / 2;
return cx >= c.x && cx < c.right;
});
const cellText = (b) => norm(b?.querySelector('.gridBoxText')?.innerText || b?.innerText || '');
const target = ${JSON.stringify(row)};
let line, rowIdx;
if (typeof target === 'number') {
if (target < 0 || target >= lines.length) {
return { error: 'row_out_of_range', row: target, loaded: lines.length };
}
line = lines[target];
rowIdx = target;
} else if (target && typeof target === 'object') {
const entries = Object.entries(target);
const colsByKey = {};
for (const [k] of entries) {
const c = resolveCol(k);
if (!c) return { error: 'filter_column_not_found', column: k, available: headers.map(h => h.name) };
colsByKey[k] = c;
}
const matches = (ln) => {
for (const [k, v] of entries) {
const c = colsByKey[k];
const cell = cellAtColX(ln, c);
const txt = cellText(cell);
const wanted = lo(v);
if (!txt) return false;
const t = txt.toLowerCase();
if (!(t === wanted || t.includes(wanted))) return false;
}
return true;
};
rowIdx = lines.findIndex(matches);
if (rowIdx < 0) return { error: 'row_not_found', filter: target };
line = lines[rowIdx];
} else {
return { error: 'invalid_row_type' };
}
const cell = cellAtColX(line, col);
if (!cell) return { error: 'cell_not_in_dom', column: col.name, rowIdx };
const r = cell.getBoundingClientRect();
const gridBox = grid.getBoundingClientRect();
// Frozen columns (.gridBoxFix) stay pinned at the left edge of the grid even
// when the rest scrolls horizontally. For non-frozen cells, "visible" means
// inside the SCROLLABLE viewport (right of any frozen columns). Frozen cells
// are always visible by definition.
const isFixed = cell.classList.contains('gridBoxFix');
let scrollableLeft = gridBox.x;
if (!isFixed) {
[...line.children].forEach(b => {
if (b.offsetWidth > 0 && b.classList.contains('gridBoxFix')) {
const br = b.getBoundingClientRect();
if (br.x + br.width > scrollableLeft) scrollableLeft = br.x + br.width;
}
});
}
// "Visible enough to click" — the cell's CENTER is inside the scrollable area
// and the cell's right edge is inside the grid. Strict left-edge check would
// reject cells that 1С rendered touching the frozen-column boundary (off by 1px).
const center = r.x + r.width / 2;
const visible = center >= scrollableLeft && center <= (gridBox.x + gridBox.width) && (r.x + r.width) <= (gridBox.x + gridBox.width);
return {
x: Math.round(r.x + r.width / 2),
y: Math.round(r.y + r.height / 2),
cellX: Math.round(r.x), cellRight: Math.round(r.x + r.width),
gridX: Math.round(gridBox.x), gridRight: Math.round(gridBox.x + gridBox.width),
scrollableLeft: Math.round(scrollableLeft),
columnText: col.name, rowIdx, isFixed,
cellText: cellText(cell),
visible
};
})()`;
}
/**
* Pick coordinates for a focus-click on a safe cell within the grid.
*
* Used both for vertical reveal-loop focus and for horizontal-scroll edge focus.
* The caller passes a profile that selects which row, which cells to exclude,
* and (for horizontal scroll) which edge of the row to take.
*
* @param {string} gridSelector
* @param {object} opts
* @param {number} [opts.rowIdx] - Pick from this row; falls back to first non-group/parent data row.
* @param {'ArrowRight'|'ArrowLeft'} [opts.direction]
* - When set, restricts to non-frozen FULLY visible cells and picks the edge
* cell in that direction (rightmost for ArrowRight, leftmost for ArrowLeft).
* - When omitted, picks a generic safe cell (skips first column to avoid tree-toggles).
*
* Always prefers non-checkbox cells (center-click on a checkbox would toggle it).
*
* Returns `{ x, y } | null`.
*/
export function findFocusCellScript(gridSelector, { rowIdx, direction } = {}) {
return `(() => {
const grid = ${gridResolver(gridSelector)};
if (!grid) return null;
const body = grid.querySelector('.gridBody');
if (!body) return null;
const lines = [...body.querySelectorAll('.gridLine')];
if (!lines.length) return null;
const rowIdx = ${rowIdx == null ? 'null' : JSON.stringify(rowIdx)};
const direction = ${direction ? JSON.stringify(direction) : 'null'};
const line = (rowIdx != null && lines[rowIdx])
|| lines.find(ln => {
const imgBox = ln.querySelector('.gridBoxImg');
return !imgBox?.querySelector('.gridListH, .gridListV');
})
|| lines[0];
if (!line) return null;
let candidates;
if (direction) {
// Horizontal-scroll mode: edge cell in the scrollable area, exclude frozen.
const gridBox = grid.getBoundingClientRect();
let scrollableLeft = gridBox.x;
[...line.children].forEach(b => {
if (b.offsetWidth > 0 && b.classList.contains('gridBoxFix')) {
const br = b.getBoundingClientRect();
if (br.x + br.width > scrollableLeft) scrollableLeft = br.x + br.width;
}
});
const visible = [...line.children]
.filter(b => b.offsetWidth > 0 && !b.classList.contains('gridBoxFix'))
.map(b => ({ b, r: b.getBoundingClientRect(), checkbox: !!b.querySelector('.checkbox') }))
.filter(({ r }) => r.x >= scrollableLeft && (r.x + r.width) <= (gridBox.x + gridBox.width));
if (!visible.length) return null;
visible.sort((a, b) => a.r.x - b.r.x);
candidates = direction === 'ArrowRight' ? [...visible].reverse() : visible;
} else {
// Generic focus mode (used by reveal-loop): pick the FIRST visible cell —
// typically a Reference column (Номенклатура in документах) which doesn't
// auto-enter edit mode on click. Number/Date/String cells auto-edit and
// break subsequent PageDown navigation.
// For tree grids (presence of .gridBoxTree), skip first column to avoid
// toggling expand/collapse of the row.
const isTree = !!body.querySelector('.gridBoxTree');
const cells = [...line.children]
.filter(b => b.offsetWidth > 0)
.map(b => ({ b, r: b.getBoundingClientRect(), checkbox: !!b.querySelector('.checkbox') }));
if (!cells.length) return null;
candidates = isTree && cells.length > 1 ? cells.slice(1) : cells;
}
const pick = candidates.find(v => !v.checkbox) || candidates[0];
if (!pick) return null;
return { x: Math.round(pick.r.x + pick.r.width / 2), y: Math.round(pick.r.y + pick.r.height / 2) };
})()`;
}
/**
* Snapshot grid state for reveal-loop end detection.
* Returns `{ firstText, lastText, lineCount, selIdx, hasBelow }`.
*
* `firstText`/`lastText` use the first cell's `.gridBoxText` content.
* `hasBelow` is derived from scrollbar widget tracks when visible, else from scrollHeight>clientHeight.
*/
export function snapshotGridScript(gridSelector) {
return `(() => {
const grid = ${gridResolver(gridSelector)};
if (!grid) return null;
const body = grid.querySelector('.gridBody');
if (!body) return null;
const lines = body.querySelectorAll('.gridLine');
// Full-row signature: join EVERY cell's text, not just the first column.
// A low-cardinality first column (e.g. all "Товар 0X") would otherwise make
// two different windows look identical and abort the reveal-loop early.
const txt = ln => ln ? [...ln.querySelectorAll('.gridBoxText')].map(b => (b.innerText || '').trim()).join('|') : '';
const selIdx = [...lines].findIndex(l => l.classList.contains('selRow') || l.classList.contains('select'));
// hasBelow priority: (1) dynamic-list turn buttons, (2) tabular scrollbar tracks, (3) scrollHeight.
let hasBelow;
const turnsBox = document.getElementById('vertButtonScroll_' + grid.id);
if (turnsBox && turnsBox.offsetHeight > 0) {
const dnBtns = turnsBox.querySelectorAll('[data-down], [data-end]');
hasBelow = [...dnBtns].some(b => !b.classList.contains('disabled'));
} else {
const vs = document.getElementById('vertScroll_' + grid.id);
if (vs && vs.classList.contains('scrollV') && vs.offsetWidth > 0) {
hasBelow = (vs.querySelector('[data-track-next]')?.offsetHeight ?? 0) > 0;
} else {
hasBelow = body.scrollHeight > body.clientHeight;
}
}
return {
firstText: txt(lines[0]),
lastText: txt(lines[lines.length - 1]),
lineCount: lines.length,
selIdx,
hasBelow
};
})()`;
}
/**
* Resolve the click target kind for `clickElement({row, column})`.
*
* Routing:
* - `tableName` specified: try to match a visible grid by name (exact → contains).
* If matched → grid. Else if form has a spreadsheet iframe → spreadsheet. Else error.
* - `tableName` omitted: spreadsheet iframe present → spreadsheet (backward-compat).
* Else first visible grid. Else error.
*
* Returns `{ kind: 'spreadsheet' } | { kind: 'grid', gridSelector, gridName } | { error, ... }`.
*/
export function resolveCellTargetScript(formNum, tableName) {
const p = `form${formNum}_`;
return `(() => {
const p = ${JSON.stringify(p)};
const tableName = ${JSON.stringify(tableName || '')};
// Spreadsheet = iframe under form prefix with non-trivial width.
const hasSpreadsheet = [...document.querySelectorAll('iframe')].some(f => {
if (f.offsetWidth < 100) return false;
let el = f.parentElement;
for (let d = 0; el && d < 30; d++, el = el.parentElement) {
if (el.id && el.id.startsWith(p)) return true;
}
return false;
});
const grids = [...document.querySelectorAll('[id^="' + p + '"].grid, [id^="' + p + '"] .grid')]
.filter(g => g.offsetWidth > 0 && g.offsetHeight > 0);
const norm = s => (s || '').replace(/ё/gi, 'е').toLowerCase();
if (tableName) {
const target = norm(tableName);
const matched = grids.find(g => norm(g.id.replace(p, '')) === target)
|| grids.find(g => norm(g.id.replace(p, '')).includes(target));
if (matched) {
return { kind: 'grid', gridSelector: '#' + CSS.escape(matched.id), gridName: matched.id.replace(p, '') };
}
if (hasSpreadsheet) return { kind: 'spreadsheet' };
return { error: 'table_not_found', table: tableName, availableGrids: grids.map(g => g.id.replace(p, '')) };
}
if (hasSpreadsheet) return { kind: 'spreadsheet' };
if (grids.length > 0) {
const g = grids[0];
return { kind: 'grid', gridSelector: '#' + CSS.escape(g.id), gridName: g.id.replace(p, '') };
}
return { error: 'no_spreadsheet_or_grid' };
})()`;
}
@@ -0,0 +1,93 @@
// web-test dom/nav v1.0 — sections panel, tabs bar, function panel commands
// Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
/** Read sections panel (left sidebar). */
export function readSectionsScript() {
return `(() => {
const sections = [];
document.querySelectorAll('[id^="themesCell_theme_"]').forEach(el => {
const entry = { name: el.innerText?.trim() || '' };
if (el.classList.contains('select')) entry.active = true;
sections.push(entry);
});
return sections;
})()`;
}
/** Read open tabs bar. */
export function readTabsScript() {
return `(() => {
const norm = s => (s?.trim().replace(/\\u00a0/g, ' ') || '').replace(/ё/gi, 'е');
const tabs = [];
document.querySelectorAll('[id^="openedCell_cmd_"]').forEach(el => {
const text = norm(el.innerText);
if (!text) return;
const entry = { name: text };
if (el.classList.contains('select')) entry.active = true;
tabs.push(entry);
});
return tabs;
})()`;
}
/** Switch to a tab by name (fuzzy match). Returns matched name or { error, available }. */
export function switchTabScript(name) {
return `(() => {
const norm = s => (s?.trim().replace(/\\u00a0/g, ' ') || '').replace(/ё/gi, 'е');
const target = ${JSON.stringify(name.toLowerCase().replace(/ё/g, 'е'))};
const tabs = [...document.querySelectorAll('[id^="openedCell_cmd_"]')].filter(el => el.offsetWidth > 0 && norm(el.innerText));
let best = tabs.find(el => norm(el.innerText).toLowerCase() === target);
if (!best) best = tabs.find(el => norm(el.innerText).toLowerCase().includes(target));
if (best) { best.click(); return norm(best.innerText); }
return { error: 'not_found', available: tabs.map(el => norm(el.innerText)) };
})()`;
}
/** Read commands in the function panel (current section). */
export function readCommandsScript() {
return `(() => {
const groups = [];
const container = document.querySelector('#funcPanel_container table tr');
if (!container) return groups;
for (const td of container.children) {
const commands = [];
td.querySelectorAll('[id^="cmd_"][id$="_txt"]').forEach(el => {
if (el.offsetWidth === 0) return;
commands.push(el.innerText?.trim() || '');
});
if (commands.length > 0) groups.push(commands);
}
return groups;
})()`;
}
/**
* Navigate to a section by name (fuzzy match).
* Returns the matched section name, or { error, available }.
*/
export function navigateSectionScript(name) {
return `(() => {
const norm = s => (s?.trim().replace(/\\u00a0/g, ' ').replace(/[\\r\\n]+/g, ' ').replace(/ +/g, ' ') || '').replace(/ё/gi, 'е');
const target = ${JSON.stringify(name.toLowerCase().replace(/ё/g, 'е').replace(/[\r\n]+/g, ' ').replace(/ +/g, ' '))};
const els = [...document.querySelectorAll('[id^="themesCell_theme_"]')];
let bestEl = els.find(el => norm(el.innerText).toLowerCase() === target);
if (!bestEl) bestEl = els.find(el => norm(el.innerText).toLowerCase().includes(target));
if (bestEl) { bestEl.click(); return norm(bestEl.innerText); }
return { error: 'not_found', available: els.map(el => norm(el.innerText)).filter(Boolean) };
})()`;
}
/**
* Open a command from function panel by name (fuzzy match).
*/
export function openCommandScript(name) {
return `(() => {
const norm = s => (s?.trim().replace(/\\u00a0/g, ' ') || '').replace(/ё/gi, 'е');
const target = ${JSON.stringify(name.toLowerCase().replace(/ё/g, 'е'))};
const els = [...document.querySelectorAll('[id^="cmd_"][id$="_txt"]')].filter(el => el.offsetWidth > 0);
let bestEl = els.find(el => norm(el.innerText).toLowerCase() === target);
if (!bestEl) bestEl = els.find(el => norm(el.innerText).toLowerCase().includes(target));
if (bestEl) { bestEl.click(); return norm(bestEl.innerText); }
return { error: 'not_found', available: els.map(el => norm(el.innerText)).filter(Boolean) };
})()`;
}
@@ -0,0 +1,149 @@
// web-test dom/submenu v1.0 — popup/submenu reading and clicking
// Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
/**
* Read open popup/submenu items.
* Looks for absolutely positioned visible popup containers with a.press items inside.
* Returns [{ id, name }] or { error }.
*/
export function readSubmenuScript() {
return `(() => {
const items = [];
const norm = s => (s?.trim().replace(/\\u00a0/g, ' ') || '').replace(/ё/gi, 'е');
// 1. DLB dropdown (#editDropDown with .eddText items)
const edd = document.getElementById('editDropDown');
if (edd && edd.offsetWidth > 0 && edd.offsetHeight > 0) {
edd.querySelectorAll('.eddText').forEach(el => {
if (el.offsetWidth === 0) return;
const text = norm(el.innerText);
if (!text) return;
const r = el.getBoundingClientRect();
items.push({ id: '', name: text, kind: 'dropdown',
x: Math.round(r.x + r.width / 2), y: Math.round(r.y + r.height / 2) });
});
// Detect "Показать все" link in EDD footer
// Structure: div.eddBottom > div > span.hyperlink "Показать все"
let showAllEl = edd.querySelector('.eddBottom .hyperlink');
if (!showAllEl || showAllEl.offsetWidth === 0) {
// Fallback: scan all visible elements for text match
const candidates = [...edd.querySelectorAll('a.press, a, span, div')]
.filter(el => el.offsetWidth > 0 && el.children.length === 0);
showAllEl = candidates.find(el => {
const t = norm(el.innerText).toLowerCase();
return t === 'показать все' || t === 'show all';
});
}
if (showAllEl) {
const r = showAllEl.getBoundingClientRect();
items.push({ id: showAllEl.id || '', name: norm(showAllEl.innerText), kind: 'showAll',
x: Math.round(r.x + r.width / 2), y: Math.round(r.y + r.height / 2) });
}
if (items.length > 0) return items;
}
// 2. Cloud submenu (allActions / command panel menus — div.cloud with .submenuText items)
// Read ALL visible high-z clouds (main menu + nested submenus)
const clouds = [...document.querySelectorAll('.cloud')].filter(c => c.offsetWidth > 0 && c.offsetHeight > 0);
const seen = new Set();
clouds.forEach(c => {
const z = parseInt(getComputedStyle(c).zIndex) || 0;
if (z <= 1000) return;
c.querySelectorAll('.submenuText').forEach(el => {
if (el.offsetWidth === 0) return;
const text = norm(el.innerText);
if (!text || seen.has(text)) return;
seen.add(text);
const block = el.closest('.submenuBlock');
if (block && block.classList.contains('submenuBlockDisabled')) return;
const hasSub = block && /_sub$/.test(block.id);
const r = el.getBoundingClientRect();
items.push({ id: block?.id || '', name: text, kind: hasSub ? 'submenuArrow' : 'submenu',
x: Math.round(r.x + r.width / 2), y: Math.round(r.y + r.height / 2) });
});
});
if (items.length > 0) return items;
// 3. Submenu popups — find the topmost positioned container with non-form a.press items
const popups = [...document.querySelectorAll('div')].filter(c => {
const style = getComputedStyle(c);
return (style.position === 'absolute' || style.position === 'fixed')
&& c.offsetWidth > 0 && c.offsetHeight > 0;
}).sort((a, b) => {
const za = parseInt(getComputedStyle(a).zIndex) || 0;
const zb = parseInt(getComputedStyle(b).zIndex) || 0;
return zb - za;
});
for (const container of popups) {
// Only direct a.press children or those not nested in another positioned div
const menuItems = [...container.querySelectorAll('a.press')].filter(el => {
if (el.offsetWidth === 0) return false;
if (el.id && /^form\\d+_/.test(el.id)) return false;
// Skip if this a.press is inside a deeper positioned container
let parent = el.parentElement;
while (parent && parent !== container) {
const ps = getComputedStyle(parent).position;
if (ps === 'absolute' || ps === 'fixed') return false;
parent = parent.parentElement;
}
return true;
});
if (menuItems.length < 2) continue; // Not a real menu
const seen = new Set();
menuItems.forEach(el => {
const text = norm(el.innerText);
if (!text) return;
if (seen.has(text)) return;
seen.add(text);
const r = el.getBoundingClientRect();
items.push({ id: el.id || '', name: text, kind: 'submenu',
x: Math.round(r.x + r.width / 2), y: Math.round(r.y + r.height / 2) });
});
if (items.length > 0) break; // Found the popup menu
}
if (items.length === 0) return { error: 'no_popup', message: 'No open popup/submenu found' };
return items;
})()`;
}
/**
* Click a popup/dropdown item by text match (evaluate-based for items without IDs).
* Returns true if clicked, false if not found.
*/
export function clickPopupItemScript(text) {
return `(() => {
const target = ${JSON.stringify(text.toLowerCase().replace(/ё/g, 'е'))};
// 1. DLB dropdown (#editDropDown .eddText items)
const edd = document.getElementById('editDropDown');
if (edd && edd.offsetWidth > 0) {
for (const el of edd.querySelectorAll('.eddText')) {
if (el.offsetWidth === 0) continue;
const t = el.innerText?.trim() || '';
if (t.toLowerCase() === target || t.toLowerCase().includes(target)) {
el.click();
return t;
}
}
}
// 2. Submenu popups (a.press in absolutely positioned containers)
const containers = [...document.querySelectorAll('div')].filter(c => {
const style = getComputedStyle(c);
return (style.position === 'absolute' || style.position === 'fixed')
&& c.offsetWidth > 0 && c.offsetHeight > 0;
});
for (const container of containers) {
const items = [...container.querySelectorAll('a.press')]
.filter(el => el.offsetWidth > 0);
for (const el of items) {
const t = el.innerText?.trim() || '';
if (t.toLowerCase() === target || t.toLowerCase().includes(target)) {
el.click();
return t;
}
}
}
return null;
})()`;
}