Files
cc-1c-skills/.claude/skills/web-test/scripts/dom.mjs
T
Nick Shirokov a314ec32fc fix(web-test): fillFields date/time fields — paste instead of selectValue
Date fields have a CB (Choose Button) that opens a calendar, not a selection
form. fillFields detected hasPick → delegated to selectValue → error
"DLB click did not open a popup or selection form".

Fix: detect date fields by `iCalendB` CSS class on CB button (dom.mjs),
propagate `isDate` flag through field mapping, and use clipboard paste
for date fields instead of selectValue. Reference fields with CB (without
iCalendB) continue using selectValue as before.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-26 18:40:12 +03:00

1289 lines
59 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// web-test dom v1.2 — DOM selectors and semantic mapping for 1C web client
// Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
/**
* DOM selectors and semantic mapping for 1C:Enterprise web client.
*
* All functions return JavaScript strings for page.evaluate().
* They produce clean semantic structures — no DOM IDs or CSS classes leak out.
* Only non-default property values are included to minimize response size.
*/
// --- Shared function strings (embedded in evaluate scripts) ---
/** Detect active form number. Picks form with most visible elements, skipping form0.
* When modalSurface is visible — prefer the highest-numbered form (modal dialog). */
const DETECT_FORM_FN = `function detectForm() {
const counts = {};
document.querySelectorAll('input.editInput[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)
const modal = document.getElementById('modalSurface');
if (modal && modal.offsetWidth > 0) {
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. */
const DETECT_FORMS_FN = `function detectForms() {
const counts = {};
document.querySelectorAll('input.editInput[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);
const modal = document.getElementById('modalSurface');
const isModal = !!(modal && modal.offsetWidth > 0);
return { allForms: nums.sort((a, b) => a - b), formCount: nums.length, modal: isModal };
}`;
/** Read form state given prefix p. Returns { fields, buttons, tabs, texts, hyperlinks, table, iframes }. */
const READ_FORM_FN = `function readForm(p) {
const result = {};
const fields = [];
const buttons = [];
const formTabs = [];
const texts = [];
const hyperlinks = [];
// 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 = (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 || '' };
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 = (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 = 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 = 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 = 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 = 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;
buttons.push(btn);
});
// Frame buttons
document.querySelectorAll('[id^="' + p + '"].frameButton, [id^="' + p + '"] .frameButton').forEach(el => {
if (el.offsetWidth === 0) return;
const text = 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) {
columns.push(text);
} 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('(checkbox)');
}
}
}
});
}
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, 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) return null;
const label = (val?.label || cb.label || cb.name).replace(/:$/, '').trim();
const s = { name: label, enabled: !!cb.value };
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;
}`;
// --- Exported script generators ---
/**
* 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 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;
})()`;
}
/**
* 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)});
})()`;
}
/**
* 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(/\u00a0/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, '') : '';
// 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
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')) {
const r = box.getBoundingClientRect();
columns.push({ text: '(checkbox)', x: r.x, w: r.width, right: r.x + r.width });
}
}
return;
}
const r = box.getBoundingClientRect();
columns.push({ text, x: r.x, w: r.width, right: r.x + r.width });
});
// 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) return;
}
// Match cell to column by X-coordinate overlap
const r = box.getBoundingClientRect();
const cx = r.x + r.width / 2;
const col = columns.find(c => cx >= c.x && cx < c.right);
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;
}
rows.push(row);
}
const isTree = !!body.querySelector('.gridBoxTree');
const hasGroups = rows.some(r => r._kind === 'group');
const result = { name, columns: columns.map(c => c.text), rows, total, offset: ${offset}, shown: rows.length };
if (isTree) result.viewMode = 'tree';
if (hasGroups) result.hierarchical = true;
return result;
})()`;
}
/**
* 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;
})()`;
}
/**
* 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) };
})()`;
}
/**
* 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);
items.push({ id: el.id, name: text || idName, label: idName, kind: isSubmenu ? 'submenu' : 'button' });
});
// 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 -> startsWith name -> startsWith label -> includes name -> includes label
// 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.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) {
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) };
}
}
}
return { error: 'not_found', available: items.map(i => i.name).filter(Boolean) };
})()`;
}
/**
* 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;
})()`;
}
/**
* 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;
})()`;
}
/**
* 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 + '"]').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;
}
}
}
return (result.balloon || result.messages || result.modal || result.confirmation) ? result : null;
})()`;
}
/**
* 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;
}
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._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;
})()`;
}