mirror of
https://github.com/Nikolay-Shirokov/cc-1c-skills.git
synced 2026-08-02 09:47:45 +03:00
feat(web-test): embed browser automation engine into skill
Move browser.mjs, dom.mjs, run.mjs from external 1c-web-client-mcp project into .claude/skills/web-test/scripts/. Now the skill is self-contained — copy .claude/skills/ + npm install is all that's needed. - Add scripts/package.json with playwright dependency - Update SKILL.md with relative runner path and setup section - Add node_modules/ and .browser-session.json to .gitignore Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.6
parent
ba963739d8
commit
c8f58b5461
@@ -22,19 +22,32 @@ Writes and runs automation scripts for 1C web client via Playwright.
|
|||||||
/web-test Проверь список контрагентов — прочитай таблицу
|
/web-test Проверь список контрагентов — прочитай таблицу
|
||||||
```
|
```
|
||||||
|
|
||||||
|
## Setup (first time)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd .claude/skills/web-test/scripts && npm install
|
||||||
|
```
|
||||||
|
|
||||||
|
Requires Node.js 18+. `npm install` downloads Playwright and Chromium browser.
|
||||||
|
|
||||||
## Workflow
|
## Workflow
|
||||||
|
|
||||||
Runner path: `C:\WS\tasks\1c-web-client-mcp\src\run.mjs`
|
Runner: `.claude/skills/web-test/scripts/run.mjs`
|
||||||
|
|
||||||
|
Use `RUN` shorthand in all commands:
|
||||||
|
```bash
|
||||||
|
RUN=".claude/skills/web-test/scripts/run.mjs"
|
||||||
|
```
|
||||||
|
|
||||||
### Interactive mode (step-by-step)
|
### Interactive mode (step-by-step)
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# 1. Start browser session — blocks, prints JSON when ready
|
# 1. Start browser session — blocks, prints JSON when ready
|
||||||
# Use run_in_background=true (Bash tool), then wait for "Browser ready"
|
# Use run_in_background=true (Bash tool), then wait for "Browser ready"
|
||||||
node src/run.mjs start <url>
|
node $RUN start <url>
|
||||||
|
|
||||||
# 2. Execute scripts — each returns JSON with results
|
# 2. Execute scripts — each returns JSON with results
|
||||||
cat <<'SCRIPT' | node src/run.mjs exec -
|
cat <<'SCRIPT' | node $RUN exec -
|
||||||
await navigateSection('Покупки');
|
await navigateSection('Покупки');
|
||||||
const form = await openCommand('Авансовые отчеты');
|
const form = await openCommand('Авансовые отчеты');
|
||||||
console.log(JSON.stringify(form.fields, null, 2));
|
console.log(JSON.stringify(form.fields, null, 2));
|
||||||
@@ -43,13 +56,13 @@ SCRIPT
|
|||||||
# 3. React to output, run more scripts...
|
# 3. React to output, run more scripts...
|
||||||
|
|
||||||
# 4. Screenshot anytime
|
# 4. Screenshot anytime
|
||||||
node src/run.mjs shot result.png
|
node $RUN shot result.png
|
||||||
|
|
||||||
# 5. Check session is alive
|
# 5. Check session is alive
|
||||||
node src/run.mjs status
|
node $RUN status
|
||||||
|
|
||||||
# 6. Stop when done (logout + close browser)
|
# 6. Stop when done (logout + close browser)
|
||||||
node src/run.mjs stop
|
node $RUN stop
|
||||||
```
|
```
|
||||||
|
|
||||||
`start` blocks forever (keeps browser alive). Run it in background, then use `exec`/`shot`/`stop` from other commands.
|
`start` blocks forever (keeps browser alive). Run it in background, then use `exec`/`shot`/`stop` from other commands.
|
||||||
@@ -59,9 +72,9 @@ node src/run.mjs stop
|
|||||||
Write `.mjs` script, run via exec:
|
Write `.mjs` script, run via exec:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
node src/run.mjs start <url> # in background
|
node $RUN start <url> # in background
|
||||||
node src/run.mjs exec test-scenario.mjs
|
node $RUN exec test-scenario.mjs
|
||||||
node src/run.mjs stop
|
node $RUN stop
|
||||||
```
|
```
|
||||||
|
|
||||||
## URL
|
## URL
|
||||||
@@ -234,7 +247,7 @@ writeFileSync('result.png', png);
|
|||||||
|
|
||||||
- **Headed mode** — 1C requires visible browser, no headless
|
- **Headed mode** — 1C requires visible browser, no headless
|
||||||
- **1C loads 30-60s** on initial connect (wait is built into `start`)
|
- **1C loads 30-60s** on initial connect (wait is built into `start`)
|
||||||
- **Fuzzy match** — all name lookups use fuzzy search (exact > includes)
|
- **Fuzzy match** — all name lookups use fuzzy search (exact > startsWith > includes)
|
||||||
- **errorModal** — if response contains `errorModal`, 1C showed an error dialog
|
- **errorModal** — if response contains `errorModal`, 1C showed an error dialog
|
||||||
- **Clipboard paste** — all fields filled via Ctrl+V (triggers 1C events properly)
|
- **Clipboard paste** — all fields filled via Ctrl+V (triggers 1C events properly)
|
||||||
- **Stdin pipe for Cyrillic** — use `cat <<'SCRIPT' | node src/run.mjs exec -` to avoid bash escaping
|
- **Stdin pipe for Cyrillic** — use `cat <<'SCRIPT' | node $RUN exec -` to avoid bash escaping
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,939 @@
|
|||||||
|
/**
|
||||||
|
* 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] >= 2) return maxForm;
|
||||||
|
}
|
||||||
|
return candidates.reduce((best, n) => counts[n] > counts[best] ? n : best);
|
||||||
|
}`;
|
||||||
|
|
||||||
|
/** 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;
|
||||||
|
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;
|
||||||
|
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();
|
||||||
|
if (!text) return;
|
||||||
|
buttons.push({ name: text, frame: 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);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Table/grid — pick the first VISIBLE grid (tab switching hides inactive grids)
|
||||||
|
const grid = [...document.querySelectorAll('[id^="' + p + '"].grid, [id^="' + p + '"] .grid')]
|
||||||
|
.find(g => g.offsetWidth > 0 && g.offsetHeight > 0);
|
||||||
|
if (grid) {
|
||||||
|
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);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
const rowCount = body ? body.querySelectorAll('.gridLine').length : 0;
|
||||||
|
result.table = { present: true, columns, 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 && /Строк[аи]Поиска/i.test(el.id));
|
||||||
|
if (searchInput?.value) {
|
||||||
|
filters.push({ type: 'search', value: searchInput.value });
|
||||||
|
}
|
||||||
|
if (filters.length) result.filters = filters;
|
||||||
|
|
||||||
|
// 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 (texts.length) result.texts = texts;
|
||||||
|
if (hyperlinks.length) result.hyperlinks = hyperlinks;
|
||||||
|
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, ' ') || '';
|
||||||
|
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, ' ') || '';
|
||||||
|
const target = ${JSON.stringify(name.toLowerCase())};
|
||||||
|
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)});
|
||||||
|
})()`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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 } = {}) {
|
||||||
|
const p = `form${formNum}_`;
|
||||||
|
return `(() => {
|
||||||
|
const p = ${JSON.stringify(p)};
|
||||||
|
const grid = [...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) 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 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}
|
||||||
|
${READ_FORM_FN}
|
||||||
|
const formNum = detectForm();
|
||||||
|
if (formNum === null) return { form: null, message: 'No form detected' };
|
||||||
|
const p = 'form' + formNum + '_';
|
||||||
|
const formData = readForm(p);
|
||||||
|
// Open tabs bar
|
||||||
|
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;
|
||||||
|
return { form: formNum, activeTab, ...formData };
|
||||||
|
})()`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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, ' ') || '';
|
||||||
|
const target = ${JSON.stringify(name.toLowerCase())};
|
||||||
|
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, ' ') || '';
|
||||||
|
const target = ${JSON.stringify(name.toLowerCase())};
|
||||||
|
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) {
|
||||||
|
const p = `form${formNum}_`;
|
||||||
|
return `(() => {
|
||||||
|
const norm = s => s?.trim().replace(/\\u00a0/g, ' ') || '';
|
||||||
|
const target = ${JSON.stringify(text.toLowerCase())};
|
||||||
|
const p = ${JSON.stringify(p)};
|
||||||
|
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);
|
||||||
|
if (!text) return;
|
||||||
|
items.push({ id: el.id, name: text, label: '', kind: 'frameButton' });
|
||||||
|
});
|
||||||
|
|
||||||
|
// 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 => {
|
||||||
|
items.push({ id: el.id, name: el.dataset.content, label: '', kind: 'tab' });
|
||||||
|
});
|
||||||
|
|
||||||
|
// Fuzzy match: exact name -> exact label -> includes name -> includes label
|
||||||
|
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().includes(target));
|
||||||
|
if (!found) found = items.find(i => i.label && i.label.toLowerCase().includes(target));
|
||||||
|
|
||||||
|
if (found) {
|
||||||
|
return { id: found.id, kind: found.kind, name: found.name };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Grid rows — fallback: search in table rows (for hierarchical/tree navigation)
|
||||||
|
const grid = document.querySelector('[id^="' + p + '"].grid');
|
||||||
|
if (grid) {
|
||||||
|
const body = grid.querySelector('.gridBody');
|
||||||
|
if (body) {
|
||||||
|
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 => b.innerText?.trim() || '').filter(Boolean);
|
||||||
|
const firstCell = rowTexts[0]?.toLowerCase() || '';
|
||||||
|
const rowText = rowTexts.join(' ').toLowerCase();
|
||||||
|
if (firstCell === target || rowText === target || 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 = imgBox?.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] || '',
|
||||||
|
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())};
|
||||||
|
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 });
|
||||||
|
});
|
||||||
|
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));
|
||||||
|
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 };
|
||||||
|
}
|
||||||
|
return { fieldName: found.name, buttonId: btnId, buttonType: suffix };
|
||||||
|
})()`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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, ' ') || '';
|
||||||
|
|
||||||
|
// 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())};
|
||||||
|
// 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. Confirmation dialog (#modalSurface + pressButton buttons)
|
||||||
|
const modalSurface = document.getElementById('modalSurface');
|
||||||
|
if (modalSurface && modalSurface.offsetWidth > 0) {
|
||||||
|
const pressButtons = [...document.querySelectorAll('a.press.pressButton')].filter(el => el.offsetWidth > 0);
|
||||||
|
if (pressButtons.length > 1) {
|
||||||
|
// Find the modal form: look for form{N}_Message staticText
|
||||||
|
let modalFormNum = null;
|
||||||
|
const allForms = new Set();
|
||||||
|
document.querySelectorAll('[id^="form"]').forEach(el => {
|
||||||
|
const m = el.id.match(/^form(\\d+)_/);
|
||||||
|
if (m) allForms.add(parseInt(m[1]));
|
||||||
|
});
|
||||||
|
const sortedForms = [...allForms].sort((a, b) => b - a); // highest first
|
||||||
|
for (const fn of sortedForms) {
|
||||||
|
const msgEl = document.getElementById('form' + fn + '_Message');
|
||||||
|
if (msgEl && msgEl.offsetWidth > 0) { modalFormNum = fn; break; }
|
||||||
|
}
|
||||||
|
const message = modalFormNum !== null
|
||||||
|
? (document.getElementById('form' + modalFormNum + '_Message')?.innerText?.trim() || '')
|
||||||
|
: '';
|
||||||
|
const buttons = pressButtons.map(el => {
|
||||||
|
const btn = { name: el.innerText?.trim() || '' };
|
||||||
|
if (el.classList.contains('pressDefault')) btn.default = true;
|
||||||
|
return btn;
|
||||||
|
}).filter(b => b.name);
|
||||||
|
result.confirmation = { message, buttons: buttons.map(b => b.name), formNum: modalFormNum };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4. Modal error dialog (high form number, pressDefault, few elements)
|
||||||
|
if (!result.confirmation) {
|
||||||
|
const defaults = [...document.querySelectorAll('a.press.pressDefault')].filter(el => el.offsetWidth > 0);
|
||||||
|
for (const btn of defaults) {
|
||||||
|
const m = btn.id.match(/^form(\\d+)_/);
|
||||||
|
if (!m) continue;
|
||||||
|
const formNum = parseInt(m[1]);
|
||||||
|
const p = 'form' + formNum + '_';
|
||||||
|
const elCount = document.querySelectorAll('[id^="' + p + '"]').length;
|
||||||
|
if (elCount > 20) continue;
|
||||||
|
const texts = [...document.querySelectorAll('[id^="' + p + '"].staticText')]
|
||||||
|
.filter(el => el.offsetWidth > 0)
|
||||||
|
.map(el => el.innerText?.trim())
|
||||||
|
.filter(Boolean);
|
||||||
|
if (texts.length > 0) {
|
||||||
|
const btnText = btn.innerText?.trim() || '';
|
||||||
|
result.modal = { message: texts.join(' '), formNum, button: btnText };
|
||||||
|
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;
|
||||||
|
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 });
|
||||||
|
});
|
||||||
|
|
||||||
|
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));
|
||||||
|
|
||||||
|
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;
|
||||||
|
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;
|
||||||
|
})()`;
|
||||||
|
}
|
||||||
+59
@@ -0,0 +1,59 @@
|
|||||||
|
{
|
||||||
|
"name": "1c-web-test",
|
||||||
|
"version": "0.2.0",
|
||||||
|
"lockfileVersion": 3,
|
||||||
|
"requires": true,
|
||||||
|
"packages": {
|
||||||
|
"": {
|
||||||
|
"name": "1c-web-test",
|
||||||
|
"version": "0.2.0",
|
||||||
|
"dependencies": {
|
||||||
|
"playwright": "^1.50.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/fsevents": {
|
||||||
|
"version": "2.3.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz",
|
||||||
|
"integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==",
|
||||||
|
"hasInstallScript": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"darwin"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/playwright": {
|
||||||
|
"version": "1.58.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/playwright/-/playwright-1.58.2.tgz",
|
||||||
|
"integrity": "sha512-vA30H8Nvkq/cPBnNw4Q8TWz1EJyqgpuinBcHET0YVJVFldr8JDNiU9LaWAE1KqSkRYazuaBhTpB5ZzShOezQ6A==",
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"dependencies": {
|
||||||
|
"playwright-core": "1.58.2"
|
||||||
|
},
|
||||||
|
"bin": {
|
||||||
|
"playwright": "cli.js"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
},
|
||||||
|
"optionalDependencies": {
|
||||||
|
"fsevents": "2.3.2"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/playwright-core": {
|
||||||
|
"version": "1.58.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.58.2.tgz",
|
||||||
|
"integrity": "sha512-yZkEtftgwS8CsfYo7nm0KE8jsvm6i/PTgVtB8DL726wNf6H2IMsDuxCpJj59KDaxCtSnrWan2AeDqM7JBaultg==",
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"bin": {
|
||||||
|
"playwright-core": "cli.js"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
{
|
||||||
|
"name": "1c-web-test",
|
||||||
|
"version": "0.2.0",
|
||||||
|
"private": true,
|
||||||
|
"type": "module",
|
||||||
|
"description": "Browser automation engine for 1C web client (Playwright)",
|
||||||
|
"dependencies": {
|
||||||
|
"playwright": "^1.50.0"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,264 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
/**
|
||||||
|
* CLI runner for 1C web client automation.
|
||||||
|
*
|
||||||
|
* Architecture: `start` launches browser + HTTP server in one process.
|
||||||
|
* `exec`, `shot`, `stop` send requests to the running server.
|
||||||
|
*
|
||||||
|
* Usage:
|
||||||
|
* node src/run.mjs start <url> — launch browser, connect to 1C, serve requests
|
||||||
|
* node src/run.mjs exec <file|-> — run script against existing session
|
||||||
|
* node src/run.mjs shot [file] — take screenshot
|
||||||
|
* node src/run.mjs stop — logout + close browser
|
||||||
|
* node src/run.mjs status — check session
|
||||||
|
*/
|
||||||
|
import http from 'http';
|
||||||
|
import * as browser from './browser.mjs';
|
||||||
|
import { readFileSync, writeFileSync, unlinkSync, existsSync } from 'fs';
|
||||||
|
import { resolve, dirname } from 'path';
|
||||||
|
import { fileURLToPath } from 'url';
|
||||||
|
|
||||||
|
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||||
|
const SESSION_FILE = resolve(__dirname, '..', '.browser-session.json');
|
||||||
|
|
||||||
|
const [,, cmd, ...args] = process.argv;
|
||||||
|
|
||||||
|
switch (cmd) {
|
||||||
|
case 'start': await cmdStart(args[0]); break;
|
||||||
|
case 'exec': await cmdExec(args[0]); break;
|
||||||
|
case 'shot': await cmdShot(args[0]); break;
|
||||||
|
case 'stop': await cmdStop(); break;
|
||||||
|
case 'status': cmdStatus(); break;
|
||||||
|
default: usage();
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// start: launch browser + HTTP server
|
||||||
|
// ============================================================
|
||||||
|
|
||||||
|
async function cmdStart(url) {
|
||||||
|
if (!url) die('Usage: node src/run.mjs start <url>');
|
||||||
|
|
||||||
|
// Connect to 1C
|
||||||
|
const state = await browser.connect(url);
|
||||||
|
|
||||||
|
// Start HTTP server for exec/shot/stop
|
||||||
|
const httpServer = http.createServer(handleRequest);
|
||||||
|
httpServer.listen(0, '127.0.0.1', () => {
|
||||||
|
const port = httpServer.address().port;
|
||||||
|
const session = {
|
||||||
|
port,
|
||||||
|
url,
|
||||||
|
pid: process.pid,
|
||||||
|
startedAt: new Date().toISOString()
|
||||||
|
};
|
||||||
|
writeFileSync(SESSION_FILE, JSON.stringify(session, null, 2));
|
||||||
|
out({ ok: true, message: 'Browser ready', port, ...state });
|
||||||
|
});
|
||||||
|
|
||||||
|
process.on('SIGINT', async () => {
|
||||||
|
await browser.disconnect();
|
||||||
|
cleanup();
|
||||||
|
process.exit(0);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleRequest(req, res) {
|
||||||
|
try {
|
||||||
|
if (req.method === 'POST' && req.url === '/exec') {
|
||||||
|
const code = await readBody(req);
|
||||||
|
const result = await executeScript(code);
|
||||||
|
json(res, result);
|
||||||
|
|
||||||
|
} else if (req.method === 'GET' && req.url === '/shot') {
|
||||||
|
const png = await browser.screenshot();
|
||||||
|
res.writeHead(200, { 'Content-Type': 'image/png' });
|
||||||
|
res.end(png);
|
||||||
|
|
||||||
|
} else if (req.method === 'POST' && req.url === '/stop') {
|
||||||
|
json(res, { ok: true, message: 'Stopping' });
|
||||||
|
await browser.disconnect();
|
||||||
|
cleanup();
|
||||||
|
process.exit(0);
|
||||||
|
|
||||||
|
} else if (req.method === 'GET' && req.url === '/status') {
|
||||||
|
json(res, { ok: true, connected: browser.isConnected() });
|
||||||
|
|
||||||
|
} else {
|
||||||
|
res.writeHead(404);
|
||||||
|
res.end('Not found');
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
json(res, { ok: false, error: e.message }, 500);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function executeScript(code) {
|
||||||
|
const output = [];
|
||||||
|
const origLog = console.log;
|
||||||
|
const origErr = console.error;
|
||||||
|
console.log = (...a) => output.push(a.map(String).join(' '));
|
||||||
|
console.error = (...a) => output.push('[ERR] ' + a.map(String).join(' '));
|
||||||
|
|
||||||
|
const t0 = Date.now();
|
||||||
|
try {
|
||||||
|
// Build sandbox: all browser.mjs exports + useful Node globals
|
||||||
|
const exports = {};
|
||||||
|
for (const [k, v] of Object.entries(browser)) {
|
||||||
|
if (k !== 'default') exports[k] = v;
|
||||||
|
}
|
||||||
|
exports.writeFileSync = writeFileSync;
|
||||||
|
exports.readFileSync = readFileSync;
|
||||||
|
|
||||||
|
const AsyncFunction = Object.getPrototypeOf(async function(){}).constructor;
|
||||||
|
const fn = new AsyncFunction(...Object.keys(exports), code);
|
||||||
|
await fn(...Object.values(exports));
|
||||||
|
|
||||||
|
console.log = origLog;
|
||||||
|
console.error = origErr;
|
||||||
|
return { ok: true, output: output.join('\n'), elapsed: elapsed(t0) };
|
||||||
|
} catch (e) {
|
||||||
|
console.log = origLog;
|
||||||
|
console.error = origErr;
|
||||||
|
|
||||||
|
// Error screenshot — save with absolute path so model can view it
|
||||||
|
let shotFile;
|
||||||
|
try {
|
||||||
|
const png = await browser.screenshot();
|
||||||
|
shotFile = resolve(__dirname, '..', 'error-shot.png');
|
||||||
|
writeFileSync(shotFile, png);
|
||||||
|
} catch {}
|
||||||
|
|
||||||
|
return { ok: false, error: e.message, output: output.join('\n'), screenshot: shotFile, elapsed: elapsed(t0) };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// exec: send script to running server
|
||||||
|
// ============================================================
|
||||||
|
|
||||||
|
async function cmdExec(fileOrDash) {
|
||||||
|
if (!fileOrDash) die('Usage: node src/run.mjs exec <file|->');
|
||||||
|
|
||||||
|
const code = fileOrDash === '-'
|
||||||
|
? await readStdin()
|
||||||
|
: readFileSync(resolve(fileOrDash), 'utf-8');
|
||||||
|
|
||||||
|
const sess = loadSession();
|
||||||
|
const resp = await fetch(`http://127.0.0.1:${sess.port}/exec`, {
|
||||||
|
method: 'POST',
|
||||||
|
body: code
|
||||||
|
});
|
||||||
|
const result = await resp.json();
|
||||||
|
out(result);
|
||||||
|
if (!result.ok) process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// shot: take screenshot via server
|
||||||
|
// ============================================================
|
||||||
|
|
||||||
|
async function cmdShot(file) {
|
||||||
|
const sess = loadSession();
|
||||||
|
const resp = await fetch(`http://127.0.0.1:${sess.port}/shot`);
|
||||||
|
if (!resp.ok) {
|
||||||
|
const err = await resp.text();
|
||||||
|
die(`Screenshot failed: ${err}`);
|
||||||
|
}
|
||||||
|
const buf = Buffer.from(await resp.arrayBuffer());
|
||||||
|
const outFile = file || 'shot.png';
|
||||||
|
writeFileSync(outFile, buf);
|
||||||
|
out({ ok: true, file: outFile });
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// stop: send stop to server
|
||||||
|
// ============================================================
|
||||||
|
|
||||||
|
async function cmdStop() {
|
||||||
|
const sess = loadSession();
|
||||||
|
try {
|
||||||
|
const resp = await fetch(`http://127.0.0.1:${sess.port}/stop`, { method: 'POST' });
|
||||||
|
const result = await resp.json();
|
||||||
|
out(result);
|
||||||
|
} catch {
|
||||||
|
// Server may have already exited before responding
|
||||||
|
out({ ok: true, message: 'Stopped' });
|
||||||
|
}
|
||||||
|
cleanup();
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// status: check session
|
||||||
|
// ============================================================
|
||||||
|
|
||||||
|
function cmdStatus() {
|
||||||
|
if (!existsSync(SESSION_FILE)) {
|
||||||
|
out({ ok: false, message: 'No active session' });
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
const sess = JSON.parse(readFileSync(SESSION_FILE, 'utf-8'));
|
||||||
|
out({ ok: true, ...sess });
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// helpers
|
||||||
|
// ============================================================
|
||||||
|
|
||||||
|
function loadSession() {
|
||||||
|
if (!existsSync(SESSION_FILE)) {
|
||||||
|
die('No active session. Run: node src/run.mjs start <url>');
|
||||||
|
}
|
||||||
|
return JSON.parse(readFileSync(SESSION_FILE, 'utf-8'));
|
||||||
|
}
|
||||||
|
|
||||||
|
function cleanup() {
|
||||||
|
try { unlinkSync(SESSION_FILE); } catch {}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function readBody(req) {
|
||||||
|
const chunks = [];
|
||||||
|
for await (const chunk of req) chunks.push(chunk);
|
||||||
|
return Buffer.concat(chunks).toString('utf-8');
|
||||||
|
}
|
||||||
|
|
||||||
|
async function readStdin() {
|
||||||
|
const chunks = [];
|
||||||
|
for await (const chunk of process.stdin) chunks.push(chunk);
|
||||||
|
return Buffer.concat(chunks).toString('utf-8');
|
||||||
|
}
|
||||||
|
|
||||||
|
function elapsed(t0) {
|
||||||
|
return Math.round((Date.now() - t0) / 100) / 10;
|
||||||
|
}
|
||||||
|
|
||||||
|
function json(res, obj, status = 200) {
|
||||||
|
res.writeHead(status, { 'Content-Type': 'application/json' });
|
||||||
|
res.end(JSON.stringify(obj, null, 2));
|
||||||
|
}
|
||||||
|
|
||||||
|
function out(obj) {
|
||||||
|
process.stdout.write(JSON.stringify(obj, null, 2) + '\n');
|
||||||
|
}
|
||||||
|
|
||||||
|
function die(msg) {
|
||||||
|
process.stderr.write(msg + '\n');
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
function usage() {
|
||||||
|
die(`Usage: node src/run.mjs <command> [args]
|
||||||
|
|
||||||
|
Commands:
|
||||||
|
start <url> Launch browser and connect to 1C web client
|
||||||
|
exec <file|-> Execute script (file path or - for stdin)
|
||||||
|
shot [file] Take screenshot (default: shot.png)
|
||||||
|
stop Logout and close browser
|
||||||
|
status Check session status`);
|
||||||
|
}
|
||||||
@@ -18,3 +18,7 @@ tools/
|
|||||||
|
|
||||||
# Локальный реестр баз данных 1С
|
# Локальный реестр баз данных 1С
|
||||||
.v8-project.json
|
.v8-project.json
|
||||||
|
|
||||||
|
# web-test: Node.js зависимости и runtime
|
||||||
|
.claude/skills/web-test/scripts/node_modules/
|
||||||
|
.claude/skills/web-test/.browser-session.json
|
||||||
|
|||||||
Reference in New Issue
Block a user