mirror of
https://github.com/Nikolay-Shirokov/cc-1c-skills.git
synced 2026-06-26 06:54:38 +03:00
Auto-build: copilot (python) from c147fd5
This commit is contained in:
@@ -0,0 +1,4 @@
|
||||
scripts/node_modules/
|
||||
.browser-session.json
|
||||
*.png
|
||||
*.mp4
|
||||
@@ -0,0 +1,579 @@
|
||||
---
|
||||
name: web-test
|
||||
description: Тестирование 1С через веб-клиент — автоматизация действий в браузере. Используй когда пользователь просит проверить, протестировать, автоматизировать действия в 1С через браузер
|
||||
argument-hint: "сценарий на естественном языке"
|
||||
allowed-tools:
|
||||
- Bash
|
||||
- Read
|
||||
- Write
|
||||
- Glob
|
||||
- Grep
|
||||
---
|
||||
|
||||
# /web-test — Browser automation for 1C web client
|
||||
|
||||
Automates user interactions with 1C:Enterprise web client via Playwright — navigating sections, filling forms, reading tables and reports, filtering lists.
|
||||
|
||||
## Quick start
|
||||
|
||||
```bash
|
||||
RUN=".github/skills/web-test/scripts/run.mjs"
|
||||
|
||||
# One-shot: opens browser → runs script → closes browser → exits
|
||||
node $RUN run http://localhost:8081/bpdemo test-scenario.js
|
||||
|
||||
# Or pipe inline:
|
||||
cat <<'SCRIPT' | node $RUN run http://localhost:8081/bpdemo -
|
||||
await navigateSection('Продажи');
|
||||
await openCommand('Заказы клиентов');
|
||||
await clickElement('Создать');
|
||||
await fillFields({ 'Клиент': 'Альфа' });
|
||||
await clickElement('Провести и закрыть');
|
||||
SCRIPT
|
||||
```
|
||||
|
||||
## Setup (first time)
|
||||
|
||||
```bash
|
||||
cd ".github/skills/web-test/scripts" && npm install
|
||||
```
|
||||
|
||||
Requires Node.js 18+. `npm install` downloads Playwright and Chromium.
|
||||
|
||||
## URL resolution
|
||||
|
||||
Read `.v8-project.json` from project root. Each database has `id` and optional `webUrl`.
|
||||
Construct URL as `http://localhost:8081/<id>` or use `webUrl` if set.
|
||||
Use `/web-publish` first if the database is not published.
|
||||
|
||||
## Execution modes
|
||||
|
||||
### Autonomous mode (preferred for complete scenarios)
|
||||
|
||||
```bash
|
||||
node $RUN run <url> script.js # exits when done, no session
|
||||
```
|
||||
|
||||
### Interactive mode (step-by-step development)
|
||||
|
||||
```bash
|
||||
# 1. Start session (run_in_background=true, prints JSON when ready)
|
||||
node $RUN start <url>
|
||||
|
||||
# 2. Execute scripts against running session
|
||||
cat <<'SCRIPT' | node $RUN exec -
|
||||
const form = await getFormState();
|
||||
console.log(JSON.stringify(form, null, 2));
|
||||
SCRIPT
|
||||
|
||||
# 2b. Execute without video recording (for debugging/testing)
|
||||
cat script.js | node $RUN exec - --no-record
|
||||
|
||||
# 2c. Override exec HTTP timeout (default 30 min). Use for long scripts
|
||||
# such as multi-block recordings + addNarration.
|
||||
cat script.js | node $RUN exec - --timeout-min=120
|
||||
cat script.js | node $RUN exec - --timeout=7200000
|
||||
WEB_TEST_EXEC_TIMEOUT_MS=7200000 node $RUN exec script.js
|
||||
|
||||
# 3. Screenshot
|
||||
node $RUN shot result.png
|
||||
|
||||
# 4. Stop (logout + close)
|
||||
node $RUN stop
|
||||
```
|
||||
|
||||
`start` runs an HTTP server in background. Use `exec`/`shot`/`stop` from other shells.
|
||||
|
||||
### Writing exec scripts
|
||||
|
||||
All browser.mjs exports are globals — no `import` needed.
|
||||
`console.log()` output is captured in the JSON response.
|
||||
`writeFileSync` / `readFileSync` also available.
|
||||
|
||||
## API reference
|
||||
|
||||
### Navigation
|
||||
|
||||
#### `navigateSection(name)` → `{ navigated, sections, commands }`
|
||||
Go to a top-level section (fuzzy match). Returns list of commands in that section.
|
||||
```js
|
||||
await navigateSection('Продажи');
|
||||
// { navigated: 'Продажи', sections: [...], commands: ['Заказы клиентов', ...] }
|
||||
```
|
||||
|
||||
#### `openCommand(name)` → form state
|
||||
Open a command from the function panel (fuzzy). Returns form state of the opened form.
|
||||
```js
|
||||
const form = await openCommand('Заказы клиентов');
|
||||
```
|
||||
|
||||
#### `navigateLink(url)` → form state
|
||||
Open any 1C object by metadata path (Shift+F11 dialog). Bypasses section/command navigation.
|
||||
```js
|
||||
await navigateLink('Документ.ЗаказКлиента');
|
||||
await navigateLink('РегистрНакопления.ЗаказыКлиентов');
|
||||
await navigateLink('Справочник.Контрагенты');
|
||||
```
|
||||
|
||||
#### `openFile(path)` → form state
|
||||
Open an external data processor or report (EPF/ERF) via File → Open. Handles the security confirmation dialog automatically.
|
||||
```js
|
||||
const form = await openFile('C:\\WS\\build\\МояОбработка.epf');
|
||||
const form = await openFile('build/МояОбработка.epf'); // relative paths work too
|
||||
```
|
||||
|
||||
#### `switchTab(name)` → form state
|
||||
Switch to an already-open tab/window (fuzzy match).
|
||||
|
||||
### Reading form state
|
||||
|
||||
#### `getFormState()` → `{ form, formCount, openForms, fields, buttons, tabs, navigation?, table, tables, filters, reportSettings? }`
|
||||
Returns current form structure. This is the primary way to understand what's on screen.
|
||||
|
||||
**form** — active form number, or `null` when no form is open (desktop).
|
||||
|
||||
**formCount** — number of open forms. Use this to know how many windows are stacked. `0` means desktop.
|
||||
|
||||
**openForms** — array of all open form numbers (e.g. `[0, 1]`). Works even when the open-windows tab bar is hidden in 1C settings.
|
||||
|
||||
**modal** — `true` when the active form is a modal dialog blocking the UI. Only present when modal is active.
|
||||
|
||||
**openTabs** — array of `{ name, active? }` from the open-windows tab bar. Only present when the tab bar is enabled in 1C settings. Do NOT rely on this — use `formCount`/`openForms` instead.
|
||||
|
||||
**fields** — each field has: `name`, `value`, `label?`, `actions?` (select, clear, open), `required?` (true for unfilled mandatory fields)
|
||||
|
||||
**navigation** — form navigation panel links (for objects with subordinate catalogs): `[{ name, active? }]`. Clickable via `clickElement()`. Only present when the form has a navigation panel (e.g. "Основное", "Объекты метаданных", "Подсистемы").
|
||||
|
||||
**tables** — array of all visible grids: `[{ name, columns, rowCount, label? }]`. `label` is the visual group title shown on screen (e.g. "Входящие"), absent when grid has no visible title. Use `readTable()` for actual data.
|
||||
|
||||
**table** — backward-compatible alias for the first grid: `{ present, columns, rowCount }`.
|
||||
|
||||
**reportSettings** — for DCS reports: human-readable filter settings instead of raw technical names:
|
||||
```js
|
||||
const form = await getFormState();
|
||||
// form.reportSettings = [
|
||||
// { name: "Склад", enabled: true, value: "Склад бытовой техники", actions: ["select"] },
|
||||
// { name: "Номенклатура", enabled: false, value: "" }
|
||||
// ]
|
||||
```
|
||||
|
||||
**errorModal** — if present, 1C showed an error dialog. Read the message and decide how to proceed.
|
||||
|
||||
**confirmation** — if present, a Yes/No dialog is shown. Call `clickElement('Да')` or `clickElement('Нет')`.
|
||||
|
||||
**errors.stateText** — array of SpreadsheetDocument state messages (e.g. `"Не установлено значение параметра \"X\""`, `"Отчет не сформирован..."`, `"Изменились настройки..."`). Present when the report area shows an info bar instead of data.
|
||||
|
||||
### Reading data
|
||||
|
||||
#### `readTable({ maxRows?, offset?, table? })` → `{ columns, rows, total, shown, offset, hasMore }`
|
||||
Read actual grid data with pagination. Each row is `{ columnName: value }`.
|
||||
|
||||
| Option | Default | Description |
|
||||
|--------|---------|-------------|
|
||||
| `maxRows` | 20 | Max rows to return per call |
|
||||
| `offset` | 0 | Skip first N rows |
|
||||
| `table` | — | Grid name from `tables[]` (for multi-grid forms) |
|
||||
|
||||
**Picture columns.** Cells that render an icon (status/stage marks, the "ЭДО" mark, the attached-files paperclip) read as `'pic:<N>'` (`N` = icon frame/state) when shown, `''` when absent — so presence is truthy and icons differ by index. Icon-only columns (no header text) still appear, named by their tooltip or `'(picture)'`. These values are read-only — filter/select rows by a text column, not by `'pic:N'`.
|
||||
```js
|
||||
if (t.rows[0]['Присоединенные файлы']) { /* has an attached file */ }
|
||||
t.rows[0]['ЭДО'] === 'pic:1'; // connected to 1С-ЭДО ('pic:0' = not)
|
||||
```
|
||||
|
||||
Special row fields:
|
||||
- `_kind: 'group'` — hierarchical group row
|
||||
- `_kind: 'parent'` — parent row in hierarchy
|
||||
- `_tree: 'expanded'|'collapsed'` — tree node state
|
||||
- `_level: N` — nesting depth in tree view
|
||||
- `_selected: true` — row is selected (highlighted). Use with `clickElement({ modifier: 'ctrl'|'shift' })` to verify multi-selection
|
||||
- `hierarchical: true` — list has groups (on result object)
|
||||
- `viewMode: 'tree'` — tree view active (on result object)
|
||||
|
||||
**`total` is misleading for long lists.** 1С virtualizes both dynamic lists and form tabular sections — the DOM holds only a window of visible rows. `total` / `shown` count what's *loaded right now*, not the size of the underlying collection. Use **`hasMore`** to know if there's more data outside the window:
|
||||
|
||||
```js
|
||||
const t = await readTable();
|
||||
// t.hasMore = { above: false, below: true } ← form tabular section, scrollbar visible
|
||||
// t.hasMore = { below: true } ← dynamic list (catalog/journal/register)
|
||||
// t.hasMore = { below: false } ← everything visible / end of list reached
|
||||
```
|
||||
|
||||
- `hasMore.below` — always present. `true` ⇒ scrolling down (PageDown / `clickElement` with `scroll:true`) will reveal more rows.
|
||||
- `hasMore.above` — usually present too. Detected via the dynamic-list page-turn buttons (#vertButtonScroll) or the tabular-section scrollbar. Absent only for rare grids that have neither widget — treat absence as unknown.
|
||||
|
||||
```js
|
||||
const t = await readTable({ maxRows: 50 });
|
||||
console.log('Columns:', t.columns);
|
||||
console.log('Loaded:', t.shown, 'rows; more below:', t.hasMore.below);
|
||||
// Pagination:
|
||||
const page2 = await readTable({ maxRows: 50, offset: 50 });
|
||||
```
|
||||
|
||||
#### `readSpreadsheet()` → `{ title?, headers?, data?, totals?, rows?, total }`
|
||||
Read report output (SpreadsheetDocument) after clicking "Сформировать".
|
||||
|
||||
Returns structured data when header row is detected:
|
||||
```js
|
||||
await clickElement('Сформировать');
|
||||
await wait(5);
|
||||
const report = await readSpreadsheet();
|
||||
// { title: "Остатки товаров", headers: ["Номенклатура", "Склад", "Количество"],
|
||||
// data: [{ "Номенклатура": "Бумага", "Склад": "Основной", "Количество": "150" }, ...],
|
||||
// totals: { "Количество": "1250" }, total: 42 }
|
||||
```
|
||||
|
||||
Falls back to `{ rows: string[][], total }` when headers can't be detected.
|
||||
|
||||
#### `getSections()` → `{ activeSection, sections, commands }`
|
||||
Read section panel and commands without navigating.
|
||||
|
||||
#### `getCommands()` → `string[]`
|
||||
Commands of the current section.
|
||||
|
||||
#### `getPageState()` → `{ activeSection, activeTab, sections, tabs }`
|
||||
Sections + all open tabs.
|
||||
|
||||
### Actions
|
||||
|
||||
**Return shape convention.** All action functions return a **flat form state** (same shape as `getFormState()`) with action-specific extras: `clicked`, `focused`, `selected`, `filled`, `notFilled`, `closed`, `opened`, `navigated`, `deleted`, `filtered`, `unfiltered`. Errors always sit at the top level under `.errors` (when present) — the exec-wrapper automatically throws on `.errors.modal` / `.errors.balloon`.
|
||||
|
||||
#### `clickElement(text, { dblclick?, table?, expand?, modifier?, scroll? })` → form state
|
||||
Click button, hyperlink, tab, navigation panel link, or grid row (fuzzy match).
|
||||
|
||||
- `table` — scope button search to a specific grid's command panel (by name from `tables[]`):
|
||||
```js
|
||||
await clickElement('Добавить', { table: 'Исходящие' }); // clicks "Добавить" near "Исходящие" grid
|
||||
```
|
||||
- Single click selects a row in a list. **Double-click opens** the item:
|
||||
```js
|
||||
await clickElement('0000-000227', { dblclick: true }); // opens document
|
||||
```
|
||||
- Returns `submenu[]` when a menu opens — click again with item name:
|
||||
```js
|
||||
const r = await clickElement('Ещё');
|
||||
// r.submenu = ['Расширенный поиск', 'Настройки', ...]
|
||||
await clickElement('Расширенный поиск');
|
||||
```
|
||||
- **Tree nodes**: default click = **select** (highlight row). Use `{ expand: true }` to **expand/collapse**:
|
||||
```js
|
||||
await clickElement('ИСУ ФХД'); // select row
|
||||
await clickElement('ИСУ ФХД', { expand: true }); // expand/collapse
|
||||
```
|
||||
- **Focus a field** (last resort, when no `table` given): if `text` matches no clickable control but matches a form field's name/label, clicks the input to focus it **without changing its value**. Returns `focused: { field, id, ok }` (`ok: false` if the field couldn't take focus). Use it to drive focus-dependent keys:
|
||||
```js
|
||||
await clickElement('Контрагент'); // focus the reference field
|
||||
await getPage().keyboard.press('F4'); // open its selection form
|
||||
```
|
||||
- **Multi-select rows** with `modifier: 'ctrl'` (add to selection) or `modifier: 'shift'` (select range):
|
||||
```js
|
||||
await clickElement('Номенклатура 1'); // select first row
|
||||
await clickElement('Номенклатура 2', { modifier: 'ctrl' }); // add to selection
|
||||
await clickElement('Номенклатура 5', { modifier: 'shift' }); // select range 2..5
|
||||
// Verify selection:
|
||||
const t = await readTable();
|
||||
t.rows.filter(r => r._selected); // rows with _selected: true
|
||||
```
|
||||
- **Cell click by (row, column)** — first argument as `{ row, column }`. Routes: spreadsheet on form → spreadsheet drill-down; otherwise → grid cell. Pass `table: 'GridName'` to force a specific grid when both are present.
|
||||
|
||||
Spreadsheet report drill-down:
|
||||
```js
|
||||
const report = await readSpreadsheet();
|
||||
// report.data[0] = { 'К1': 'Материалы строительные', 'К6': '150 000', ... }
|
||||
await clickElement({ row: 0, column: 'К6' }, { dblclick: true }); // by index
|
||||
await clickElement({ row: { 'К1': 'Материалы' }, column: 'К6' }, { dblclick: true }); // by filter
|
||||
await clickElement({ row: 'totals', column: 'К6' }, { dblclick: true }); // totals row
|
||||
await clickElement('150 000', { dblclick: true }); // fallback: by text
|
||||
```
|
||||
|
||||
Form grid cell (catalog list, journal, table part). Off-viewport columns auto-scroll horizontally (works around frozen columns). Use `scroll: true | number` for filter-based rows outside the current DOM window:
|
||||
```js
|
||||
await clickElement({ row: 0, column: 'Количество' }, { table: 'Товары', dblclick: true });
|
||||
await clickElement({ row: { 'Номенклатура': 'Бумага' }, column: 'Цена' }, { table: 'Товары' });
|
||||
await clickElement({ row: { 'Номер': '0000-000601' }, column: 'Сумма' },
|
||||
{ table: 'Реализации', scroll: true }); // PageDown loop, max 50
|
||||
```
|
||||
|
||||
Gotchas:
|
||||
- `row: <number>` is the index in the **current DOM window**, not absolute — 1С virtualizes long lists. `row: 0` is the topmost loaded row after any prior scroll. For arbitrary rows in a long list use `row: { col: val }` + `scroll: true`.
|
||||
- `scroll: true` walks **down only** (PageDown). For going up first press `Home` via `getPage().keyboard` or narrow with `filterList`.
|
||||
- First matching row wins on duplicate filter matches — refine the filter to disambiguate.
|
||||
|
||||
#### `fillFields({ name: value })` → form state with `filled`
|
||||
Fill form fields by label (fuzzy match). Auto-detects field type.
|
||||
|
||||
| Value | Field type | Method |
|
||||
|-------|-----------|--------|
|
||||
| `'Конфетпром'` | Reference | Clipboard paste + typeahead |
|
||||
| `'5000'` | Plain text | Clipboard paste |
|
||||
| `'true'` / `'да'` | Checkbox | Toggle |
|
||||
| `'Оплата поставщику'` | Radio | Fuzzy label match |
|
||||
| `''` / `null` | Any (except checkbox/radio) | Clear via Shift+F4 |
|
||||
|
||||
**DCS report filters**: use human-readable label names. Checkbox is auto-enabled:
|
||||
```js
|
||||
await fillFields({
|
||||
'Склад': 'Склад бытовой техники', // auto-enables "Склад" checkbox + fills value
|
||||
'Номенклатура': 'Вентилятор' // same: enables checkbox + fills
|
||||
});
|
||||
```
|
||||
|
||||
Returns form state with `filled: [{ field, ok: true, value, method }]` (method: `clear`|`toggle`|`radio`|`paste`|`dropdown`|`form`|`typeahead`). **Throws on any per-field failure** with a detailed message listing problematic fields and available options — if the call returned, all fields were filled, no per-item check needed.
|
||||
|
||||
#### `selectValue(field, search, opts?)` → form state with `selected`
|
||||
Select a value from reference field via dropdown or selection form. More reliable than `fillFields` for reference fields that need exact selection from a catalog. Pass empty `search` (`''` or `null`) to clear the field (Shift+F4).
|
||||
|
||||
`search` — string for simple search, or `{ field: value }` object for per-field advanced search:
|
||||
```js
|
||||
await selectValue('Организация', 'Конфетпром');
|
||||
// result.selected = { field: 'Организация', search: 'Конфетпром', method: 'dropdown'|'form' }
|
||||
|
||||
// Per-field search (disambiguate by multiple columns):
|
||||
await selectValue('Документ', { 'Номер': '0000-000601', 'Дата': '29.12.2016' }, { type: 'Реализация (акт' });
|
||||
```
|
||||
|
||||
For **composite-type fields** (accepting multiple types), specify `type` to first select the type, then the value:
|
||||
```js
|
||||
await selectValue('Документ', '0000-000601', { type: 'Реализация (акт' });
|
||||
// Clears field → opens type dialog → picks type via Ctrl+F → picks value from selection form
|
||||
// result.selected = { field: 'Документ', search: '0000-000601', type: 'Реализация (акт', method: 'form' }
|
||||
```
|
||||
|
||||
Also supports DCS labels — auto-enables the paired checkbox.
|
||||
|
||||
#### `fillTableRow(fields, opts)` → form state with `filled` (+ optional `notFilled`)
|
||||
Fill table row cells via Tab navigation. Value is a plain string, `{ value, type }` for composite-type cells, or `''`/`null` to clear (Shift+F4).
|
||||
|
||||
Returns form state with `filled: [{ field, ok, ...}]`. Items are `{ field, ok: true, method, value }` on success (method: `direct`|`paste`|`dropdown`|`form`|`type-direct`|`skip`|`clear`|`toggle`) or `{ field, ok: false, error, message }` on per-field failure. Unmatched fields → `notFilled: [...]`.
|
||||
|
||||
**Unlike `fillFields`, `fillTableRow` does NOT throw on per-field failures** — errors appear as `ok: false` items in `filled[]` so the caller can react selectively (e.g. retry one cell while the rest of the row stays filled). Check via `r.filled.filter(f => !f.ok)`. Error codes: `composite_type`/`type_required`/`type_dialog_failed` (retry with `{value, type}`); `column_not_found` (check column name via `readTable`); `no_selection_form`/`no_selection_after_type` (retry or fall back to `selectValue`); `not_found`/`no_match`/`ambiguous` (refine search text); `still_open` (picked a group — pick a leaf row). Soft validation errors from 1C (`balloon`, `modal`) still throw via the exec-wrapper.
|
||||
|
||||
| Option | Description |
|
||||
|--------|-------------|
|
||||
| `tab` | Switch to tab before filling |
|
||||
| `add` | Add new row before filling |
|
||||
| `row` | Edit existing row: 0-based index, **or** a `{ col: value }` filter (one or more columns) to locate the row by its cell values |
|
||||
| `scroll` | With a `row` filter — scan beyond the current DOM window (`true` = up to 50 PageDowns, number = limit) |
|
||||
| `table` | Grid name from `tables[]` (for multi-grid forms) |
|
||||
|
||||
```js
|
||||
// Add new row:
|
||||
await fillTableRow(
|
||||
{ 'Номенклатура': 'Бумага', 'Количество': '10', 'Цена': '100' },
|
||||
{ tab: 'Товары', add: true }
|
||||
);
|
||||
// Edit existing row by index:
|
||||
await fillTableRow(
|
||||
{ 'Количество': '20' },
|
||||
{ tab: 'Товары', row: 0 }
|
||||
);
|
||||
// Edit existing row located by cell values (одна или несколько колонок):
|
||||
await fillTableRow({ 'Цена': '120' }, { table: 'Товары', row: { 'Номенклатура': 'Бумага' } });
|
||||
await fillTableRow({ 'Сумма': '500' }, { row: { 'Номер': '0000-000601', 'Дата': '29.12.2016' }, scroll: true });
|
||||
// Multi-grid form — add row to specific table:
|
||||
await fillTableRow(
|
||||
{ 'Объект': 'БДДС' },
|
||||
{ table: 'Исходящие', add: true }
|
||||
);
|
||||
// Composite-type cell (e.g. SubConto accepting multiple types):
|
||||
await fillTableRow(
|
||||
{ 'СубконтоКт1': { value: 'Голованов', type: 'Физическое лицо' } },
|
||||
{ tab: 'Проводки' }
|
||||
);
|
||||
```
|
||||
|
||||
- Tab-based sequential navigation — field order set by 1C form config
|
||||
- Fuzzy cell match: "Количество" matches "ТоварыКоличество"
|
||||
- Reference cells auto-detected by autocomplete popup
|
||||
|
||||
#### `deleteTableRow(row, { tab?, table? })` → form state
|
||||
Delete row by 0-based index. `table` targets a specific grid on multi-grid forms.
|
||||
|
||||
#### `closeForm({ save? })` → form state with `closed`
|
||||
Close the current form via Escape. Returns form state with `closed: true/false` indicating whether the form actually closed.
|
||||
|
||||
| Argument | Behavior |
|
||||
|----------|----------|
|
||||
| `{ save: false }` | Auto-clicks "Нет" on confirmation |
|
||||
| `{ save: true }` | Auto-clicks "Да" on confirmation |
|
||||
| `{}` (omitted) | Returns `confirmation` field if dialog appears |
|
||||
|
||||
**`closed`** — `true` if the form was closed (form number changed), `false` if it stayed open (e.g. Escape was ignored). Always check this to confirm the form actually closed. After closing, check `formCount` to see how many forms remain.
|
||||
|
||||
Preferred over `clickElement('×')` — close buttons on tabs are ambiguous.
|
||||
|
||||
#### `filterList(text, opts?)` → form state
|
||||
Filter list. Simple mode searches all columns, advanced mode targets a specific field.
|
||||
|
||||
```js
|
||||
await filterList('КП00-000018'); // simple — all columns
|
||||
await filterList('Мишка', { field: 'Наименование' }); // advanced — specific column
|
||||
await filterList('Мишка', { field: 'Наименование', exact: true }); // exact match
|
||||
```
|
||||
|
||||
Works on hierarchical catalogs too (flattens the view).
|
||||
|
||||
#### `unfilterList({ field? })` → form state
|
||||
Clear filters. Without arguments clears all, with `{ field }` clears specific badge.
|
||||
|
||||
### Utility
|
||||
|
||||
#### `screenshot()` → PNG Buffer
|
||||
#### `wait(seconds)` → form state
|
||||
#### `getPage()` → Playwright Page (raw, for advanced scripting)
|
||||
#### `startRecording(path, opts?)` / `stopRecording()` → MP4 video recording (`{ force: true }` to restart if already recording)
|
||||
#### `showCaption(text, opts?)` / `hideCaption()` → text overlay on page
|
||||
#### `showTitleSlide(text, opts?)` / `hideTitleSlide()` → full-screen title card (intro/outro)
|
||||
#### `isRecording()` → boolean
|
||||
#### `setHighlight(on)` / `isHighlightMode()` → auto-highlight mode for video
|
||||
#### `highlight(text)` / `unhighlight()` → manual element highlighting (error lists available elements)
|
||||
#### `addNarration(videoPath, opts?)` → narrated MP4 with TTS voiceover
|
||||
#### `getCaptions()` → caption timestamps from last recording
|
||||
|
||||
See [recording.md](recording.md) for setup (ffmpeg), highlight mode, TTS narration, API details, and examples.
|
||||
If `.v8-project.json` has `ffmpegPath`, pass it to `startRecording({ ffmpegPath })`.
|
||||
If `.v8-project.json` has `tts` config, pass it to `addNarration()` (provider, voice, apiKey).
|
||||
|
||||
## Common patterns
|
||||
|
||||
### Create and save a document
|
||||
|
||||
```js
|
||||
await navigateSection('Продажи');
|
||||
await openCommand('Заказы клиентов');
|
||||
await clickElement('Создать');
|
||||
await fillFields({ 'Организация': 'Конфетпром', 'Контрагент': 'Альфа' });
|
||||
await fillTableRow({ 'Номенклатура': 'Бумага', 'Количество': '10' }, { tab: 'Товары', add: true });
|
||||
await clickElement('Провести и закрыть');
|
||||
```
|
||||
|
||||
### Open item from list
|
||||
|
||||
```js
|
||||
await clickElement('КП00-000227', { dblclick: true });
|
||||
// Always use { dblclick: true } — single click only selects the row
|
||||
```
|
||||
|
||||
### Work with hierarchical lists
|
||||
|
||||
```js
|
||||
await filterList('Конфетпром'); // flatten + search
|
||||
await clickElement('Конфетпром ООО', { dblclick: true }); // open
|
||||
await closeForm();
|
||||
await unfilterList(); // restore hierarchy
|
||||
```
|
||||
|
||||
### Generate and read a report
|
||||
|
||||
```js
|
||||
// Fill report filters using readable labels
|
||||
await fillFields({ 'Склад': 'Основной склад' });
|
||||
await clickElement('Сформировать');
|
||||
await wait(5);
|
||||
const report = await readSpreadsheet();
|
||||
console.log('Title:', report.title);
|
||||
console.log('Data rows:', report.data?.length);
|
||||
```
|
||||
|
||||
### Drill-down report cells
|
||||
|
||||
```js
|
||||
// Generate report
|
||||
await clickElement('Сформировать');
|
||||
await wait(5);
|
||||
const report = await readSpreadsheet();
|
||||
|
||||
// Double-click cell to open drill-down (uses coordinates from readSpreadsheet)
|
||||
await clickElement({ row: 0, column: 'К6' }, { dblclick: true });
|
||||
// Modal dialog "Выбор поля" opens
|
||||
await clickElement('Регистратор');
|
||||
await clickElement('Выбрать');
|
||||
await wait(10);
|
||||
const drilldown = await readSpreadsheet();
|
||||
```
|
||||
|
||||
### Work with multi-grid forms
|
||||
|
||||
Some forms have multiple grids (e.g. "Входящие" and "Исходящие" tables on a single form). Without `table`, buttons like "Добавить" hit the first match and `readTable` reads the first grid — which may not be the one you need.
|
||||
|
||||
**Step 1 — discover tables** via `getFormState()`:
|
||||
```js
|
||||
const form = await getFormState();
|
||||
// form.tables = [
|
||||
// { name: "ДеревоБизнесПроцессов", columns: ["Полный код", "Бизнес-процесс"], rowCount: 21 },
|
||||
// { name: "Входящие", label: "Входящие", columns: ["Объект", "Бизнес-процесс источник", ...], rowCount: 1 },
|
||||
// { name: "Исходящие", label: "Исходящие", columns: ["Объект", "Бизнес-процесс приемник", ...], rowCount: 1 }
|
||||
// ]
|
||||
```
|
||||
|
||||
**Step 2 — use `table` name** in any grid operation:
|
||||
```js
|
||||
// Read specific table
|
||||
const t = await readTable({ table: 'Исходящие' });
|
||||
|
||||
// Add row — fillTableRow with add:true already clicks the right "Добавить" button
|
||||
await fillTableRow({ 'Объект': 'БДДС' }, { table: 'Исходящие', add: true });
|
||||
|
||||
// Or click buttons separately
|
||||
await clickElement('Добавить', { table: 'Входящие' });
|
||||
|
||||
// Delete from specific table
|
||||
await deleteTableRow(0, { table: 'Исходящие' });
|
||||
```
|
||||
|
||||
Table matching accepts both technical name (`tables[].name`) and visual label (`tables[].label`). Label is the group title shown on screen — useful when working from screenshots. Name match takes priority over label match.
|
||||
|
||||
### Keyboard shortcuts
|
||||
|
||||
```js
|
||||
const page = await getPage();
|
||||
await page.keyboard.press('F8'); // example: create new item in focused reference field
|
||||
```
|
||||
|
||||
| Key | Context | Action |
|
||||
|-----|---------|--------|
|
||||
| `F8` | Reference field focused | Create new catalog item |
|
||||
| `Shift+F4` | Any input field focused | Clear field value (auto via `''`/`null` in fillFields/selectValue/fillTableRow) |
|
||||
| `F4` | Reference field focused | Open selection form |
|
||||
| `Alt+F` | List/table form | Open advanced search dialog |
|
||||
|
||||
### Closing forms — which method to use
|
||||
|
||||
| Goal | Method |
|
||||
|------|--------|
|
||||
| Post & close document | `clickElement('Провести и закрыть')` |
|
||||
| Save & close catalog | `clickElement('Записать и закрыть')` |
|
||||
| Close without saving | `closeForm({ save: false })` |
|
||||
| Close and save | `closeForm({ save: true })` |
|
||||
| Close (manual confirm) | `closeForm()` — returns `confirmation` if dialog appears |
|
||||
|
||||
## Exec response format
|
||||
|
||||
```json
|
||||
{ "ok": true, "output": "...console.log output...", "elapsed": 3.2 }
|
||||
```
|
||||
|
||||
On error (auto-screenshot taken):
|
||||
```json
|
||||
{ "ok": false, "error": "Element not found", "output": "...", "screenshot": "error-shot.png", "elapsed": 1.5 }
|
||||
```
|
||||
|
||||
## Avoiding loops
|
||||
|
||||
- **Max 2 attempts per operation.** If an action fails twice with the same approach — stop and report to the user
|
||||
- **Not found = not found.** If `filterList` returns 0 rows or `readTable` is empty after filtering — the item likely doesn't exist in this database. Don't retry the same search 5 times with slight variations
|
||||
- **Try a different approach, not the same one.** Couldn't find via section navigation? Try `navigateLink`. Couldn't find via simple search? Try advanced search with a specific field. But don't repeat the same method
|
||||
- **Report partial results.** If you found the list but not the specific item — say so. Show what IS available instead of silently retrying
|
||||
|
||||
## Important notes
|
||||
|
||||
- **Headed mode** — 1C requires visible browser, no headless
|
||||
- **Startup time** — 1C loads 30-60s on initial connect (built into `start`)
|
||||
- **Fuzzy matching** — all name lookups: exact > startsWith > includes
|
||||
- **Clipboard paste** — all text fields filled via Ctrl+V (triggers 1C events properly). The OS clipboard is automatically saved before each action and restored after, so a local user's clipboard survives a test run. Opt out with `--no-preserve-clipboard` (any command), `WEB_TEST_PRESERVE_CLIPBOARD=0` env, or `preserveClipboard: false` in `webtest.config.mjs`
|
||||
- **Cyrillic in bash** — use `cat <<'SCRIPT' | node $RUN exec -` to avoid escaping issues
|
||||
- **Non-breaking spaces** — 1C uses `\u00a0` instead of regular spaces. All matching is normalized internally
|
||||
- **Section panel display** — `navigateSection()` works with any panel position (side, top) but requires "Picture and text" or "Text" display mode. Icon-only mode is not supported — API cannot read section names from icons alone
|
||||
|
||||
## Regression suites
|
||||
|
||||
When the user asks to cover a 1C solution with automated regression — multi-file test suites with assertions, hooks, tags, retries, Allure/JUnit reports, multi-user process tests — switch to the `test` mode. See [regress.md](regress.md) for authoring discipline, recon flow (metadata + live walkthrough via `exec`), per-application folder layout, ready-to-paste templates, and failure triage. Default to ad-hoc `run`/`exec` for single-script automation — `test` is the specialised mode for project-wide coverage.
|
||||
@@ -0,0 +1,348 @@
|
||||
# Video Recording
|
||||
|
||||
Record browser automation sessions as MP4 video files. Uses CDP `Page.startScreencast` to capture JPEG frames and pipes them to ffmpeg for encoding.
|
||||
|
||||
## Prerequisites
|
||||
|
||||
**ffmpeg** must be installed. Choose один из вариантов:
|
||||
|
||||
### Вариант 1: в проект (рекомендуется)
|
||||
|
||||
Скачать essentials build с https://www.gyan.dev/ffmpeg/builds/, распаковать в `tools/ffmpeg/` проекта:
|
||||
|
||||
```
|
||||
tools/ffmpeg/
|
||||
├── bin/
|
||||
│ ├── ffmpeg.exe ← этот файл ищет startRecording()
|
||||
│ ├── ffplay.exe
|
||||
│ └── ffprobe.exe
|
||||
└── ...
|
||||
```
|
||||
|
||||
Код автоматически найдёт `tools/ffmpeg/bin/ffmpeg.exe` — ничего больше настраивать не нужно.
|
||||
|
||||
### Вариант 2: глобально (один раз на машину)
|
||||
|
||||
Скачать, распаковать в любой каталог (напр. `C:\tools\ffmpeg`), добавить `bin/` в системный PATH.
|
||||
После этого ffmpeg доступен во всех проектах.
|
||||
|
||||
### Вариант 3: через .v8-project.json (общий путь)
|
||||
|
||||
Чтобы не копировать ffmpeg в каждый проект, указать путь в конфиге:
|
||||
|
||||
```json
|
||||
{
|
||||
"ffmpegPath": "C:\\tools\\ffmpeg\\bin\\ffmpeg.exe"
|
||||
}
|
||||
```
|
||||
|
||||
Модель прочитает это поле и передаст в `startRecording({ ffmpegPath })`.
|
||||
|
||||
### Порядок поиска ffmpeg
|
||||
|
||||
1. `opts.ffmpegPath` — явный путь (из `.v8-project.json` или параметра)
|
||||
2. `FFMPEG_PATH` — переменная окружения
|
||||
3. `ffmpeg` — в системном PATH
|
||||
4. `tools/ffmpeg/bin/ffmpeg.exe` — относительно корня проекта
|
||||
|
||||
## API
|
||||
|
||||
### `startRecording(outputPath, opts?)`
|
||||
|
||||
Start recording the browser viewport to an MP4 file.
|
||||
|
||||
| Parameter | Type | Default | Description |
|
||||
|-----------|------|---------|-------------|
|
||||
| `outputPath` | string | required | Output .mp4 file path |
|
||||
| `opts.fps` | number | 25 | Target framerate |
|
||||
| `opts.quality` | number | 80 | JPEG quality (1-100) |
|
||||
| `opts.ffmpegPath` | string | auto | Explicit path to ffmpeg binary |
|
||||
| `opts.speechRate` | number | 70 | Ms per character for smart TTS wait. Increase for slower TTS providers (e.g. 85 for ElevenLabs) |
|
||||
|
||||
- Output directory is created automatically if it doesn't exist
|
||||
- Throws if already recording or browser not connected
|
||||
- Recording auto-stops when `disconnect()` is called
|
||||
|
||||
### `stopRecording()` → `{ file, duration, size, captions }`
|
||||
|
||||
Stop recording and finalize the MP4 file. Saves `.captions.json` next to the video if captions were collected.
|
||||
|
||||
| Return field | Type | Description |
|
||||
|-------------|------|-------------|
|
||||
| `file` | string | Absolute path to the MP4 file |
|
||||
| `duration` | number | Recording duration in seconds |
|
||||
| `size` | number | File size in bytes |
|
||||
| `captions` | number | Number of captions collected during recording |
|
||||
|
||||
### `isRecording()` → boolean
|
||||
|
||||
Check if recording is active.
|
||||
|
||||
### `showCaption(text, opts?)`
|
||||
|
||||
Display a text overlay on the page (visible in recording). Calling again updates the text.
|
||||
|
||||
| Parameter | Type | Default | Description |
|
||||
|-----------|------|---------|-------------|
|
||||
| `text` | string | required | Caption text |
|
||||
| `opts.position` | `'top'` \| `'bottom'` | `'bottom'` | Vertical position |
|
||||
| `opts.fontSize` | number | 24 | Font size in px |
|
||||
| `opts.background` | string | `'rgba(0,0,0,0.7)'` | Background color |
|
||||
| `opts.color` | string | `'#fff'` | Text color |
|
||||
| `opts.speech` | string \| false | - | TTS narration text. Omit = use displayed text, string = custom narration, false = skip narration |
|
||||
| `opts.voice` | string | - | Per-caption voice override (provider-specific voice name/ID). Used by `addNarration` instead of the global voice |
|
||||
|
||||
When `text` is empty but `speech` is a string, the caption is still recorded for TTS (no visible overlay). Useful for narration-only captions (e.g. podcast mode).
|
||||
|
||||
The overlay uses `pointer-events: none` — does not interfere with clicking.
|
||||
|
||||
**Smart TTS wait** (during recording): `showCaption` automatically pauses for the estimated TTS speech duration (default ~70ms per character, min 2s; configurable via `startRecording({ speechRate })`). The next `wait()` call accounts for this — if the explicit pause is shorter than the TTS wait already done, no extra delay is added. If longer, only the remaining difference is waited. This means script authors don't need to calculate TTS timing manually.
|
||||
|
||||
### `hideCaption()`
|
||||
|
||||
Remove the caption overlay.
|
||||
|
||||
### `showTitleSlide(text, opts?)`
|
||||
|
||||
Display a full-screen title slide overlay (gradient background, centered text). Useful for intro/outro frames in video recordings. Calling again updates the content.
|
||||
|
||||
| Parameter | Type | Default | Description |
|
||||
|-----------|------|---------|-------------|
|
||||
| `text` | string | required | Title text (`\n` → line break) |
|
||||
| `opts.subtitle` | string | `''` | Smaller text below the title |
|
||||
| `opts.background` | string | dark gradient | CSS background |
|
||||
| `opts.color` | string | `'#fff'` | Text color |
|
||||
| `opts.fontSize` | number | 36 | Title font size in px |
|
||||
| `opts.speech` | string \| false | - | TTS narration text. String = custom text, `true` = use title text, omit/false = no narration |
|
||||
| `opts.voice` | string | - | Per-caption voice override for `addNarration` |
|
||||
|
||||
The overlay covers the entire viewport with `z-index: 999999` and `pointer-events: none`.
|
||||
|
||||
### `hideTitleSlide()`
|
||||
|
||||
Remove the title slide overlay.
|
||||
|
||||
### `showImage(imagePath, opts?)`
|
||||
|
||||
Display a full-screen image overlay (e.g. presentation slide screenshot). Reads the file, base64-encodes it, and renders as `<img>` in a fixed overlay — captured by CDP screencast automatically.
|
||||
|
||||
| Parameter | Type | Default | Description |
|
||||
|-----------|------|---------|-------------|
|
||||
| `imagePath` | string | required | Path to image file (PNG, JPG, GIF, WebP, SVG) |
|
||||
| `opts.style` | `'blur'` \| `'dark'` \| `'light'` \| `'full'` | `'blur'` | Display style preset |
|
||||
| `opts.background` | string | - | Custom background (overrides preset) |
|
||||
| `opts.shadow` | boolean | preset | Show drop shadow on image |
|
||||
| `opts.speech` | string \| false | - | TTS narration text while image is shown |
|
||||
| `opts.voice` | string | - | Per-caption voice override for `addNarration` |
|
||||
|
||||
**Style presets:**
|
||||
- `blur` — blurred+dimmed copy of the image as background, centered image with shadow
|
||||
- `dark` — dark background (#2a2a2a) with shadow
|
||||
- `light` — white background with shadow
|
||||
- `full` — image fills entire screen (contain, no crop), black background, no shadow
|
||||
|
||||
Images are auto-scaled: small images scale up (min 50% of viewport), large images scale down (max 92%).
|
||||
|
||||
### `hideImage()`
|
||||
|
||||
Remove the image overlay.
|
||||
|
||||
### `setHighlight(on)`
|
||||
|
||||
Enable or disable auto-highlight mode. When enabled, action functions (`navigateSection`, `openCommand`, `clickElement`, `selectValue`, `fillFields`) automatically highlight the target element for 500ms before performing the action.
|
||||
|
||||
| Parameter | Type | Description |
|
||||
|-----------|------|-------------|
|
||||
| `on` | boolean | `true` to enable, `false` to disable |
|
||||
|
||||
**How it works**: each action highlights the element → waits 500ms (viewer reads) → removes highlight → performs the action. This prevents the highlight overlay from interfering with modals, dropdowns, or focus changes caused by the action.
|
||||
|
||||
**Search priority**: form elements (buttons, links, fields, grid rows) are searched first. Sections and commands are used as fallback only if the element is not found in the current form. This avoids false matches (e.g., "ОК" matching section "Покупки" via substring).
|
||||
|
||||
### `isHighlightMode()` → boolean
|
||||
|
||||
Check if auto-highlight mode is active.
|
||||
|
||||
### `highlight(text)`
|
||||
|
||||
Manually highlight a UI element by name (fuzzy match). Places a semi-transparent blue overlay (`rgba(0,100,255,0.25)`) with a border on the element. The overlay tracks element position via `requestAnimationFrame`.
|
||||
|
||||
| Parameter | Type | Description |
|
||||
|-----------|------|-------------|
|
||||
| `text` | string | Element name — button, link, field, group/panel, section, or command |
|
||||
|
||||
- Fuzzy match order: exact → startsWith → includes
|
||||
- Search priority: popup items → commands → **form groups/panels** → form elements (buttons, fields) → sections
|
||||
- Groups are matched by visible title or internal name (e.g., `highlight('Оргструктура')` finds the group panel)
|
||||
- `pointer-events: none` — does not block clicks
|
||||
|
||||
### `unhighlight()`
|
||||
|
||||
Remove the highlight overlay.
|
||||
|
||||
## Example: Record a workflow with highlight, title slide, and captions
|
||||
|
||||
```js
|
||||
await startRecording('recordings/create-order.mp4');
|
||||
|
||||
// Title slide with narration
|
||||
await showTitleSlide('Создание заказа клиента', {
|
||||
subtitle: 'Демонстрация',
|
||||
speech: 'Создание заказа клиента. Демонстрация.'
|
||||
});
|
||||
await wait(1);
|
||||
await hideTitleSlide();
|
||||
|
||||
// Presentation slide (optional)
|
||||
await showImage('slides/overview.png', {
|
||||
speech: 'На этом слайде показана общая схема процесса'
|
||||
});
|
||||
await wait(1);
|
||||
await hideImage();
|
||||
|
||||
setHighlight(true); // enable auto-highlight for all actions
|
||||
|
||||
// Steps: caption → pause → action (highlight is automatic)
|
||||
await showCaption('Шаг 1. Переходим в раздел «Продажи»');
|
||||
await wait(1.5);
|
||||
await navigateSection('Продажи');
|
||||
|
||||
await showCaption('Шаг 2. Открываем заказы клиентов');
|
||||
await wait(1.5);
|
||||
await openCommand('Заказы клиентов');
|
||||
|
||||
await showCaption('Шаг 3. Создаём новый заказ');
|
||||
await wait(1.5);
|
||||
await clickElement('Создать');
|
||||
await wait(2); // wait for form to load
|
||||
|
||||
await showCaption('Шаг 4. Заполняем шапку');
|
||||
await wait(1.5);
|
||||
await fillFields({ 'Организация': 'Конфетпром', 'Контрагент': 'Альфа' });
|
||||
await wait(1);
|
||||
|
||||
await hideCaption();
|
||||
setHighlight(false);
|
||||
const result = await stopRecording();
|
||||
console.log(`Recorded ${result.duration}s, ${(result.size / 1024 / 1024).toFixed(1)} MB`);
|
||||
```
|
||||
|
||||
**Caption timing**: show the caption *before* the action — `showCaption` auto-waits for estimated TTS duration during recording. The subsequent `wait()` is absorbed by the credit system (no double-waiting). Add `wait()` *after* the action only when the next step needs the result to load (e.g., form opening).
|
||||
|
||||
**Highlight timing**: `setHighlight(true)` enables auto-mode — each action function highlights the target for 500ms, then removes the highlight before performing the action. No manual `highlight()`/`unhighlight()` calls needed. Enable after title slide, disable before `stopRecording()`.
|
||||
|
||||
## TTS Narration
|
||||
|
||||
Add voiceover to recorded videos. Captions shown via `showCaption()` are automatically collected during recording and can be synthesized into speech.
|
||||
|
||||
### Prerequisites
|
||||
|
||||
- **ffmpeg** — same as for video recording (ffprobe must be next to ffmpeg)
|
||||
- **node-edge-tts** — `npm install --prefix tools/tts node-edge-tts` (for Edge TTS provider, free, no API key). Also works if installed globally or at project level — the resolver tries multiple locations automatically
|
||||
|
||||
### Configuration in `.v8-project.json`
|
||||
|
||||
```json
|
||||
{
|
||||
"tts": {
|
||||
"provider": "edge",
|
||||
"voice": "ru-RU-DmitryNeural"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
For OpenAI-compatible provider:
|
||||
```json
|
||||
{
|
||||
"tts": {
|
||||
"provider": "openai",
|
||||
"apiKey": "sk-...",
|
||||
"voice": "alloy"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
For ElevenLabs:
|
||||
```json
|
||||
{
|
||||
"tts": {
|
||||
"provider": "elevenlabs",
|
||||
"apiKey": "sk_...",
|
||||
"voice": "JBFqnCBsd6RMkjVDRZzb"
|
||||
}
|
||||
}
|
||||
```
|
||||
Note: `voice` is the ElevenLabs voice ID (not a name). Default model: `eleven_multilingual_v2` (supports Russian and other languages).
|
||||
|
||||
### `showCaption()` speech parameter
|
||||
|
||||
The `speech` option controls what text is narrated (vs displayed):
|
||||
|
||||
```js
|
||||
await showCaption('Дт 60.02 — Кт 51'); // narrates the displayed text
|
||||
await showCaption('Дт 60.02 — Кт 51', { speech: 'Проводка: дебет шестьдесят ноль два, кредит пятьдесят один' }); // custom narration
|
||||
await showCaption('Техническая информация', { speech: false }); // no narration for this caption
|
||||
```
|
||||
|
||||
### `addNarration(videoPath, opts?)`
|
||||
|
||||
Generate TTS and merge audio with video. Call after `stopRecording()`.
|
||||
|
||||
**Parameters:**
|
||||
|
||||
| Parameter | Type | Description |
|
||||
|-----------|------|-------------|
|
||||
| `videoPath` | `string` | Path to the recorded MP4 file |
|
||||
| `opts.captions` | `Array` | Explicit captions (default: from last recording or `.captions.json`). Each caption may include a `voice` field to override the global voice for that segment |
|
||||
| `opts.provider` | `string` | `'edge'` (default), `'openai'`, or `'elevenlabs'` |
|
||||
| `opts.voice` | `string` | Voice name (provider-specific) |
|
||||
| `opts.apiKey` | `string` | API key (for openai) |
|
||||
| `opts.apiUrl` | `string` | Endpoint (for openai) |
|
||||
| `opts.model` | `string` | Model (for openai, default: `tts-1`) |
|
||||
| `opts.ffmpegPath` | `string` | Path to ffmpeg binary |
|
||||
| `opts.outputPath` | `string` | Output file (default: `video-narrated.mp4`) |
|
||||
|
||||
**Returns:** `{ file, duration, size, captions, warnings? }`
|
||||
|
||||
### `getCaptions()`
|
||||
|
||||
Returns captions from the current or last recording: `Array<{ text, speech, time, voice? }>`.
|
||||
|
||||
### Example: Record and narrate
|
||||
|
||||
```js
|
||||
await startRecording('recordings/demo.mp4');
|
||||
await showCaption('Переходим в раздел Банк и касса');
|
||||
await wait(1.5);
|
||||
await navigateSection('Банк и касса');
|
||||
await showCaption('Открываем банковские выписки');
|
||||
await wait(1.5);
|
||||
await openCommand('Банковские выписки');
|
||||
await hideCaption();
|
||||
const video = await stopRecording();
|
||||
|
||||
// Add narration (reads tts config from .v8-project.json)
|
||||
const narrated = await addNarration(video.file, { voice: 'ru-RU-DmitryNeural' });
|
||||
console.log(`Narrated: ${narrated.file}, ${narrated.duration}s`);
|
||||
```
|
||||
|
||||
### Re-narration
|
||||
|
||||
After recording, a `.captions.json` file is saved next to the video. You can re-narrate with a different voice without re-recording:
|
||||
|
||||
```js
|
||||
const result = await addNarration('recordings/demo.mp4', { voice: 'ru-RU-SvetlanaNeural' });
|
||||
```
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
| Problem | Solution |
|
||||
|---------|----------|
|
||||
| "ffmpeg not found" | Install ffmpeg and ensure it's discoverable (see Prerequisites) |
|
||||
| Recording file is 0 bytes | Check that output path is writable. ffmpeg may have crashed |
|
||||
| Video is choppy | Add `wait()` between steps. Reduce `quality` for faster capture |
|
||||
| "Already recording" | Call `stopRecording()` before starting a new recording |
|
||||
| Recording stops on disconnect | Expected — auto-stop prevents orphaned ffmpeg processes |
|
||||
| "No captions available" | Use `showCaption()` during recording, or pass `opts.captions` |
|
||||
| TTS timeout | Check internet connection. Edge TTS requires network access |
|
||||
| Audio cuts off between captions | Smart TTS wait should handle this automatically. If warnings appear, add longer `wait()` after `showCaption` |
|
||||
@@ -0,0 +1,421 @@
|
||||
# Regression suite authoring
|
||||
|
||||
Use this when the user asks to cover a 1C solution with automated regression tests, build out a test suite, or run an existing suite and analyse failures. For ad-hoc single-script automation, stay with the `run`/`exec` modes from SKILL.md instead.
|
||||
|
||||
The runner is the same `run.mjs`. The mode is `test`:
|
||||
|
||||
```bash
|
||||
node $RUN test [url] <dir|file> [flags]
|
||||
```
|
||||
|
||||
Tests live next to the project they cover (not inside the skill). Convention: `tests/` at the project root, with `_hooks.mjs` and `webtest.config.mjs` at the suite root. Tests are ES modules with `*.test.mjs` suffix.
|
||||
|
||||
## When to choose `test` over `exec`
|
||||
|
||||
| Goal | Mode |
|
||||
|------|------|
|
||||
| Explore a form, prototype a single step, debug one selector | `exec` (interactive session) |
|
||||
| Reproduce a bug as a failing test before fixing it | `test` |
|
||||
| Cover a feature so future changes are checked automatically | `test` |
|
||||
| Run the project's regression on a new build | `test` |
|
||||
| Generate a screencast walkthrough | `exec` with `startRecording` |
|
||||
|
||||
Don't write a `.test.mjs` for a one-shot user request. Don't drive a regression suite through chained `exec` calls.
|
||||
|
||||
## Before writing tests — recon
|
||||
|
||||
Two layers, in order.
|
||||
|
||||
**1. Static recon — metadata.** Never invent identifiers. For every metadata object the user mentions, run the matching info skill first: `/meta-info` (attributes/tabular sections), `/form-info` (form layout), `/skd-info` (DCS), `/mxl-info` (templates), `/role-info` (rights), `/subsystem-info` (composition / command interface). If the user names objects you can't find — stop and ask.
|
||||
|
||||
**2. Live recon — interactive walkthrough.** For any non-trivial scenario, walk the path live in `exec` mode before transcribing it. Metadata tells you what exists; the live walkthrough tells you what actually happens. Capture from `getFormState()`: exact button names (`'Провести и закрыть'`, not `'Сохранить'`), table section names for multi-grid forms, required fields, places where a real async wait is needed. Then transcribe the working sequence into `*.test.mjs`, wrapping logical chunks in `step('...', async () => { ... })`.
|
||||
|
||||
The mechanics of `exec` / `getFormState` / `fillFields` / `clickElement` are in [SKILL.md](SKILL.md) — read it before recon if you haven't already.
|
||||
|
||||
When live recon is overkill: trivial reads (`navigateSection` + `readTable` + assert non-empty), or scenarios you've already proven once in this session. When it's essential: confirmation dialogs, posting/cancellation flows, reports with custom filters, multi-grid forms, user-customised forms.
|
||||
|
||||
## Suite layout
|
||||
|
||||
**Each application gets its own subfolder under `tests/`.** A single repo may host several independent suites side by side — they must not share `_hooks.mjs` or `webtest.config.mjs`, because each suite restores a different DB, publishes to a different URL, and ships its own test data.
|
||||
|
||||
```
|
||||
tests/
|
||||
<app-name>/ # application regression — one per solution
|
||||
_hooks.mjs
|
||||
webtest.config.mjs
|
||||
_allure/ # optional static Allure config
|
||||
01-login/
|
||||
02-counterparties/
|
||||
...
|
||||
<another-app>/ # second solution, fully isolated
|
||||
```
|
||||
|
||||
Inside the application subfolder, organize by **feature**, not by metadata kind. Numeric prefixes on both folder and file enforce run order — discovery walks recursively and sorts files by full relative path; entries starting with `_` or `.` are skipped (so `_hooks.mjs`, `_allure/` won't be picked up as tests).
|
||||
|
||||
```
|
||||
tests/<app-name>/
|
||||
01-login/
|
||||
01-open-base.test.mjs
|
||||
02-section-navigation.test.mjs
|
||||
02-counterparties/
|
||||
01-create.test.mjs
|
||||
02-edit-phone.test.mjs
|
||||
03-goods-receipt/
|
||||
01-fill.test.mjs
|
||||
02-post.test.mjs
|
||||
05-approval-process/
|
||||
01-end-to-end.test.mjs # multi-user
|
||||
```
|
||||
|
||||
Per-folder `_hooks.mjs` / `webtest.config.mjs` inside the application subfolder are NOT supported — only the application-root copies are loaded.
|
||||
|
||||
## Test file anatomy
|
||||
|
||||
```js
|
||||
export const name = 'Создание контрагента'; // required
|
||||
export const tags = ['catalog', 'create']; // optional, used for filtering + Allure
|
||||
export const timeout = 60000; // optional, default 30000
|
||||
// export const skip = 'pending fix #123'; // optional: true | string
|
||||
// export const only = true; // debug-only — never commit
|
||||
// export const context = 'manager'; // optional, single non-default context
|
||||
// export const contexts = ['clerk', 'manager']; // optional, multi-user test
|
||||
// export const severity = 'critical'; // optional, overrides config severity
|
||||
|
||||
export async function setup(ctx) {
|
||||
// per-test prep — runs before default. Skip if not needed.
|
||||
}
|
||||
|
||||
export async function teardown(ctx) {
|
||||
// per-test cleanup — runs after default, always (even on failure).
|
||||
}
|
||||
|
||||
export default async function(ctx) {
|
||||
const { navigateSection, openCommand, clickElement, fillFields,
|
||||
readTable, closeForm, getFormState,
|
||||
assert, step, log } = ctx;
|
||||
|
||||
await step('Открыть список контрагентов', async () => {
|
||||
await navigateSection('Продажи');
|
||||
await openCommand('Контрагенты');
|
||||
});
|
||||
|
||||
await step('Создать нового контрагента', async () => {
|
||||
await clickElement('Создать');
|
||||
await fillFields({ 'Наименование': 'Тест ' + Date.now() });
|
||||
await clickElement('Записать и закрыть');
|
||||
});
|
||||
|
||||
await step('Убедиться, что элемент появился в списке', async () => {
|
||||
const t = await readTable();
|
||||
assert.tableHasRow(t, r => r['Наименование']?.startsWith('Тест '));
|
||||
});
|
||||
}
|
||||
```
|
||||
|
||||
**Step names — in Russian, descriptive.** Step labels surface in the console output, in JSON/JUnit, and as Allure step nodes. Russian-speaking QA reads them. Use a full action phrase (`'Создать нового контрагента'`), not a tag (`'create'`) and not a transliteration. Same applies to `export const name` and `displayName` in `webtest.config.mjs`.
|
||||
|
||||
## `ctx` contract
|
||||
|
||||
The runner injects every `browser.mjs` export into `ctx` (all 1C action functions auto-detect platform errors — see SKILL.md), plus the test utilities below.
|
||||
|
||||
### Test utilities
|
||||
|
||||
```js
|
||||
step(name, fn) // async wrapper. Records start/stop. Nested calls supported.
|
||||
// On throw: marks the step failed, re-throws.
|
||||
// On screenshot='every-step': captures after fn().
|
||||
log(...args) // adds a line to ctx.testInfo's output (goes into JSON / Allure
|
||||
// attachment). Use instead of console.log inside tests.
|
||||
assert.* // see "Assertions" below
|
||||
```
|
||||
|
||||
### `ctx.testInfo` (always set, read-only)
|
||||
|
||||
```js
|
||||
{
|
||||
name, // 'Навигация по разделам' (with params substituted)
|
||||
file, // '01-navigation.test.mjs' (basename)
|
||||
filePath, // relative path inside testDir
|
||||
tags, // ['nav', 'smoke']
|
||||
timeout, // ms
|
||||
attempt, // 1..maxAttempts (1-based)
|
||||
maxAttempts, // 1 + retry
|
||||
param, // { ... } | undefined (only when export const params is set)
|
||||
contexts: { // mirrors config.contexts; includes custom fields like displayName
|
||||
clerk: { url, isolation, displayName, ... },
|
||||
manager: { ... },
|
||||
},
|
||||
primaryContext, // 'clerk' — name of the context active at test entry
|
||||
// (= t.context for single, t.contexts[0] for multi)
|
||||
}
|
||||
```
|
||||
|
||||
### `ctx.testResult` (only in `afterEach`)
|
||||
|
||||
```js
|
||||
{
|
||||
status, // 'passed' | 'failed'
|
||||
duration, // ms
|
||||
attempts, // attempts actually executed
|
||||
error, // { message, step?, screenshot? } | null
|
||||
steps, // array of step results (each: { name, start, stop, status, error?, steps[] })
|
||||
}
|
||||
```
|
||||
|
||||
### Context shape
|
||||
|
||||
- **Single-context (default or `export const context = 'manager'`):** all API on `ctx` top-level — `ctx.clickElement(...)`, `ctx.getFormState()`, etc.
|
||||
- **Multi-context (`export const contexts = ['clerk', 'manager']`):** each name is its own scoped namespace — `ctx.clerk.clickElement(...)`, `ctx.manager.fillFields(...)`. `step`, `assert`, `log`, `testInfo` stay top-level. Scoped methods auto-switch the active page before each call.
|
||||
|
||||
## Assertions
|
||||
|
||||
All on `ctx.assert`. Throw `AssertionError` with `.message`, `.actual`, `.expected`. No dependencies.
|
||||
|
||||
```js
|
||||
// generic
|
||||
assert.ok(value, msg?) // truthy
|
||||
assert.equal(actual, expected, msg?) // ===
|
||||
assert.notEqual(actual, expected, msg?) // !==
|
||||
assert.deepEqual(actual, expected, msg?) // JSON-compare
|
||||
assert.includes(haystack, needle, msg?) // string.includes / array.includes
|
||||
assert.match(string, regex, msg?) // regex.test(string)
|
||||
await assert.throws(asyncFn, msg?) // passes if fn throws (use await)
|
||||
|
||||
// 1C-specific — operate on getFormState() / readTable() output
|
||||
assert.formHasField(state, 'Контрагент', msg?) // state.fields[name] exists
|
||||
assert.formTitle(state, expected, msg?) // state.title includes expected
|
||||
assert.tableHasRow(table, predicate, msg?) // predicate: object (partial match) or fn(row) => bool
|
||||
// object form: { 'Наименование': 'Тест' }
|
||||
// fn form: r => r['Сумма'] > 100
|
||||
assert.tableRowCount(table, expected, msg?) // table.rows.length === expected
|
||||
assert.noErrors(state, msg?) // !state.errors
|
||||
```
|
||||
|
||||
Beyond these, just use plain JS (`throw new Error(...)`) — there's no custom matcher extension API. The 1C-specific helpers are the ones worth preferring over hand-rolled equivalents because their error messages name the actual fields/rows present, which speeds up triage.
|
||||
|
||||
## webtest.config.mjs
|
||||
|
||||
```js
|
||||
export default {
|
||||
// Single-context shorthand:
|
||||
url: 'http://localhost:9191/myapp/ru_RU',
|
||||
|
||||
// OR multi-context:
|
||||
// contexts: {
|
||||
// clerk: { url: 'http://localhost:9191/myapp-clerk/ru_RU', displayName: 'Кладовщик' },
|
||||
// manager: { url: 'http://localhost:9191/myapp-manager/ru_RU', displayName: 'Менеджер' },
|
||||
// },
|
||||
// defaultContext: 'clerk',
|
||||
|
||||
timeout: 30000,
|
||||
retries: 0,
|
||||
screenshot: 'on-failure', // 'every-step' | 'off'
|
||||
record: false,
|
||||
|
||||
// Severity → tags mapping for Allure. Each tag at most one bucket.
|
||||
severity: {
|
||||
critical: ['smoke', 'crud'],
|
||||
minor: ['recording'],
|
||||
},
|
||||
defaultSeverity: 'normal',
|
||||
};
|
||||
```
|
||||
|
||||
CLI flags override config. Use latin context IDs + Russian `displayName` for ergonomics — `ctx.testInfo.contexts.clerk.displayName` is friendlier than mixed-case Cyrillic keys.
|
||||
|
||||
## _hooks.mjs
|
||||
|
||||
Two layers. Infra hooks run without a browser; testlevel hooks receive `ctx`.
|
||||
|
||||
```js
|
||||
import { execSync } from 'child_process';
|
||||
|
||||
// Infra — runs once around the whole suite.
|
||||
export async function prepare({ hookArgs, log, config }) {
|
||||
// hookArgs: everything after `--` on the CLI, as a string[]. Parse yourself.
|
||||
const force = hookArgs.includes('--rebuild-stand');
|
||||
const dataArg = hookArgs.find(a => a.startsWith('--data='))?.slice('--data='.length);
|
||||
log('preparing stand, force=', force, 'data=', dataArg);
|
||||
// Idempotent hash-locks on inputs (config sources, EPF spec, DB dump) keep
|
||||
// warm starts to a liveness probe.
|
||||
}
|
||||
|
||||
export async function cleanup({ log, config }) { /* optional */ }
|
||||
|
||||
// Testlevel — runs with browser ctx.
|
||||
export async function beforeAll(ctx) { /* once after first context opens */ }
|
||||
export async function afterAll(ctx) { /* once before final teardown */ }
|
||||
export async function beforeEach(ctx) { /* ctx.testInfo is set */ }
|
||||
export async function afterEach(ctx) { /* ctx.testInfo + ctx.testResult set */ }
|
||||
|
||||
// Per-context — runs whenever a context is created/closed.
|
||||
export async function afterOpenContext(ctx, name, spec) { /* spec = config.contexts[name] */ }
|
||||
export async function beforeCloseContext(ctx, name, spec) { }
|
||||
```
|
||||
|
||||
Built-in state reset (`dismissPendingErrors` + close all forms) runs after `afterEach` automatically. Don't reimplement it in `afterEach`.
|
||||
|
||||
Pass hook args after `--`:
|
||||
|
||||
```bash
|
||||
node $RUN test tests/<app-name>/ --bail -- --rebuild-stand --data=demo
|
||||
└─runner─┘ └────── hookArgs ─────────┘
|
||||
```
|
||||
|
||||
**Where to put data setup:**
|
||||
- DB restore, publication, EPF build → `prepare()`. Make it idempotent (hash-locks).
|
||||
- Test-specific seed data → per-test `setup`.
|
||||
- Shared session-wide warmup → `beforeAll`.
|
||||
|
||||
## Ready-to-paste patterns
|
||||
|
||||
A minimal CRUD shape is in *Test file anatomy* above — use it as the rhythm for catalog/document tests, swapping in the right section/command/fields. The patterns below cover what's specific to the regression engine, not the browser API (those live in SKILL.md).
|
||||
|
||||
### DCS report
|
||||
|
||||
```js
|
||||
await openCommand('Остатки товаров');
|
||||
// Reset user settings — 1C persists them between sessions.
|
||||
await clickElement('Ещё');
|
||||
await clickElement('Установить стандартные настройки');
|
||||
|
||||
await selectValue('Номенклатура', 'Товар 02'); // auto-enables the filter checkbox
|
||||
await clickElement('Сформировать');
|
||||
await wait(3);
|
||||
const r = await readSpreadsheet();
|
||||
assert.deepEqual(r.headers, ['Номенклатура', 'Количество', 'Сумма']);
|
||||
assert.ok(r.data.length >= 1);
|
||||
assert.ok(r.totals?.['Сумма']);
|
||||
```
|
||||
|
||||
### Multi-user process
|
||||
|
||||
```js
|
||||
export const contexts = ['clerk', 'manager'];
|
||||
|
||||
export default async function({ clerk, manager, step, assert }) {
|
||||
await step('Кладовщик создаёт накладную', async () => {
|
||||
await clerk.navigateSection('Склад');
|
||||
await clerk.openCommand('Приходные накладные');
|
||||
await clerk.clickElement('Создать');
|
||||
await clerk.fillFields({ 'Контрагент': 'ООО Север' });
|
||||
await clerk.clickElement('Записать');
|
||||
});
|
||||
await step('Менеджер утверждает накладную', async () => {
|
||||
await manager.navigateSection('Согласование');
|
||||
await manager.openCommand('На утверждении');
|
||||
await manager.clickElement('ООО Север', { dblclick: true });
|
||||
await manager.clickElement('Утвердить');
|
||||
});
|
||||
await step('Кладовщик видит новый статус', async () => {
|
||||
const s = await clerk.getFormState();
|
||||
assert.equal(s.fields['Статус']?.value, 'Утверждён');
|
||||
});
|
||||
await step('Освободить сессию кладовщика', async () => {
|
||||
await manager.closeContext('clerk'); // free a 1C license for the next test
|
||||
});
|
||||
}
|
||||
```
|
||||
|
||||
Close contexts you no longer need (`manager.closeContext('clerk')`) before the next multi-user test starts — frees a 1C web-client license and stops the previous role from holding state.
|
||||
|
||||
### Failing-test repro
|
||||
|
||||
```js
|
||||
export const name = 'Bug #123: накладная без контрагента не должна проводиться';
|
||||
export const tags = ['bug', 'validation'];
|
||||
|
||||
export default async function({ openCommand, clickElement, getFormState, assert, step }) {
|
||||
await openCommand('Приходные накладные');
|
||||
await clickElement('Создать');
|
||||
await clickElement('Провести');
|
||||
const s = await getFormState();
|
||||
assert.ok(s.errorModal || s.fields['Контрагент']?.required,
|
||||
'Должна быть ошибка валидации или поле помечено обязательным');
|
||||
}
|
||||
```
|
||||
|
||||
Write it red first, hand it to the user, fix the underlying issue, re-run green.
|
||||
|
||||
### Parameterised test
|
||||
|
||||
```js
|
||||
export const name = 'Заполнение поля {type}';
|
||||
export const params = [
|
||||
{ type: 'String', field: 'Наименование', value: 'Тест' },
|
||||
{ type: 'Number', field: 'Цена', value: '100.50' },
|
||||
{ type: 'Date', field: 'ДатаПоступления', value: '01.01.2024' },
|
||||
];
|
||||
|
||||
export default async function({ fillFields, getFormState, assert }, { type, field, value }) {
|
||||
await fillFields({ [field]: value });
|
||||
const state = await getFormState();
|
||||
assert.equal(state.fields[field]?.value, String(value));
|
||||
}
|
||||
```
|
||||
|
||||
Each `params` entry becomes its own test in the report. `{key}` placeholders in `name` get substituted; without placeholders, a `[index]` suffix is added. `ctx.testInfo.param` carries the current row.
|
||||
|
||||
## Running
|
||||
|
||||
```bash
|
||||
node $RUN test tests/<app-name>/ # full app suite
|
||||
node $RUN test tests/<app-name>/03-goods-receipt/ # one feature folder
|
||||
node $RUN test tests/<app-name>/02-counterparties/01-create.test.mjs # one file
|
||||
node $RUN test tests/<app-name>/ --tags=smoke # by tag (intersection)
|
||||
node $RUN test tests/<app-name>/ --grep='накладн' # by name regex
|
||||
node $RUN test tests/<app-name>/ --bail --retry=1 # stop on first fail, allow 1 retry
|
||||
node $RUN test tests/<app-name>/ --report=allure-results --format=allure --report-dir=allure-results
|
||||
node $RUN test tests/<app-name>/ --report=- # machine JSON to stdout, progress to stderr
|
||||
node $RUN test tests/<app-name>/ -- --rebuild-stand # after `--` → hookArgs
|
||||
```
|
||||
|
||||
**Output contract.** `test` behaves like a test runner: by default the human report (with the summary as the last line) goes to **stdout** — read the tail of stdout + exit code. The machine report is opt-in via `--report`: `--report=path` writes it to a file (default JSON; XML for `--format=junit`), `--report=-` writes it to stdout while progress moves to stderr. Allure needs `--format=allure` + a directory (`-` is invalid for allure). For detailed triage use `--report=path` or `--report=-`. **In `--report=-` mode never use `2>&1`** — it merges stderr progress into the stdout JSON. (In the default mode there is no JSON in stdout, so `… | tail` is safe.)
|
||||
|
||||
### Allure static config — `_allure/`
|
||||
|
||||
The runner copies `<testDir>/_allure/` into the report directory before generating Allure output. Drop in `categories.json` (regex-based failure classification — useful for 1C-specific buckets: license pool exhaustion, platform exceptions, runner timeouts, assertion failures), `environment.properties` (optional, often emitted dynamically by `prepare()`), `executor.json` (CI metadata, skip locally). The underscore prefix keeps the directory out of test discovery.
|
||||
|
||||
## Severity guidance
|
||||
|
||||
When the user doesn't dictate, default to:
|
||||
|
||||
| Test kind | Severity |
|
||||
|-----------|----------|
|
||||
| Login + section navigation, basic CRUD on covered entities | `critical` (also tag `smoke`) |
|
||||
| Documents posting, report generation, end-to-end processes | `critical` |
|
||||
| Field-level edge cases, formatting, optional flows | `normal` |
|
||||
| Cosmetic / recording / non-functional | `minor` |
|
||||
| Reserved for show-stopper protections | `blocker` (use sparingly) |
|
||||
|
||||
Don't promote everything to `critical` — it loses signal in the Allure dashboard.
|
||||
|
||||
## Anti-patterns
|
||||
|
||||
- **Sleeps as a substitute for assertions.** `wait(5)` after `openCommand` is fine; `wait(30)` because something flakes is a bug — wait on `getFormState` instead.
|
||||
- **Retry as a substitute for understanding.** "Not found" twice means the data isn't there or the label is wrong. Don't loop.
|
||||
- **Position-based row identification** (`rows[0]`) when the DB has shared seed data. Filter by a unique marker (`Date.now()` suffix) instead.
|
||||
- **Hand-writing reset code in `afterEach`.** The runner already closes forms and dismisses errors after the hook.
|
||||
- **Cross-test state assumptions.** Each test must start from the desktop and seed its own data. Order-of-execution coupling is a regression-suite trap.
|
||||
- **`tags: ['smoke']` on a 90-second test.** Smoke means fast.
|
||||
- **Skipping recon** because "I know what this catalog looks like." The project's customisation almost certainly differs from stock.
|
||||
|
||||
(General browser-API anti-patterns — raw DOM, `clickElement('Закрыть')` instead of `closeForm()` — live in SKILL.md.)
|
||||
|
||||
## After a run — failure triage
|
||||
|
||||
1. Scan the JSON or Allure summary for `failed`.
|
||||
2. For each failure, read `error.message` + `error.step` + screenshot.
|
||||
3. If `error.onecError.stack` is present — it's a 1C exception, look at the platform trace.
|
||||
4. Classify:
|
||||
- **Test bug** — selector wrong, expectation wrong, race with no anchor → fix the test.
|
||||
- **Application bug** — actual misbehaviour reproduced → report to the user with the failing step name and the platform stack.
|
||||
- **Stand flake** — Apache timeout, login form not loading, license shortage → fix the hook idempotency or session-cleanup logic, not the test.
|
||||
5. After fixes, re-run only the affected files before the full suite.
|
||||
|
||||
Report back to the user with the classification, not raw failure dumps.
|
||||
|
||||
## Reference
|
||||
|
||||
- Browser API: [SKILL.md](SKILL.md)
|
||||
- Video and narration: [recording.md](recording.md)
|
||||
@@ -0,0 +1,56 @@
|
||||
// web-test browser v1.18 — engine facade: re-exports the public API from engine/*
|
||||
// Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
|
||||
/**
|
||||
* Public API of the web-test engine. Pure re-export facade — no logic here.
|
||||
* Implementation lives in `./engine/*`. External callers (run.mjs, exec scripts,
|
||||
* tests) import from this file; engine internals import each other directly.
|
||||
*/
|
||||
|
||||
// ── core ──────────────────────────────────────────────────────────────────
|
||||
export {
|
||||
isConnected, getPage, ensureConnected, setPreserveClipboard,
|
||||
} from './engine/core/state.mjs';
|
||||
export {
|
||||
pasteText, saveClipboard, restoreClipboard,
|
||||
} from './engine/core/clipboard.mjs';
|
||||
export { getFormState } from './engine/forms/state.mjs';
|
||||
export { fetchErrorStack } from './engine/core/errors.mjs';
|
||||
export { clickElement } from './engine/core/click.mjs';
|
||||
|
||||
// ── session ───────────────────────────────────────────────────────────────
|
||||
export {
|
||||
connect, disconnect, attach, detach, getSession,
|
||||
createContext, setActiveContext, listContexts, getActiveContext,
|
||||
hasContext, closeContext,
|
||||
} from './engine/core/session.mjs';
|
||||
|
||||
// ── navigation ────────────────────────────────────────────────────────────
|
||||
export {
|
||||
getPageState, getSections, navigateSection, getCommands,
|
||||
openCommand, switchTab, openFile, navigateLink,
|
||||
} from './engine/nav/navigation.mjs';
|
||||
|
||||
// ── forms ─────────────────────────────────────────────────────────────────
|
||||
export { selectValue } from './engine/forms/select-value.mjs';
|
||||
export { fillFields, fillField } from './engine/forms/fill.mjs';
|
||||
export { closeForm } from './engine/forms/close.mjs';
|
||||
|
||||
// ── tables ────────────────────────────────────────────────────────────────
|
||||
export { readTable, deleteTableRow } from './engine/table/grid.mjs';
|
||||
export { readSpreadsheet } from './engine/spreadsheet/spreadsheet.mjs';
|
||||
export { fillTableRow } from './engine/table/row-fill.mjs';
|
||||
export { filterList, unfilterList } from './engine/table/filter.mjs';
|
||||
|
||||
// ── recording / overlays ──────────────────────────────────────────────────
|
||||
export {
|
||||
screenshot, wait, isRecording, startRecording, stopRecording,
|
||||
} from './engine/recording/capture.mjs';
|
||||
export {
|
||||
showCaption, hideCaption, getCaptions,
|
||||
showTitleSlide, hideTitleSlide,
|
||||
showImage, hideImage,
|
||||
} from './engine/recording/captions.mjs';
|
||||
export {
|
||||
highlight, unhighlight, setHighlight, isHighlightMode,
|
||||
} from './engine/recording/highlight.mjs';
|
||||
export { addNarration } from './engine/recording/narration.mjs';
|
||||
@@ -0,0 +1,36 @@
|
||||
// web-test cli/commands/exec v1.0 — send script to running server
|
||||
// Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
|
||||
import http from 'http';
|
||||
import { readFileSync } from 'fs';
|
||||
import { resolve } from 'path';
|
||||
import { out, die, readStdin } from '../util.mjs';
|
||||
import { loadSession } from '../session.mjs';
|
||||
|
||||
export async function cmdExec(fileOrDash, flags = {}) {
|
||||
if (!fileOrDash) die('Usage: node src/run.mjs exec <file|-> [--no-record]');
|
||||
|
||||
const code = fileOrDash === '-'
|
||||
? await readStdin()
|
||||
: readFileSync(resolve(fileOrDash), 'utf-8');
|
||||
|
||||
const sess = loadSession();
|
||||
const headers = {};
|
||||
if (flags.noRecord) headers['x-no-record'] = '1';
|
||||
const timeoutMs = flags.execTimeoutMs ?? 30 * 60 * 1000;
|
||||
const result = await new Promise((resolveP, reject) => {
|
||||
const req = http.request({
|
||||
hostname: '127.0.0.1', port: sess.port, path: '/exec',
|
||||
method: 'POST', timeout: timeoutMs, headers,
|
||||
}, res => {
|
||||
let data = '';
|
||||
res.on('data', chunk => data += chunk);
|
||||
res.on('end', () => { try { resolveP(JSON.parse(data)); } catch { reject(new Error(data)); } });
|
||||
});
|
||||
req.on('error', reject);
|
||||
req.on('timeout', () => { req.destroy(new Error(`Exec timeout (${Math.round(timeoutMs / 60000)} min)`)); });
|
||||
req.write(code);
|
||||
req.end();
|
||||
});
|
||||
out(result);
|
||||
if (!result.ok) process.exit(1);
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
// web-test cli/commands/run v1.0 — autonomous connect → exec → disconnect (no server)
|
||||
// Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
|
||||
import { readFileSync } from 'fs';
|
||||
import { resolve } from 'path';
|
||||
import * as browser from '../../browser.mjs';
|
||||
import { out, die, readStdin } from '../util.mjs';
|
||||
import { executeScript } from '../exec-context.mjs';
|
||||
|
||||
export async function cmdRun(url, fileOrDash) {
|
||||
if (!url || !fileOrDash) die('Usage: node src/run.mjs run <url> <file|->');
|
||||
|
||||
const code = fileOrDash === '-'
|
||||
? await readStdin()
|
||||
: readFileSync(resolve(fileOrDash), 'utf-8');
|
||||
|
||||
await browser.connect(url);
|
||||
const result = await executeScript(code);
|
||||
await browser.disconnect();
|
||||
|
||||
out(result);
|
||||
if (!result.ok) process.exit(1);
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
// web-test cli/commands/shot v1.0 — take screenshot via server
|
||||
// Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
|
||||
import { writeFileSync } from 'fs';
|
||||
import { out, die } from '../util.mjs';
|
||||
import { loadSession } from '../session.mjs';
|
||||
|
||||
export 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 });
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
// web-test cli/commands/start v1.0
|
||||
// Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
|
||||
import http from 'http';
|
||||
import { writeFileSync } from 'fs';
|
||||
import * as browser from '../../browser.mjs';
|
||||
import { out, die } from '../util.mjs';
|
||||
import { SESSION_FILE, cleanup } from '../session.mjs';
|
||||
import { handleRequest } from '../server.mjs';
|
||||
|
||||
export async function cmdStart(url) {
|
||||
if (!url) die('Usage: node src/run.mjs start <url>');
|
||||
|
||||
const state = await browser.connect(url);
|
||||
|
||||
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);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
// web-test cli/commands/status v1.0 — check session
|
||||
// Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
|
||||
import { existsSync, readFileSync } from 'fs';
|
||||
import { out } from '../util.mjs';
|
||||
import { SESSION_FILE } from '../session.mjs';
|
||||
|
||||
export 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 });
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
// web-test cli/commands/stop v1.0 — send stop to server
|
||||
// Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
|
||||
import { out } from '../util.mjs';
|
||||
import { loadSession, cleanup } from '../session.mjs';
|
||||
|
||||
export 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();
|
||||
}
|
||||
@@ -0,0 +1,449 @@
|
||||
// web-test cli/commands/test v1.2 — regression test runner
|
||||
// Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
|
||||
import { existsSync, writeFileSync, mkdirSync } from 'fs';
|
||||
import { resolve, dirname, basename, relative } from 'path';
|
||||
import * as browser from '../../browser.mjs';
|
||||
import { out, die, elapsed, slugify, formatDuration, interpolate, printSteps } from '../util.mjs';
|
||||
import { buildContext, buildScopedContext } from '../exec-context.mjs';
|
||||
import { createAssertions } from '../test-runner/assertions.mjs';
|
||||
import { buildSeverityIndex } from '../test-runner/severity.mjs';
|
||||
import { writeAllure, buildJUnit, syncAllureExtras } from '../test-runner/reporters.mjs';
|
||||
import { discoverTests, resetState } from '../test-runner/discover.mjs';
|
||||
|
||||
export async function cmdTest(rawArgs) {
|
||||
// Split off everything after `--` — those args belong to user-defined hooks
|
||||
// (see spec §6: "all arguments after `--` are forwarded verbatim to _hooks.mjs
|
||||
// via the hookArgs field; the runner does not interpret them").
|
||||
const sepIdx = rawArgs.indexOf('--');
|
||||
const ownArgs = sepIdx >= 0 ? rawArgs.slice(0, sepIdx) : rawArgs;
|
||||
const hookArgs = sepIdx >= 0 ? rawArgs.slice(sepIdx + 1) : [];
|
||||
|
||||
// Parse flags
|
||||
const opts = { bail: false, retry: 0, timeout: 30000, report: null, format: 'json', screenshot: null, reportDir: null, record: false };
|
||||
let tags = null, grep = null;
|
||||
const positional = [];
|
||||
for (const a of ownArgs) {
|
||||
if (a.startsWith('--tags=')) tags = a.slice(7).split(',');
|
||||
else if (a.startsWith('--grep=')) grep = new RegExp(a.slice(7), 'i');
|
||||
else if (a === '--bail') opts.bail = true;
|
||||
else if (a.startsWith('--retry=')) opts.retry = parseInt(a.slice(8)) || 0;
|
||||
else if (a.startsWith('--timeout=')) opts.timeout = parseInt(a.slice(10)) || 30000;
|
||||
else if (a.startsWith('--report=')) opts.report = a.slice(9);
|
||||
else if (a.startsWith('--format=')) opts.format = a.slice(9);
|
||||
else if (a.startsWith('--screenshot=')) opts.screenshot = a.slice(13);
|
||||
else if (a.startsWith('--report-dir=')) opts.reportDir = a.slice(13);
|
||||
else if (a === '--record') opts.record = true;
|
||||
else if (!a.startsWith('--')) positional.push(a);
|
||||
}
|
||||
|
||||
// Determine URL and test path
|
||||
let url, testPath;
|
||||
if (positional.length === 2) {
|
||||
url = positional[0];
|
||||
testPath = resolve(positional[1]);
|
||||
} else if (positional.length === 1) {
|
||||
testPath = resolve(positional[0]);
|
||||
} else {
|
||||
die('Usage: node run.mjs test [url] <dir|file> [--tags=...] [--bail] [--retry=N] [--timeout=ms] [--report=path]');
|
||||
}
|
||||
|
||||
// Load config if exists
|
||||
const isFile = testPath.endsWith('.test.mjs');
|
||||
const testDir = isFile ? dirname(testPath) : testPath;
|
||||
const configPath = resolve(testDir, 'webtest.config.mjs');
|
||||
let config = {};
|
||||
if (existsSync(configPath)) {
|
||||
const mod = await import('file:///' + configPath.replace(/\\/g, '/'));
|
||||
config = mod.default || {};
|
||||
}
|
||||
const severityIndex = buildSeverityIndex(config);
|
||||
|
||||
// Build context registry: name → url. Supports config.contexts or single config.url / CLI url.
|
||||
const contextSpecs = {};
|
||||
let defaultContextName = 'default';
|
||||
const defaultIsolation = config.isolation || 'tab';
|
||||
if (config.contexts && typeof config.contexts === 'object' && Object.keys(config.contexts).length) {
|
||||
for (const [n, spec] of Object.entries(config.contexts)) {
|
||||
contextSpecs[n] = { ...spec };
|
||||
}
|
||||
defaultContextName = config.defaultContext || Object.keys(config.contexts)[0];
|
||||
if (url) contextSpecs[defaultContextName] = { ...contextSpecs[defaultContextName], url };
|
||||
} else {
|
||||
const fallbackUrl = url || config.url;
|
||||
if (!fallbackUrl) die('No URL provided and no webtest.config.mjs found');
|
||||
contextSpecs.default = { url: fallbackUrl };
|
||||
}
|
||||
if (!contextSpecs[defaultContextName]) {
|
||||
die(`defaultContext "${defaultContextName}" not found in contexts: [${Object.keys(contextSpecs).join(', ')}]`);
|
||||
}
|
||||
if (!url) url = contextSpecs[defaultContextName].url;
|
||||
|
||||
// Apply config defaults (CLI flags override)
|
||||
if (!tags && config.tags) tags = config.tags;
|
||||
opts.timeout = ownArgs.some(a => a.startsWith('--timeout=')) ? opts.timeout : (config.timeout || opts.timeout);
|
||||
opts.retry = ownArgs.some(a => a.startsWith('--retry=')) ? opts.retry : (config.retries || opts.retry);
|
||||
if (config.preserveClipboard === false && !ownArgs.includes('--no-preserve-clipboard')) {
|
||||
browser.setPreserveClipboard(false);
|
||||
}
|
||||
opts.record = opts.record || !!config.record;
|
||||
opts.screenshot = opts.screenshot || config.screenshot || 'on-failure';
|
||||
if (!['on-failure', 'every-step', 'off'].includes(opts.screenshot)) {
|
||||
die(`Invalid --screenshot=${opts.screenshot} (expected on-failure|every-step|off)`);
|
||||
}
|
||||
if (!['json', 'allure', 'junit'].includes(opts.format)) {
|
||||
die(`Invalid --format=${opts.format} (expected json|allure|junit)`);
|
||||
}
|
||||
if (opts.format === 'junit' && !opts.report) {
|
||||
die('--format=junit requires --report=path.xml');
|
||||
}
|
||||
// `--report=-` means "machine report to stdout" (Unix `-` convention).
|
||||
// Only meaningful for streamable formats (json/junit); allure is a directory.
|
||||
const reportToStdout = opts.report === '-';
|
||||
if (reportToStdout && opts.format === 'allure') {
|
||||
die('--report=- (stdout) is not valid with --format=allure: allure emits a directory of files, not a single stream. Use --report-dir=<dir> instead.');
|
||||
}
|
||||
const reportDir = opts.reportDir
|
||||
? resolve(opts.reportDir)
|
||||
: (opts.report && !reportToStdout ? dirname(resolve(opts.report)) : testDir);
|
||||
if (opts.screenshot !== 'off') {
|
||||
try { mkdirSync(reportDir, { recursive: true }); } catch {}
|
||||
}
|
||||
|
||||
// Discover test files
|
||||
const testFiles = discoverTests(testPath);
|
||||
if (!testFiles.length) die(`No *.test.mjs files found in ${testPath}`);
|
||||
|
||||
// Import and filter tests
|
||||
const tests = [];
|
||||
let hasOnly = false;
|
||||
for (const file of testFiles) {
|
||||
const mod = await import('file:///' + file.replace(/\\/g, '/'));
|
||||
const base = {
|
||||
file: relative(testDir, file).replace(/\\/g, '/'),
|
||||
name: mod.name || basename(file, '.test.mjs'),
|
||||
tags: mod.tags || [],
|
||||
timeout: mod.timeout || opts.timeout,
|
||||
skip: mod.skip || false,
|
||||
only: mod.only || false,
|
||||
setup: mod.setup,
|
||||
teardown: mod.teardown,
|
||||
fn: mod.default,
|
||||
param: undefined,
|
||||
context: mod.context || null,
|
||||
contexts: Array.isArray(mod.contexts) ? mod.contexts : null,
|
||||
severity: typeof mod.severity === 'string' ? mod.severity : null,
|
||||
};
|
||||
if (base.only) hasOnly = true;
|
||||
if (Array.isArray(mod.params) && mod.params.length) {
|
||||
for (let i = 0; i < mod.params.length; i++) {
|
||||
const p = mod.params[i];
|
||||
const name = base.name.includes('{') ? interpolate(base.name, p) : `${base.name}[${i}]`;
|
||||
tests.push({ ...base, name, param: p });
|
||||
}
|
||||
} else {
|
||||
tests.push(base);
|
||||
}
|
||||
}
|
||||
|
||||
// Filter
|
||||
const filtered = tests.filter(t => {
|
||||
if (hasOnly && !t.only) return false;
|
||||
if (tags && !tags.some(tag => t.tags.includes(tag))) return false;
|
||||
if (grep && !grep.test(t.name)) return false;
|
||||
return true;
|
||||
});
|
||||
|
||||
// Load hooks
|
||||
const hooksPath = resolve(testDir, '_hooks.mjs');
|
||||
let hooks = {};
|
||||
if (existsSync(hooksPath)) {
|
||||
hooks = await import('file:///' + hooksPath.replace(/\\/g, '/'));
|
||||
}
|
||||
|
||||
// Human-readable report goes to stdout (test-runner convention: jest/pytest/playwright).
|
||||
// In `--report -` mode the machine JSON/XML takes over stdout, so progress moves to stderr.
|
||||
const W = reportToStdout ? process.stderr : process.stdout;
|
||||
W.write(`\nweb-test -- ${url}\n`);
|
||||
W.write(`Running ${filtered.length} tests from ${relative(process.cwd(), testDir).replace(/\\/g, '/') || '.'}/\n\n`);
|
||||
|
||||
const startedAt = new Date().toISOString();
|
||||
const results = [];
|
||||
let passCount = 0, failCount = 0, skipCount = 0;
|
||||
|
||||
const hookLog = (...a) => W.write(`[hooks] ${a.map(String).join(' ')}\n`);
|
||||
const hookEnv = { hookArgs, log: hookLog, config };
|
||||
if (hooks.prepare) await hooks.prepare(hookEnv);
|
||||
|
||||
// Lazy context creation
|
||||
async function ensureContext(name) {
|
||||
if (browser.hasContext(name)) return;
|
||||
const spec = contextSpecs[name];
|
||||
if (!spec) throw new Error(`Unknown context "${name}". Defined: [${Object.keys(contextSpecs).join(', ')}]`);
|
||||
await browser.createContext(name, spec.url, { isolation: spec.isolation || defaultIsolation });
|
||||
if (hooks.afterOpenContext && hookCtx) {
|
||||
try { await hooks.afterOpenContext(hookCtx, name, spec); }
|
||||
catch (e) { hookLog(`afterOpenContext("${name}") threw: ${e.message.split('\n')[0]}`); }
|
||||
}
|
||||
}
|
||||
|
||||
let hookCtx = null;
|
||||
|
||||
function wrapCloseContextHook(target) {
|
||||
const orig = target.closeContext;
|
||||
if (typeof orig !== 'function') return;
|
||||
target.closeContext = async (name) => {
|
||||
if (hooks.beforeCloseContext) {
|
||||
try { await hooks.beforeCloseContext(target, name, contextSpecs[name]); }
|
||||
catch (e) { hookLog(`beforeCloseContext("${name}") threw: ${e.message.split('\n')[0]}`); }
|
||||
}
|
||||
return await orig(name);
|
||||
};
|
||||
}
|
||||
|
||||
try {
|
||||
// Connect: create default context up front
|
||||
await ensureContext(defaultContextName);
|
||||
|
||||
const ctx = buildContext({ noRecord: false });
|
||||
ctx.assert = createAssertions();
|
||||
ctx.log = (...a) => { /* per-test, overridden below */ };
|
||||
wrapCloseContextHook(ctx);
|
||||
hookCtx = ctx;
|
||||
|
||||
// Default context was created BEFORE hookCtx existed → fire afterOpenContext now.
|
||||
if (hooks.afterOpenContext) {
|
||||
try { await hooks.afterOpenContext(ctx, defaultContextName, contextSpecs[defaultContextName]); }
|
||||
catch (e) { hookLog(`afterOpenContext("${defaultContextName}") threw: ${e.message.split('\n')[0]}`); }
|
||||
}
|
||||
|
||||
if (hooks.beforeAll) await hooks.beforeAll(ctx);
|
||||
|
||||
let testIdx = 0;
|
||||
for (const t of filtered) {
|
||||
testIdx++;
|
||||
const declaredContexts = t.contexts && t.contexts.length
|
||||
? t.contexts
|
||||
: [t.context || defaultContextName];
|
||||
|
||||
if (t.skip) {
|
||||
const reason = typeof t.skip === 'string' ? t.skip : '';
|
||||
W.write(` ○ ${t.name}${reason ? ` (skip: ${reason})` : ' (skip)'}\n`);
|
||||
results.push({ name: t.name, file: t.file, tags: t.tags, contexts: declaredContexts, status: 'skipped', duration: 0, attempts: 0, steps: [], output: '', error: null, screenshot: null });
|
||||
skipCount++;
|
||||
continue;
|
||||
}
|
||||
|
||||
const testContextNames = declaredContexts;
|
||||
try {
|
||||
for (const cn of testContextNames) await ensureContext(cn);
|
||||
await browser.setActiveContext(testContextNames[0]);
|
||||
} catch (e) {
|
||||
W.write(` ✗ ${t.name} (context setup failed: ${e.message})\n`);
|
||||
results.push({ name: t.name, file: t.file, tags: t.tags, contexts: declaredContexts, status: 'failed', duration: 0, attempts: 0, steps: [], output: '', error: { message: e.message }, screenshot: null });
|
||||
failCount++;
|
||||
if (opts.bail) break;
|
||||
continue;
|
||||
}
|
||||
|
||||
let lastError = null;
|
||||
let testResult = null;
|
||||
const maxAttempts = 1 + opts.retry;
|
||||
|
||||
for (let attempt = 1; attempt <= maxAttempts; attempt++) {
|
||||
const output = [];
|
||||
let steps = [];
|
||||
let currentSteps = steps;
|
||||
let stepIdx = 0;
|
||||
const t0 = Date.now();
|
||||
|
||||
ctx.testInfo = {
|
||||
name: t.name,
|
||||
file: basename(t.file),
|
||||
filePath: t.file,
|
||||
tags: t.tags,
|
||||
timeout: t.timeout,
|
||||
attempt,
|
||||
maxAttempts,
|
||||
param: t.param,
|
||||
contexts: Object.fromEntries(testContextNames.map(n => [n, contextSpecs[n]])),
|
||||
primaryContext: testContextNames[0],
|
||||
};
|
||||
ctx.testResult = null;
|
||||
|
||||
let videoFile = null;
|
||||
if (opts.record) {
|
||||
videoFile = resolve(reportDir, `${testIdx}-${slugify(t.name)}.mp4`);
|
||||
try { await browser.startRecording(videoFile, { force: true }); } catch { videoFile = null; }
|
||||
}
|
||||
|
||||
ctx.log = (...a) => output.push(a.map(String).join(' '));
|
||||
ctx.step = async (name, fn) => {
|
||||
const s = { name, start: Date.now(), status: 'passed', steps: [] };
|
||||
currentSteps.push(s);
|
||||
const prev = currentSteps;
|
||||
currentSteps = s.steps;
|
||||
stepIdx++;
|
||||
const myIdx = stepIdx;
|
||||
try {
|
||||
await fn();
|
||||
} catch (e) {
|
||||
s.status = 'failed';
|
||||
s.error = e.message;
|
||||
throw e;
|
||||
} finally {
|
||||
s.stop = Date.now();
|
||||
currentSteps = prev;
|
||||
if (opts.screenshot === 'every-step' && s.status === 'passed') {
|
||||
try {
|
||||
const slug = slugify(name);
|
||||
const file = resolve(reportDir, `${testIdx}-${myIdx}-${slug}.png`);
|
||||
const png = await browser.screenshot();
|
||||
writeFileSync(file, png);
|
||||
s.screenshot = file;
|
||||
} catch {}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const scopedKeys = [];
|
||||
if (t.contexts && t.contexts.length) {
|
||||
for (const cn of t.contexts) {
|
||||
ctx[cn] = buildScopedContext(cn);
|
||||
wrapCloseContextHook(ctx[cn]);
|
||||
scopedKeys.push(cn);
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
if (hooks.beforeEach) await hooks.beforeEach(ctx);
|
||||
if (t.setup) await t.setup(ctx);
|
||||
|
||||
let timeoutTimer;
|
||||
try {
|
||||
await Promise.race([
|
||||
t.fn(ctx, t.param),
|
||||
new Promise((_, reject) => { timeoutTimer = setTimeout(() => reject(new Error(`Timeout (${t.timeout}ms)`)), t.timeout); }),
|
||||
]);
|
||||
} finally {
|
||||
// Clear the guard timer — otherwise it stays armed in the event loop and,
|
||||
// since the success path never calls process.exit(), node can't exit until
|
||||
// it fires (up to `timeout` ms after the last test finished).
|
||||
clearTimeout(timeoutTimer);
|
||||
}
|
||||
|
||||
if (t.teardown) try { await t.teardown(ctx); } catch {}
|
||||
ctx.testResult = { status: 'passed', duration: elapsed(t0), attempts: attempt, error: null, steps };
|
||||
if (hooks.afterEach) try { await hooks.afterEach(ctx); } catch {}
|
||||
for (const cn of testContextNames) {
|
||||
try { await browser.setActiveContext(cn); await resetState(ctx); } catch {}
|
||||
}
|
||||
for (const k of scopedKeys) delete ctx[k];
|
||||
|
||||
if (videoFile) {
|
||||
try { await browser.stopRecording(); } catch {}
|
||||
}
|
||||
const dur = elapsed(t0);
|
||||
testResult = { name: t.name, file: t.file, tags: t.tags, contexts: testContextNames, severity: t.severity, status: 'passed', duration: dur, attempts: attempt, start: t0, stop: Date.now(), steps, output: output.join('\n'), error: null, screenshot: null, video: videoFile };
|
||||
lastError = null;
|
||||
break;
|
||||
|
||||
} catch (e) {
|
||||
// Screenshot on failure FIRST — before teardown/afterEach/resetState reset the UI.
|
||||
let shotFile = e.onecError?.screenshot;
|
||||
if (!shotFile && opts.screenshot !== 'off') {
|
||||
try {
|
||||
const png = await browser.screenshot();
|
||||
shotFile = resolve(reportDir, `error-${testIdx}-${slugify(t.file.replace(/\.test\.mjs$/, ''))}.png`);
|
||||
writeFileSync(shotFile, png);
|
||||
} catch {}
|
||||
}
|
||||
|
||||
if (t.teardown) try { await t.teardown(ctx); } catch {}
|
||||
const errInfo = { message: e.message, step: e.onecError?.step, screenshot: shotFile, onecError: e.onecError };
|
||||
ctx.testResult = { status: 'failed', duration: elapsed(t0), attempts: attempt, error: errInfo, steps };
|
||||
if (hooks.afterEach) try { await hooks.afterEach(ctx); } catch {}
|
||||
for (const cn of testContextNames) {
|
||||
try { await browser.setActiveContext(cn); await resetState(ctx); } catch {}
|
||||
}
|
||||
for (const k of scopedKeys) delete ctx[k];
|
||||
|
||||
if (videoFile) {
|
||||
try { await browser.stopRecording(); } catch {}
|
||||
}
|
||||
lastError = errInfo;
|
||||
const dur = elapsed(t0);
|
||||
testResult = { name: t.name, file: t.file, tags: t.tags, contexts: testContextNames, severity: t.severity, status: 'failed', duration: dur, attempts: attempt, start: t0, stop: Date.now(), steps, output: output.join('\n'), error: errInfo, screenshot: shotFile, video: videoFile };
|
||||
}
|
||||
}
|
||||
|
||||
results.push(testResult);
|
||||
|
||||
if (testResult.status === 'passed') {
|
||||
passCount++;
|
||||
W.write(` ✓ ${t.name} (${testResult.duration}s)\n`);
|
||||
} else {
|
||||
failCount++;
|
||||
W.write(` ✗ ${t.name} (${testResult.duration}s)\n`);
|
||||
printSteps(W, testResult.steps, ' ');
|
||||
if (lastError?.message) W.write(` ${lastError.message}\n`);
|
||||
if (lastError?.screenshot) W.write(` screenshot: ${lastError.screenshot}\n`);
|
||||
}
|
||||
|
||||
if (opts.bail && testResult.status === 'failed') break;
|
||||
}
|
||||
|
||||
if (hooks.afterAll) try { await hooks.afterAll(ctx); } catch {}
|
||||
|
||||
} finally {
|
||||
// Per-context teardown
|
||||
try {
|
||||
const remaining = browser.listContexts();
|
||||
if (remaining.length > 0) {
|
||||
const survivor = remaining[0];
|
||||
try { await browser.setActiveContext(survivor); } catch {}
|
||||
for (let i = remaining.length - 1; i >= 1; i--) {
|
||||
const name = remaining[i];
|
||||
if (hooks.beforeCloseContext && hookCtx) {
|
||||
try { await hooks.beforeCloseContext(hookCtx, name, contextSpecs[name]); }
|
||||
catch (e) { hookLog(`beforeCloseContext("${name}") threw: ${e.message.split('\n')[0]}`); }
|
||||
}
|
||||
try { await browser.closeContext(name); }
|
||||
catch (e) { hookLog(`closeContext("${name}") failed: ${e.message.split('\n')[0]}`); }
|
||||
}
|
||||
if (hooks.beforeCloseContext && hookCtx) {
|
||||
try { await hooks.beforeCloseContext(hookCtx, survivor, contextSpecs[survivor]); }
|
||||
catch (e) { hookLog(`beforeCloseContext("${survivor}") threw: ${e.message.split('\n')[0]}`); }
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
hookLog(`final teardown loop failed: ${e.message.split('\n')[0]}`);
|
||||
}
|
||||
try { await browser.disconnect(); } catch {}
|
||||
if (hooks.cleanup) try { await hooks.cleanup(hookEnv); } catch {}
|
||||
}
|
||||
|
||||
const finishedAt = new Date().toISOString();
|
||||
const totalDuration = results.reduce((s, r) => s + r.duration, 0);
|
||||
|
||||
W.write(`\n${passCount} passed, ${failCount} failed, ${skipCount} skipped (${formatDuration(totalDuration)})\n\n`);
|
||||
|
||||
const report = {
|
||||
runner: 'web-test', url, startedAt, finishedAt,
|
||||
duration: totalDuration,
|
||||
summary: { total: results.length, passed: passCount, failed: failCount, skipped: skipCount },
|
||||
tests: results,
|
||||
};
|
||||
if (opts.format === 'allure') {
|
||||
writeAllure(results, reportDir, severityIndex);
|
||||
syncAllureExtras(testDir, reportDir);
|
||||
} else if (opts.format === 'junit') {
|
||||
if (reportToStdout) process.stdout.write(buildJUnit(report, testDir) + '\n');
|
||||
else writeFileSync(resolve(opts.report), buildJUnit(report, testDir));
|
||||
} else if (reportToStdout) {
|
||||
out(report);
|
||||
} else if (opts.report) {
|
||||
writeFileSync(resolve(opts.report), JSON.stringify(report, null, 2));
|
||||
}
|
||||
|
||||
if (failCount > 0) process.exit(1);
|
||||
}
|
||||
@@ -0,0 +1,148 @@
|
||||
// web-test cli/exec-context v1.0 — buildContext + executeScript для run/exec/test
|
||||
// Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
|
||||
import { readFileSync, writeFileSync } from 'fs';
|
||||
import { resolve, dirname } from 'path';
|
||||
import { fileURLToPath } from 'url';
|
||||
import * as browser from '../browser.mjs';
|
||||
import { elapsed } from './util.mjs';
|
||||
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||
const ERROR_SHOT_PATH = resolve(__dirname, '..', '..', 'error-shot.png');
|
||||
|
||||
/**
|
||||
* Build a per-context wrapper: same shape as buildContext output, but every call
|
||||
* is prefixed with `setActiveContext(name)` so the test can interleave actions
|
||||
* across contexts (`ctx.a.click(...); ctx.b.click(...)`).
|
||||
*/
|
||||
export function buildScopedContext(name) {
|
||||
const inner = buildContext({ noRecord: false });
|
||||
const scoped = {};
|
||||
for (const [k, v] of Object.entries(inner)) {
|
||||
if (typeof v === 'function') {
|
||||
scoped[k] = async (...args) => {
|
||||
await browser.setActiveContext(name);
|
||||
return v(...args);
|
||||
};
|
||||
} else {
|
||||
scoped[k] = v;
|
||||
}
|
||||
}
|
||||
return scoped;
|
||||
}
|
||||
|
||||
export function buildContext({ noRecord = false } = {}) {
|
||||
const ctx = {};
|
||||
for (const [k, v] of Object.entries(browser)) {
|
||||
if (k !== 'default') ctx[k] = v;
|
||||
}
|
||||
ctx.writeFileSync = writeFileSync;
|
||||
ctx.readFileSync = readFileSync;
|
||||
|
||||
// --no-record: stub recording/narration functions to return safe defaults
|
||||
if (noRecord) {
|
||||
const noop = async () => {};
|
||||
ctx.startRecording = noop;
|
||||
ctx.stopRecording = async () => ({ file: null, duration: 0, size: 0 });
|
||||
ctx.addNarration = async () => ({ file: null, duration: 0, size: 0, captions: 0 });
|
||||
for (const fn of ['showCaption', 'hideCaption']) {
|
||||
ctx[fn] = noop;
|
||||
}
|
||||
ctx.isRecording = () => false;
|
||||
ctx.getCaptions = () => [];
|
||||
}
|
||||
|
||||
// Wrap action functions to auto-detect 1C errors (modal, balloon)
|
||||
// and stop execution immediately with diagnostic info
|
||||
const ACTION_FNS = [
|
||||
'clickElement', 'fillFields', 'fillField', 'selectValue', 'fillTableRow',
|
||||
'deleteTableRow', 'openCommand', 'navigateSection', 'navigateLink', 'openFile',
|
||||
'closeForm', 'filterList', 'unfilterList'
|
||||
];
|
||||
for (const name of ACTION_FNS) {
|
||||
if (typeof ctx[name] !== 'function') continue;
|
||||
const orig = ctx[name];
|
||||
ctx[name] = async (...args) => {
|
||||
const result = await orig(...args);
|
||||
const errors = result?.errors;
|
||||
if (errors?.modal || errors?.balloon) {
|
||||
// Screenshot while the error modal is still visible (before fetchErrorStack closes it)
|
||||
let errorShot;
|
||||
try {
|
||||
const png = await ctx.screenshot();
|
||||
errorShot = ERROR_SHOT_PATH;
|
||||
writeFileSync(errorShot, png);
|
||||
} catch {}
|
||||
// Try to fetch call stack for modal errors before throwing
|
||||
let stack = null;
|
||||
if (errors?.modal && typeof ctx.fetchErrorStack === 'function') {
|
||||
try {
|
||||
stack = await ctx.fetchErrorStack(errors.modal.formNum, errors.modal.hasReport);
|
||||
} catch { /* don't fail if stack fetch fails */ }
|
||||
}
|
||||
const msg = errors.modal?.message || errors.balloon?.message || 'Unknown 1C error';
|
||||
const err = new Error(msg);
|
||||
err.onecError = { step: name, args, errors, formState: result, stack, screenshot: errorShot };
|
||||
throw err;
|
||||
}
|
||||
return result;
|
||||
};
|
||||
}
|
||||
|
||||
return ctx;
|
||||
}
|
||||
|
||||
export async function executeScript(code, { noRecord } = {}) {
|
||||
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 {
|
||||
const ctx = buildContext({ noRecord });
|
||||
|
||||
// Normalize Windows backslash paths to prevent JS parse errors
|
||||
// (e.g. C:\Users\... → \u triggers "Invalid Unicode escape sequence")
|
||||
code = code.replace(/[A-Za-z]:\\[^\s'"`;\n)}\]]+/g, m => m.replace(/\\/g, '/'));
|
||||
|
||||
const AsyncFunction = Object.getPrototypeOf(async function(){}).constructor;
|
||||
const fn = new AsyncFunction(...Object.keys(ctx), code);
|
||||
await fn(...Object.values(ctx));
|
||||
|
||||
console.log = origLog;
|
||||
console.error = origErr;
|
||||
return { ok: true, output: output.join('\n'), elapsed: elapsed(t0) };
|
||||
} catch (e) {
|
||||
console.log = origLog;
|
||||
console.error = origErr;
|
||||
|
||||
// Auto-stop recording if active (prevents "Already recording" on next exec)
|
||||
if (browser.isRecording()) {
|
||||
try { await browser.stopRecording(); } catch {}
|
||||
}
|
||||
|
||||
// Error screenshot (skip if already taken before fetchErrorStack closed the modal)
|
||||
let shotFile = e.onecError?.screenshot;
|
||||
if (!shotFile) {
|
||||
try {
|
||||
const png = await browser.screenshot();
|
||||
shotFile = ERROR_SHOT_PATH;
|
||||
writeFileSync(shotFile, png);
|
||||
} catch {}
|
||||
}
|
||||
|
||||
const result = { ok: false, error: e.message, output: output.join('\n'), screenshot: shotFile, elapsed: elapsed(t0) };
|
||||
|
||||
// Enrich with 1C error context if available
|
||||
if (e.onecError) {
|
||||
result.step = e.onecError.step;
|
||||
result.stepArgs = e.onecError.args;
|
||||
result.onecErrors = e.onecError.errors;
|
||||
result.formState = e.onecError.formState;
|
||||
if (e.onecError.stack) result.stack = e.onecError.stack;
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
// web-test cli/server v1.0 — HTTP server для exec/shot/stop/status в процессе start
|
||||
// Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
|
||||
import * as browser from '../browser.mjs';
|
||||
import { json, readBody } from './util.mjs';
|
||||
import { cleanup } from './session.mjs';
|
||||
import { executeScript } from './exec-context.mjs';
|
||||
|
||||
export async function handleRequest(req, res) {
|
||||
try {
|
||||
if (req.method === 'POST' && req.url === '/exec') {
|
||||
const code = await readBody(req);
|
||||
const noRecord = req.headers['x-no-record'] === '1';
|
||||
const result = await executeScript(code, { noRecord });
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
// web-test cli/session v1.0 — session-file helpers for HTTP-server mode
|
||||
// Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
|
||||
import { existsSync, readFileSync, unlinkSync } from 'fs';
|
||||
import { resolve, dirname } from 'path';
|
||||
import { fileURLToPath } from 'url';
|
||||
import { die } from './util.mjs';
|
||||
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||
export const SESSION_FILE = resolve(__dirname, '..', '..', '.browser-session.json');
|
||||
|
||||
export 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'));
|
||||
}
|
||||
|
||||
export function cleanup() {
|
||||
try { unlinkSync(SESSION_FILE); } catch {}
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
// web-test cli/test-runner/assertions v1.0 — ctx.assert API
|
||||
// Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
|
||||
|
||||
export function createAssertions() {
|
||||
class AssertionError extends Error {
|
||||
constructor(msg, actual, expected) {
|
||||
super(msg);
|
||||
this.name = 'AssertionError';
|
||||
this.actual = actual;
|
||||
this.expected = expected;
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
ok(value, msg) {
|
||||
if (!value) throw new AssertionError(msg || `Expected truthy, got ${JSON.stringify(value)}`, value, true);
|
||||
},
|
||||
equal(actual, expected, msg) {
|
||||
if (actual !== expected) throw new AssertionError(msg || `Expected ${JSON.stringify(expected)}, got ${JSON.stringify(actual)}`, actual, expected);
|
||||
},
|
||||
notEqual(actual, expected, msg) {
|
||||
if (actual === expected) throw new AssertionError(msg || `Expected not ${JSON.stringify(expected)}`, actual, expected);
|
||||
},
|
||||
deepEqual(actual, expected, msg) {
|
||||
const a = JSON.stringify(actual), b = JSON.stringify(expected);
|
||||
if (a !== b) throw new AssertionError(msg || `Deep equal failed:\n actual: ${a}\n expected: ${b}`, actual, expected);
|
||||
},
|
||||
includes(haystack, needle, msg) {
|
||||
const h = Array.isArray(haystack) ? haystack : String(haystack);
|
||||
if (!h.includes(needle)) throw new AssertionError(msg || `Expected ${JSON.stringify(h)} to include ${JSON.stringify(needle)}`, haystack, needle);
|
||||
},
|
||||
match(string, regex, msg) {
|
||||
if (!regex.test(string)) throw new AssertionError(msg || `Expected ${JSON.stringify(string)} to match ${regex}`, string, regex);
|
||||
},
|
||||
async throws(fn, msg) {
|
||||
try { await fn(); } catch { return; }
|
||||
throw new AssertionError(msg || 'Expected function to throw');
|
||||
},
|
||||
// 1C-specific
|
||||
formHasField(state, fieldName, msg) {
|
||||
if (!state?.fields?.[fieldName]) throw new AssertionError(msg || `Field "${fieldName}" not found in form. Available: ${Object.keys(state?.fields || {}).join(', ')}`, null, fieldName);
|
||||
},
|
||||
formTitle(state, expected, msg) {
|
||||
if (!state?.title?.includes(expected)) throw new AssertionError(msg || `Form title "${state?.title}" does not contain "${expected}"`, state?.title, expected);
|
||||
},
|
||||
tableHasRow(table, predicate, msg) {
|
||||
const rows = table?.rows || [];
|
||||
let found;
|
||||
if (typeof predicate === 'function') {
|
||||
found = rows.some(predicate);
|
||||
} else {
|
||||
found = rows.some(r => Object.entries(predicate).every(([k, v]) => r[k] === v));
|
||||
}
|
||||
if (!found) throw new AssertionError(msg || `No row matching predicate in table (${rows.length} rows)`, null, predicate);
|
||||
},
|
||||
tableRowCount(table, expected, msg) {
|
||||
const actual = table?.rows?.length ?? 0;
|
||||
if (actual !== expected) throw new AssertionError(msg || `Expected ${expected} rows, got ${actual}`, actual, expected);
|
||||
},
|
||||
noErrors(state, msg) {
|
||||
if (state?.errors) throw new AssertionError(msg || `Form has errors: ${JSON.stringify(state.errors)}`, state.errors, null);
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
// web-test cli/test-runner/discover v1.0 — test file discovery + state reset between tests
|
||||
// Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
|
||||
import { existsSync, readdirSync } from 'fs';
|
||||
import { resolve } from 'path';
|
||||
|
||||
export function discoverTests(testPath) {
|
||||
if (testPath.endsWith('.test.mjs')) return existsSync(testPath) ? [testPath] : [];
|
||||
const files = [];
|
||||
function walk(dir) {
|
||||
for (const entry of readdirSync(dir, { withFileTypes: true })) {
|
||||
if (entry.name.startsWith('_') || entry.name.startsWith('.')) continue;
|
||||
const full = resolve(dir, entry.name);
|
||||
if (entry.isDirectory()) walk(full);
|
||||
else if (entry.name.endsWith('.test.mjs')) files.push(full);
|
||||
}
|
||||
}
|
||||
walk(testPath);
|
||||
return files.sort();
|
||||
}
|
||||
|
||||
export async function resetState(ctx) {
|
||||
try { if (typeof ctx.dismissPendingErrors === 'function') await ctx.dismissPendingErrors(); } catch {}
|
||||
for (let i = 0; i < 10; i++) {
|
||||
try {
|
||||
const state = await ctx.getFormState();
|
||||
// form === null means no form open (desktop). form === 0 is a real background form
|
||||
// 1C exposes in some states — must still close it to fully reset.
|
||||
if (state.form == null) break;
|
||||
await ctx.closeForm({ save: false });
|
||||
} catch { break; }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,113 @@
|
||||
// web-test cli/test-runner/reporters v1.0 — Allure/JUnit writers + extras sync
|
||||
// Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
|
||||
import { writeFileSync, existsSync, readdirSync, copyFileSync, statSync } from 'fs';
|
||||
import { resolve, dirname, basename, relative } from 'path';
|
||||
import { randomUUID } from 'crypto';
|
||||
import { xmlEscape } from '../util.mjs';
|
||||
import { resolveSeverity } from './severity.mjs';
|
||||
|
||||
/**
|
||||
* Copy any files from `<testDir>/_allure/` into `reportDir`. Convention for
|
||||
* Allure customization that doesn't fit inside per-test JSON:
|
||||
* - `categories.json` — failure classification (regex → bucket)
|
||||
* - `environment.properties` — values shown in the Environment widget
|
||||
* - `executor.json` — CI/CD metadata
|
||||
* Underscored folder mirrors `_hooks.mjs` convention (infra, not a test).
|
||||
* Silent if folder absent.
|
||||
*/
|
||||
export function syncAllureExtras(testDir, reportDir) {
|
||||
const extrasDir = resolve(testDir, '_allure');
|
||||
if (!existsSync(extrasDir)) return;
|
||||
try {
|
||||
if (!statSync(extrasDir).isDirectory()) return;
|
||||
} catch { return; }
|
||||
for (const entry of readdirSync(extrasDir, { withFileTypes: true })) {
|
||||
if (!entry.isFile()) continue;
|
||||
try { copyFileSync(resolve(extrasDir, entry.name), resolve(reportDir, entry.name)); }
|
||||
catch { /* best-effort */ }
|
||||
}
|
||||
}
|
||||
|
||||
export function writeAllure(results, reportDir, severityIndex) {
|
||||
for (const tr of results) {
|
||||
if (tr.status === 'skipped') continue; // Allure ignores skipped without start/stop
|
||||
const uuid = randomUUID();
|
||||
const suite = dirname(tr.file);
|
||||
const suiteLabel = (suite && suite !== '.') ? suite : 'root';
|
||||
const severity = resolveSeverity(tr, severityIndex);
|
||||
const out = {
|
||||
uuid,
|
||||
name: tr.name,
|
||||
fullName: tr.file,
|
||||
status: tr.status,
|
||||
stage: 'finished',
|
||||
start: tr.start,
|
||||
stop: tr.stop,
|
||||
labels: [
|
||||
...(tr.tags || []).map(t => ({ name: 'tag', value: t })),
|
||||
{ name: 'suite', value: suiteLabel },
|
||||
{ name: 'severity', value: severity },
|
||||
],
|
||||
steps: (tr.steps || []).map(allureStep),
|
||||
attachments: [
|
||||
...(tr.screenshot ? [{ name: 'Screenshot on failure', source: basename(tr.screenshot), type: 'image/png' }] : []),
|
||||
...(tr.video ? [{ name: 'Video', source: basename(tr.video), type: 'video/mp4' }] : []),
|
||||
],
|
||||
};
|
||||
if (tr.status === 'failed' && tr.error) {
|
||||
const traceParts = [];
|
||||
if (tr.output) traceParts.push(tr.output);
|
||||
const onecStack = tr.error.onecError?.stack?.raw;
|
||||
if (onecStack) {
|
||||
if (traceParts.length) traceParts.push('\n--- 1C stack ---\n');
|
||||
traceParts.push(onecStack);
|
||||
}
|
||||
out.statusDetails = { message: tr.error.message || '', trace: traceParts.join('') };
|
||||
}
|
||||
writeFileSync(resolve(reportDir, `${uuid}-result.json`), JSON.stringify(out, null, 2));
|
||||
}
|
||||
}
|
||||
|
||||
function allureStep(s) {
|
||||
const out = {
|
||||
name: s.name,
|
||||
status: s.status,
|
||||
stage: 'finished',
|
||||
start: s.start,
|
||||
stop: s.stop,
|
||||
steps: (s.steps || []).map(allureStep),
|
||||
};
|
||||
if (s.screenshot) {
|
||||
out.attachments = [{ name: 'Screenshot', source: basename(s.screenshot), type: 'image/png' }];
|
||||
}
|
||||
if (s.status === 'failed' && s.error) {
|
||||
out.statusDetails = { message: s.error, trace: s.error };
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
export function buildJUnit(report, testDir) {
|
||||
const { summary, duration, tests } = report;
|
||||
const suiteName = relative(process.cwd(), testDir).replace(/\\/g, '/') || '.';
|
||||
const lines = ['<?xml version="1.0" encoding="UTF-8"?>'];
|
||||
lines.push(`<testsuites name="web-test" tests="${summary.total}" failures="${summary.failed}" skipped="${summary.skipped}" time="${duration.toFixed(3)}">`);
|
||||
lines.push(` <testsuite name="${xmlEscape(suiteName)}" tests="${summary.total}" failures="${summary.failed}" skipped="${summary.skipped}" time="${duration.toFixed(3)}">`);
|
||||
for (const t of tests) {
|
||||
const attrs = `name="${xmlEscape(t.name)}" classname="${xmlEscape(t.file)}" time="${(t.duration || 0).toFixed(3)}"`;
|
||||
if (t.status === 'passed') {
|
||||
lines.push(` <testcase ${attrs}/>`);
|
||||
} else if (t.status === 'skipped') {
|
||||
lines.push(` <testcase ${attrs}><skipped/></testcase>`);
|
||||
} else {
|
||||
lines.push(` <testcase ${attrs}>`);
|
||||
const msg = t.error?.message || '';
|
||||
const trace = t.output || '';
|
||||
lines.push(` <failure message="${xmlEscape(msg)}">${xmlEscape(trace)}</failure>`);
|
||||
if (t.screenshot) lines.push(` <system-out>screenshot: ${xmlEscape(t.screenshot)}</system-out>`);
|
||||
lines.push(` </testcase>`);
|
||||
}
|
||||
}
|
||||
lines.push(` </testsuite>`);
|
||||
lines.push(`</testsuites>`);
|
||||
return lines.join('\n');
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
// web-test cli/test-runner/severity v1.0 — Allure severity policy resolver
|
||||
// Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
|
||||
import { die } from '../util.mjs';
|
||||
|
||||
export const SEVERITY_RANK = { blocker: 5, critical: 4, normal: 3, minor: 2, trivial: 1 };
|
||||
export const SEVERITY_LEVELS = Object.keys(SEVERITY_RANK);
|
||||
|
||||
/**
|
||||
* Validate config.severity (inverted map: severity → [tags]) at config load time.
|
||||
* Returns:
|
||||
* - tagToSeverity: Map<tag, severity> (precomputed lookup for the resolver)
|
||||
* - defaultSeverity: string (validated, defaults to 'normal')
|
||||
* Throws (via die) on invalid keys, invalid default, or duplicate tag across buckets.
|
||||
*/
|
||||
export function buildSeverityIndex(config) {
|
||||
const tagToSeverity = new Map();
|
||||
const sev = config.severity || {};
|
||||
if (typeof sev !== 'object' || Array.isArray(sev)) {
|
||||
die(`config.severity must be an object, got ${typeof sev}`);
|
||||
}
|
||||
for (const [level, tags] of Object.entries(sev)) {
|
||||
if (!SEVERITY_LEVELS.includes(level)) {
|
||||
die(`config.severity: unknown level "${level}". Allowed: ${SEVERITY_LEVELS.join('|')}`);
|
||||
}
|
||||
if (!Array.isArray(tags)) {
|
||||
die(`config.severity.${level} must be an array of tag names, got ${typeof tags}`);
|
||||
}
|
||||
for (const tag of tags) {
|
||||
if (tagToSeverity.has(tag)) {
|
||||
die(`config.severity: tag "${tag}" listed under both "${tagToSeverity.get(tag)}" and "${level}" — pick one`);
|
||||
}
|
||||
tagToSeverity.set(tag, level);
|
||||
}
|
||||
}
|
||||
const def = config.defaultSeverity || 'normal';
|
||||
if (!SEVERITY_LEVELS.includes(def)) {
|
||||
die(`config.defaultSeverity: "${def}" is not a valid level. Allowed: ${SEVERITY_LEVELS.join('|')}`);
|
||||
}
|
||||
return { tagToSeverity, defaultSeverity: def };
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve a test's severity. Precedence:
|
||||
* 1. explicit `export const severity` from the test module
|
||||
* 2. max-rank severity found among tags (either standard severity name, or mapped via config)
|
||||
* 3. defaultSeverity from config (or 'normal' if not set)
|
||||
* Returns one of SEVERITY_LEVELS.
|
||||
*/
|
||||
export function resolveSeverity(t, severityIndex) {
|
||||
if (t.severity) {
|
||||
if (!SEVERITY_LEVELS.includes(t.severity)) {
|
||||
return severityIndex.defaultSeverity;
|
||||
}
|
||||
return t.severity;
|
||||
}
|
||||
let best = null;
|
||||
for (const tag of t.tags || []) {
|
||||
let candidate = null;
|
||||
if (SEVERITY_LEVELS.includes(tag)) candidate = tag;
|
||||
else if (severityIndex.tagToSeverity.has(tag)) candidate = severityIndex.tagToSeverity.get(tag);
|
||||
if (candidate && (best === null || SEVERITY_RANK[candidate] > SEVERITY_RANK[best])) {
|
||||
best = candidate;
|
||||
}
|
||||
}
|
||||
return best || severityIndex.defaultSeverity;
|
||||
}
|
||||
@@ -0,0 +1,112 @@
|
||||
// web-test cli/util v1.1 — generic helpers for CLI commands
|
||||
// Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
|
||||
|
||||
export function out(obj) {
|
||||
process.stdout.write(JSON.stringify(obj, null, 2) + '\n');
|
||||
}
|
||||
|
||||
export function die(msg) {
|
||||
process.stderr.write(msg + '\n');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
export function json(res, obj, status = 200) {
|
||||
res.writeHead(status, { 'Content-Type': 'application/json' });
|
||||
res.end(JSON.stringify(obj, null, 2));
|
||||
}
|
||||
|
||||
export async function readBody(req) {
|
||||
const chunks = [];
|
||||
for await (const chunk of req) chunks.push(chunk);
|
||||
return Buffer.concat(chunks).toString('utf-8');
|
||||
}
|
||||
|
||||
export async function readStdin() {
|
||||
const chunks = [];
|
||||
for await (const chunk of process.stdin) chunks.push(chunk);
|
||||
return Buffer.concat(chunks).toString('utf-8');
|
||||
}
|
||||
|
||||
export function elapsed(t0) {
|
||||
return Math.round((Date.now() - t0) / 100) / 10;
|
||||
}
|
||||
|
||||
export function elapsed2(start, stop) {
|
||||
return Math.round(((stop || Date.now()) - start) / 100) / 10;
|
||||
}
|
||||
|
||||
export function slugify(s) {
|
||||
return String(s).trim()
|
||||
.replace(/[\s/\\:*?"<>|]+/g, '-')
|
||||
.replace(/-+/g, '-')
|
||||
.replace(/^-|-$/g, '')
|
||||
.slice(0, 60) || 'step';
|
||||
}
|
||||
|
||||
export function formatDuration(seconds) {
|
||||
if (seconds < 60) return `${Math.round(seconds * 10) / 10}s`;
|
||||
const m = Math.floor(seconds / 60);
|
||||
const s = Math.round((seconds - m * 60) * 10) / 10;
|
||||
return `${m}m ${s}s`;
|
||||
}
|
||||
|
||||
export function xmlEscape(s) {
|
||||
return String(s == null ? '' : s)
|
||||
.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>')
|
||||
.replace(/"/g, '"').replace(/'/g, ''');
|
||||
}
|
||||
|
||||
export function interpolate(template, params) {
|
||||
return String(template).replace(/\{(\w+)\}/g, (_, key) =>
|
||||
params[key] !== undefined ? String(params[key]) : `{${key}}`);
|
||||
}
|
||||
|
||||
export function printSteps(W, steps, indent) {
|
||||
for (let i = 0; i < steps.length; i++) {
|
||||
const s = steps[i];
|
||||
const last = i === steps.length - 1;
|
||||
const prefix = last ? '└' : '├';
|
||||
const mark = s.status === 'failed' ? '✗ ' : '';
|
||||
W.write(`${indent}${prefix} ${mark}${s.name} (${elapsed2(s.start, s.stop)}s)\n`);
|
||||
if (s.error && s.status === 'failed') {
|
||||
W.write(`${indent} ${s.error}\n`);
|
||||
}
|
||||
if (s.steps.length) printSteps(W, s.steps, indent + ' ');
|
||||
}
|
||||
}
|
||||
|
||||
export function usage() {
|
||||
die(`Usage: node run.mjs <command> [args]
|
||||
|
||||
Commands:
|
||||
start <url> Launch browser and connect to 1C web client
|
||||
run <url> <file|-> Autonomous: connect, execute script, disconnect
|
||||
exec <file|-> [options] Execute script (file path or - for stdin)
|
||||
shot [file] Take screenshot (default: shot.png)
|
||||
stop Logout and close browser
|
||||
status Check session status
|
||||
test [url] <dir|file> Run regression tests (*.test.mjs)
|
||||
|
||||
Options for exec:
|
||||
--no-record Skip video recording (record() becomes no-op)
|
||||
|
||||
Global options (any command):
|
||||
--no-preserve-clipboard Don't save/restore OS clipboard around action calls.
|
||||
Default: on (env: WEB_TEST_PRESERVE_CLIPBOARD=0 to disable globally).
|
||||
|
||||
Options for test:
|
||||
--tags=smoke,crud Filter tests by tags
|
||||
--grep=pattern Filter tests by name (regex)
|
||||
--bail Stop on first failure
|
||||
--retry=N Retry failed tests N times
|
||||
--timeout=ms Per-test timeout (default: 30000)
|
||||
--report=path Write machine report (JSON/JUnit) to file
|
||||
--report=- Write machine report to stdout (progress moves to stderr)
|
||||
--report-dir=path Directory for screenshots and other artifacts
|
||||
--screenshot=mode on-failure (default) | every-step | off
|
||||
--format=fmt json (default) | allure | junit
|
||||
--record Record video for each test (mp4 in report-dir)
|
||||
-- <hook-args...> Everything after \`--\` is forwarded to _hooks.mjs
|
||||
prepare/cleanup as hookArgs (runner does not parse it).
|
||||
Example: ... tests/web-test/ -- --rebuild-stand`);
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
// web-test dom v1.16 — facade re-exporting injectable DOM scripts from dom/
|
||||
// Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
|
||||
/**
|
||||
* Facade: re-exports DOM selector & semantic mapping script generators.
|
||||
* Внутренности живут в dom/*. Публичный набор имён неизменен.
|
||||
*
|
||||
* 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.
|
||||
*/
|
||||
|
||||
export {
|
||||
detectFormScript,
|
||||
readFormScript,
|
||||
findClickTargetScript,
|
||||
findFieldButtonScript,
|
||||
resolveFieldsScript,
|
||||
detectNewFormScript,
|
||||
findSearchInputScript,
|
||||
findNamedButtonScript,
|
||||
findCompareTypeRadioScript,
|
||||
isFormVisibleScript,
|
||||
findPatternInputIdScript,
|
||||
isTypeDialogScript,
|
||||
isNotInListCloudVisibleScript,
|
||||
clickShowAllInNotInListCloudScript,
|
||||
findChildFormByButtonScript,
|
||||
readTypeDialogVisibleRowsScript,
|
||||
} from './dom/forms.mjs';
|
||||
|
||||
export {
|
||||
findFirstGridCellCoordsScript,
|
||||
findColumnFirstCellCoordsScript,
|
||||
readFieldSelectorInfoScript,
|
||||
pickFieldInSelectorDropdownScript,
|
||||
readFilterDialogInfoScript,
|
||||
findFilterBadgeCloseScript,
|
||||
findFirstFilterBadgeCloseScript,
|
||||
} from './dom/filter.mjs';
|
||||
|
||||
export {
|
||||
isInputFocusedScript,
|
||||
isInputFocusedInGridScript,
|
||||
findOpenPopupScript,
|
||||
} from './dom/edit-state.mjs';
|
||||
|
||||
export {
|
||||
readEddScript,
|
||||
isEddVisibleScript,
|
||||
clickEddItemViaDispatchScript,
|
||||
clickShowAllInEddScript,
|
||||
} from './dom/edd.mjs';
|
||||
|
||||
export { getFormStateScript } from './dom/form-state.mjs';
|
||||
|
||||
export {
|
||||
resolveGridScript,
|
||||
readTableScript,
|
||||
countGridRowsScript,
|
||||
isTreeGridScript,
|
||||
findGridHeadCenterCoordsScript,
|
||||
getSelectedOrLastRowIndexScript,
|
||||
findGridCellScript,
|
||||
findFocusCellScript,
|
||||
snapshotGridScript,
|
||||
resolveCellTargetScript,
|
||||
} from './dom/grid.mjs';
|
||||
|
||||
export {
|
||||
sortFieldKeysByColindexScript,
|
||||
findCellCoordsByFieldsScript,
|
||||
findNextCellCoordsByKeyScript,
|
||||
findCheckboxAtPointScript,
|
||||
findRowCommitClickCoordsScript,
|
||||
getGridEditCheckScript,
|
||||
readActiveGridCellScript,
|
||||
getElementCenterCoordsByIdScript,
|
||||
} from './dom/grid-edit.mjs';
|
||||
|
||||
export {
|
||||
readSectionsScript,
|
||||
readTabsScript,
|
||||
switchTabScript,
|
||||
readCommandsScript,
|
||||
navigateSectionScript,
|
||||
openCommandScript,
|
||||
} from './dom/nav.mjs';
|
||||
|
||||
export {
|
||||
readSubmenuScript,
|
||||
clickPopupItemScript,
|
||||
} from './dom/submenu.mjs';
|
||||
|
||||
export { checkErrorsScript } from './dom/errors.mjs';
|
||||
@@ -0,0 +1,391 @@
|
||||
// web-test dom shared v1.0 — embedded JS function constants
|
||||
// Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
|
||||
/**
|
||||
* Shared function strings embedded into page.evaluate() generators.
|
||||
* Не экспортируются наружу через dom.mjs facade — внутренняя кухня.
|
||||
*/
|
||||
|
||||
/** Find visible #modalSurface. 1C may leave multiple #modalSurface in DOM (duplicate id),
|
||||
* e.g. when a second form (drill-down) creates its own alongside a stale one from the first
|
||||
* form. getElementById returns the FIRST in document order, which may be hidden. Scan all. */
|
||||
export const HAS_VISIBLE_MODAL_FN = `function hasVisibleModal() {
|
||||
const all = document.querySelectorAll('#modalSurface');
|
||||
for (const el of all) { if (el.offsetWidth > 0) return true; }
|
||||
return false;
|
||||
}`;
|
||||
|
||||
/** Detect active form number. Picks form with most visible elements, skipping form0.
|
||||
* When modalSurface is visible — prefer the highest-numbered form (modal dialog). */
|
||||
export const DETECT_FORM_FN = HAS_VISIBLE_MODAL_FN + `
|
||||
function detectForm() {
|
||||
const counts = {};
|
||||
document.querySelectorAll('input.editInput[id], textarea[id], a.press[id]').forEach(el => {
|
||||
if (el.offsetWidth === 0) return;
|
||||
const m = el.id.match(/^form(\\d+)_/);
|
||||
if (m) counts[m[1]] = (counts[m[1]] || 0) + 1;
|
||||
});
|
||||
const nums = Object.keys(counts).map(Number);
|
||||
if (!nums.length) return null;
|
||||
const candidates = nums.filter(n => n > 0);
|
||||
if (!candidates.length) return nums[0];
|
||||
// When modal surface is visible, prefer the highest-numbered form (modal dialog)
|
||||
if (hasVisibleModal()) {
|
||||
const maxForm = Math.max(...candidates);
|
||||
if (counts[maxForm] >= 1) return maxForm;
|
||||
}
|
||||
return candidates.reduce((best, n) => counts[n] > counts[best] ? n : best);
|
||||
}`;
|
||||
|
||||
/** Detect all open forms + modal state. Returns { activeForm, allForms, formCount, modal }.
|
||||
* Works even when the open-windows tab bar is hidden. */
|
||||
export const DETECT_FORMS_FN = HAS_VISIBLE_MODAL_FN + `
|
||||
function detectForms() {
|
||||
const counts = {};
|
||||
document.querySelectorAll('input.editInput[id], textarea[id], a.press[id]').forEach(el => {
|
||||
if (el.offsetWidth === 0) return;
|
||||
const m = el.id.match(/^form(\\d+)_/);
|
||||
if (m) counts[m[1]] = (counts[m[1]] || 0) + 1;
|
||||
});
|
||||
const nums = Object.keys(counts).map(Number);
|
||||
return { allForms: nums.sort((a, b) => a - b), formCount: nums.length, modal: hasVisibleModal() };
|
||||
}`;
|
||||
|
||||
/** Read form state given prefix p. Returns { fields, buttons, tabs, texts, hyperlinks, table, iframes }. */
|
||||
export const READ_FORM_FN = `function readForm(p) {
|
||||
const result = {};
|
||||
const fields = [];
|
||||
const buttons = [];
|
||||
const formTabs = [];
|
||||
const texts = [];
|
||||
const hyperlinks = [];
|
||||
// Normalize non-breaking spaces to regular spaces
|
||||
const nbsp = s => (s || '').replace(/\\u00a0/g, ' ');
|
||||
|
||||
// Fields (inputs)
|
||||
document.querySelectorAll('input.editInput[id^="' + p + '"]').forEach(el => {
|
||||
if (el.offsetWidth === 0) return;
|
||||
const name = el.id.replace(p, '').replace(/_i\\d+$/, '');
|
||||
const titleEl = document.getElementById(p + name + '#title_text')
|
||||
|| document.getElementById(p + name + '#title_div');
|
||||
const label = nbsp((titleEl?.innerText?.trim() || '').replace(/\\n/g, ' '));
|
||||
const actions = [];
|
||||
if (document.getElementById(p + name + '_DLB')?.offsetWidth > 0) actions.push('select');
|
||||
if (document.getElementById(p + name + '_OB')?.offsetWidth > 0) actions.push('open');
|
||||
if (document.getElementById(p + name + '_CLR')?.offsetWidth > 0) actions.push('clear');
|
||||
if (document.getElementById(p + name + '_CB')?.offsetWidth > 0) actions.push('pick');
|
||||
const field = { name, value: el.value || '' };
|
||||
// Multi-value reference fields keep their value in .chipsItem chips, not in input.value
|
||||
if (!field.value) {
|
||||
const labelEl = document.getElementById(p + name);
|
||||
if (labelEl) {
|
||||
const chipTexts = [...labelEl.querySelectorAll('.chipsItem .chipsTitle')]
|
||||
.map(c => nbsp(c.innerText?.trim() || ''))
|
||||
.filter(Boolean);
|
||||
if (chipTexts.length) field.value = chipTexts.join(', ');
|
||||
}
|
||||
}
|
||||
if (label && label !== name) field.label = label;
|
||||
if (el.readOnly) field.readonly = true;
|
||||
if (el.disabled) field.disabled = true;
|
||||
if (el.type && el.type !== 'text') field.type = el.type;
|
||||
if (document.activeElement === el) field.focused = true;
|
||||
if (actions.length) field.actions = actions;
|
||||
if (el.closest('.inputsBox')?.classList.contains('markIncomplete')) field.required = true;
|
||||
fields.push(field);
|
||||
});
|
||||
|
||||
// Textareas
|
||||
document.querySelectorAll('textarea[id^="' + p + '"]').forEach(el => {
|
||||
if (el.offsetWidth === 0) return;
|
||||
const name = el.id.replace(p, '').replace(/_i\\d+$/, '');
|
||||
const titleEl = document.getElementById(p + name + '#title_text')
|
||||
|| document.getElementById(p + name + '#title_div');
|
||||
const label = nbsp((titleEl?.innerText?.trim() || '').replace(/\\n/g, ' '));
|
||||
const field = { name, value: el.value || '', type: 'textarea' };
|
||||
if (label && label !== name) field.label = label;
|
||||
if (el.readOnly) field.readonly = true;
|
||||
if (el.disabled) field.disabled = true;
|
||||
if (document.activeElement === el) field.focused = true;
|
||||
if (el.closest('.inputsBox')?.classList.contains('markIncomplete')) field.required = true;
|
||||
fields.push(field);
|
||||
});
|
||||
|
||||
// Checkboxes
|
||||
document.querySelectorAll('[id^="' + p + '"].checkbox').forEach(el => {
|
||||
if (el.offsetWidth === 0) return;
|
||||
const name = el.id.replace(p, '');
|
||||
const titleEl = document.getElementById(p + name + '#title_text');
|
||||
const label = nbsp(titleEl?.innerText?.trim() || '');
|
||||
const field = {
|
||||
name,
|
||||
value: el.classList.contains('checked') || el.classList.contains('checkboxOn') || el.classList.contains('select'),
|
||||
type: 'checkbox'
|
||||
};
|
||||
if (label && label !== name) field.label = label;
|
||||
fields.push(field);
|
||||
});
|
||||
|
||||
// Radio buttons — base element is option 0, others are #N#radio (N >= 1)
|
||||
const radioGroups = {};
|
||||
document.querySelectorAll('[id^="' + p + '"].radio').forEach(el => {
|
||||
if (el.offsetWidth === 0) return;
|
||||
const id = el.id.replace(p, '');
|
||||
const m = id.match(/^(.+?)#(\\d+)#radio$/);
|
||||
if (m) {
|
||||
// Options 1, 2, ... have explicit #N#radio suffix
|
||||
const [, groupName, idx] = m;
|
||||
if (!radioGroups[groupName]) radioGroups[groupName] = [];
|
||||
const labelEl = document.getElementById(p + groupName + '#' + idx + '#radio_text');
|
||||
const label = nbsp(labelEl?.innerText?.trim() || 'option' + idx);
|
||||
radioGroups[groupName].push({ index: parseInt(idx), label, selected: el.classList.contains('select') });
|
||||
} else if (!id.includes('#')) {
|
||||
// Base element = option 0 (no #0#radio suffix)
|
||||
if (!radioGroups[id]) radioGroups[id] = [];
|
||||
const labelEl = document.getElementById(p + id + '#0#radio_text');
|
||||
const label = nbsp(labelEl?.innerText?.trim() || 'option0');
|
||||
radioGroups[id].unshift({ index: 0, label, selected: el.classList.contains('select') });
|
||||
}
|
||||
});
|
||||
for (const [name, options] of Object.entries(radioGroups)) {
|
||||
const titleEl = document.getElementById(p + name + '#title_text');
|
||||
const label = titleEl?.innerText?.trim() || '';
|
||||
const selected = options.find(o => o.selected);
|
||||
const field = {
|
||||
name,
|
||||
value: selected?.label || '',
|
||||
type: 'radio',
|
||||
options: options.map(o => o.label)
|
||||
};
|
||||
if (label && label !== name) field.label = label;
|
||||
fields.push(field);
|
||||
}
|
||||
|
||||
// Buttons (a.press)
|
||||
document.querySelectorAll('a.press[id^="' + p + '"]').forEach(el => {
|
||||
if (el.offsetWidth === 0) return;
|
||||
const idName = el.id.replace(p, '');
|
||||
if (/_(?:DLB|CLR|OB|CB)$/.test(idName)) return;
|
||||
const span = el.querySelector('.submenuText') || el.querySelector('span');
|
||||
const text = nbsp(span?.textContent?.trim() || el.innerText?.trim() || '');
|
||||
if (!text && !el.classList.contains('pressCommand')) return;
|
||||
const btn = { name: text || idName };
|
||||
if (el.classList.contains('pressDefault')) btn.default = true;
|
||||
if (el.classList.contains('pressDisabled')) btn.disabled = true;
|
||||
// Icon-only buttons: expose tooltip from DOM title attribute (1C puts title on parent .framePress)
|
||||
if (!text) {
|
||||
const tip = nbsp(el.title || el.parentElement?.title || '');
|
||||
if (tip) btn.tooltip = tip;
|
||||
}
|
||||
buttons.push(btn);
|
||||
});
|
||||
|
||||
// Frame buttons
|
||||
document.querySelectorAll('[id^="' + p + '"].frameButton, [id^="' + p + '"] .frameButton').forEach(el => {
|
||||
if (el.offsetWidth === 0) return;
|
||||
const text = nbsp(el.innerText?.trim() || '');
|
||||
const idName = el.id?.replace(p, '') || '';
|
||||
if (!text && !idName) return;
|
||||
buttons.push({ name: text || idName, frame: true });
|
||||
});
|
||||
|
||||
// Tumbler items
|
||||
document.querySelectorAll('[id^="' + p + '"].tumblerItem').forEach(el => {
|
||||
if (el.offsetWidth === 0) return;
|
||||
const text = el.innerText?.trim();
|
||||
const idName = el.id?.replace(p, '') || '';
|
||||
buttons.push({ name: text || idName, tumbler: true });
|
||||
});
|
||||
|
||||
// Tabs — scoped to form by checking ancestor IDs
|
||||
document.querySelectorAll('[data-content]').forEach(el => {
|
||||
if (el.offsetWidth === 0) return;
|
||||
let node = el.parentElement;
|
||||
let inForm = false;
|
||||
while (node) {
|
||||
if (node.id && node.id.startsWith(p)) { inForm = true; break; }
|
||||
node = node.parentElement;
|
||||
}
|
||||
if (!inForm) return;
|
||||
const tab = { name: el.dataset.content };
|
||||
if (el.classList.contains('select')) tab.active = true;
|
||||
formTabs.push(tab);
|
||||
});
|
||||
|
||||
// Static texts and hyperlinks
|
||||
document.querySelectorAll('[id^="' + p + '"].staticText').forEach(el => {
|
||||
if (el.offsetWidth === 0) return;
|
||||
const name = el.id.replace(p, '');
|
||||
if (name.endsWith('_div') || name.includes('#title')) return;
|
||||
const text = el.innerText?.trim();
|
||||
if (!text) return;
|
||||
if (el.classList.contains('staticTextHyper')) {
|
||||
hyperlinks.push({ name: text });
|
||||
} else {
|
||||
const titleEl = document.getElementById(p + name + '#title_text');
|
||||
const label = titleEl?.innerText?.trim() || '';
|
||||
const entry = { name, value: text };
|
||||
if (label) entry.label = label;
|
||||
texts.push(entry);
|
||||
}
|
||||
});
|
||||
|
||||
// Tables/grids — collect ALL visible grids
|
||||
const allGrids = [...document.querySelectorAll('[id^="' + p + '"].grid, [id^="' + p + '"] .grid')]
|
||||
.filter(g => g.offsetWidth > 0 && g.offsetHeight > 0);
|
||||
if (allGrids.length > 0) {
|
||||
const tables = allGrids.map(grid => {
|
||||
const name = grid.id ? grid.id.replace(p, '') : '';
|
||||
const head = grid.querySelector('.gridHead');
|
||||
const body = grid.querySelector('.gridBody');
|
||||
const columns = [];
|
||||
if (head) {
|
||||
const headLine = head.querySelector('.gridLine') || head;
|
||||
[...headLine.children].forEach(box => {
|
||||
if (box.offsetWidth === 0) return;
|
||||
const textEl = box.querySelector('.gridBoxText');
|
||||
const text = (textEl || box).innerText?.trim().replace(/\\n/g, ' ') || '';
|
||||
if (text) {
|
||||
const r = box.getBoundingClientRect();
|
||||
columns.push({ text, x: r.x, right: r.x + r.width, y: r.y, h: r.height });
|
||||
} else {
|
||||
// Unnamed column — check if data cells contain checkboxes
|
||||
const firstLine = body?.querySelector('.gridLine');
|
||||
if (firstLine) {
|
||||
const visibleHeaders = [...headLine.children].filter(c => c.offsetWidth > 0);
|
||||
const idx = visibleHeaders.indexOf(box);
|
||||
const cells = [...firstLine.children].filter(c => c.offsetWidth > 0);
|
||||
if (cells[idx]?.querySelector('.checkbox')) {
|
||||
columns.push({ text: '(checkbox)', x: 0, right: 0, y: 0, h: 0 });
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
// Expand single merged headers with multiple data sub-rows (e.g. "Субконто Дт" → 1/2/3)
|
||||
const firstLine = body?.querySelector('.gridLine');
|
||||
if (firstLine && columns.length > 0) {
|
||||
const xGrp = new Map();
|
||||
columns.forEach(c => {
|
||||
const k = Math.round(c.x) + ':' + Math.round(c.right);
|
||||
if (!xGrp.has(k)) xGrp.set(k, []);
|
||||
xGrp.get(k).push(c);
|
||||
});
|
||||
for (const [k, hdrs] of xGrp) {
|
||||
if (hdrs.length !== 1) continue;
|
||||
let cnt = 0;
|
||||
[...firstLine.children].forEach(box => {
|
||||
if (box.offsetWidth === 0) return;
|
||||
const r = box.getBoundingClientRect();
|
||||
const cx = r.x + r.width / 2;
|
||||
if (cx >= hdrs[0].x && cx < hdrs[0].right) cnt++;
|
||||
});
|
||||
if (cnt > 1) {
|
||||
const base = hdrs[0];
|
||||
const baseIdx = columns.indexOf(base);
|
||||
columns.splice(baseIdx, 1);
|
||||
for (let si = 0; si < cnt; si++) {
|
||||
columns.splice(baseIdx + si, 0, { text: base.text + ' ' + (si + 1), x: base.x, right: base.right, y: 0, h: 0 });
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
const colNames = columns.map(c => c.text);
|
||||
const rowCount = body ? body.querySelectorAll('.gridLine').length : 0;
|
||||
// Visual label from group title (e.g. "Входящие:" for grid "Входящие")
|
||||
const titleEl = document.getElementById(p + name + '#title_div')
|
||||
|| document.getElementById(p + 'Группа' + name + '#title_div');
|
||||
const label = titleEl ? (titleEl.innerText?.trim().replace(/:\\s*$/, '').replace(/\\u00a0/g, ' ') || null) : null;
|
||||
return { name, columns: colNames, rowCount, ...(label ? { label } : {}) };
|
||||
});
|
||||
result.tables = tables;
|
||||
// Backward compat: table = first grid summary
|
||||
const first = tables[0];
|
||||
result.table = { present: true, columns: first.columns, rowCount: first.rowCount };
|
||||
}
|
||||
|
||||
// Active filters (train badges above grid: *СостояниеПросмотра)
|
||||
const filters = [];
|
||||
document.querySelectorAll('[id^="' + p + '"].trainItem').forEach(el => {
|
||||
if (el.offsetWidth === 0) return;
|
||||
const titleEl = el.querySelector('.trainName');
|
||||
const valueEl = el.querySelector('.trainTitle');
|
||||
if (!titleEl && !valueEl) return;
|
||||
const field = (titleEl?.innerText?.trim() || '').replace(/\\n/g, ' ').replace(/\\s*:$/, '').trim();
|
||||
const value = valueEl?.innerText?.trim()?.replace(/\\n/g, ' ') || '';
|
||||
if (field || value) filters.push({ field, value });
|
||||
});
|
||||
// Also check search field value
|
||||
const searchInput = [...document.querySelectorAll('input.editInput[id^="' + p + '"]')]
|
||||
.find(el => el.offsetWidth > 0 && /Строк[аи]Поиска|SearchString/i.test(el.id));
|
||||
if (searchInput?.value) {
|
||||
filters.push({ type: 'search', value: searchInput.value });
|
||||
}
|
||||
if (filters.length) result.filters = filters;
|
||||
|
||||
// Navigation panel (FormNavigationPanel) — lives in parent page{N} container
|
||||
const navigation = [];
|
||||
const formEl = document.querySelector('[id^="' + p + '"]');
|
||||
if (formEl) {
|
||||
let pageEl = formEl.parentElement;
|
||||
while (pageEl && !(pageEl.id && /^page\\d+$/.test(pageEl.id))) pageEl = pageEl.parentElement;
|
||||
if (pageEl) {
|
||||
pageEl.querySelectorAll('.navigationItem').forEach(el => {
|
||||
if (el.offsetWidth === 0) return;
|
||||
const nameEl = el.querySelector('.navigationItemName');
|
||||
const text = (nameEl?.innerText?.trim() || '').replace(/\\u00a0/g, ' ');
|
||||
if (!text) return;
|
||||
const nav = { name: text };
|
||||
if (el.classList.contains('select')) nav.active = true;
|
||||
navigation.push(nav);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Iframes
|
||||
let iframeCount = 0;
|
||||
document.querySelectorAll('[id^="' + p + '"] iframe, iframe[id^="' + p + '"]').forEach(el => {
|
||||
if (el.offsetWidth > 0 && el.offsetHeight > 0) iframeCount++;
|
||||
});
|
||||
if (iframeCount) result.iframes = iframeCount;
|
||||
|
||||
if (fields.length) result.fields = fields;
|
||||
if (buttons.length) result.buttons = buttons;
|
||||
if (formTabs.length) result.tabs = formTabs;
|
||||
if (navigation.length) result.navigation = navigation;
|
||||
if (texts.length) result.texts = texts;
|
||||
if (hyperlinks.length) result.hyperlinks = hyperlinks;
|
||||
|
||||
// Group DCS report settings into readable format
|
||||
if (result.fields) {
|
||||
const dcsRe = /^(.+Элемент(\\d+))(Использование|Значение|ВидСравнения)$/;
|
||||
const dcsGroups = {};
|
||||
const dcsNames = new Set();
|
||||
for (const f of result.fields) {
|
||||
const m = f.name.match(dcsRe);
|
||||
if (!m) continue;
|
||||
if (!dcsGroups[m[1]]) dcsGroups[m[1]] = { _n: parseInt(m[2]) };
|
||||
dcsGroups[m[1]][m[3]] = f;
|
||||
dcsNames.add(f.name);
|
||||
}
|
||||
const dcsEntries = Object.entries(dcsGroups).sort((a, b) => a[1]._n - b[1]._n);
|
||||
if (dcsEntries.length) {
|
||||
result.reportSettings = dcsEntries.map(([, g]) => {
|
||||
const cb = g['Использование'];
|
||||
const val = g['Значение'];
|
||||
if (!cb && !val) return null;
|
||||
// No checkbox present (class="staticText" instead of .checkbox) — setting is always enabled
|
||||
const label = (val?.label || cb?.label || val?.name || cb?.name || '').replace(/:$/, '').trim();
|
||||
const s = { name: label, enabled: cb ? !!cb.value : true };
|
||||
if (val) {
|
||||
s.value = val.value || '';
|
||||
if (val.actions && val.actions.length) s.actions = val.actions;
|
||||
}
|
||||
return s;
|
||||
}).filter(Boolean);
|
||||
result.fields = result.fields.filter(f => !dcsNames.has(f.name));
|
||||
if (!result.fields.length) delete result.fields;
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}`;
|
||||
@@ -0,0 +1,108 @@
|
||||
// web-test dom/edd v1.0 — DOM scripts for the #editDropDown autocomplete popup
|
||||
// Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
|
||||
|
||||
/**
|
||||
* Read the `#editDropDown` autocomplete popup.
|
||||
*
|
||||
* Returns `{ visible: false }` when EDD is absent/hidden, or
|
||||
* `{ visible: true, items: [{ name, x, y }] }` with center coords suitable
|
||||
* for `page.mouse.click(x, y)`.
|
||||
*
|
||||
* Note: `page.mouse.click` is often intercepted by `div.surface` overlays
|
||||
* from DLB — prefer `clickEddItemViaDispatchScript` for those cases.
|
||||
*/
|
||||
export function readEddScript() {
|
||||
return `(() => {
|
||||
const edd = document.getElementById('editDropDown');
|
||||
if (!edd || edd.offsetWidth === 0) return { visible: false };
|
||||
const eddTexts = [...edd.querySelectorAll('.eddText')].filter(el => el.offsetWidth > 0);
|
||||
return {
|
||||
visible: true,
|
||||
items: eddTexts.map(el => {
|
||||
const r = el.getBoundingClientRect();
|
||||
return { name: el.innerText?.trim() || '', x: r.x + r.width / 2, y: r.y + r.height / 2 };
|
||||
})
|
||||
};
|
||||
})()`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Is the EDD popup currently visible? Returns boolean.
|
||||
* Lighter than `readEddScript` when only presence matters.
|
||||
*/
|
||||
export function isEddVisibleScript() {
|
||||
return `(() => {
|
||||
const edd = document.getElementById('editDropDown');
|
||||
return !!(edd && edd.offsetWidth > 0);
|
||||
})()`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Click an EDD item by name via `dispatchEvent` — bypasses `div.surface`
|
||||
* overlays from DLB that intercept `page.mouse.click`.
|
||||
*
|
||||
* Matching is fuzzy: exact (with optional `(suffix)` strip) → includes,
|
||||
* normalizes ё/е and NBSP.
|
||||
*
|
||||
* Returns the clicked item's innerText (trimmed), or `null` when no match.
|
||||
*/
|
||||
export function clickEddItemViaDispatchScript(itemName) {
|
||||
return `(() => {
|
||||
const edd = document.getElementById('editDropDown');
|
||||
if (!edd || edd.offsetWidth === 0) return null;
|
||||
const ny = s => s.replace(/ё/gi, 'е').replace(/\\u00a0/g, ' ');
|
||||
const target = ny(${JSON.stringify(itemName.toLowerCase())});
|
||||
const items = [...edd.querySelectorAll('.eddText')].filter(el => el.offsetWidth > 0);
|
||||
function clickEl(el) {
|
||||
const r = el.getBoundingClientRect();
|
||||
const opts = { bubbles: true, cancelable: true, clientX: r.x + r.width/2, clientY: r.y + r.height/2 };
|
||||
el.dispatchEvent(new MouseEvent('mousedown', opts));
|
||||
el.dispatchEvent(new MouseEvent('mouseup', opts));
|
||||
el.dispatchEvent(new MouseEvent('click', opts));
|
||||
return el.innerText.trim();
|
||||
}
|
||||
// Pass 1: exact match (prefer over partial)
|
||||
for (const el of items) {
|
||||
const t = ny((el.innerText?.trim() || '').toLowerCase());
|
||||
if (t === target) return clickEl(el);
|
||||
const stripped = t.replace(/\\s*\\([^)]*\\)\\s*$/, '');
|
||||
if (stripped === target) return clickEl(el);
|
||||
}
|
||||
// Pass 2: partial match
|
||||
for (const el of items) {
|
||||
const t = ny((el.innerText?.trim() || '').toLowerCase());
|
||||
if (t.includes(target) || target.includes(t.replace(/\\s*\\([^)]*\\)\\s*$/, ''))) return clickEl(el);
|
||||
}
|
||||
return null;
|
||||
})()`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Click the "Показать все" / "Show all" link in the EDD footer via
|
||||
* `dispatchEvent`. Tries `.eddBottom .hyperlink` first, then falls back
|
||||
* to scanning for span/div/a with the literal text.
|
||||
*
|
||||
* Returns boolean — whether the link was found and clicked.
|
||||
*/
|
||||
export function clickShowAllInEddScript() {
|
||||
return `(() => {
|
||||
const edd = document.getElementById('editDropDown');
|
||||
if (!edd || edd.offsetWidth === 0) return false;
|
||||
let el = edd.querySelector('.eddBottom .hyperlink');
|
||||
if (!el || el.offsetWidth === 0) {
|
||||
const candidates = [...edd.querySelectorAll('span, div, a')]
|
||||
.filter(e => e.offsetWidth > 0 && e.children.length === 0);
|
||||
el = candidates.find(e => {
|
||||
const t = (e.innerText?.trim() || '').toLowerCase();
|
||||
return t === 'показать все' || t === 'show all';
|
||||
});
|
||||
}
|
||||
if (!el) return false;
|
||||
const r = el.getBoundingClientRect();
|
||||
const opts = { bubbles: true, cancelable: true, clientX: r.x + r.width/2, clientY: r.y + r.height/2 };
|
||||
el.dispatchEvent(new MouseEvent('mousedown', opts));
|
||||
el.dispatchEvent(new MouseEvent('mouseup', opts));
|
||||
el.dispatchEvent(new MouseEvent('click', opts));
|
||||
return true;
|
||||
})()`;
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
// web-test dom/edit-state v1.1 — focus and popup detection inside the 1C web client
|
||||
// Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
|
||||
|
||||
/**
|
||||
* Is the currently focused element an INPUT (optionally TEXTAREA too)?
|
||||
* Returns boolean.
|
||||
*
|
||||
* @param {object} [opts]
|
||||
* @param {boolean} [opts.allowTextarea=false] — also return true for TEXTAREA.
|
||||
*/
|
||||
export function isInputFocusedScript({ allowTextarea = false } = {}) {
|
||||
const cond = allowTextarea
|
||||
? `f.tagName === 'INPUT' || f.tagName === 'TEXTAREA'`
|
||||
: `f.tagName === 'INPUT'`;
|
||||
return `(() => {
|
||||
const f = document.activeElement;
|
||||
return !!(f && (${cond}));
|
||||
})()`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Is the currently focused INPUT/TEXTAREA inside a `.grid` ancestor?
|
||||
* Used to verify grid edit-mode (active cell editor).
|
||||
*
|
||||
* @param {string} [gridSelector] — when given, only `true` if the focused input
|
||||
* is inside that specific grid. Without it — any `.grid` ancestor counts.
|
||||
*
|
||||
* Returns boolean.
|
||||
*/
|
||||
export function isInputFocusedInGridScript(gridSelector) {
|
||||
const sel = gridSelector ? JSON.stringify(gridSelector) : 'null';
|
||||
return `(() => {
|
||||
const f = document.activeElement;
|
||||
if (!f || (f.tagName !== 'INPUT' && f.tagName !== 'TEXTAREA')) return false;
|
||||
const sel = ${sel};
|
||||
if (sel) {
|
||||
const grid = document.querySelector(sel);
|
||||
return !!(grid && grid.contains(f));
|
||||
}
|
||||
let n = f;
|
||||
while (n) {
|
||||
if (n.classList?.contains('grid')) return true;
|
||||
n = n.parentElement;
|
||||
}
|
||||
return false;
|
||||
})()`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Is a calculator (`.calculate`) or calendar (`.frameCalendar`) popup visible?
|
||||
* Returns `'calculator' | 'calendar' | null`.
|
||||
*
|
||||
* For the "popup gone" check, callers use: `!await findOpenPopup()`.
|
||||
*/
|
||||
export function findOpenPopupScript() {
|
||||
return `(() => {
|
||||
const calc = document.querySelector('.calculate');
|
||||
if (calc && calc.offsetWidth > 0) return 'calculator';
|
||||
const cal = document.querySelector('.frameCalendar');
|
||||
if (cal && cal.offsetWidth > 0) return 'calendar';
|
||||
return null;
|
||||
})()`;
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
// web-test dom/errors-stack v1.0 — DOM scripts for fetching error stack via OpenReport link.
|
||||
// Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
|
||||
//
|
||||
// Path-1 flow for platform exceptions: click "Сформировать отчет об ошибке" link,
|
||||
// open detailed error dialog, read textarea, close cleanup dialogs.
|
||||
|
||||
/** Find OpenReport link coordinates on the error modal for given formNum. */
|
||||
export function getOpenReportCoordsScript(formNum) {
|
||||
return `(() => {
|
||||
const el = document.getElementById('form${formNum}_OpenReport#text');
|
||||
if (!el || el.offsetWidth <= 2) return null;
|
||||
const rect = el.getBoundingClientRect();
|
||||
return { x: rect.x + rect.width / 2, y: rect.y + rect.height / 2 };
|
||||
})()`;
|
||||
}
|
||||
|
||||
/** Check whether the "подробный текст ошибки" link is visible (signals report dialog ready). */
|
||||
export function isErrorDetailLinkVisibleScript() {
|
||||
return `(() => {
|
||||
const links = document.querySelectorAll('a, [class*="hyper"], span');
|
||||
for (const el of links) {
|
||||
if (el.offsetWidth > 0 && el.textContent.includes('подробный текст ошибки')) return true;
|
||||
}
|
||||
return false;
|
||||
})()`;
|
||||
}
|
||||
|
||||
/** Read the largest visible non-empty textarea — contains the detailed error stack. */
|
||||
export function readLargestVisibleTextareaScript() {
|
||||
return `(() => {
|
||||
let best = null;
|
||||
document.querySelectorAll('textarea').forEach(ta => {
|
||||
if (ta.offsetWidth > 0 && ta.value.length > 0) {
|
||||
if (!best || ta.value.length > best.value.length) best = ta;
|
||||
}
|
||||
});
|
||||
return best?.value || null;
|
||||
})()`;
|
||||
}
|
||||
|
||||
/** Click the OK button in the topmost cloud window (closes "Подробный текст ошибки"). */
|
||||
export function clickTopCloudOkButtonScript() {
|
||||
return `(() => {
|
||||
const psWins = [...document.querySelectorAll('[id^="ps"][id$="win"]')]
|
||||
.filter(w => w.offsetWidth > 0)
|
||||
.sort((a, b) => parseInt(b.style?.zIndex || '0') - parseInt(a.style?.zIndex || '0'));
|
||||
for (const w of psWins) {
|
||||
const ok = w.querySelector('button.webBtn, .pressDefault');
|
||||
if (ok && ok.textContent.trim() === 'OK') { ok.click(); return true; }
|
||||
}
|
||||
return false;
|
||||
})()`;
|
||||
}
|
||||
|
||||
/** Click the × CloseButton in the topmost visible cloud window (closes "Отчет об ошибке"). */
|
||||
export function clickReportCloseButtonScript() {
|
||||
return `(() => {
|
||||
const psWins = [...document.querySelectorAll('[id^="ps"][id$="win"]')]
|
||||
.filter(w => w.offsetWidth > 0);
|
||||
for (const w of psWins) {
|
||||
const closeBtn = w.querySelector('[id$="_cmd_CloseButton"]');
|
||||
if (closeBtn && closeBtn.offsetWidth > 0) { closeBtn.click(); break; }
|
||||
}
|
||||
})()`;
|
||||
}
|
||||
@@ -0,0 +1,127 @@
|
||||
// web-test dom/errors v1.0 — error/diagnostic detection (balloon, messages, modal, stateWindow)
|
||||
// Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
|
||||
|
||||
/**
|
||||
* Check for validation errors / diagnostics after an action.
|
||||
* Detects three patterns:
|
||||
* 1. Inline balloon tooltip (div.balloon with .balloonMessage)
|
||||
* 2. Messages panel (div.messages with msg0, msg1... grid rows)
|
||||
* 3. Modal error dialog (high-numbered form with pressDefault + static texts)
|
||||
* Returns { balloon, messages[], modal } or null if no errors.
|
||||
*/
|
||||
export function checkErrorsScript() {
|
||||
return `(() => {
|
||||
const result = {};
|
||||
|
||||
// 1. Inline balloon tooltip
|
||||
const balloon = document.querySelector('.balloon');
|
||||
if (balloon && balloon.offsetWidth > 0) {
|
||||
const msg = balloon.querySelector('.balloonMessage');
|
||||
const title = balloon.querySelector('.balloonTitle');
|
||||
if (msg) {
|
||||
result.balloon = {
|
||||
title: title?.innerText?.trim() || 'Ошибка',
|
||||
message: msg.innerText?.trim() || ''
|
||||
};
|
||||
// Count navigation arrows to indicate total errors
|
||||
const fwd = balloon.querySelector('.balloonJumpFwd');
|
||||
const back = balloon.querySelector('.balloonJumpBack');
|
||||
const fwdDisabled = fwd?.classList.contains('disabled');
|
||||
const backDisabled = back?.classList.contains('disabled');
|
||||
if (fwd && !fwdDisabled) result.balloon.hasNext = true;
|
||||
if (back && !backDisabled) result.balloon.hasPrev = true;
|
||||
}
|
||||
}
|
||||
|
||||
// 2. Messages panel (div.messages — pick visible one, multiple may exist across tabs)
|
||||
const msgPanels = [...document.querySelectorAll('.messages')].filter(el => el.offsetWidth > 0);
|
||||
for (const msgPanel of msgPanels) {
|
||||
const msgs = [];
|
||||
msgPanel.querySelectorAll('[id^="msg"]').forEach(line => {
|
||||
if (line.offsetWidth === 0) return;
|
||||
const textEl = line.querySelector('.gridBoxText');
|
||||
const text = (textEl || line).innerText?.trim();
|
||||
if (text) msgs.push(text);
|
||||
});
|
||||
if (msgs.length > 0) { result.messages = msgs; break; }
|
||||
}
|
||||
|
||||
// 3+4. Modal dialogs: confirmation (multiple buttons) or error (single pressDefault)
|
||||
// Uses form container ancestry to group buttons — pressButton elements often lack form-prefixed IDs
|
||||
// Note: 1C shows some modals WITHOUT #modalSurface (e.g. "Не удалось записать" uses ps*win floating window)
|
||||
// so we always scan for small forms with button patterns, regardless of modalSurface state
|
||||
const formButtons = {};
|
||||
[...document.querySelectorAll('a.press.pressButton')].forEach(btn => {
|
||||
if (btn.offsetWidth === 0) return;
|
||||
const container = btn.closest('[id$="_container"]');
|
||||
const m = container?.id?.match(/^form(\\d+)_/);
|
||||
if (!m) return;
|
||||
const fn = m[1];
|
||||
if (!formButtons[fn]) formButtons[fn] = [];
|
||||
formButtons[fn].push(btn);
|
||||
});
|
||||
|
||||
for (const [fn, buttons] of Object.entries(formButtons)) {
|
||||
const p = 'form' + fn + '_';
|
||||
const elCount = document.querySelectorAll('[id^="' + p + '"]').length;
|
||||
if (elCount > 100) continue; // Skip large content forms
|
||||
if (buttons.length > 1) {
|
||||
// Confirmation dialog (multiple buttons: Да/Нет, OK/Отмена, etc.)
|
||||
// Must have a Message element — real 1C confirmations always have form{N}_Message.
|
||||
// Without it, this is just a regular form with multiple buttons (e.g. EPF form).
|
||||
const msgEl = document.getElementById(p + 'Message');
|
||||
if (!msgEl || msgEl.offsetWidth === 0) continue;
|
||||
const message = msgEl.innerText?.trim() || '';
|
||||
const btnNames = buttons.map(el => {
|
||||
const b = { name: el.innerText?.trim() || '' };
|
||||
if (el.classList.contains('pressDefault')) b.default = true;
|
||||
return b;
|
||||
}).filter(b => b.name);
|
||||
result.confirmation = { message, buttons: btnNames.map(b => b.name), formNum: parseInt(fn) };
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// Single-button modal: error dialog with pressDefault + staticText
|
||||
// Skip forms with input fields — those are data entry forms (e.g. register record),
|
||||
// not error dialogs. Real error modals only have staticText + buttons.
|
||||
if (!result.confirmation) {
|
||||
for (const [fn, buttons] of Object.entries(formButtons)) {
|
||||
const p = 'form' + fn + '_';
|
||||
const elCount = document.querySelectorAll('[id^="' + p + '"]').length;
|
||||
if (elCount > 100) continue;
|
||||
if (buttons.length !== 1 || !buttons[0].classList.contains('pressDefault')) continue;
|
||||
const hasInputs = document.querySelectorAll('input.editInput[id^="' + p + '"], textarea[id^="' + p + '"]').length > 0;
|
||||
if (hasInputs) continue;
|
||||
const texts = [...document.querySelectorAll('[id^="' + p + '"].staticText')]
|
||||
.filter(el => el.offsetWidth > 0)
|
||||
.map(el => el.innerText?.trim())
|
||||
.filter(Boolean);
|
||||
if (texts.length > 0) {
|
||||
result.modal = { message: texts.join(' '), formNum: parseInt(fn), button: buttons[0].innerText?.trim() || '' };
|
||||
// Check if OpenReport link is available (platform exceptions have visible link text)
|
||||
const reportLink = document.getElementById(p + 'OpenReport#text');
|
||||
if (reportLink && reportLink.offsetWidth > 2 && reportLink.textContent.trim()) {
|
||||
result.modal.hasReport = true;
|
||||
}
|
||||
// Grab AdditionalInfo/ServerText if filled (may contain extra error details)
|
||||
const addInfo = document.getElementById(p + 'AdditionalInfo');
|
||||
if (addInfo && addInfo.textContent && addInfo.textContent.trim()) result.modal.additionalInfo = addInfo.textContent.trim();
|
||||
const srvText = document.getElementById(p + 'ServerText');
|
||||
if (srvText && srvText.textContent && srvText.textContent.trim()) result.modal.serverText = srvText.textContent.trim();
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 5. SpreadsheetDocument state window (info bar inside moxelContainer)
|
||||
// Shows messages like "Не установлено значение параметра X" or "Отчет не сформирован"
|
||||
const stateWins = [...document.querySelectorAll('.stateWindowSupportSurface')].filter(el => el.offsetWidth > 0);
|
||||
if (stateWins.length) {
|
||||
const texts = stateWins.map(el => el.innerText?.trim()).filter(Boolean);
|
||||
if (texts.length) result.stateText = texts;
|
||||
}
|
||||
|
||||
return (result.balloon || result.messages || result.modal || result.confirmation || result.stateText) ? result : null;
|
||||
})()`;
|
||||
}
|
||||
@@ -0,0 +1,187 @@
|
||||
// web-test dom/filter v1.0 — DOM scripts for filterList / unfilterList
|
||||
// Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
|
||||
|
||||
/**
|
||||
* Find the first grid cell on the form and return its center coords.
|
||||
* Used as a fallback target for Alt+F when there's no search input.
|
||||
*
|
||||
* Returns `{ x, y } | null`.
|
||||
*/
|
||||
export function findFirstGridCellCoordsScript(formNum) {
|
||||
return `(() => {
|
||||
const p = 'form${formNum}_';
|
||||
const grid = [...document.querySelectorAll('[id^="' + p + '"].grid, [id^="' + p + '"] .grid')]
|
||||
.find(g => g.offsetWidth > 0);
|
||||
if (!grid) return null;
|
||||
const rows = [...grid.querySelectorAll('.gridBody .gridLine')];
|
||||
if (!rows.length) return null;
|
||||
const cells = [...rows[0].querySelectorAll('.gridBox')];
|
||||
if (!cells.length) return null;
|
||||
const r = cells[0].getBoundingClientRect();
|
||||
return { x: Math.round(r.x + r.width / 2), y: Math.round(r.y + r.height / 2) };
|
||||
})()`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Find the grid cell of the first row in the column whose header text matches `field`
|
||||
* (fuzzy: exact → startsWith → includes; normalizes ё/е and NBSP).
|
||||
*
|
||||
* If the column isn't in the grid, returns coords of the first cell + `needDlb: true`
|
||||
* so the caller can use DLB to switch FieldSelector after opening the dialog.
|
||||
*
|
||||
* Returns:
|
||||
* - `{ x, y, needDlb? } ` — coords to click (advanced search target)
|
||||
* - `{ error }` — `'no_grid' | 'no_rows' | 'no_cells' | 'cell_not_found'`
|
||||
*/
|
||||
export function findColumnFirstCellCoordsScript(formNum, field) {
|
||||
return `(() => {
|
||||
const p = 'form${formNum}_';
|
||||
const grid = [...document.querySelectorAll('[id^="' + p + '"].grid, [id^="' + p + '"] .grid')]
|
||||
.find(g => g.offsetWidth > 0);
|
||||
if (!grid) return { error: 'no_grid' };
|
||||
const targetField = ${JSON.stringify(field)};
|
||||
const headers = [...grid.querySelectorAll('.gridHead .gridBox')];
|
||||
let colIndex = -1;
|
||||
let startsWithIdx = -1;
|
||||
let includesIdx = -1;
|
||||
for (let i = 0; i < headers.length; i++) {
|
||||
const t = headers[i].innerText?.trim().replace(/\\u00a0/g, ' ');
|
||||
if (!t) continue;
|
||||
const ny = s => s.replace(/ё/gi, 'е').replace(/\\u00a0/g, ' ');
|
||||
const tl = ny(t.toLowerCase()), fl = ny(targetField.toLowerCase());
|
||||
if (tl === fl) { colIndex = i; break; }
|
||||
if (startsWithIdx < 0 && tl.startsWith(fl)) { startsWithIdx = i; }
|
||||
else if (includesIdx < 0 && tl.includes(fl)) { includesIdx = i; }
|
||||
}
|
||||
if (colIndex < 0) colIndex = startsWithIdx >= 0 ? startsWithIdx : includesIdx;
|
||||
const rows = [...grid.querySelectorAll('.gridBody .gridLine')];
|
||||
if (!rows.length) return { error: 'no_rows' };
|
||||
if (colIndex < 0) {
|
||||
const cells = [...rows[0].querySelectorAll('.gridBox')];
|
||||
if (!cells.length) return { error: 'no_cells' };
|
||||
const r = cells[0].getBoundingClientRect();
|
||||
return { x: Math.round(r.x + r.width / 2), y: Math.round(r.y + r.height / 2), needDlb: true };
|
||||
}
|
||||
const cells = [...rows[0].querySelectorAll('.gridBox')];
|
||||
if (colIndex >= cells.length) return { error: 'cell_not_found' };
|
||||
const r = cells[colIndex].getBoundingClientRect();
|
||||
return { x: Math.round(r.x + r.width / 2), y: Math.round(r.y + r.height / 2) };
|
||||
})()`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Read FieldSelector input + its DLB button coords on the advanced search dialog.
|
||||
* Returns `{ current, dlbX, dlbY }` (zero coords if DLB not visible).
|
||||
*/
|
||||
export function readFieldSelectorInfoScript(dialogForm) {
|
||||
return `(() => {
|
||||
const p = 'form' + ${JSON.stringify(String(dialogForm))} + '_';
|
||||
const fsInput = [...document.querySelectorAll('input.editInput[id^="' + p + '"]')]
|
||||
.find(el => el.offsetWidth > 0 && /FieldSelector/i.test(el.id));
|
||||
const dlb = document.getElementById(p + 'FieldSelector_DLB');
|
||||
return {
|
||||
current: fsInput?.value?.trim() || '',
|
||||
dlbX: dlb && dlb.offsetWidth > 0 ? Math.round(dlb.getBoundingClientRect().x + dlb.getBoundingClientRect().width / 2) : 0,
|
||||
dlbY: dlb && dlb.offsetWidth > 0 ? Math.round(dlb.getBoundingClientRect().y + dlb.getBoundingClientRect().height / 2) : 0
|
||||
};
|
||||
})()`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Pick a field name in the FieldSelector EDD dropdown (fuzzy: exact → includes,
|
||||
* normalizes ё/е and NBSP).
|
||||
*
|
||||
* Returns:
|
||||
* - `{ x, y, name }` — coords + matched name to click
|
||||
* - `{ error, available? }` — `'no_dropdown'` or `'field_not_found'` with list of available names
|
||||
*/
|
||||
export function pickFieldInSelectorDropdownScript(field) {
|
||||
return `(() => {
|
||||
const edd = document.getElementById('editDropDown');
|
||||
if (!edd || edd.offsetWidth === 0) return { error: 'no_dropdown' };
|
||||
const ny = s => s.replace(/ё/gi, 'е').replace(/\\u00a0/g, ' ');
|
||||
const target = ny(${JSON.stringify(field.toLowerCase())});
|
||||
const items = [...edd.querySelectorAll('div')].filter(el =>
|
||||
el.offsetWidth > 0 && el.innerText?.trim() && !el.innerText.includes('\\n'));
|
||||
const match = items.find(el => ny(el.innerText.trim().toLowerCase()) === target)
|
||||
|| items.find(el => ny(el.innerText.trim().toLowerCase()).includes(target));
|
||||
if (!match) return { error: 'field_not_found', available: items.map(el => el.innerText.trim()) };
|
||||
const r = match.getBoundingClientRect();
|
||||
return { x: Math.round(r.x + r.width / 2), y: Math.round(r.y + r.height / 2), name: match.innerText.trim() };
|
||||
})()`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Read advanced search dialog state — FieldSelector value, Pattern input id+value,
|
||||
* and field type flags (isDate via iCalendB button, isRef via iDLB button on Pattern).
|
||||
*
|
||||
* Returns `{ fieldSelector, patternValue, patternId, isDate, isRef }`.
|
||||
*/
|
||||
export function readFilterDialogInfoScript(dialogForm) {
|
||||
return `(() => {
|
||||
const p = 'form' + ${JSON.stringify(String(dialogForm))} + '_';
|
||||
const fsInput = [...document.querySelectorAll('input.editInput[id^="' + p + '"]')]
|
||||
.find(el => el.offsetWidth > 0 && /FieldSelector/i.test(el.id));
|
||||
const ptInput = [...document.querySelectorAll('input.editInput[id^="' + p + '"]')]
|
||||
.find(el => el.offsetWidth > 0 && /Pattern/i.test(el.id));
|
||||
const ptLabel = ptInput?.closest('label');
|
||||
const btns = ptLabel ? [...ptLabel.querySelectorAll('span.btn')].map(b => b.className) : [];
|
||||
const isDate = btns.some(c => c.includes('iCalendB'));
|
||||
const isRef = !isDate && btns.some(c => c.includes('iDLB'));
|
||||
return {
|
||||
fieldSelector: fsInput?.value?.trim() || '',
|
||||
patternValue: ptInput?.value?.trim() || '',
|
||||
patternId: ptInput?.id || '',
|
||||
isDate,
|
||||
isRef
|
||||
};
|
||||
})()`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Find the × close button on the filter badge whose title matches `field`
|
||||
* (exact → includes; normalizes ё/е and NBSP).
|
||||
*
|
||||
* Returns:
|
||||
* - `{ x, y, field }` — coords + actual field title from the badge
|
||||
* - `{ error, available }` — `'not_found'` with list of available badge titles
|
||||
*/
|
||||
export function findFilterBadgeCloseScript(formNum, field) {
|
||||
return `(() => {
|
||||
const p = 'form${formNum}_';
|
||||
const norm = s => s?.trim().replace(/\\u00a0/g, ' ').replace(/:$/, '').replace(/\\n/g, ' ') || '';
|
||||
const ny = s => s.replace(/ё/gi, 'е').replace(/\\u00a0/g, ' ');
|
||||
const target = ny(${JSON.stringify(field.toLowerCase())});
|
||||
const items = [...document.querySelectorAll('[id^="' + p + '"].trainItem')].filter(el => el.offsetWidth > 0);
|
||||
for (const item of items) {
|
||||
const titleEl = item.querySelector('.trainName');
|
||||
const title = ny(norm(titleEl?.innerText).toLowerCase());
|
||||
if (title === target || title.includes(target)) {
|
||||
const close = item.querySelector('.trainClose');
|
||||
if (close) {
|
||||
const r = close.getBoundingClientRect();
|
||||
return { x: Math.round(r.x + r.width / 2), y: Math.round(r.y + r.height / 2), field: norm(titleEl?.innerText) };
|
||||
}
|
||||
}
|
||||
}
|
||||
const available = items.map(item => norm(item.querySelector('.trainName')?.innerText));
|
||||
return { error: 'not_found', available };
|
||||
})()`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Find the × close button on the FIRST visible filter badge (for clear-all loop).
|
||||
* Returns `{ x, y } | null`.
|
||||
*/
|
||||
export function findFirstFilterBadgeCloseScript(formNum) {
|
||||
return `(() => {
|
||||
const p = 'form${formNum}_';
|
||||
const item = [...document.querySelectorAll('[id^="' + p + '"].trainItem')]
|
||||
.find(el => el.offsetWidth > 0);
|
||||
if (!item) return null;
|
||||
const close = item.querySelector('.trainClose');
|
||||
if (!close) return null;
|
||||
const r = close.getBoundingClientRect();
|
||||
return { x: Math.round(r.x + r.width / 2), y: Math.round(r.y + r.height / 2) };
|
||||
})()`;
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
// web-test dom/form-state v1.0 — combined detectForm + readForm + open tabs
|
||||
// Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
|
||||
import { DETECT_FORM_FN, DETECT_FORMS_FN, READ_FORM_FN } from './_shared.mjs';
|
||||
|
||||
/**
|
||||
* Combined: detect form + read form + read open tabs.
|
||||
* Single evaluate call instead of 3. Used by browser.getFormState().
|
||||
*/
|
||||
export function getFormStateScript() {
|
||||
return `(() => {
|
||||
${DETECT_FORM_FN}
|
||||
${DETECT_FORMS_FN}
|
||||
${READ_FORM_FN}
|
||||
const formNum = detectForm();
|
||||
const meta = detectForms();
|
||||
if (formNum === null) return { form: null, formCount: 0, message: 'No form detected' };
|
||||
const p = 'form' + formNum + '_';
|
||||
const formData = readForm(p);
|
||||
// Open tabs bar (present only when tab panel is enabled in 1C settings)
|
||||
const openTabs = [];
|
||||
document.querySelectorAll('[id^="openedCell_cmd_"]').forEach(el => {
|
||||
const text = el.innerText?.trim();
|
||||
if (!text) return;
|
||||
const entry = { name: text };
|
||||
if (el.classList.contains('select')) entry.active = true;
|
||||
openTabs.push(entry);
|
||||
});
|
||||
const activeTab = openTabs.find(t => t.active)?.name || null;
|
||||
const result = { form: formNum, activeTab, openForms: meta.allForms, formCount: meta.formCount, ...formData };
|
||||
if (meta.modal) result.modal = true;
|
||||
if (openTabs.length) result.openTabs = openTabs;
|
||||
return result;
|
||||
})()`;
|
||||
}
|
||||
@@ -0,0 +1,647 @@
|
||||
// web-test dom/forms v1.6 — form detection, content read, click-target/field-button resolution
|
||||
// Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
|
||||
import { DETECT_FORM_FN, READ_FORM_FN } from './_shared.mjs';
|
||||
|
||||
/**
|
||||
* Detect the active form number.
|
||||
* Picks the form with the most visible elements (excluding form0 = home page).
|
||||
*/
|
||||
export function detectFormScript() {
|
||||
return `(() => {
|
||||
${DETECT_FORM_FN}
|
||||
return detectForm();
|
||||
})()`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Read full form state for a given form number.
|
||||
* Uses shared READ_FORM_FN.
|
||||
*/
|
||||
export function readFormScript(formNum) {
|
||||
const p = `form${formNum}_`;
|
||||
return `(() => {
|
||||
${READ_FORM_FN}
|
||||
return readForm(${JSON.stringify(p)});
|
||||
})()`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Find a clickable element on the current form (button, hyperlink, tab, frame button).
|
||||
* Returns { id, kind, name } for Playwright page.click(), or { error, available }.
|
||||
* Supports synonym matching: visible text AND internal name from DOM ID.
|
||||
* Fuzzy order: exact name -> exact label -> includes name -> includes label.
|
||||
*/
|
||||
export function findClickTargetScript(formNum, text, { tableName, gridSelector } = {}) {
|
||||
const p = `form${formNum}_`;
|
||||
return `(() => {
|
||||
const norm = s => (s?.trim().replace(/\\u00a0/g, ' ') || '').replace(/ё/gi, 'е');
|
||||
const target = ${JSON.stringify(text.toLowerCase().replace(/ё/g, 'е'))};
|
||||
const p = ${JSON.stringify(p)};
|
||||
const tableName = ${JSON.stringify(tableName || '')};
|
||||
const gridSelector = ${JSON.stringify(gridSelector || '')};
|
||||
const items = [];
|
||||
|
||||
// Buttons (a.press)
|
||||
[...document.querySelectorAll('a.press[id^="' + p + '"]')].filter(el => el.offsetWidth > 0).forEach(el => {
|
||||
const idName = el.id.replace(p, '');
|
||||
if (/_(?:DLB|CLR|OB|CB)$/.test(idName)) return;
|
||||
const span = el.querySelector('.submenuText') || el.querySelector('span');
|
||||
const text = norm(span?.textContent) || norm(el.innerText);
|
||||
if (!text && !el.classList.contains('pressCommand')) return;
|
||||
const isSubmenu = /^(?:Подменю|allActions)/i.test(idName);
|
||||
const item = { id: el.id, name: text || idName, label: idName, kind: isSubmenu ? 'submenu' : 'button' };
|
||||
// Icon-only buttons: use tooltip for fuzzy match (1C puts title on parent .framePress)
|
||||
if (!text) { const tip = norm(el.title || el.parentElement?.title || ''); if (tip) item.tooltip = tip; }
|
||||
items.push(item);
|
||||
});
|
||||
|
||||
// Hyperlinks (staticTextHyper)
|
||||
[...document.querySelectorAll('[id^="' + p + '"].staticTextHyper')].filter(el => el.offsetWidth > 0).forEach(el => {
|
||||
const idName = el.id.replace(p, '');
|
||||
const text = norm(el.innerText);
|
||||
items.push({ id: el.id, name: text, label: idName, kind: 'hyperlink' });
|
||||
});
|
||||
|
||||
// Frame buttons
|
||||
[...document.querySelectorAll('[id^="' + p + '"] .frameButton, [id^="' + p + '"].frameButton')].filter(el => el.offsetWidth > 0).forEach(el => {
|
||||
const text = norm(el.innerText);
|
||||
const idName = el.id.replace(p, '');
|
||||
if (!text && !idName) return;
|
||||
items.push({ id: el.id, name: text || idName, label: text ? '' : idName, kind: 'frameButton' });
|
||||
});
|
||||
|
||||
// Tumbler items (toggle switch segments)
|
||||
[...document.querySelectorAll('[id^="' + p + '"].tumblerItem')].filter(el => el.offsetWidth > 0).forEach(el => {
|
||||
const idName = el.id.replace(p, '');
|
||||
const text = norm(el.innerText);
|
||||
items.push({ id: el.id, name: text || idName, label: idName, kind: 'tumbler' });
|
||||
});
|
||||
|
||||
// Checkboxes (div.checkbox) — match by label or internal name
|
||||
[...document.querySelectorAll('[id^="' + p + '"].checkbox')].filter(el => el.offsetWidth > 0).forEach(el => {
|
||||
const idName = el.id.replace(p, '');
|
||||
const titleEl = document.getElementById(p + idName + '#title_text');
|
||||
const label = norm(titleEl?.innerText || '').replace(/:/g, '').trim();
|
||||
items.push({ id: el.id, name: label || idName, label: idName, kind: 'checkbox' });
|
||||
});
|
||||
|
||||
// Tabs (scoped to form)
|
||||
[...document.querySelectorAll('[data-content]')].filter(el => {
|
||||
if (el.offsetWidth === 0) return false;
|
||||
let node = el.parentElement;
|
||||
while (node) {
|
||||
if (node.id && node.id.startsWith(p)) return true;
|
||||
node = node.parentElement;
|
||||
}
|
||||
return false;
|
||||
}).forEach(el => {
|
||||
const r = el.getBoundingClientRect();
|
||||
items.push({ id: el.id, name: el.dataset.content, label: '', kind: 'tab',
|
||||
x: Math.round(r.x + r.width / 2), y: Math.round(r.y + r.height / 2) });
|
||||
});
|
||||
|
||||
// Navigation panel items (FormNavigationPanel) — in parent page{N}
|
||||
const formEl = document.querySelector('[id^="' + p + '"]');
|
||||
if (formEl) {
|
||||
let pageEl = formEl.parentElement;
|
||||
while (pageEl && !(pageEl.id && /^page\\d+$/.test(pageEl.id))) pageEl = pageEl.parentElement;
|
||||
if (pageEl) {
|
||||
pageEl.querySelectorAll('.navigationItem').forEach(el => {
|
||||
if (el.offsetWidth === 0) return;
|
||||
const nameEl = el.querySelector('.navigationItemName');
|
||||
const text = norm(nameEl?.innerText || '');
|
||||
if (!text) return;
|
||||
items.push({ id: el.id, name: text, label: '', kind: 'navigation' });
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// When table is specified, scope button search to grid's parent container
|
||||
if (gridSelector) {
|
||||
const gridEl = document.querySelector(gridSelector);
|
||||
if (gridEl) {
|
||||
// Find parent container that has id with formPrefix and contains the grid
|
||||
let container = gridEl.parentElement;
|
||||
while (container && container !== document.body) {
|
||||
if (container.id && container.id.startsWith(p)) break;
|
||||
container = container.parentElement;
|
||||
}
|
||||
// Filter items to those inside the container
|
||||
const containerItems = container && container !== document.body
|
||||
? items.filter(i => { const el = document.getElementById(i.id); return el && container.contains(el); })
|
||||
: [];
|
||||
// Try fuzzy match within container first
|
||||
let cf = containerItems.find(i => i.name.toLowerCase() === target);
|
||||
if (!cf) cf = containerItems.find(i => i.label && i.label.toLowerCase() === target);
|
||||
if (!cf && target.length >= 4) cf = containerItems.find(i => i.name.toLowerCase().includes(target));
|
||||
if (!cf && target.length >= 4) cf = containerItems.find(i => i.label && i.label.toLowerCase().includes(target));
|
||||
if (cf) { const res = { id: cf.id, kind: cf.kind, name: cf.name }; if (cf.x != null) { res.x = cf.x; res.y = cf.y; } return res; }
|
||||
// Fallback: filter by gridName id-prefix (e.g. ИсходящиеКоманднаяПанель_Добавить)
|
||||
const gridName = gridEl.id ? gridEl.id.replace(p, '') : '';
|
||||
if (gridName) {
|
||||
const prefixItems = items.filter(i => i.label && i.label.includes(gridName));
|
||||
let pf = prefixItems.find(i => i.name.toLowerCase() === target);
|
||||
if (!pf && target.length >= 4) pf = prefixItems.find(i => i.label && i.label.toLowerCase().includes(target));
|
||||
if (!pf && target.length >= 4) pf = prefixItems.find(i => i.name.toLowerCase().includes(target));
|
||||
if (pf) { const res = { id: pf.id, kind: pf.kind, name: pf.name }; if (pf.x != null) { res.x = pf.x; res.y = pf.y; } return res; }
|
||||
}
|
||||
}
|
||||
// Fall through to unscoped search
|
||||
}
|
||||
|
||||
// Fuzzy match: exact name -> exact label -> exact tooltip -> startsWith name -> startsWith label -> includes name -> includes label -> includes tooltip
|
||||
// Skip includes() for short strings (< 4 chars) to avoid false positives
|
||||
// e.g. "Да" matching "КомандаУстановитьВсе"
|
||||
let found = items.find(i => i.name.toLowerCase() === target);
|
||||
if (!found) found = items.find(i => i.label && i.label.toLowerCase() === target);
|
||||
if (!found) found = items.find(i => i.tooltip && i.tooltip.toLowerCase() === target);
|
||||
if (!found) found = items.find(i => i.name.toLowerCase().startsWith(target));
|
||||
if (!found) found = items.find(i => i.label && i.label.toLowerCase().startsWith(target));
|
||||
if (!found && target.length >= 4) found = items.find(i => i.name.toLowerCase().includes(target));
|
||||
if (!found && target.length >= 4) found = items.find(i => i.label && i.label.toLowerCase().includes(target));
|
||||
if (!found && target.length >= 4) found = items.find(i => i.tooltip && i.tooltip.toLowerCase().includes(target));
|
||||
|
||||
if (found) {
|
||||
const res = { id: found.id, kind: found.kind, name: found.name };
|
||||
if (found.x != null) { res.x = found.x; res.y = found.y; }
|
||||
return res;
|
||||
}
|
||||
|
||||
// Grid rows — fallback: search in table rows (for hierarchical/tree navigation)
|
||||
// Search ALL visible grids (or specific grid when table parameter is set)
|
||||
let grids;
|
||||
if (gridSelector) {
|
||||
const g = document.querySelector(gridSelector);
|
||||
grids = g ? [g] : [];
|
||||
} else {
|
||||
grids = [...document.querySelectorAll('[id^="' + p + '"].grid')].filter(g => g.offsetWidth > 0);
|
||||
}
|
||||
for (const grid of grids) {
|
||||
const body = grid.querySelector('.gridBody');
|
||||
if (!body) continue;
|
||||
const lines = [...body.querySelectorAll('.gridLine')];
|
||||
for (const line of lines) {
|
||||
const textBoxes = [...line.querySelectorAll('.gridBoxText')].filter(b => b.offsetWidth > 0);
|
||||
const rowTexts = textBoxes.map(b => norm(b.innerText) || '').filter(Boolean);
|
||||
const firstCell = rowTexts[0]?.toLowerCase() || '';
|
||||
const rowText = rowTexts.join(' ').toLowerCase();
|
||||
if (firstCell === target || rowText === target || (target.length >= 4 && (firstCell.includes(target) || rowText.includes(target)))) {
|
||||
const imgBox = line.querySelector('.gridBoxImg');
|
||||
const isGroup = imgBox?.querySelector('.gridListH') !== null;
|
||||
const isParent = imgBox?.querySelector('.gridListV') !== null;
|
||||
const isTreeNode = line.querySelector('.gridBoxTree') !== null;
|
||||
const hasChildren = line.querySelector('[tree="true"]') !== null;
|
||||
let kind;
|
||||
if (isGroup) kind = 'gridGroup';
|
||||
else if (isParent) kind = 'gridParent';
|
||||
else if (isTreeNode && hasChildren) kind = 'gridTreeNode';
|
||||
else kind = 'gridRow';
|
||||
const r = line.getBoundingClientRect();
|
||||
return { id: '', kind, name: rowTexts[0] || '', gridId: grid.id,
|
||||
x: Math.round(r.x + r.width / 2), y: Math.round(r.y + r.height / 2) };
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Form input fields — LAST resort: focus a field by name/label without changing its value.
|
||||
// Only when no table scope is given ("если нет уточнения таблицы"): grid cells are handled elsewhere.
|
||||
// Reached only after every clickable target (button/link/tab/nav/grid row) failed to match,
|
||||
// so collisions between a field name and a real control are unlikely.
|
||||
const fields = [];
|
||||
if (!tableName) {
|
||||
[...document.querySelectorAll('input.editInput[id^="' + p + '"], textarea[id^="' + p + '"]')].forEach(el => {
|
||||
if (el.offsetWidth === 0) return;
|
||||
// Skip inputs inside a grid — those are table cells, not form fields.
|
||||
let n = el.parentElement; let inGrid = false;
|
||||
while (n) { if (n.classList && n.classList.contains('grid')) { inGrid = true; break; } n = n.parentElement; }
|
||||
if (inGrid) return;
|
||||
const idName = el.id.replace(p, '').replace(/_i\\d+$/, '');
|
||||
const titleEl = document.getElementById(p + idName + '#title_text') || document.getElementById(p + idName + '#title_div');
|
||||
const label = norm(titleEl?.innerText || '').replace(/:/g, '').trim();
|
||||
fields.push({ id: el.id, name: idName, label });
|
||||
});
|
||||
let ff = fields.find(f => f.label && f.label.toLowerCase() === target);
|
||||
if (!ff) ff = fields.find(f => f.name.toLowerCase() === target);
|
||||
if (!ff) ff = fields.find(f => f.label && f.label.toLowerCase().startsWith(target));
|
||||
if (!ff) ff = fields.find(f => f.name.toLowerCase().startsWith(target));
|
||||
if (!ff && target.length >= 4) ff = fields.find(f => f.label && f.label.toLowerCase().includes(target));
|
||||
if (!ff && target.length >= 4) ff = fields.find(f => f.name.toLowerCase().includes(target));
|
||||
if (ff) return { id: ff.id, kind: 'field', name: ff.label || ff.name };
|
||||
}
|
||||
|
||||
const available = items.map(i => i.tooltip ? i.name + ' [' + i.tooltip + ']' : i.name).filter(Boolean);
|
||||
for (const f of fields) { const nm = f.label || f.name; if (nm && !available.includes(nm)) available.push(nm); }
|
||||
return { error: 'not_found', available };
|
||||
})()`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Find a field's action button (DLB, OB, CLR, CB) by fuzzy field name.
|
||||
* Returns { fieldName, buttonId, buttonType } or { error, available }.
|
||||
*/
|
||||
export function findFieldButtonScript(formNum, fieldName, buttonSuffix = 'DLB') {
|
||||
const p = `form${formNum}_`;
|
||||
return `(() => {
|
||||
const p = ${JSON.stringify(p)};
|
||||
const target = ${JSON.stringify(fieldName.toLowerCase().replace(/ё/g, 'е'))};
|
||||
const suffix = ${JSON.stringify(buttonSuffix)};
|
||||
const allFields = [];
|
||||
document.querySelectorAll('input.editInput[id^="' + p + '"], textarea[id^="' + p + '"]').forEach(el => {
|
||||
if (el.offsetWidth === 0) return;
|
||||
const name = el.id.replace(p, '').replace(/_i\\d+$/, '');
|
||||
const titleEl = document.getElementById(p + name + '#title_text')
|
||||
|| document.getElementById(p + name + '#title_div');
|
||||
const label = (titleEl?.innerText?.trim() || '').replace(/\\n/g, ' ').replace(/:$/, '');
|
||||
allFields.push({ name, label });
|
||||
});
|
||||
// Also collect checkboxes for DCS pair matching
|
||||
const allCheckboxes = [];
|
||||
document.querySelectorAll('[id^="' + p + '"].checkbox').forEach(el => {
|
||||
if (el.offsetWidth === 0) return;
|
||||
const name = el.id.replace(p, '');
|
||||
const titleEl = document.getElementById(p + name + '#title_text');
|
||||
const label = (titleEl?.innerText?.trim() || '').replace(/\\n/g, ' ').replace(/:$/, '');
|
||||
allCheckboxes.push({ inputId: el.id, name, label });
|
||||
});
|
||||
// Build DCS pairs: checkbox label → paired value field
|
||||
const dcsPairs = {};
|
||||
for (const f of [...allFields, ...allCheckboxes]) {
|
||||
const m = f.name.match(/^(.+Элемент\\d+)(Использование|Значение)$/);
|
||||
if (!m) continue;
|
||||
if (!dcsPairs[m[1]]) dcsPairs[m[1]] = {};
|
||||
dcsPairs[m[1]][m[2]] = f;
|
||||
}
|
||||
let found = allFields.find(f => f.name.toLowerCase() === target);
|
||||
if (!found) found = allFields.find(f => f.label && f.label.toLowerCase() === target);
|
||||
if (!found) found = allFields.find(f => f.name.toLowerCase().includes(target));
|
||||
if (!found) found = allFields.find(f => f.label && f.label.toLowerCase().includes(target));
|
||||
// DCS pair: match checkbox or value label → resolve to paired value field
|
||||
let dcsCheckbox = null;
|
||||
if (!found) {
|
||||
for (const pair of Object.values(dcsPairs)) {
|
||||
const cb = pair['Использование'];
|
||||
const val = pair['Значение'];
|
||||
if (!cb || !val) continue;
|
||||
const pairLabel = ((val.label || cb.label || '').replace(/:$/, '')).toLowerCase();
|
||||
if (pairLabel && (pairLabel === target || pairLabel.includes(target) || target.includes(pairLabel))) {
|
||||
found = val;
|
||||
dcsCheckbox = cb;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (!found) {
|
||||
return { error: 'field_not_found', available: allFields.map(f => f.label ? f.name + ' (' + f.label + ')' : f.name) };
|
||||
}
|
||||
const btnId = p + found.name + '_' + suffix;
|
||||
const btn = document.getElementById(btnId);
|
||||
if (!btn || btn.offsetWidth === 0) {
|
||||
return { error: 'button_not_found', fieldName: found.name, message: suffix + ' button not visible for field ' + found.name };
|
||||
}
|
||||
const result = { fieldName: found.name, buttonId: btnId, buttonType: suffix };
|
||||
if (dcsCheckbox) result.dcsCheckbox = { inputId: dcsCheckbox.inputId };
|
||||
return result;
|
||||
})()`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve field names to element IDs for Playwright page.fill().
|
||||
* Returns [{ field, inputId, name, label }] or [{ field, error, available }].
|
||||
* Supports synonym matching: internal name AND visible label.
|
||||
* Fuzzy order: exact name -> exact label -> includes name -> includes label.
|
||||
*/
|
||||
export function resolveFieldsScript(formNum, fields) {
|
||||
const p = `form${formNum}_`;
|
||||
return `(() => {
|
||||
const p = ${JSON.stringify(p)};
|
||||
const fieldNames = ${JSON.stringify(Object.keys(fields))};
|
||||
const results = [];
|
||||
|
||||
// Build field map with name + label for synonym matching
|
||||
const allFields = [];
|
||||
document.querySelectorAll('input.editInput[id^="' + p + '"], textarea[id^="' + p + '"]').forEach(el => {
|
||||
if (el.offsetWidth === 0) return;
|
||||
const name = el.id.replace(p, '').replace(/_i\\d+$/, '');
|
||||
const titleEl = document.getElementById(p + name + '#title_text')
|
||||
|| document.getElementById(p + name + '#title_div');
|
||||
const label = (titleEl?.innerText?.trim() || '').replace(/\\n/g, ' ').replace(/:$/, '');
|
||||
const last = { inputId: el.id, name, label };
|
||||
if (document.getElementById(p + name + '_DLB')?.offsetWidth > 0) last.hasSelect = true;
|
||||
const cbEl = document.getElementById(p + name + '_CB');
|
||||
if (cbEl?.offsetWidth > 0) {
|
||||
last.hasPick = true;
|
||||
if (cbEl.classList.contains('iCalendB')) last.isDate = true;
|
||||
else if (cbEl.classList.contains('iCalcB')) last.isCalc = true;
|
||||
}
|
||||
allFields.push(last);
|
||||
});
|
||||
// Checkboxes
|
||||
document.querySelectorAll('[id^="' + p + '"].checkbox').forEach(el => {
|
||||
if (el.offsetWidth === 0) return;
|
||||
const name = el.id.replace(p, '');
|
||||
const titleEl = document.getElementById(p + name + '#title_text');
|
||||
const label = (titleEl?.innerText?.trim() || '').replace(/\\n/g, ' ').replace(/:$/, '');
|
||||
const checked = el.classList.contains('checked') || el.classList.contains('checkboxOn') || el.classList.contains('select');
|
||||
allFields.push({ inputId: el.id, name, label, isCheckbox: true, checked });
|
||||
});
|
||||
// Radio button groups — base element = option 0, others are #N#radio
|
||||
const radioSeen = new Set();
|
||||
document.querySelectorAll('[id^="' + p + '"].radio').forEach(el => {
|
||||
if (el.offsetWidth === 0) return;
|
||||
const id = el.id.replace(p, '');
|
||||
// Skip if already processed or if it's a sub-element (#N#radio)
|
||||
const m = id.match(/^(.+?)#(\\d+)#radio$/);
|
||||
const groupName = m ? m[1] : (!id.includes('#') ? id : null);
|
||||
if (!groupName || radioSeen.has(groupName)) return;
|
||||
radioSeen.add(groupName);
|
||||
const titleEl = document.getElementById(p + groupName + '#title_text');
|
||||
const label = (titleEl?.innerText?.trim() || '').replace(/\\n/g, ' ').replace(/:$/, '');
|
||||
// Collect options: option 0 is the base element, options 1+ have #N#radio
|
||||
const options = [];
|
||||
// Option 0: base element
|
||||
const base = document.getElementById(p + groupName);
|
||||
if (base && base.classList.contains('radio') && base.offsetWidth > 0) {
|
||||
const textEl = document.getElementById(p + groupName + '#0#radio_text');
|
||||
options.push({ index: 0, label: textEl?.innerText?.trim() || '', selected: base.classList.contains('select') });
|
||||
}
|
||||
// Options 1+
|
||||
for (let i = 1; i < 20; i++) {
|
||||
const opt = document.getElementById(p + groupName + '#' + i + '#radio');
|
||||
if (!opt || opt.offsetWidth === 0) break;
|
||||
const textEl = document.getElementById(p + groupName + '#' + i + '#radio_text');
|
||||
options.push({ index: i, label: textEl?.innerText?.trim() || '', selected: opt.classList.contains('select') });
|
||||
}
|
||||
allFields.push({ inputId: p + groupName, name: groupName, label, isRadio: true, options });
|
||||
});
|
||||
|
||||
// Build DCS pairs: checkbox label → paired value field
|
||||
const dcsPairs = {};
|
||||
for (const f of allFields) {
|
||||
const m = f.name.match(/^(.+Элемент\\d+)(Использование|Значение)$/);
|
||||
if (!m) continue;
|
||||
if (!dcsPairs[m[1]]) dcsPairs[m[1]] = {};
|
||||
dcsPairs[m[1]][m[2]] = f;
|
||||
}
|
||||
|
||||
for (const fieldName of fieldNames) {
|
||||
const target = fieldName.toLowerCase().replace(/\\n/g, ' ').replace(/:$/, '');
|
||||
// Fuzzy: exact name -> exact label -> includes name -> includes label
|
||||
let found = allFields.find(f => f.name.toLowerCase() === target);
|
||||
if (!found) found = allFields.find(f => f.label && f.label.toLowerCase() === target);
|
||||
if (!found) found = allFields.find(f => f.name.toLowerCase().includes(target));
|
||||
if (!found) found = allFields.find(f => f.label && f.label.toLowerCase().includes(target));
|
||||
// DCS pair: match checkbox or value label → resolve to paired value field
|
||||
if (!found) {
|
||||
for (const pair of Object.values(dcsPairs)) {
|
||||
const cb = pair['Использование'];
|
||||
const val = pair['Значение'];
|
||||
if (!cb || !val) continue;
|
||||
const pairLabel = ((val.label || cb.label || '').replace(/:$/, '')).toLowerCase();
|
||||
if (pairLabel && (pairLabel === target || pairLabel.includes(target) || target.includes(pairLabel))) {
|
||||
found = val;
|
||||
found._dcsCheckbox = cb;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (found) {
|
||||
const entry = { field: fieldName, inputId: found.inputId, name: found.name, label: found.label };
|
||||
if (found.isCheckbox) { entry.isCheckbox = true; entry.checked = found.checked; }
|
||||
if (found.isRadio) { entry.isRadio = true; entry.options = found.options; }
|
||||
if (found.hasSelect) entry.hasSelect = true;
|
||||
if (found.hasPick) entry.hasPick = true;
|
||||
if (found.isDate) entry.isDate = true;
|
||||
if (found.isCalc) entry.isCalc = true;
|
||||
if (found._dcsCheckbox) {
|
||||
entry.dcsCheckbox = { inputId: found._dcsCheckbox.inputId, checked: found._dcsCheckbox.checked };
|
||||
delete found._dcsCheckbox;
|
||||
}
|
||||
results.push(entry);
|
||||
} else {
|
||||
const available = allFields.map(f => f.label ? f.name + ' (' + f.label + ')' : f.name);
|
||||
results.push({ field: fieldName, error: 'not_found', available });
|
||||
}
|
||||
}
|
||||
return results;
|
||||
})()`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Detect a new form opened above `prevFormNum`. Two modes:
|
||||
* default (broad) — counts any visible `[id]` element; finds dialogs whose
|
||||
* `a.press` buttons have empty IDs. Used by selectValue / fillTableRow.
|
||||
* `{ strict: true }` — only counts visible interactive elements
|
||||
* (`input.editInput[id], a.press[id]`); used by fillReferenceField.
|
||||
*
|
||||
* Returns the highest new form number or `null`.
|
||||
*/
|
||||
export function detectNewFormScript(prevFormNum, { strict = false } = {}) {
|
||||
const selector = strict ? 'input.editInput[id], a.press[id]' : '[id]';
|
||||
const visibleCheck = strict
|
||||
? 'el.offsetWidth === 0'
|
||||
: 'el.offsetWidth === 0 && el.offsetHeight === 0';
|
||||
return `(() => {
|
||||
const forms = {};
|
||||
document.querySelectorAll(${JSON.stringify(selector)}).forEach(el => {
|
||||
if (${visibleCheck}) return;
|
||||
const m = el.id.match(/^form(\\d+)_/);
|
||||
if (m) forms[m[1]] = true;
|
||||
});
|
||||
const nums = Object.keys(forms).map(Number).filter(n => n > ${prevFormNum});
|
||||
return nums.length > 0 ? Math.max(...nums) : null;
|
||||
})()`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Find the search input on a list form (matches `SearchString` / `ПоискаСтроки` id).
|
||||
* Returns `{ id, value } | null`.
|
||||
*/
|
||||
export function findSearchInputScript(formNum) {
|
||||
return `(() => {
|
||||
const p = 'form${formNum}_';
|
||||
const el = [...document.querySelectorAll('input.editInput[id^="' + p + '"]')]
|
||||
.find(el => el.offsetWidth > 0 && /Строк[аи]Поиска|SearchString/i.test(el.id));
|
||||
return el ? { id: el.id, value: el.value || '' } : null;
|
||||
})()`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Find a visible `a.press` button by its exact innerText (after trim).
|
||||
* Returns `{ x, y } | null` for `page.mouse.click(x, y)`.
|
||||
*
|
||||
* Used for modal dialog buttons (Найти, OK) where page.click may be blocked.
|
||||
*/
|
||||
export function findNamedButtonScript(buttonText) {
|
||||
return `(() => {
|
||||
const btns = [...document.querySelectorAll('a.press')].filter(el => el.offsetWidth > 0);
|
||||
const btn = btns.find(el => el.innerText?.trim() === ${JSON.stringify(buttonText)});
|
||||
if (!btn) return null;
|
||||
const r = btn.getBoundingClientRect();
|
||||
return { x: Math.round(r.x + r.width / 2), y: Math.round(r.y + r.height / 2) };
|
||||
})()`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Find a CompareType radio button by index (1 = "contains", 2 = "exact", etc.)
|
||||
* on a search/filter dialog.
|
||||
*
|
||||
* Returns:
|
||||
* - `{ already: true }` — the group is disabled OR the radio is already selected
|
||||
* - `{ x, y } | null` — coords to click, or null if radio not present
|
||||
*/
|
||||
export function findCompareTypeRadioScript(dialogForm, radioIndex) {
|
||||
return `(() => {
|
||||
const p = 'form' + ${JSON.stringify(String(dialogForm))} + '_';
|
||||
const group = document.getElementById(p + 'CompareType');
|
||||
if (group && group.classList.contains('disabled')) return { already: true };
|
||||
const el = document.getElementById(p + 'CompareType#' + ${JSON.stringify(String(radioIndex))} + '#radio');
|
||||
if (!el || el.offsetWidth === 0) return null;
|
||||
if (el.classList.contains('select')) return { already: true };
|
||||
const r = el.getBoundingClientRect();
|
||||
return { x: Math.round(r.x + r.width / 2), y: Math.round(r.y + r.height / 2) };
|
||||
})()`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Is any element of `form{dialogForm}_` currently visible?
|
||||
* Used to poll dialog dismissal after Escape.
|
||||
*/
|
||||
export function isFormVisibleScript(dialogForm) {
|
||||
return `(() => {
|
||||
const p = 'form${dialogForm}_';
|
||||
return [...document.querySelectorAll('[id^="' + p + '"]')].some(el => el.offsetWidth > 0);
|
||||
})()`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Find the Pattern input id on a search/filter dialog. Returns `id | null`.
|
||||
*/
|
||||
export function findPatternInputIdScript(dialogForm) {
|
||||
return `(() => {
|
||||
const p = 'form${dialogForm}_';
|
||||
const el = [...document.querySelectorAll('input.editInput[id^="' + p + '"]')]
|
||||
.find(el => el.offsetWidth > 0 && /Pattern/i.test(el.id));
|
||||
return el ? el.id : null;
|
||||
})()`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Is the given form a type selection dialog ("Выбор типа данных")?
|
||||
*
|
||||
* Detection signals (any one is sufficient):
|
||||
* - `form{N}_OK` element exists (selection forms use "Выбрать", not "OK")
|
||||
* - `form{N}_ValueList` grid exists (specific to type/value list dialogs)
|
||||
* - window title contains "Выбор типа" on a visible `.toplineBoxTitle`
|
||||
*
|
||||
* Returns boolean.
|
||||
*/
|
||||
export function isTypeDialogScript(formNum) {
|
||||
return `(() => {
|
||||
const p = 'form' + ${formNum} + '_';
|
||||
const hasOK = !!document.getElementById(p + 'OK');
|
||||
const hasValueList = !!document.getElementById(p + 'ValueList');
|
||||
const hasTitle = [...document.querySelectorAll('.toplineBoxTitle')]
|
||||
.some(el => el.offsetWidth > 0 && /выбор типа/i.test(el.getAttribute('title') || ''));
|
||||
return hasOK || hasValueList || hasTitle;
|
||||
})()`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Click the "Показать все" / "Show all" link inside the "нет в списке"
|
||||
* cloud popup via `dispatchEvent`. Returns boolean — whether clicked.
|
||||
*/
|
||||
export function clickShowAllInNotInListCloudScript() {
|
||||
return `(() => {
|
||||
for (const el of document.querySelectorAll('div')) {
|
||||
if (el.offsetWidth === 0 || el.offsetHeight === 0) continue;
|
||||
const s = getComputedStyle(el);
|
||||
if (s.position !== 'absolute' && s.position !== 'fixed') continue;
|
||||
if ((parseInt(s.zIndex) || 0) < 100) continue;
|
||||
if (!(el.innerText || '').includes('нет в списке')) continue;
|
||||
const links = [...el.querySelectorAll('a, span, div')]
|
||||
.filter(e => e.offsetWidth > 0 && e.children.length === 0);
|
||||
const showAll = links.find(e => {
|
||||
const t = (e.innerText?.trim() || '').toLowerCase();
|
||||
return t === 'показать все' || t === 'show all';
|
||||
});
|
||||
if (showAll) {
|
||||
const r = showAll.getBoundingClientRect();
|
||||
const opts = { bubbles:true, cancelable:true,
|
||||
clientX: r.x + r.width/2, clientY: r.y + r.height/2 };
|
||||
showAll.dispatchEvent(new MouseEvent('mousedown', opts));
|
||||
showAll.dispatchEvent(new MouseEvent('mouseup', opts));
|
||||
showAll.dispatchEvent(new MouseEvent('click', opts));
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
return false;
|
||||
})()`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Is the "нет в списке" cloud popup visible? 1C shows it as a positioned div
|
||||
* (absolute/fixed, high z-index) whose text contains "нет в списке".
|
||||
* Returns boolean.
|
||||
*/
|
||||
export function isNotInListCloudVisibleScript() {
|
||||
return `(() => {
|
||||
const divs = document.querySelectorAll('div');
|
||||
for (const el of divs) {
|
||||
if (el.offsetWidth === 0 || el.offsetHeight === 0) continue;
|
||||
const style = getComputedStyle(el);
|
||||
if (style.position !== 'absolute' && style.position !== 'fixed') continue;
|
||||
const z = parseInt(style.zIndex) || 0;
|
||||
if (z < 100) continue;
|
||||
if ((el.innerText || '').includes('нет в списке')) return true;
|
||||
}
|
||||
return false;
|
||||
})()`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Find a child form opened above `prevFormNum` whose `form{N}_{buttonName}` button is visible.
|
||||
* Used by type-dialog Ctrl+F flow to locate the "Найти" sub-dialog form number.
|
||||
* Returns the form number or `null`.
|
||||
*/
|
||||
export function findChildFormByButtonScript(prevFormNum, buttonName, range = 20) {
|
||||
return `(() => {
|
||||
for (let n = ${prevFormNum} + 1; n < ${prevFormNum} + ${range}; n++) {
|
||||
const btn = document.getElementById('form' + n + '_' + ${JSON.stringify(buttonName)});
|
||||
if (btn && btn.offsetWidth > 0) return n;
|
||||
}
|
||||
return null;
|
||||
})()`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Read visible rows of a type-dialog ValueList grid and return rows that fuzzy-match `typeNorm`.
|
||||
*
|
||||
* `typeNorm` should already be lowercased, NBSP-normalized, ё→е normalized (use `normYo`).
|
||||
*
|
||||
* Returns `{ visible: string[], matches: Array<{ text, x, y }> }`.
|
||||
*/
|
||||
export function readTypeDialogVisibleRowsScript(formNum, typeNorm) {
|
||||
return `(() => {
|
||||
const grid = document.getElementById('form${formNum}_ValueList');
|
||||
if (!grid) return { visible: [], matches: [] };
|
||||
const body = grid.querySelector('.gridBody');
|
||||
if (!body) return { visible: [], matches: [] };
|
||||
const lines = body.querySelectorAll('.gridLine');
|
||||
const norm = s => (s || '').replace(/\\u00a0/g, ' ').trim();
|
||||
const typeNorm = ${JSON.stringify(typeNorm)};
|
||||
const visible = [];
|
||||
const matches = [];
|
||||
for (const line of lines) {
|
||||
const text = norm(line.innerText);
|
||||
if (!text) continue;
|
||||
visible.push(text);
|
||||
if (text.toLowerCase().replace(/ё/gi, 'е').includes(typeNorm)) {
|
||||
const r = line.getBoundingClientRect();
|
||||
matches.push({ text, x: Math.round(r.x + r.width / 2), y: Math.round(r.y + r.height / 2) });
|
||||
}
|
||||
}
|
||||
return { visible, matches };
|
||||
})()`;
|
||||
}
|
||||
@@ -0,0 +1,292 @@
|
||||
// web-test dom/grid-edit v1.0 — DOM scripts for row-fill (grid edit-time operations)
|
||||
// Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
|
||||
//
|
||||
// All helpers below accept an optional `gridSelector`. When passed, they target
|
||||
// that exact grid; when null/undefined they pick the LAST visible `.grid` on
|
||||
// the page (this matches the implicit "current grid" used by row-fill).
|
||||
|
||||
/** Inline JS fragment that resolves the target grid into `const grid`. */
|
||||
function gridResolver(gridSelector) {
|
||||
return gridSelector
|
||||
? `document.querySelector(${JSON.stringify(gridSelector)})`
|
||||
: `(() => { const grids = [...document.querySelectorAll('.grid')].filter(el => el.offsetWidth > 0); return grids[grids.length - 1]; })()`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Read the grid's column header texts paired with their `colindex` attribute,
|
||||
* fuzzy-match `fieldKeys` (lowercased) against them, and return the keys in
|
||||
* left-to-right colindex order.
|
||||
*
|
||||
* Keys that don't match a column get colindex `999` (pushed to the end);
|
||||
* caller is expected to preserve their original relative order.
|
||||
*
|
||||
* Returns `string[] | null` (null when no grid or no head).
|
||||
*/
|
||||
export function sortFieldKeysByColindexScript(gridSelector, fieldKeys) {
|
||||
return `(() => {
|
||||
const grid = ${gridResolver(gridSelector)};
|
||||
if (!grid) return null;
|
||||
const head = grid.querySelector('.gridHead');
|
||||
if (!head) return null;
|
||||
const headLine = head.querySelector('.gridLine') || head;
|
||||
const cols = [];
|
||||
[...headLine.children].forEach(box => {
|
||||
if (box.offsetWidth === 0) return;
|
||||
const t = ((box.querySelector('.gridBoxText') || box).innerText?.trim() || '').toLowerCase();
|
||||
const ci = parseInt(box.getAttribute('colindex') || '-1');
|
||||
if (t) cols.push({ text: t, colindex: ci });
|
||||
});
|
||||
const keys = ${JSON.stringify(fieldKeys)};
|
||||
const mapped = keys.map(k => {
|
||||
const exact = cols.find(c => c.text === k);
|
||||
if (exact) return { key: k, colindex: exact.colindex };
|
||||
const inc = cols.find(c => c.text.includes(k) || k.includes(c.text));
|
||||
return { key: k, colindex: inc ? inc.colindex : 999 };
|
||||
});
|
||||
mapped.sort((a, b) => a.colindex - b.colindex);
|
||||
return mapped.map(m => m.key);
|
||||
})()`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve cell coords for row `row` by matching the first column whose header
|
||||
* fuzzy-matches any of `fieldKeys` (lowercased). Falls back to the second
|
||||
* visible (non-`.gridBoxComp`) box when no header matches.
|
||||
*
|
||||
* Returns one of:
|
||||
* - `{ x, y, currentText }` — coords + cell text
|
||||
* - `{ error: 'no_grid' | 'no_grid_body' | 'no_cell' }`
|
||||
* - `{ error: 'row_out_of_range', total }`
|
||||
*/
|
||||
export function findCellCoordsByFieldsScript(gridSelector, row, fieldKeys) {
|
||||
return `(() => {
|
||||
const grid = ${gridResolver(gridSelector)};
|
||||
if (!grid) return { error: 'no_grid' };
|
||||
const head = grid.querySelector('.gridHead');
|
||||
const body = grid.querySelector('.gridBody');
|
||||
if (!head || !body) return { error: 'no_grid_body' };
|
||||
|
||||
// Read column headers to find target colindex
|
||||
const headLine = head.querySelector('.gridLine') || head;
|
||||
const cols = [];
|
||||
[...headLine.children].forEach(box => {
|
||||
if (box.offsetWidth === 0) return;
|
||||
const t = box.querySelector('.gridBoxText');
|
||||
const ci = box.getAttribute('colindex');
|
||||
cols.push({ colindex: ci, text: ((t || box).innerText?.trim() || '').toLowerCase() });
|
||||
});
|
||||
|
||||
const keys = ${JSON.stringify(fieldKeys)};
|
||||
let targetColindex = null;
|
||||
for (const key of keys) {
|
||||
const exact = cols.find(c => c.text === key);
|
||||
if (exact) { targetColindex = exact.colindex; break; }
|
||||
const inc = cols.find(c => c.text.includes(key) || key.includes(c.text));
|
||||
if (inc) { targetColindex = inc.colindex; break; }
|
||||
}
|
||||
|
||||
const rows = [...body.querySelectorAll('.gridLine')];
|
||||
if (${row} >= rows.length) return { error: 'row_out_of_range', total: rows.length };
|
||||
const line = rows[${row}];
|
||||
|
||||
// Find body cell by colindex (reliable across merged headers)
|
||||
let box = null;
|
||||
if (targetColindex != null) {
|
||||
box = [...line.children].find(b => b.offsetWidth > 0 && b.getAttribute('colindex') === targetColindex);
|
||||
}
|
||||
// Fallback: second visible box (skip checkbox/N column)
|
||||
if (!box) {
|
||||
const boxes = [...line.children].filter(b => b.offsetWidth > 0 && !b.classList.contains('gridBoxComp'));
|
||||
box = boxes.length > 1 ? boxes[1] : boxes[0];
|
||||
}
|
||||
if (!box) return { error: 'no_cell' };
|
||||
box.scrollIntoView({ block: 'nearest', inline: 'nearest' });
|
||||
const cell = box.querySelector('.gridBoxText') || box;
|
||||
const r = cell.getBoundingClientRect();
|
||||
const currentText = (cell.innerText?.trim() || '').replace(/\\u00a0/g, ' ');
|
||||
return { x: Math.round(r.x + r.width / 2), y: Math.round(r.y + r.height / 2), currentText };
|
||||
})()`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Like `findCellCoordsByFieldsScript` but for a SINGLE key, with extra
|
||||
* "no-space/no-dash" fuzzy fallback (e.g. "Группа Контрагентов" header matches
|
||||
* key "ГруппаКонтрагентов").
|
||||
*
|
||||
* Returns `{ x, y, currentText } | null`.
|
||||
*/
|
||||
export function findNextCellCoordsByKeyScript(gridSelector, row, key) {
|
||||
return `(() => {
|
||||
const grid = ${gridResolver(gridSelector)};
|
||||
if (!grid) return null;
|
||||
const head = grid.querySelector('.gridHead');
|
||||
const body = grid.querySelector('.gridBody');
|
||||
if (!head || !body) return null;
|
||||
const headLine = head.querySelector('.gridLine') || head;
|
||||
const cols = [];
|
||||
[...headLine.children].forEach(box => {
|
||||
if (box.offsetWidth === 0) return;
|
||||
const t = box.querySelector('.gridBoxText');
|
||||
const ci = box.getAttribute('colindex');
|
||||
cols.push({ colindex: ci, text: ((t || box).innerText?.trim() || '').toLowerCase() });
|
||||
});
|
||||
const kl = ${JSON.stringify(key.toLowerCase())};
|
||||
const klNoSpace = kl.replace(/[\\s\\-]+/g, '');
|
||||
let targetColindex = null;
|
||||
const exact = cols.find(c => c.text === kl);
|
||||
if (exact) targetColindex = exact.colindex;
|
||||
else {
|
||||
const inc = cols.find(c => c.text.includes(kl) || kl.includes(c.text)
|
||||
|| c.text.includes(klNoSpace) || klNoSpace.includes(c.text));
|
||||
if (inc) targetColindex = inc.colindex;
|
||||
}
|
||||
if (targetColindex == null) return null;
|
||||
const rows = [...body.querySelectorAll('.gridLine')];
|
||||
if (${row} >= rows.length) return null;
|
||||
const line = rows[${row}];
|
||||
const box = [...line.children].find(b => b.offsetWidth > 0 && b.getAttribute('colindex') === targetColindex);
|
||||
if (!box) return null;
|
||||
box.scrollIntoView({ block: 'nearest', inline: 'nearest' });
|
||||
const cell = box.querySelector('.gridBoxText') || box;
|
||||
const r = cell.getBoundingClientRect();
|
||||
const currentText = (cell.innerText?.trim() || '').replace(/\\u00a0/g, ' ');
|
||||
return { x: Math.round(r.x + r.width / 2), y: Math.round(r.y + r.height / 2), currentText };
|
||||
})()`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Inspect the element at point `(x, y)`. If it's inside a `.gridBox` containing
|
||||
* a `.checkbox`, return `{ checked, x, y }` (coords of the checkbox center for
|
||||
* direct click).
|
||||
*
|
||||
* Returns `null` when there's no cell, or the cell isn't a checkbox cell.
|
||||
*/
|
||||
export function findCheckboxAtPointScript(x, y) {
|
||||
return `(() => {
|
||||
const el = document.elementFromPoint(${x}, ${y});
|
||||
const cell = el?.closest('.gridBox');
|
||||
if (!cell) return null;
|
||||
const chk = cell.querySelector('.checkbox');
|
||||
if (!chk) return null;
|
||||
const r = chk.getBoundingClientRect();
|
||||
return { checked: chk.classList.contains('select'), x: Math.round(r.x + r.width/2), y: Math.round(r.y + r.height/2) };
|
||||
})()`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Find center coords of the first VISIBLE non-`.gridBoxComp` cell on a row
|
||||
* OTHER than `row` (used to commit an edit by clicking off the edited row —
|
||||
* Escape would cancel in tree grids).
|
||||
*
|
||||
* For `row === 0`, targets row 1; otherwise targets row 0.
|
||||
*
|
||||
* Returns `{ x, y } | null` (null when there's no other row).
|
||||
*/
|
||||
export function findRowCommitClickCoordsScript(gridSelector, row) {
|
||||
return `(() => {
|
||||
const grid = ${gridResolver(gridSelector)};
|
||||
if (!grid) return null;
|
||||
const body = grid.querySelector('.gridBody');
|
||||
if (!body) return null;
|
||||
const rows = [...body.querySelectorAll('.gridLine')];
|
||||
const otherIdx = ${row} === 0 ? 1 : 0;
|
||||
const other = rows[otherIdx];
|
||||
if (!other) return null;
|
||||
const visBoxes = [...other.children].filter(b => b.offsetWidth > 0 && !b.classList.contains('gridBoxComp'));
|
||||
const box = visBoxes.length > 1 ? visBoxes[1] : visBoxes[0];
|
||||
if (!box) return null;
|
||||
const r = box.getBoundingClientRect();
|
||||
return { x: Math.round(r.x + r.width / 2), y: Math.round(r.y + r.height / 2) };
|
||||
})()`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Diagnostic: are we in grid edit mode (active INPUT inside `.grid` or
|
||||
* `.gridContent`)? Returns an OBJECT (not a boolean) suitable for diagnostics:
|
||||
* - `{ inEdit: true }` — good
|
||||
* - `{ inEdit: false, tag: 'DIV' }` — active element wasn't INPUT
|
||||
* - `{ inEdit: false, hint: 'input not inside grid' }` — input but no grid ancestor
|
||||
*/
|
||||
export function getGridEditCheckScript() {
|
||||
return `(() => {
|
||||
const f = document.activeElement;
|
||||
if (!f || f.tagName !== 'INPUT') return { inEdit: false, tag: f?.tagName };
|
||||
let node = f;
|
||||
while (node) {
|
||||
if (node.classList?.contains('grid') || node.classList?.contains('gridContent')) return { inEdit: true };
|
||||
node = node.parentElement;
|
||||
}
|
||||
return { inEdit: false, hint: 'input not inside grid' };
|
||||
})()`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Read the currently focused element if it's an editable grid cell (INPUT or
|
||||
* TEXTAREA inside `.grid` / `.gridContent`). Resolves the header text by
|
||||
* matching x-overlap of the input's bounding rect against header boxes.
|
||||
*
|
||||
* Returns one of:
|
||||
* - `{ tag: 'INPUT', id, fullName, headerText }` — editable cell
|
||||
* - `{ tag: 'DIV' | 'BODY' | ... }` — focused but not an editable cell
|
||||
* - `{ tag: 'none' }` — nothing focused
|
||||
*
|
||||
* `fullName` strips both `form{N}_` prefix and `_i{M}` suffix.
|
||||
*/
|
||||
export function readActiveGridCellScript() {
|
||||
return `(() => {
|
||||
const f = document.activeElement;
|
||||
if (!f) return { tag: 'none' };
|
||||
if (f.tagName === 'INPUT' || f.tagName === 'TEXTAREA') {
|
||||
const inGrid = (() => { let n = f; while (n) { if (n.classList?.contains('grid') || n.classList?.contains('gridContent')) return true; n = n.parentElement; } return false; })();
|
||||
if (inGrid) {
|
||||
let headerText = '';
|
||||
let grid = f; while (grid && !grid.classList?.contains('grid')) grid = grid.parentElement;
|
||||
if (grid) {
|
||||
const fr = f.getBoundingClientRect();
|
||||
const head = grid.querySelector('.gridHead');
|
||||
const hl = head?.querySelector('.gridLine') || head;
|
||||
if (hl) for (const h of hl.children) {
|
||||
if (h.offsetWidth === 0) continue;
|
||||
const hr = h.getBoundingClientRect();
|
||||
if (fr.x >= hr.x && fr.x < hr.x + hr.width) {
|
||||
const t = h.querySelector('.gridBoxText');
|
||||
headerText = (t || h).innerText?.trim() || '';
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
// Classify the cell's choice button (if any): ref (_DLB), calc/date (_CB iCalcB/iCalendB),
|
||||
// or bare 'choice' (_CB iCB — value picked from a programmatic list, e.g. НачалоВыбора).
|
||||
let buttonKind = null;
|
||||
const base = f.id.replace(/_i\\d+$/, '');
|
||||
const dlb = document.getElementById(base + '_DLB');
|
||||
const cb = document.getElementById(base + '_CB');
|
||||
if (dlb && dlb.offsetWidth > 0) buttonKind = 'ref';
|
||||
else if (cb && cb.offsetWidth > 0) {
|
||||
if (cb.classList.contains('iCalcB')) buttonKind = 'calc';
|
||||
else if (cb.classList.contains('iCalendB')) buttonKind = 'date';
|
||||
else buttonKind = 'choice';
|
||||
}
|
||||
return {
|
||||
tag: 'INPUT', id: f.id,
|
||||
fullName: f.id.replace(/^form\\d+_/, '').replace(/_i\\d+$/, ''),
|
||||
headerText, buttonKind
|
||||
};
|
||||
}
|
||||
}
|
||||
return { tag: f.tagName || 'none' };
|
||||
})()`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Return center coords of the element with the given id.
|
||||
* Returns `{ x, y } | null`.
|
||||
*/
|
||||
export function getElementCenterCoordsByIdScript(elementId) {
|
||||
return `(() => {
|
||||
const el = document.getElementById(${JSON.stringify(elementId)});
|
||||
if (!el) return null;
|
||||
const r = el.getBoundingClientRect();
|
||||
return { x: r.x + r.width / 2, y: r.y + r.height / 2 };
|
||||
})()`;
|
||||
}
|
||||
@@ -0,0 +1,755 @@
|
||||
// web-test dom/grid v1.9 — grid resolution + table reading + edit-time helpers
|
||||
// Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
|
||||
|
||||
/**
|
||||
* Resolve a specific grid by semantic name (table parameter).
|
||||
* Cascade: exact gridName match → gridName contains → column contains.
|
||||
* Returns { gridSelector, gridId, gridName, gridIndex, columns } or { error, available }.
|
||||
*/
|
||||
export function resolveGridScript(formNum, tableName) {
|
||||
const p = `form${formNum}_`;
|
||||
return `(() => {
|
||||
const p = ${JSON.stringify(p)};
|
||||
const target = ${JSON.stringify(tableName.toLowerCase().replace(/ё/g, 'е'))};
|
||||
const norm = s => (s || '').replace(/ё/gi, 'е');
|
||||
const allGrids = [...document.querySelectorAll('[id^="' + p + '"].grid, [id^="' + p + '"] .grid')]
|
||||
.filter(g => g.offsetWidth > 0 && g.offsetHeight > 0);
|
||||
if (!allGrids.length) return { error: 'no_grids', message: 'No grids found on form' };
|
||||
const infos = allGrids.map((g, idx) => {
|
||||
const gridId = g.id || '';
|
||||
const gridName = gridId.replace(p, '');
|
||||
const head = g.querySelector('.gridHead');
|
||||
const columns = [];
|
||||
if (head) {
|
||||
const headLine = head.querySelector('.gridLine') || head;
|
||||
[...headLine.children].forEach(box => {
|
||||
if (box.offsetWidth === 0) return;
|
||||
const textEl = box.querySelector('.gridBoxText');
|
||||
const text = (textEl || box).innerText?.trim().replace(/\\n/g, ' ') || '';
|
||||
if (text) columns.push(text);
|
||||
});
|
||||
}
|
||||
// Visual label from group title element
|
||||
const titleEl = document.getElementById(p + gridName + '#title_div')
|
||||
|| document.getElementById(p + 'Группа' + gridName + '#title_div');
|
||||
const label = titleEl ? (titleEl.innerText?.trim().replace(/:\s*$/, '').replace(/ /g, ' ') || '') : '';
|
||||
return { idx, gridId, gridName, label, columns, el: g };
|
||||
});
|
||||
// 1. Exact gridName match (case-insensitive)
|
||||
let found = infos.find(i => norm(i.gridName).toLowerCase() === target);
|
||||
// 2. Exact label match
|
||||
if (!found) found = infos.find(i => i.label && norm(i.label).toLowerCase() === target);
|
||||
// 3. gridName contains target
|
||||
if (!found) found = infos.find(i => norm(i.gridName).toLowerCase().includes(target));
|
||||
// 4. Label contains target
|
||||
if (!found) found = infos.find(i => i.label && norm(i.label).toLowerCase().includes(target));
|
||||
// 5. Any column contains target
|
||||
if (!found) found = infos.find(i => i.columns.some(c => norm(c).toLowerCase().includes(target)));
|
||||
if (found) {
|
||||
return {
|
||||
gridSelector: found.gridId ? '#' + CSS.escape(found.gridId) : null,
|
||||
gridId: found.gridId,
|
||||
gridName: found.gridName,
|
||||
gridIndex: found.idx,
|
||||
columns: found.columns
|
||||
};
|
||||
}
|
||||
return {
|
||||
error: 'not_found',
|
||||
message: 'Table "' + ${JSON.stringify(tableName)} + '" not found',
|
||||
available: infos.map(i => ({ name: i.gridName, ...(i.label ? { label: i.label } : {}), columns: i.columns }))
|
||||
};
|
||||
})()`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Read table/grid data with pagination.
|
||||
* Parses grid.innerText — \n separates rows, \t separates cells.
|
||||
* First row = column headers.
|
||||
* Returns { name, columns[], rows[{col:val}], total, offset, shown }.
|
||||
*/
|
||||
export function readTableScript(formNum, { maxRows = 20, offset = 0, gridSelector } = {}) {
|
||||
const p = `form${formNum}_`;
|
||||
return `(() => {
|
||||
const p = ${JSON.stringify(p)};
|
||||
const grid = ${gridSelector
|
||||
? `document.querySelector(${JSON.stringify(gridSelector)})`
|
||||
: `[...document.querySelectorAll('[id^="' + p + '"].grid, [id^="' + p + '"] .grid')]
|
||||
.find(g => g.offsetWidth > 0 && g.offsetHeight > 0)`};
|
||||
if (!grid) return { error: 'no_table', message: 'No table found on form ${formNum}' };
|
||||
const name = grid.id ? grid.id.replace(p, '') : '';
|
||||
|
||||
// Detect a "picture value" cell: a sprite from a picture collection
|
||||
// (.gridBoxImg .dIB with background-image .../pictureCollection/picture/<id>?...&gx=<N>).
|
||||
// Excludes decorative tree/group markers (gridListH/gridListV/[tree]/gridBoxTree).
|
||||
// Returns { gx } — the sprite frame index that encodes the cell state, or null.
|
||||
function picInfo(cell) {
|
||||
if (!cell) return null;
|
||||
if (cell.querySelector('.gridListH, .gridListV, [tree="true"], .gridBoxTree')) return null;
|
||||
const dib = cell.querySelector('.gridBoxImg .dIB');
|
||||
if (!dib) return null;
|
||||
const bg = dib.style.backgroundImage || '';
|
||||
if (!bg.includes('pictureCollection/picture/')) return null;
|
||||
const m = bg.match(/[?&]gx=(\\d+)/);
|
||||
return { gx: m ? m[1] : '0' };
|
||||
}
|
||||
|
||||
// DOM-based parsing: gridHead → columns, gridBody → gridLine rows → gridBox cells
|
||||
const head = grid.querySelector('.gridHead');
|
||||
const body = grid.querySelector('.gridBody');
|
||||
if (!head || !body) {
|
||||
// Fallback: innerText-based (for non-standard grids)
|
||||
const gText = grid.innerText?.trim() || '';
|
||||
const lines = gText.split('\\n').filter(Boolean);
|
||||
return { name, columns: [], rows: [], total: lines.length, offset: 0, shown: 0,
|
||||
hint: 'Grid has no gridHead/gridBody structure' };
|
||||
}
|
||||
|
||||
// Extract column headers with X-coordinates for alignment
|
||||
const columns = [];
|
||||
const headLine = head.querySelector('.gridLine') || head;
|
||||
[...headLine.children].forEach(box => {
|
||||
if (box.offsetWidth === 0) return;
|
||||
const textEl = box.querySelector('.gridBoxText');
|
||||
const text = (textEl || box).innerText?.trim().replace(/\\n/g, ' ') || '';
|
||||
if (!text) {
|
||||
// Unnamed column — check if data cells contain checkboxes or pictures.
|
||||
// Picture columns have no header text (only an icon + a title tooltip); 1С
|
||||
// doesn't expose the technical column name in the DOM, so we name them by
|
||||
// the header's title attribute, falling back to '(picture)'.
|
||||
const firstLine = body?.querySelector('.gridLine');
|
||||
const visibleHeaders = [...headLine.children].filter(c => c.offsetWidth > 0);
|
||||
const idx = visibleHeaders.indexOf(box);
|
||||
const cells = firstLine ? [...firstLine.children].filter(c => c.offsetWidth > 0) : [];
|
||||
const r = box.getBoundingClientRect();
|
||||
if (cells[idx]?.querySelector('.checkbox')) {
|
||||
columns.push({ text: '(checkbox)', x: r.x, w: r.width, right: r.x + r.width, y: r.y, h: r.height });
|
||||
} else if (picInfo(box) || picInfo(cells[idx])) {
|
||||
let title = (box.getAttribute('title') || '').trim() || '(picture)';
|
||||
// Disambiguate duplicate picture-column names with a numeric suffix.
|
||||
if (columns.some(c => c.text === title)) {
|
||||
let n = 2;
|
||||
while (columns.some(c => c.text === title + ' ' + n)) n++;
|
||||
title = title + ' ' + n;
|
||||
}
|
||||
columns.push({ text: title, x: r.x, w: r.width, right: r.x + r.width, y: r.y, h: r.height });
|
||||
}
|
||||
return;
|
||||
}
|
||||
const r = box.getBoundingClientRect();
|
||||
columns.push({ text, x: r.x, w: r.width, right: r.x + r.width, y: r.y, h: r.height });
|
||||
});
|
||||
|
||||
// Multi-row grid support: detect stacked/merged headers.
|
||||
// Group headers by X-range. For each group, count data sub-rows from first line.
|
||||
// - Stacked headers (2+ headers at same X) with multiple data rows → match by Y-order
|
||||
// - Single merged header with multiple data rows → expand to numbered columns (e.g. "Субконто Дт 1")
|
||||
const xGroups = new Map();
|
||||
columns.forEach(c => {
|
||||
const key = Math.round(c.x) + ':' + Math.round(c.right);
|
||||
if (!xGroups.has(key)) xGroups.set(key, []);
|
||||
xGroups.get(key).push(c);
|
||||
});
|
||||
for (const [, hdrs] of xGroups) hdrs.sort((a, b) => a.y - b.y);
|
||||
|
||||
const firstDataLine = body?.querySelector('.gridLine');
|
||||
const subRowMap = new Map();
|
||||
if (firstDataLine) {
|
||||
[...firstDataLine.children].forEach(box => {
|
||||
if (box.offsetWidth === 0) return;
|
||||
const r = box.getBoundingClientRect();
|
||||
const cx = r.x + r.width / 2;
|
||||
for (const [key, hdrs] of xGroups) {
|
||||
const h0 = hdrs[0];
|
||||
if (cx >= h0.x && cx < h0.right) {
|
||||
if (!subRowMap.has(key)) subRowMap.set(key, []);
|
||||
subRowMap.get(key).push({ y: r.y });
|
||||
break;
|
||||
}
|
||||
}
|
||||
});
|
||||
for (const [, subs] of subRowMap) subs.sort((a, b) => a.y - b.y);
|
||||
}
|
||||
|
||||
const multiRowGroups = new Map();
|
||||
for (const [key, hdrs] of xGroups) {
|
||||
const subs = subRowMap.get(key);
|
||||
if (!subs || subs.length <= 1) continue;
|
||||
if (hdrs.length >= 2) {
|
||||
multiRowGroups.set(key, hdrs);
|
||||
} else if (hdrs.length === 1 && subs.length > 1) {
|
||||
const base = hdrs[0];
|
||||
const baseIdx = columns.indexOf(base);
|
||||
columns.splice(baseIdx, 1);
|
||||
const expanded = [];
|
||||
for (let si = 0; si < subs.length; si++) {
|
||||
const numbered = {
|
||||
text: base.text + ' ' + (si + 1),
|
||||
x: base.x, w: base.w, right: base.right,
|
||||
y: base.y + si, h: base.h / subs.length, _subIdx: si
|
||||
};
|
||||
columns.splice(baseIdx + si, 0, numbered);
|
||||
expanded.push(numbered);
|
||||
}
|
||||
multiRowGroups.set(key, expanded);
|
||||
}
|
||||
}
|
||||
|
||||
function matchColumn(cellX, cellW, cellY) {
|
||||
const cx = cellX + cellW / 2;
|
||||
for (const [key, hdrs] of multiRowGroups) {
|
||||
const h0 = hdrs[0];
|
||||
if (cx >= h0.x && cx < h0.right) {
|
||||
const subs = subRowMap.get(key);
|
||||
if (subs) {
|
||||
const subIdx = subs.findIndex(s => Math.abs(s.y - cellY) < 5);
|
||||
if (subIdx >= 0 && subIdx < hdrs.length) return hdrs[subIdx];
|
||||
}
|
||||
let best = hdrs[0], bestDist = Infinity;
|
||||
for (const h of hdrs) {
|
||||
const dist = Math.abs(cellY - h.y);
|
||||
if (dist < bestDist) { bestDist = dist; best = h; }
|
||||
}
|
||||
return best;
|
||||
}
|
||||
}
|
||||
return columns.find(c => cx >= c.x && cx < c.right);
|
||||
}
|
||||
|
||||
// Extract data rows from gridBody
|
||||
const allLines = body.querySelectorAll('.gridLine');
|
||||
const total = allLines.length;
|
||||
const rows = [];
|
||||
const end = Math.min(${offset} + ${maxRows}, total);
|
||||
for (let i = ${offset}; i < end; i++) {
|
||||
const line = allLines[i];
|
||||
if (!line) break;
|
||||
const row = {};
|
||||
columns.forEach(c => { row[c.text] = ''; });
|
||||
[...line.children].forEach(box => {
|
||||
if (box.offsetWidth === 0) return;
|
||||
const textEl = box.querySelector('.gridBoxText');
|
||||
const chk = box.querySelector('.checkbox');
|
||||
let val;
|
||||
if (chk) {
|
||||
val = chk.classList.contains('select') ? 'true' : 'false';
|
||||
} else {
|
||||
val = (textEl || box).innerText?.trim().replace(/\\n/g, ' ') || '';
|
||||
if (!val) {
|
||||
// Empty text → maybe a picture cell. 'pic:<gx>' encodes the sprite frame
|
||||
// (state). Absent picture stays '' (truthy check distinguishes presence).
|
||||
const pic = picInfo(box);
|
||||
if (pic) val = 'pic:' + pic.gx;
|
||||
else return;
|
||||
}
|
||||
}
|
||||
// Match cell to column by X+Y overlap (multi-row aware)
|
||||
const r = box.getBoundingClientRect();
|
||||
const col = matchColumn(r.x, r.width, r.y);
|
||||
if (col) {
|
||||
row[col.text] = row[col.text] ? row[col.text] + ' / ' + val : val;
|
||||
}
|
||||
});
|
||||
// Detect row kind: group (gridListH), parent/up (gridListV), or element
|
||||
const imgBox = line.querySelector('.gridBoxImg');
|
||||
if (imgBox) {
|
||||
if (imgBox.querySelector('.gridListH')) row._kind = 'group';
|
||||
else if (imgBox.querySelector('.gridListV')) row._kind = 'parent';
|
||||
}
|
||||
// Tree mode: detect expand/collapse state and indent level
|
||||
const treeBox = line.querySelector('.gridBoxTree');
|
||||
if (treeBox) {
|
||||
const treeIcon = imgBox?.querySelector('[tree="true"]');
|
||||
if (treeIcon) {
|
||||
const bg = treeIcon.style.backgroundImage || '';
|
||||
row._tree = bg.includes('gx=0') ? 'expanded' : 'collapsed';
|
||||
}
|
||||
row._level = imgBox ? imgBox.querySelectorAll('.dIB').length - 1 : 0;
|
||||
}
|
||||
// Selection state: selRow = selected row in grid
|
||||
if (line.classList.contains('selRow') || line.classList.contains('select')) row._selected = true;
|
||||
rows.push(row);
|
||||
}
|
||||
const isTree = !!body.querySelector('.gridBoxTree');
|
||||
const hasGroups = rows.some(r => r._kind === 'group');
|
||||
// Virtualization-aware hasMore signal. Three sources in priority order:
|
||||
// 1. Dynamic-list turn buttons (#vertButtonScroll_<gridId>, sibling of grid).
|
||||
// Buttons carry data-home/data-up (above) and data-down/data-end (below);
|
||||
// class "disabled" on a direction means nothing to show there.
|
||||
// 2. Tabular-section scrollbar (#vertScroll_<gridId>, class scrollV) —
|
||||
// track-back/track-next pixel heights tell us above/below precisely.
|
||||
// 3. Fallback: scrollHeight>clientHeight for "below"; "above" unknown.
|
||||
let hasMore;
|
||||
const turnsBox = document.getElementById('vertButtonScroll_' + grid.id);
|
||||
if (turnsBox && turnsBox.offsetHeight > 0) {
|
||||
const upBtns = turnsBox.querySelectorAll('[data-home], [data-up]');
|
||||
const dnBtns = turnsBox.querySelectorAll('[data-down], [data-end]');
|
||||
hasMore = {
|
||||
above: [...upBtns].some(b => !b.classList.contains('disabled')),
|
||||
below: [...dnBtns].some(b => !b.classList.contains('disabled')),
|
||||
};
|
||||
} else {
|
||||
const vsId = 'vertScroll_' + grid.id;
|
||||
const vs = document.getElementById(vsId);
|
||||
if (vs && vs.classList.contains('scrollV') && vs.offsetWidth > 0) {
|
||||
const back = vs.querySelector('[data-track-back]')?.offsetHeight ?? 0;
|
||||
const next = vs.querySelector('[data-track-next]')?.offsetHeight ?? 0;
|
||||
hasMore = { above: back > 0, below: next > 0 };
|
||||
} else {
|
||||
hasMore = { below: body.scrollHeight > body.clientHeight };
|
||||
}
|
||||
}
|
||||
const result = { name, columns: columns.map(c => c.text), rows, total, offset: ${offset}, shown: rows.length, hasMore };
|
||||
if (isTree) result.viewMode = 'tree';
|
||||
if (hasGroups) result.hierarchical = true;
|
||||
return result;
|
||||
})()`;
|
||||
}
|
||||
|
||||
// ─── Edit-time grid helpers (for fillTableRow / row-fill) ────────────────────
|
||||
//
|
||||
// All helpers below accept an optional `gridSelector`. When passed, they target
|
||||
// that exact grid; when null/undefined they pick the LAST visible `.grid` on
|
||||
// the page (this matches the implicit "current grid" used by row-fill).
|
||||
|
||||
/** Inline JS fragment that resolves the target grid into `const grid`. */
|
||||
function gridResolver(gridSelector) {
|
||||
return gridSelector
|
||||
? `document.querySelector(${JSON.stringify(gridSelector)})`
|
||||
: `(() => { const grids = [...document.querySelectorAll('.grid')].filter(el => el.offsetWidth > 0); return grids[grids.length - 1]; })()`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Find center coords of a target row for click-select (used by deleteTableRow).
|
||||
* Picks the second visible gridBox container in the row (skips row-number/checkbox col).
|
||||
*
|
||||
* Returns `{ x, y, total } | { error: 'no_grid'|'no_grid_body'|'row_out_of_range'|'no_cell', total? }`.
|
||||
*/
|
||||
export function findDeleteRowCoordsScript(gridSelector, row) {
|
||||
return `(() => {
|
||||
const grid = ${gridResolver(gridSelector)};
|
||||
if (!grid) return { error: 'no_grid' };
|
||||
const body = grid.querySelector('.gridBody');
|
||||
if (!body) return { error: 'no_grid_body' };
|
||||
const rows = [...body.querySelectorAll('.gridLine')];
|
||||
if (${row} >= rows.length) return { error: 'row_out_of_range', total: rows.length };
|
||||
const line = rows[${row}];
|
||||
// Use visible gridBox containers (not gridBoxText) to avoid clicking checkboxes
|
||||
const boxes = [...line.children].filter(b => b.offsetWidth > 0 && !b.classList.contains('gridBoxComp'));
|
||||
// Skip first column (row number / checkbox) — pick second visible box
|
||||
const box = boxes.length > 1 ? boxes[1] : boxes[0];
|
||||
if (!box) return { error: 'no_cell' };
|
||||
const cell = box.querySelector('.gridBoxText') || box;
|
||||
const r = cell.getBoundingClientRect();
|
||||
return { x: Math.round(r.x + r.width / 2), y: Math.round(r.y + r.height / 2), total: rows.length };
|
||||
})()`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Count `.gridLine` rows in the body of the target grid.
|
||||
* Returns the row count, or `0` when grid/body absent.
|
||||
*/
|
||||
export function countGridRowsScript(gridSelector) {
|
||||
return `(() => {
|
||||
const grid = ${gridResolver(gridSelector)};
|
||||
const body = grid?.querySelector('.gridBody');
|
||||
return body ? body.querySelectorAll('.gridLine').length : 0;
|
||||
})()`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Is the target grid a tree grid? (presence of `.gridBoxTree`)
|
||||
* Returns boolean.
|
||||
*/
|
||||
export function isTreeGridScript(gridSelector) {
|
||||
return `(() => {
|
||||
const grid = ${gridResolver(gridSelector)};
|
||||
return grid ? !!grid.querySelector('.gridBoxTree') : false;
|
||||
})()`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Return center coords of the grid's `.gridHead` element.
|
||||
* Used as a click target to commit a pending cell edit (clicking the header
|
||||
* defocuses the input without selecting another row).
|
||||
*
|
||||
* Returns `{ x, y } | null`.
|
||||
*/
|
||||
export function findGridHeadCenterCoordsScript(gridSelector) {
|
||||
return `(() => {
|
||||
const grid = ${gridResolver(gridSelector)};
|
||||
if (!grid) return null;
|
||||
const head = grid.querySelector('.gridHead');
|
||||
if (!head) return null;
|
||||
const r = head.getBoundingClientRect();
|
||||
return { x: Math.round(r.x + r.width / 2), y: Math.round(r.y + r.height / 2) };
|
||||
})()`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Return the index of the currently selected row in the target grid, or
|
||||
* fall back to the last row when nothing is selected.
|
||||
*
|
||||
* Returns row index, or `-1` when no rows.
|
||||
*/
|
||||
export function getSelectedOrLastRowIndexScript(gridSelector) {
|
||||
return `(() => {
|
||||
const grid = ${gridResolver(gridSelector)};
|
||||
if (!grid) return -1;
|
||||
const body = grid.querySelector('.gridBody');
|
||||
if (!body) return -1;
|
||||
const lines = [...body.querySelectorAll('.gridLine')];
|
||||
const sel = lines.findIndex(l => l.classList.contains('selected'));
|
||||
return sel >= 0 ? sel : lines.length - 1;
|
||||
})()`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Scan a form's grid for a row matching `searchLower` (case- and ё-insensitive,
|
||||
* NBSP-normalised). Match order: exact → startsWith → includes.
|
||||
*
|
||||
* When `searchLower` is empty, returns coords of the first row (fallback).
|
||||
*
|
||||
* Returns `{ rowCount, x, y, isGroup } | { rowCount: 0 } | null`.
|
||||
*/
|
||||
export function scanGridRowsScript(formNum, searchLower) {
|
||||
return `(() => {
|
||||
const p = 'form${formNum}_';
|
||||
const grid = document.querySelector('[id^="' + p + '"].grid, [id^="' + p + '"] .grid');
|
||||
if (!grid) return null;
|
||||
const body = grid.querySelector('.gridBody');
|
||||
if (!body) return null;
|
||||
const lines = [...body.querySelectorAll('.gridLine')];
|
||||
if (!lines.length) return { rowCount: 0 };
|
||||
const searchLower = ${JSON.stringify(searchLower || '')};
|
||||
let sel = null;
|
||||
if (searchLower) {
|
||||
const norm = s => (s || '').replace(/\\u00a0/g, ' ').trim().toLowerCase().replace(/ё/gi, 'е');
|
||||
const rowData = lines.map(l => ({ el: l, text: norm(l.innerText) }));
|
||||
sel = rowData.find(r => r.text === searchLower)?.el
|
||||
|| rowData.find(r => r.text.startsWith(searchLower))?.el
|
||||
|| rowData.find(r => r.text.includes(searchLower))?.el;
|
||||
} else {
|
||||
sel = lines[0]; // empty search → first row
|
||||
}
|
||||
if (!sel) return null;
|
||||
const imgBox = sel.querySelector('.gridBoxImg');
|
||||
const isGroup = imgBox ? !!imgBox.querySelector('.gridListH') : false;
|
||||
const r = sel.getBoundingClientRect();
|
||||
return { rowCount: lines.length, x: Math.round(r.x + r.width / 2), y: Math.round(r.y + r.height / 2), isGroup };
|
||||
})()`;
|
||||
}
|
||||
|
||||
// ─── Cell-click DOM scripts (for clickElement({row, column}) on grids) ───────
|
||||
|
||||
/**
|
||||
* Resolve a target cell in a grid by (row, column).
|
||||
* - `column` matched: exact (case+ё-insensitive) → endsWith ' / X' → includes.
|
||||
* - `row`: number = index in current DOM window; object = {col: value, ...} filter
|
||||
* (matches first non-group/parent row where every column condition passes).
|
||||
*
|
||||
* Returns `{ x, y, cellX, cellRight, gridX, gridRight, columnText, rowIdx, cellText, visible } | { error, ... }`.
|
||||
*
|
||||
* Visibility (`visible`) is true when the cell is fully within the grid's horizontal viewport.
|
||||
* Callers should horizontally scroll first if `visible === false`.
|
||||
*/
|
||||
export function findGridCellScript(formNum, gridSelector, { row, column }) {
|
||||
const p = `form${formNum}_`;
|
||||
return `(() => {
|
||||
const norm = s => (s || '').replace(/\\u00a0/g, ' ').replace(/ё/gi, 'е').trim();
|
||||
const lo = s => norm(s).toLowerCase();
|
||||
|
||||
const p = ${JSON.stringify(p)};
|
||||
const grid = ${gridSelector
|
||||
? `document.querySelector(${JSON.stringify(gridSelector)})`
|
||||
: `[...document.querySelectorAll('[id^="' + p + '"].grid, [id^="' + p + '"] .grid')]
|
||||
.find(g => g.offsetWidth > 0 && g.offsetHeight > 0)`};
|
||||
if (!grid) return { error: 'no_grid' };
|
||||
const head = grid.querySelector('.gridHead');
|
||||
const body = grid.querySelector('.gridBody');
|
||||
if (!head || !body) return { error: 'no_grid_structure' };
|
||||
|
||||
// Header X-ranges (mirror of readTableScript logic, simplified). We also
|
||||
// remember whether each header is frozen (gridBoxFix) — frozen and scrollable
|
||||
// columns can share X coordinates after horizontal scroll, so cell matching
|
||||
// must respect the frozen/scrollable partition.
|
||||
const headLine = head.querySelector('.gridLine') || head;
|
||||
const headers = [...headLine.children]
|
||||
.filter(c => c.offsetWidth > 0)
|
||||
.map(c => {
|
||||
const textEl = c.querySelector('.gridBoxText');
|
||||
const text = (textEl || c).innerText?.trim().replace(/\\n/g, ' ') || '';
|
||||
// Picture/icon columns have no header text — fall back to the title tooltip
|
||||
// (mirrors readTable naming) so they can still be targeted for clicking.
|
||||
const title = (c.getAttribute('title') || '').trim();
|
||||
const r = c.getBoundingClientRect();
|
||||
return { text, title, name: text || title, x: r.x, right: r.x + r.width, fixed: c.classList.contains('gridBoxFix') };
|
||||
})
|
||||
.filter(h => h.name);
|
||||
|
||||
const resolveCol = (name) => {
|
||||
const suffix = ' / ' + name;
|
||||
const cand = h => [h.text, h.title].filter(Boolean);
|
||||
return headers.find(h => cand(h).some(t => lo(t) === lo(name)))
|
||||
|| headers.find(h => cand(h).some(t => t.endsWith(suffix)))
|
||||
|| headers.find(h => cand(h).some(t => lo(t).includes(lo(name))));
|
||||
};
|
||||
|
||||
const targetCol = ${JSON.stringify(column)};
|
||||
const col = resolveCol(targetCol);
|
||||
if (!col) return { error: 'column_not_found', column: targetCol, available: headers.map(h => h.name) };
|
||||
|
||||
const lines = [...body.querySelectorAll('.gridLine')];
|
||||
if (lines.length === 0) return { error: 'empty_grid' };
|
||||
|
||||
// Match cell to column by X overlap, but only among cells with the same
|
||||
// fixed/scrollable kind as the header. After horizontal scroll a scrollable
|
||||
// cell may have the same x as a frozen one — without this guard cellAtColX
|
||||
// would silently return the frozen cell for a scrollable header.
|
||||
const cellAtColX = (line, c) => [...line.children]
|
||||
.filter(b => b.offsetWidth > 0 && b.classList.contains('gridBoxFix') === c.fixed)
|
||||
.find(b => {
|
||||
const r = b.getBoundingClientRect();
|
||||
const cx = r.x + r.width / 2;
|
||||
return cx >= c.x && cx < c.right;
|
||||
});
|
||||
const cellText = (b) => norm(b?.querySelector('.gridBoxText')?.innerText || b?.innerText || '');
|
||||
|
||||
const target = ${JSON.stringify(row)};
|
||||
let line, rowIdx;
|
||||
if (typeof target === 'number') {
|
||||
if (target < 0 || target >= lines.length) {
|
||||
return { error: 'row_out_of_range', row: target, loaded: lines.length };
|
||||
}
|
||||
line = lines[target];
|
||||
rowIdx = target;
|
||||
} else if (target && typeof target === 'object') {
|
||||
const entries = Object.entries(target);
|
||||
const colsByKey = {};
|
||||
for (const [k] of entries) {
|
||||
const c = resolveCol(k);
|
||||
if (!c) return { error: 'filter_column_not_found', column: k, available: headers.map(h => h.name) };
|
||||
colsByKey[k] = c;
|
||||
}
|
||||
const matches = (ln) => {
|
||||
for (const [k, v] of entries) {
|
||||
const c = colsByKey[k];
|
||||
const cell = cellAtColX(ln, c);
|
||||
const txt = cellText(cell);
|
||||
const wanted = lo(v);
|
||||
if (!txt) return false;
|
||||
const t = txt.toLowerCase();
|
||||
if (!(t === wanted || t.includes(wanted))) return false;
|
||||
}
|
||||
return true;
|
||||
};
|
||||
rowIdx = lines.findIndex(matches);
|
||||
if (rowIdx < 0) return { error: 'row_not_found', filter: target };
|
||||
line = lines[rowIdx];
|
||||
} else {
|
||||
return { error: 'invalid_row_type' };
|
||||
}
|
||||
|
||||
const cell = cellAtColX(line, col);
|
||||
if (!cell) return { error: 'cell_not_in_dom', column: col.name, rowIdx };
|
||||
const r = cell.getBoundingClientRect();
|
||||
const gridBox = grid.getBoundingClientRect();
|
||||
// Frozen columns (.gridBoxFix) stay pinned at the left edge of the grid even
|
||||
// when the rest scrolls horizontally. For non-frozen cells, "visible" means
|
||||
// inside the SCROLLABLE viewport (right of any frozen columns). Frozen cells
|
||||
// are always visible by definition.
|
||||
const isFixed = cell.classList.contains('gridBoxFix');
|
||||
let scrollableLeft = gridBox.x;
|
||||
if (!isFixed) {
|
||||
[...line.children].forEach(b => {
|
||||
if (b.offsetWidth > 0 && b.classList.contains('gridBoxFix')) {
|
||||
const br = b.getBoundingClientRect();
|
||||
if (br.x + br.width > scrollableLeft) scrollableLeft = br.x + br.width;
|
||||
}
|
||||
});
|
||||
}
|
||||
// "Visible enough to click" — the cell's CENTER is inside the scrollable area
|
||||
// and the cell's right edge is inside the grid. Strict left-edge check would
|
||||
// reject cells that 1С rendered touching the frozen-column boundary (off by 1px).
|
||||
const center = r.x + r.width / 2;
|
||||
const visible = center >= scrollableLeft && center <= (gridBox.x + gridBox.width) && (r.x + r.width) <= (gridBox.x + gridBox.width);
|
||||
return {
|
||||
x: Math.round(r.x + r.width / 2),
|
||||
y: Math.round(r.y + r.height / 2),
|
||||
cellX: Math.round(r.x), cellRight: Math.round(r.x + r.width),
|
||||
gridX: Math.round(gridBox.x), gridRight: Math.round(gridBox.x + gridBox.width),
|
||||
scrollableLeft: Math.round(scrollableLeft),
|
||||
columnText: col.name, rowIdx, isFixed,
|
||||
cellText: cellText(cell),
|
||||
visible
|
||||
};
|
||||
})()`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Pick coordinates for a focus-click on a safe cell within the grid.
|
||||
*
|
||||
* Used both for vertical reveal-loop focus and for horizontal-scroll edge focus.
|
||||
* The caller passes a profile that selects which row, which cells to exclude,
|
||||
* and (for horizontal scroll) which edge of the row to take.
|
||||
*
|
||||
* @param {string} gridSelector
|
||||
* @param {object} opts
|
||||
* @param {number} [opts.rowIdx] - Pick from this row; falls back to first non-group/parent data row.
|
||||
* @param {'ArrowRight'|'ArrowLeft'} [opts.direction]
|
||||
* - When set, restricts to non-frozen FULLY visible cells and picks the edge
|
||||
* cell in that direction (rightmost for ArrowRight, leftmost for ArrowLeft).
|
||||
* - When omitted, picks a generic safe cell (skips first column to avoid tree-toggles).
|
||||
*
|
||||
* Always prefers non-checkbox cells (center-click on a checkbox would toggle it).
|
||||
*
|
||||
* Returns `{ x, y } | null`.
|
||||
*/
|
||||
export function findFocusCellScript(gridSelector, { rowIdx, direction } = {}) {
|
||||
return `(() => {
|
||||
const grid = ${gridResolver(gridSelector)};
|
||||
if (!grid) return null;
|
||||
const body = grid.querySelector('.gridBody');
|
||||
if (!body) return null;
|
||||
const lines = [...body.querySelectorAll('.gridLine')];
|
||||
if (!lines.length) return null;
|
||||
|
||||
const rowIdx = ${rowIdx == null ? 'null' : JSON.stringify(rowIdx)};
|
||||
const direction = ${direction ? JSON.stringify(direction) : 'null'};
|
||||
|
||||
const line = (rowIdx != null && lines[rowIdx])
|
||||
|| lines.find(ln => {
|
||||
const imgBox = ln.querySelector('.gridBoxImg');
|
||||
return !imgBox?.querySelector('.gridListH, .gridListV');
|
||||
})
|
||||
|| lines[0];
|
||||
if (!line) return null;
|
||||
|
||||
let candidates;
|
||||
if (direction) {
|
||||
// Horizontal-scroll mode: edge cell in the scrollable area, exclude frozen.
|
||||
const gridBox = grid.getBoundingClientRect();
|
||||
let scrollableLeft = gridBox.x;
|
||||
[...line.children].forEach(b => {
|
||||
if (b.offsetWidth > 0 && b.classList.contains('gridBoxFix')) {
|
||||
const br = b.getBoundingClientRect();
|
||||
if (br.x + br.width > scrollableLeft) scrollableLeft = br.x + br.width;
|
||||
}
|
||||
});
|
||||
const visible = [...line.children]
|
||||
.filter(b => b.offsetWidth > 0 && !b.classList.contains('gridBoxFix'))
|
||||
.map(b => ({ b, r: b.getBoundingClientRect(), checkbox: !!b.querySelector('.checkbox') }))
|
||||
.filter(({ r }) => r.x >= scrollableLeft && (r.x + r.width) <= (gridBox.x + gridBox.width));
|
||||
if (!visible.length) return null;
|
||||
visible.sort((a, b) => a.r.x - b.r.x);
|
||||
candidates = direction === 'ArrowRight' ? [...visible].reverse() : visible;
|
||||
} else {
|
||||
// Generic focus mode (used by reveal-loop): pick the FIRST visible cell —
|
||||
// typically a Reference column (Номенклатура in документах) which doesn't
|
||||
// auto-enter edit mode on click. Number/Date/String cells auto-edit and
|
||||
// break subsequent PageDown navigation.
|
||||
// For tree grids (presence of .gridBoxTree), skip first column to avoid
|
||||
// toggling expand/collapse of the row.
|
||||
const isTree = !!body.querySelector('.gridBoxTree');
|
||||
const cells = [...line.children]
|
||||
.filter(b => b.offsetWidth > 0)
|
||||
.map(b => ({ b, r: b.getBoundingClientRect(), checkbox: !!b.querySelector('.checkbox') }));
|
||||
if (!cells.length) return null;
|
||||
candidates = isTree && cells.length > 1 ? cells.slice(1) : cells;
|
||||
}
|
||||
const pick = candidates.find(v => !v.checkbox) || candidates[0];
|
||||
if (!pick) return null;
|
||||
return { x: Math.round(pick.r.x + pick.r.width / 2), y: Math.round(pick.r.y + pick.r.height / 2) };
|
||||
})()`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Snapshot grid state for reveal-loop end detection.
|
||||
* Returns `{ firstText, lastText, lineCount, selIdx, hasBelow }`.
|
||||
*
|
||||
* `firstText`/`lastText` use the first cell's `.gridBoxText` content.
|
||||
* `hasBelow` is derived from scrollbar widget tracks when visible, else from scrollHeight>clientHeight.
|
||||
*/
|
||||
export function snapshotGridScript(gridSelector) {
|
||||
return `(() => {
|
||||
const grid = ${gridResolver(gridSelector)};
|
||||
if (!grid) return null;
|
||||
const body = grid.querySelector('.gridBody');
|
||||
if (!body) return null;
|
||||
const lines = body.querySelectorAll('.gridLine');
|
||||
// Full-row signature: join EVERY cell's text, not just the first column.
|
||||
// A low-cardinality first column (e.g. all "Товар 0X") would otherwise make
|
||||
// two different windows look identical and abort the reveal-loop early.
|
||||
const txt = ln => ln ? [...ln.querySelectorAll('.gridBoxText')].map(b => (b.innerText || '').trim()).join('|') : '';
|
||||
const selIdx = [...lines].findIndex(l => l.classList.contains('selRow') || l.classList.contains('select'));
|
||||
// hasBelow priority: (1) dynamic-list turn buttons, (2) tabular scrollbar tracks, (3) scrollHeight.
|
||||
let hasBelow;
|
||||
const turnsBox = document.getElementById('vertButtonScroll_' + grid.id);
|
||||
if (turnsBox && turnsBox.offsetHeight > 0) {
|
||||
const dnBtns = turnsBox.querySelectorAll('[data-down], [data-end]');
|
||||
hasBelow = [...dnBtns].some(b => !b.classList.contains('disabled'));
|
||||
} else {
|
||||
const vs = document.getElementById('vertScroll_' + grid.id);
|
||||
if (vs && vs.classList.contains('scrollV') && vs.offsetWidth > 0) {
|
||||
hasBelow = (vs.querySelector('[data-track-next]')?.offsetHeight ?? 0) > 0;
|
||||
} else {
|
||||
hasBelow = body.scrollHeight > body.clientHeight;
|
||||
}
|
||||
}
|
||||
return {
|
||||
firstText: txt(lines[0]),
|
||||
lastText: txt(lines[lines.length - 1]),
|
||||
lineCount: lines.length,
|
||||
selIdx,
|
||||
hasBelow
|
||||
};
|
||||
})()`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the click target kind for `clickElement({row, column})`.
|
||||
*
|
||||
* Routing:
|
||||
* - `tableName` specified: try to match a visible grid by name (exact → contains).
|
||||
* If matched → grid. Else if form has a spreadsheet iframe → spreadsheet. Else error.
|
||||
* - `tableName` omitted: spreadsheet iframe present → spreadsheet (backward-compat).
|
||||
* Else first visible grid. Else error.
|
||||
*
|
||||
* Returns `{ kind: 'spreadsheet' } | { kind: 'grid', gridSelector, gridName } | { error, ... }`.
|
||||
*/
|
||||
export function resolveCellTargetScript(formNum, tableName) {
|
||||
const p = `form${formNum}_`;
|
||||
return `(() => {
|
||||
const p = ${JSON.stringify(p)};
|
||||
const tableName = ${JSON.stringify(tableName || '')};
|
||||
// Spreadsheet = iframe under form prefix with non-trivial width.
|
||||
const hasSpreadsheet = [...document.querySelectorAll('iframe')].some(f => {
|
||||
if (f.offsetWidth < 100) return false;
|
||||
let el = f.parentElement;
|
||||
for (let d = 0; el && d < 30; d++, el = el.parentElement) {
|
||||
if (el.id && el.id.startsWith(p)) return true;
|
||||
}
|
||||
return false;
|
||||
});
|
||||
const grids = [...document.querySelectorAll('[id^="' + p + '"].grid, [id^="' + p + '"] .grid')]
|
||||
.filter(g => g.offsetWidth > 0 && g.offsetHeight > 0);
|
||||
const norm = s => (s || '').replace(/ё/gi, 'е').toLowerCase();
|
||||
|
||||
if (tableName) {
|
||||
const target = norm(tableName);
|
||||
const matched = grids.find(g => norm(g.id.replace(p, '')) === target)
|
||||
|| grids.find(g => norm(g.id.replace(p, '')).includes(target));
|
||||
if (matched) {
|
||||
return { kind: 'grid', gridSelector: '#' + CSS.escape(matched.id), gridName: matched.id.replace(p, '') };
|
||||
}
|
||||
if (hasSpreadsheet) return { kind: 'spreadsheet' };
|
||||
return { error: 'table_not_found', table: tableName, availableGrids: grids.map(g => g.id.replace(p, '')) };
|
||||
}
|
||||
if (hasSpreadsheet) return { kind: 'spreadsheet' };
|
||||
if (grids.length > 0) {
|
||||
const g = grids[0];
|
||||
return { kind: 'grid', gridSelector: '#' + CSS.escape(g.id), gridName: g.id.replace(p, '') };
|
||||
}
|
||||
return { error: 'no_spreadsheet_or_grid' };
|
||||
})()`;
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
// web-test dom/nav v1.0 — sections panel, tabs bar, function panel commands
|
||||
// Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
|
||||
|
||||
/** Read sections panel (left sidebar). */
|
||||
export function readSectionsScript() {
|
||||
return `(() => {
|
||||
const sections = [];
|
||||
document.querySelectorAll('[id^="themesCell_theme_"]').forEach(el => {
|
||||
const entry = { name: el.innerText?.trim() || '' };
|
||||
if (el.classList.contains('select')) entry.active = true;
|
||||
sections.push(entry);
|
||||
});
|
||||
return sections;
|
||||
})()`;
|
||||
}
|
||||
|
||||
/** Read open tabs bar. */
|
||||
export function readTabsScript() {
|
||||
return `(() => {
|
||||
const norm = s => (s?.trim().replace(/\\u00a0/g, ' ') || '').replace(/ё/gi, 'е');
|
||||
const tabs = [];
|
||||
document.querySelectorAll('[id^="openedCell_cmd_"]').forEach(el => {
|
||||
const text = norm(el.innerText);
|
||||
if (!text) return;
|
||||
const entry = { name: text };
|
||||
if (el.classList.contains('select')) entry.active = true;
|
||||
tabs.push(entry);
|
||||
});
|
||||
return tabs;
|
||||
})()`;
|
||||
}
|
||||
|
||||
/** Switch to a tab by name (fuzzy match). Returns matched name or { error, available }. */
|
||||
export function switchTabScript(name) {
|
||||
return `(() => {
|
||||
const norm = s => (s?.trim().replace(/\\u00a0/g, ' ') || '').replace(/ё/gi, 'е');
|
||||
const target = ${JSON.stringify(name.toLowerCase().replace(/ё/g, 'е'))};
|
||||
const tabs = [...document.querySelectorAll('[id^="openedCell_cmd_"]')].filter(el => el.offsetWidth > 0 && norm(el.innerText));
|
||||
let best = tabs.find(el => norm(el.innerText).toLowerCase() === target);
|
||||
if (!best) best = tabs.find(el => norm(el.innerText).toLowerCase().includes(target));
|
||||
if (best) { best.click(); return norm(best.innerText); }
|
||||
return { error: 'not_found', available: tabs.map(el => norm(el.innerText)) };
|
||||
})()`;
|
||||
}
|
||||
|
||||
/** Read commands in the function panel (current section). */
|
||||
export function readCommandsScript() {
|
||||
return `(() => {
|
||||
const groups = [];
|
||||
const container = document.querySelector('#funcPanel_container table tr');
|
||||
if (!container) return groups;
|
||||
for (const td of container.children) {
|
||||
const commands = [];
|
||||
td.querySelectorAll('[id^="cmd_"][id$="_txt"]').forEach(el => {
|
||||
if (el.offsetWidth === 0) return;
|
||||
commands.push(el.innerText?.trim() || '');
|
||||
});
|
||||
if (commands.length > 0) groups.push(commands);
|
||||
}
|
||||
return groups;
|
||||
})()`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Navigate to a section by name (fuzzy match).
|
||||
* Returns the matched section name, or { error, available }.
|
||||
*/
|
||||
export function navigateSectionScript(name) {
|
||||
return `(() => {
|
||||
const norm = s => (s?.trim().replace(/\\u00a0/g, ' ').replace(/[\\r\\n]+/g, ' ').replace(/ +/g, ' ') || '').replace(/ё/gi, 'е');
|
||||
const target = ${JSON.stringify(name.toLowerCase().replace(/ё/g, 'е').replace(/[\r\n]+/g, ' ').replace(/ +/g, ' '))};
|
||||
const els = [...document.querySelectorAll('[id^="themesCell_theme_"]')];
|
||||
let bestEl = els.find(el => norm(el.innerText).toLowerCase() === target);
|
||||
if (!bestEl) bestEl = els.find(el => norm(el.innerText).toLowerCase().includes(target));
|
||||
if (bestEl) { bestEl.click(); return norm(bestEl.innerText); }
|
||||
return { error: 'not_found', available: els.map(el => norm(el.innerText)).filter(Boolean) };
|
||||
})()`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Open a command from function panel by name (fuzzy match).
|
||||
*/
|
||||
export function openCommandScript(name) {
|
||||
return `(() => {
|
||||
const norm = s => (s?.trim().replace(/\\u00a0/g, ' ') || '').replace(/ё/gi, 'е');
|
||||
const target = ${JSON.stringify(name.toLowerCase().replace(/ё/g, 'е'))};
|
||||
const els = [...document.querySelectorAll('[id^="cmd_"][id$="_txt"]')].filter(el => el.offsetWidth > 0);
|
||||
let bestEl = els.find(el => norm(el.innerText).toLowerCase() === target);
|
||||
if (!bestEl) bestEl = els.find(el => norm(el.innerText).toLowerCase().includes(target));
|
||||
if (bestEl) { bestEl.click(); return norm(bestEl.innerText); }
|
||||
return { error: 'not_found', available: els.map(el => norm(el.innerText)).filter(Boolean) };
|
||||
})()`;
|
||||
}
|
||||
@@ -0,0 +1,149 @@
|
||||
// web-test dom/submenu v1.0 — popup/submenu reading and clicking
|
||||
// Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
|
||||
|
||||
/**
|
||||
* Read open popup/submenu items.
|
||||
* Looks for absolutely positioned visible popup containers with a.press items inside.
|
||||
* Returns [{ id, name }] or { error }.
|
||||
*/
|
||||
export function readSubmenuScript() {
|
||||
return `(() => {
|
||||
const items = [];
|
||||
const norm = s => (s?.trim().replace(/\\u00a0/g, ' ') || '').replace(/ё/gi, 'е');
|
||||
|
||||
// 1. DLB dropdown (#editDropDown with .eddText items)
|
||||
const edd = document.getElementById('editDropDown');
|
||||
if (edd && edd.offsetWidth > 0 && edd.offsetHeight > 0) {
|
||||
edd.querySelectorAll('.eddText').forEach(el => {
|
||||
if (el.offsetWidth === 0) return;
|
||||
const text = norm(el.innerText);
|
||||
if (!text) return;
|
||||
const r = el.getBoundingClientRect();
|
||||
items.push({ id: '', name: text, kind: 'dropdown',
|
||||
x: Math.round(r.x + r.width / 2), y: Math.round(r.y + r.height / 2) });
|
||||
});
|
||||
// Detect "Показать все" link in EDD footer
|
||||
// Structure: div.eddBottom > div > span.hyperlink "Показать все"
|
||||
let showAllEl = edd.querySelector('.eddBottom .hyperlink');
|
||||
if (!showAllEl || showAllEl.offsetWidth === 0) {
|
||||
// Fallback: scan all visible elements for text match
|
||||
const candidates = [...edd.querySelectorAll('a.press, a, span, div')]
|
||||
.filter(el => el.offsetWidth > 0 && el.children.length === 0);
|
||||
showAllEl = candidates.find(el => {
|
||||
const t = norm(el.innerText).toLowerCase();
|
||||
return t === 'показать все' || t === 'show all';
|
||||
});
|
||||
}
|
||||
if (showAllEl) {
|
||||
const r = showAllEl.getBoundingClientRect();
|
||||
items.push({ id: showAllEl.id || '', name: norm(showAllEl.innerText), kind: 'showAll',
|
||||
x: Math.round(r.x + r.width / 2), y: Math.round(r.y + r.height / 2) });
|
||||
}
|
||||
if (items.length > 0) return items;
|
||||
}
|
||||
|
||||
// 2. Cloud submenu (allActions / command panel menus — div.cloud with .submenuText items)
|
||||
// Read ALL visible high-z clouds (main menu + nested submenus)
|
||||
const clouds = [...document.querySelectorAll('.cloud')].filter(c => c.offsetWidth > 0 && c.offsetHeight > 0);
|
||||
const seen = new Set();
|
||||
clouds.forEach(c => {
|
||||
const z = parseInt(getComputedStyle(c).zIndex) || 0;
|
||||
if (z <= 1000) return;
|
||||
c.querySelectorAll('.submenuText').forEach(el => {
|
||||
if (el.offsetWidth === 0) return;
|
||||
const text = norm(el.innerText);
|
||||
if (!text || seen.has(text)) return;
|
||||
seen.add(text);
|
||||
const block = el.closest('.submenuBlock');
|
||||
if (block && block.classList.contains('submenuBlockDisabled')) return;
|
||||
const hasSub = block && /_sub$/.test(block.id);
|
||||
const r = el.getBoundingClientRect();
|
||||
items.push({ id: block?.id || '', name: text, kind: hasSub ? 'submenuArrow' : 'submenu',
|
||||
x: Math.round(r.x + r.width / 2), y: Math.round(r.y + r.height / 2) });
|
||||
});
|
||||
});
|
||||
if (items.length > 0) return items;
|
||||
|
||||
// 3. Submenu popups — find the topmost positioned container with non-form a.press items
|
||||
const popups = [...document.querySelectorAll('div')].filter(c => {
|
||||
const style = getComputedStyle(c);
|
||||
return (style.position === 'absolute' || style.position === 'fixed')
|
||||
&& c.offsetWidth > 0 && c.offsetHeight > 0;
|
||||
}).sort((a, b) => {
|
||||
const za = parseInt(getComputedStyle(a).zIndex) || 0;
|
||||
const zb = parseInt(getComputedStyle(b).zIndex) || 0;
|
||||
return zb - za;
|
||||
});
|
||||
for (const container of popups) {
|
||||
// Only direct a.press children or those not nested in another positioned div
|
||||
const menuItems = [...container.querySelectorAll('a.press')].filter(el => {
|
||||
if (el.offsetWidth === 0) return false;
|
||||
if (el.id && /^form\\d+_/.test(el.id)) return false;
|
||||
// Skip if this a.press is inside a deeper positioned container
|
||||
let parent = el.parentElement;
|
||||
while (parent && parent !== container) {
|
||||
const ps = getComputedStyle(parent).position;
|
||||
if (ps === 'absolute' || ps === 'fixed') return false;
|
||||
parent = parent.parentElement;
|
||||
}
|
||||
return true;
|
||||
});
|
||||
if (menuItems.length < 2) continue; // Not a real menu
|
||||
const seen = new Set();
|
||||
menuItems.forEach(el => {
|
||||
const text = norm(el.innerText);
|
||||
if (!text) return;
|
||||
if (seen.has(text)) return;
|
||||
seen.add(text);
|
||||
const r = el.getBoundingClientRect();
|
||||
items.push({ id: el.id || '', name: text, kind: 'submenu',
|
||||
x: Math.round(r.x + r.width / 2), y: Math.round(r.y + r.height / 2) });
|
||||
});
|
||||
if (items.length > 0) break; // Found the popup menu
|
||||
}
|
||||
|
||||
if (items.length === 0) return { error: 'no_popup', message: 'No open popup/submenu found' };
|
||||
return items;
|
||||
})()`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Click a popup/dropdown item by text match (evaluate-based for items without IDs).
|
||||
* Returns true if clicked, false if not found.
|
||||
*/
|
||||
export function clickPopupItemScript(text) {
|
||||
return `(() => {
|
||||
const target = ${JSON.stringify(text.toLowerCase().replace(/ё/g, 'е'))};
|
||||
// 1. DLB dropdown (#editDropDown .eddText items)
|
||||
const edd = document.getElementById('editDropDown');
|
||||
if (edd && edd.offsetWidth > 0) {
|
||||
for (const el of edd.querySelectorAll('.eddText')) {
|
||||
if (el.offsetWidth === 0) continue;
|
||||
const t = el.innerText?.trim() || '';
|
||||
if (t.toLowerCase() === target || t.toLowerCase().includes(target)) {
|
||||
el.click();
|
||||
return t;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 2. Submenu popups (a.press in absolutely positioned containers)
|
||||
const containers = [...document.querySelectorAll('div')].filter(c => {
|
||||
const style = getComputedStyle(c);
|
||||
return (style.position === 'absolute' || style.position === 'fixed')
|
||||
&& c.offsetWidth > 0 && c.offsetHeight > 0;
|
||||
});
|
||||
for (const container of containers) {
|
||||
const items = [...container.querySelectorAll('a.press')]
|
||||
.filter(el => el.offsetWidth > 0);
|
||||
for (const el of items) {
|
||||
const t = el.innerText?.trim() || '';
|
||||
if (t.toLowerCase() === target || t.toLowerCase().includes(target)) {
|
||||
el.click();
|
||||
return t;
|
||||
}
|
||||
}
|
||||
}
|
||||
return null;
|
||||
})()`;
|
||||
}
|
||||
@@ -0,0 +1,129 @@
|
||||
// web-test core/click v1.22 — clickElement dispatcher: routes to spreadsheet / popup / grid-row / form-element / field-focus handlers by target kind.
|
||||
// Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
|
||||
|
||||
import { page, ensureConnected, highlightMode } from './state.mjs';
|
||||
import {
|
||||
detectFormScript, findClickTargetScript, resolveGridScript,
|
||||
readSubmenuScript, resolveCellTargetScript,
|
||||
} from '../../dom.mjs';
|
||||
import { dismissPendingErrors, checkForErrors } from './errors.mjs';
|
||||
import { waitForStable } from './wait.mjs';
|
||||
import { highlight, unhighlight } from '../recording/highlight.mjs';
|
||||
import { modifierClick, returnFormState } from './helpers.mjs';
|
||||
import {
|
||||
clickGridGroupTarget, clickGridTreeNodeTarget, clickGridRowTarget,
|
||||
} from '../table/click-row.mjs';
|
||||
import { clickGridCell } from '../table/click-cell.mjs';
|
||||
import {
|
||||
clickConfirmationButton, tryClickPopupItem,
|
||||
} from '../forms/click-popup.mjs';
|
||||
import { clickFormTarget, focusFormField } from '../forms/click-form.mjs';
|
||||
import {
|
||||
clickSpreadsheetCell, findSpreadsheetCellByText,
|
||||
} from '../spreadsheet/spreadsheet.mjs';
|
||||
|
||||
/** Click a button/hyperlink/tab on the current form. Use {dblclick: true} to double-click (open items from lists).
|
||||
* First argument can also be an object { row, column } to click a cell in a SpreadsheetDocument (отчёт) or a form grid (таблица/табчасть). */
|
||||
export async function clickElement(text, { dblclick, table, toggle, expand, modifier, scroll, timeout } = {}) {
|
||||
ensureConnected();
|
||||
|
||||
// Dispatch to cell handler when first arg is { row, column }.
|
||||
// Routing (see resolveCellTargetScript):
|
||||
// - `table` named: matches grid → grid cell; falls back to spreadsheet if it's the spreadsheet's name.
|
||||
// - no `table`: form has spreadsheet → spreadsheet cell (backward-compat);
|
||||
// else first visible grid → grid cell.
|
||||
if (typeof text === 'object' && text !== null && text.column != null) {
|
||||
await dismissPendingErrors();
|
||||
const formNum = await page.evaluate(detectFormScript());
|
||||
if (formNum === null) throw new Error('clickElement: no form found');
|
||||
const route = await page.evaluate(resolveCellTargetScript(formNum, table));
|
||||
if (route.error === 'table_not_found') {
|
||||
throw new Error(`clickElement: table "${table}" not found. Available grids: ${(route.availableGrids || []).join(', ') || 'none'}`);
|
||||
}
|
||||
if (route.error) {
|
||||
throw new Error(`clickElement: no spreadsheet or grid on form to click cell in.`);
|
||||
}
|
||||
if (route.kind === 'spreadsheet') {
|
||||
return clickSpreadsheetCell(text, { dblclick, modifier });
|
||||
}
|
||||
// route.kind === 'grid'
|
||||
return clickGridCell(text, {
|
||||
formNum,
|
||||
gridSelector: route.gridSelector,
|
||||
gridName: route.gridName,
|
||||
modifier, dblclick, scroll,
|
||||
});
|
||||
}
|
||||
|
||||
await dismissPendingErrors();
|
||||
if (highlightMode) {
|
||||
try { await highlight(text, { table }); await page.waitForTimeout(500); await unhighlight(); } catch {}
|
||||
}
|
||||
|
||||
try {
|
||||
// 1. Intercept open confirmation dialog (Да/Нет/Отмена) — match button by text.
|
||||
const pending = await checkForErrors();
|
||||
if (pending?.confirmation) {
|
||||
return await clickConfirmationButton(text);
|
||||
}
|
||||
|
||||
// 2. Intercept open popup (from previous submenu/split-button click).
|
||||
// Returns null if popup is open but `text` doesn't match — fall through.
|
||||
const popupItems = await page.evaluate(readSubmenuScript());
|
||||
if (Array.isArray(popupItems) && popupItems.length > 0) {
|
||||
const popupResult = await tryClickPopupItem(text, popupItems);
|
||||
if (popupResult) return popupResult;
|
||||
}
|
||||
|
||||
// 3. Find a target on the current form.
|
||||
let formNum = await page.evaluate(detectFormScript());
|
||||
if (formNum === null) throw new Error(`clickElement: no form found`);
|
||||
|
||||
let gridSelector;
|
||||
if (table) {
|
||||
const resolved = await page.evaluate(resolveGridScript(formNum, table));
|
||||
if (resolved.error) throw new Error(`clickElement: table "${table}" not found. Available: ${resolved.available?.map(a => a.name).join(', ') || 'none'}`);
|
||||
gridSelector = resolved.gridSelector;
|
||||
}
|
||||
|
||||
let target = await page.evaluate(findClickTargetScript(formNum, text, { tableName: table, gridSelector }));
|
||||
|
||||
// Retry: if not found, a modal form may still be loading (e.g. after F4).
|
||||
if (target?.error) {
|
||||
for (let retry = 0; retry < 4; retry++) {
|
||||
await page.waitForTimeout(500);
|
||||
const newForm = await page.evaluate(detectFormScript());
|
||||
if (newForm !== null && newForm !== formNum) {
|
||||
formNum = newForm;
|
||||
target = await page.evaluate(findClickTargetScript(formNum, text, { tableName: table, gridSelector }));
|
||||
if (!target?.error) break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Spreadsheet fallback: search iframes for text match before giving up.
|
||||
if (target?.error) {
|
||||
const ssCell = await findSpreadsheetCellByText(formNum, text);
|
||||
if (ssCell) {
|
||||
const cx = ssCell.box.x + ssCell.box.width / 2;
|
||||
const cy = ssCell.box.y + ssCell.box.height / 2;
|
||||
await modifierClick(cx, cy, modifier, { dbl: !!dblclick });
|
||||
await waitForStable();
|
||||
return returnFormState({
|
||||
clicked: { kind: 'spreadsheetCell', name: ssCell.text, ...(dblclick ? { dblclick: true } : {}) },
|
||||
});
|
||||
}
|
||||
throw new Error(`clickElement: "${text}" not found. Available: ${target.available?.join(', ') || 'none'}`);
|
||||
}
|
||||
|
||||
// 4. Dispatch to the right handler by target kind.
|
||||
const ctx = { formNum, modifier, dblclick, toggle, expand, timeout, table, gridSelector };
|
||||
if (target.kind === 'gridGroup' || target.kind === 'gridParent') return await clickGridGroupTarget(target, ctx);
|
||||
if (target.kind === 'gridTreeNode') return await clickGridTreeNodeTarget(target, ctx);
|
||||
if (target.kind === 'gridRow') return await clickGridRowTarget(target, ctx);
|
||||
if (target.kind === 'field') return await focusFormField(target, ctx);
|
||||
return await clickFormTarget(target, ctx);
|
||||
} finally {
|
||||
if (highlightMode) try { await unhighlight(); } catch {}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
// web-test engine/core/clipboard v1.17 — OS-clipboard preservation around trusted paste.
|
||||
// Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
|
||||
//
|
||||
// pasteText() — the only path 1C respects for autocomplete and Cyrillic input.
|
||||
// saveClipboard/restoreClipboard preserve full clipboard contents (all MIME
|
||||
// types) around the writeText+Ctrl+V pair so a user's concurrent Ctrl+C isn't
|
||||
// clobbered. Blobs are stashed on `window` to avoid CDP serialization.
|
||||
|
||||
import {
|
||||
page, preserveClipboard, clipboardWarnLogged, setClipboardWarnLogged,
|
||||
} from './state.mjs';
|
||||
|
||||
export async function saveClipboard() {
|
||||
if (!page) return;
|
||||
try {
|
||||
await page.evaluate(async () => {
|
||||
try {
|
||||
const items = await navigator.clipboard.read();
|
||||
const saved = [];
|
||||
for (const item of items) {
|
||||
const types = {};
|
||||
for (const t of item.types) types[t] = await item.getType(t);
|
||||
saved.push(types);
|
||||
}
|
||||
window.__webTestSavedClipboard = saved;
|
||||
delete window.__webTestClipboardError;
|
||||
} catch (e) {
|
||||
window.__webTestSavedClipboard = null;
|
||||
window.__webTestClipboardError = e?.name || String(e);
|
||||
}
|
||||
});
|
||||
} catch {
|
||||
// page.evaluate itself failed (closed page, navigation in flight) — skip.
|
||||
}
|
||||
}
|
||||
|
||||
export async function restoreClipboard() {
|
||||
if (!page) return;
|
||||
let err = null;
|
||||
try {
|
||||
err = await page.evaluate(async () => {
|
||||
const saved = window.__webTestSavedClipboard;
|
||||
const captured = window.__webTestClipboardError || null;
|
||||
delete window.__webTestSavedClipboard;
|
||||
delete window.__webTestClipboardError;
|
||||
try {
|
||||
if (!saved || saved.length === 0) {
|
||||
// Save failed (e.g. CF_HDROP from Explorer not readable via Clipboard API)
|
||||
// or buffer was empty. Either way, the test's writeText already destroyed
|
||||
// any prior native formats in the OS clipboard, so explicitly clear here
|
||||
// to avoid leaking the test value into the user's clipboard.
|
||||
await navigator.clipboard.writeText('');
|
||||
return captured;
|
||||
}
|
||||
const items = saved.map(types => new ClipboardItem(types));
|
||||
await navigator.clipboard.write(items);
|
||||
return null;
|
||||
} catch (e) {
|
||||
return e?.name || String(e);
|
||||
}
|
||||
});
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
if (err && !clipboardWarnLogged) {
|
||||
setClipboardWarnLogged(true);
|
||||
console.error(`[web-test] clipboard preserve skipped: ${err} (logged once per session)`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Paste `text` via OS clipboard (the only trusted-paste path that 1C respects
|
||||
* for autocomplete and Cyrillic). Wraps the writeText+confirm-key pair in a
|
||||
* narrow save/restore so a user's clipboard survives the test run — the window
|
||||
* between save and restore is microseconds.
|
||||
*
|
||||
* - `confirm` — key (string) or sequence (array) to press after writeText.
|
||||
* Defaults to 'Control+V'. Use ['Control+a', 'Control+v'] for select-all-then-paste,
|
||||
* or 'Shift+F11' for the goto-link dialog.
|
||||
* - `postDelay` — ms to wait between confirm-press and restore, for dialogs
|
||||
* that read clipboard asynchronously (e.g. Shift+F11). Default 0.
|
||||
*/
|
||||
export async function pasteText(text, { confirm = 'Control+V', postDelay = 0 } = {}) {
|
||||
if (!page) return;
|
||||
if (preserveClipboard) await saveClipboard();
|
||||
try {
|
||||
await page.evaluate(`navigator.clipboard.writeText(${JSON.stringify(String(text))})`);
|
||||
if (Array.isArray(confirm)) {
|
||||
for (const key of confirm) await page.keyboard.press(key);
|
||||
} else if (confirm) {
|
||||
await page.keyboard.press(confirm);
|
||||
}
|
||||
if (postDelay) await page.waitForTimeout(postDelay);
|
||||
} finally {
|
||||
if (preserveClipboard) await restoreClipboard();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,310 @@
|
||||
// web-test core/errors v1.18 — Error/modal/platform-dialog handling: dismiss, detect, fetch stack from 1C UI.
|
||||
// Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
|
||||
|
||||
import { page } from './state.mjs';
|
||||
import { checkErrorsScript } from '../../dom.mjs';
|
||||
import {
|
||||
getOpenReportCoordsScript, isErrorDetailLinkVisibleScript,
|
||||
readLargestVisibleTextareaScript, clickTopCloudOkButtonScript,
|
||||
clickReportCloseButtonScript,
|
||||
} from '../../dom/errors-stack.mjs';
|
||||
import { waitForStable } from './wait.mjs';
|
||||
|
||||
/**
|
||||
* Close startup modals and guide tabs.
|
||||
* Strategy: Escape → click default buttons → close extra tabs → repeat.
|
||||
*/
|
||||
export async function closeModals() {
|
||||
for (let attempt = 0; attempt < 5; attempt++) {
|
||||
// 1. Press Escape to dismiss any popup/modal
|
||||
await page.keyboard.press('Escape');
|
||||
await page.waitForTimeout(1000);
|
||||
|
||||
// 2. Try clicking default "Закрыть"/"OK" buttons
|
||||
const clicked = await page.evaluate(`(() => {
|
||||
const btns = [...document.querySelectorAll('a.press.pressDefault')].filter(el => el.offsetWidth > 0);
|
||||
for (const btn of btns) {
|
||||
const text = (btn.innerText?.trim() || '').toLowerCase();
|
||||
if (['закрыть', 'ok', 'ок', 'нет', 'отмена'].includes(text)) {
|
||||
btn.click();
|
||||
return text;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
})()`);
|
||||
if (clicked) { await page.waitForTimeout(1000); continue; }
|
||||
|
||||
// 3. Close extra tabs (Путеводитель etc.) via openedClose button
|
||||
const tabClosed = await page.evaluate(`(() => {
|
||||
const btn = document.querySelector('.openedClose');
|
||||
if (btn && btn.offsetWidth > 0) { btn.click(); return true; }
|
||||
return false;
|
||||
})()`);
|
||||
if (tabClosed) { await page.waitForTimeout(1000); continue; }
|
||||
|
||||
// Nothing to close — done
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check for validation errors / diagnostics after an action.
|
||||
* Detects: inline balloon tooltip, messages panel, modal error dialog.
|
||||
* Returns { balloon, messages[], modal } or null.
|
||||
*/
|
||||
export async function checkForErrors() {
|
||||
return await page.evaluate(checkErrorsScript());
|
||||
}
|
||||
|
||||
/**
|
||||
* Dismiss pending error modal if present (single OK button dialog).
|
||||
* Called at the start of action functions so that a leftover error modal
|
||||
* from a previous operation doesn't block the next action.
|
||||
* Does NOT dismiss confirmations (Да/Нет — require user decision).
|
||||
* Returns the dismissed error object or null.
|
||||
*/
|
||||
export async function dismissPendingErrors() {
|
||||
// Close leftover platform dialogs first (About, Support Info, Error Report)
|
||||
// These block all interaction via modalSurface and are invisible to 1C form detection
|
||||
try {
|
||||
const pd = await detectPlatformDialogs();
|
||||
if (pd.length) await closePlatformDialogs();
|
||||
} catch { /* OK */ }
|
||||
const err = await checkForErrors();
|
||||
if (!err?.modal) return null;
|
||||
try {
|
||||
// Target pressDefault within the modal's form container specifically
|
||||
const formNum = err.modal.formNum;
|
||||
const sel = formNum != null
|
||||
? `#form${formNum}_container a.press.pressDefault`
|
||||
: 'a.press.pressDefault';
|
||||
const btn = await page.$(sel);
|
||||
if (btn) { await btn.click({ force: true }); await page.waitForTimeout(500); }
|
||||
} catch { /* OK */ }
|
||||
await waitForStable();
|
||||
return err;
|
||||
}
|
||||
|
||||
/**
|
||||
* Detect open platform-level dialogs (About, Support Info, Error Report).
|
||||
* Returns array of { type, title? } for each detected dialog, or empty array.
|
||||
*/
|
||||
export async function detectPlatformDialogs() {
|
||||
return await page.evaluate(() => {
|
||||
const result = [];
|
||||
// "О программе" dialog
|
||||
const about = document.getElementById('aboutContainer');
|
||||
if (about && about.offsetWidth > 0) result.push({ type: 'about', title: 'О программе' });
|
||||
// "Информация для технической поддержки" (inside a ps*win with errJournalInput)
|
||||
const errJ = document.getElementById('errJournalInput');
|
||||
if (errJ && errJ.offsetWidth > 0) result.push({ type: 'supportInfo', title: 'Информация для технической поддержки' });
|
||||
// "Отчет об ошибке" / "Подробный текст ошибки" — ps*win cloud windows without aboutContainer
|
||||
if (!result.length) {
|
||||
document.querySelectorAll('[id^="ps"][id$="win"]').forEach(w => {
|
||||
if (w.offsetWidth === 0 || w.offsetHeight === 0) return;
|
||||
// Skip the main app window (ps*win that contains the 1C forms)
|
||||
if (w.querySelector('[id^="form"][id$="_container"]')) return;
|
||||
// Check title text
|
||||
const titleEl = w.querySelector('[id$="headerTopLine_cmd_Title"]');
|
||||
const title = titleEl?.textContent?.trim() || '';
|
||||
if (title) result.push({ type: 'platformWindow', title });
|
||||
});
|
||||
}
|
||||
return result;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Close any platform-level dialogs that may be left open (about, support info, error report).
|
||||
* These are NOT 1C forms — they are platform UI overlays invisible to getFormState().
|
||||
* Each close is wrapped in try/catch to avoid cascading failures.
|
||||
*/
|
||||
export async function closePlatformDialogs() {
|
||||
await page.evaluate(() => {
|
||||
// "Подробный текст ошибки" OK button (inside error report detail view)
|
||||
// It's a cloud window with its own OK button — look for visible pressDefault in small ps*win
|
||||
const psWins = document.querySelectorAll('[id^="ps"][id$="win"]');
|
||||
for (const w of psWins) {
|
||||
if (w.offsetWidth === 0) continue;
|
||||
// Check if this is a small dialog (error detail, about, support info)
|
||||
const closeBtn = w.querySelector('[id$="_cmd_CloseButton"]');
|
||||
if (closeBtn && closeBtn.offsetWidth > 0) {
|
||||
try { closeBtn.click(); } catch {}
|
||||
}
|
||||
}
|
||||
// "Информация для технической поддержки" — extOkBtn
|
||||
const extOk = document.getElementById('extOkBtn');
|
||||
if (extOk && extOk.offsetWidth > 0) try { extOk.click(); } catch {}
|
||||
// "О программе" — aboutOkButton
|
||||
const aboutOk = document.getElementById('aboutOkButton');
|
||||
if (aboutOk && aboutOk.offsetWidth > 0) try { aboutOk.click(); } catch {}
|
||||
});
|
||||
await page.waitForTimeout(300);
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse raw error stack text into structured entries.
|
||||
* Input: raw text from errJournalInput (first block) or "Подробный текст ошибки" textarea.
|
||||
* Returns { raw, timestamp?, entries: [{location, code}] }
|
||||
*/
|
||||
function parseErrorStack(raw) {
|
||||
if (!raw) return null;
|
||||
const result = { raw, entries: [] };
|
||||
// Extract timestamp if present (format: DD.MM.YYYY HH:MM:SS)
|
||||
const tsMatch = raw.match(/^(\d{2}\.\d{2}\.\d{4}\s+\d{1,2}:\d{2}:\d{2})/m);
|
||||
if (tsMatch) result.timestamp = tsMatch[1];
|
||||
// Extract {Module.Path(lineNum)}: code entries
|
||||
const entryRe = /\{([^}]+)\}:\s*(.+)/g;
|
||||
let m;
|
||||
while ((m = entryRe.exec(raw)) !== null) {
|
||||
result.entries.push({ location: m[1].trim(), code: m[2].trim() });
|
||||
}
|
||||
return result.entries.length > 0 ? result : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch error call stack from the 1C platform UI.
|
||||
* Uses two strategies:
|
||||
* Path 1 (hasReport=true): Click OpenReport link → "подробный текст ошибки" → read textarea
|
||||
* Path 2 (fallback): Hamburger → "О программе" → "Информация для техподдержки" → errJournalInput
|
||||
*
|
||||
* Always closes the error modal and any platform dialogs it opened.
|
||||
* Returns parsed stack object or null on failure.
|
||||
*
|
||||
* @param {number} formNum - form number of the error modal (e.g. 6 for form6_)
|
||||
* @param {boolean} hasReport - true if OpenReport link is available
|
||||
*/
|
||||
export async function fetchErrorStack(formNum, hasReport) {
|
||||
try {
|
||||
// Platform exception modals are initially unstable — they redraw within ~1s.
|
||||
// The initial state may lack the OpenReport link. Re-check after a short delay.
|
||||
if (!hasReport) {
|
||||
await page.waitForTimeout(1500);
|
||||
hasReport = await page.evaluate((fn) => {
|
||||
const el = document.getElementById('form' + fn + '_OpenReport#text');
|
||||
return !!(el && el.offsetWidth > 2 && el.textContent.trim());
|
||||
}, formNum);
|
||||
}
|
||||
if (hasReport) return await fetchStackViaReport(formNum);
|
||||
return await fetchStackViaHamburger(formNum);
|
||||
} catch {
|
||||
return null;
|
||||
} finally {
|
||||
// Ensure all platform dialogs are closed
|
||||
try { await closePlatformDialogs(); } catch {}
|
||||
// Ensure the error modal itself is closed
|
||||
try {
|
||||
const sel = formNum != null
|
||||
? `#form${formNum}_container a.press.pressDefault`
|
||||
: 'a.press.pressDefault';
|
||||
const btn = await page.$(sel);
|
||||
if (btn) await btn.click({ force: true });
|
||||
await page.waitForTimeout(300);
|
||||
} catch {}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Path 1: Fetch stack via OpenReport link (for platform exceptions).
|
||||
* The error modal must still be open with a visible "Сформировать отчет об ошибке" link.
|
||||
*/
|
||||
async function fetchStackViaReport(formNum) {
|
||||
// 1. Get coordinates of the OpenReport link and click via mouse (modalSurface blocks JS clicks)
|
||||
const coords = await page.evaluate(getOpenReportCoordsScript(formNum));
|
||||
if (!coords) return null;
|
||||
|
||||
await page.mouse.click(coords.x, coords.y);
|
||||
|
||||
// 2. Wait for "Отчет об ошибке" dialog — look for "подробный текст ошибки" link
|
||||
let found = false;
|
||||
for (let i = 0; i < 20; i++) {
|
||||
await page.waitForTimeout(500);
|
||||
found = await page.evaluate(isErrorDetailLinkVisibleScript());
|
||||
if (found) break;
|
||||
}
|
||||
if (!found) return null;
|
||||
|
||||
// 3. Click "подробный текст ошибки"
|
||||
await page.getByText('подробный текст ошибки').click();
|
||||
await page.waitForTimeout(2000);
|
||||
|
||||
// 4. Read the textarea with detailed error text (find the largest visible textarea)
|
||||
const raw = await page.evaluate(readLargestVisibleTextareaScript());
|
||||
|
||||
// 5. Close "Подробный текст ошибки" dialog (click its OK button)
|
||||
try {
|
||||
await page.evaluate(clickTopCloudOkButtonScript());
|
||||
await page.waitForTimeout(300);
|
||||
} catch {}
|
||||
|
||||
// 6. Close "Отчет об ошибке" dialog (click its × close button)
|
||||
try {
|
||||
await page.evaluate(clickReportCloseButtonScript());
|
||||
await page.waitForTimeout(300);
|
||||
} catch {}
|
||||
|
||||
return parseErrorStack(raw);
|
||||
}
|
||||
|
||||
/**
|
||||
* Path 2: Fetch stack via hamburger menu → "О программе" → "Информация для техподдержки".
|
||||
* Works for all error types including simple ВызватьИсключение.
|
||||
* The error modal is closed first to allow access to the hamburger menu.
|
||||
*/
|
||||
async function fetchStackViaHamburger(formNum) {
|
||||
// 1. Close the error modal first
|
||||
try {
|
||||
const sel = formNum != null
|
||||
? `#form${formNum}_container a.press.pressDefault`
|
||||
: 'a.press.pressDefault';
|
||||
const btn = await page.$(sel);
|
||||
if (btn) await btn.click({ force: true });
|
||||
await page.waitForTimeout(500);
|
||||
} catch {}
|
||||
|
||||
// 2. Click hamburger menu
|
||||
await page.click('#captionbarMore', { timeout: 5000 });
|
||||
await page.waitForTimeout(1000);
|
||||
|
||||
// 3. Click "О программе..."
|
||||
await page.getByText('О программе...', { exact: true }).click({ timeout: 5000 });
|
||||
await page.waitForTimeout(2000);
|
||||
|
||||
// 4. Click "Информация для технической поддержки"
|
||||
await page.click('#aboutHyperLink', { timeout: 5000 });
|
||||
|
||||
// 5. Wait for errJournalInput to appear and be filled
|
||||
let raw = null;
|
||||
for (let i = 0; i < 20; i++) {
|
||||
await page.waitForTimeout(500);
|
||||
raw = await page.evaluate(() => {
|
||||
const el = document.getElementById('errJournalInput');
|
||||
return (el && el.offsetWidth > 0 && el.value.length > 50) ? el.value : null;
|
||||
});
|
||||
if (raw) break;
|
||||
}
|
||||
if (!raw) return null;
|
||||
|
||||
// 6. Parse first error block (most recent — before first separator)
|
||||
const separator = / - - - - /;
|
||||
const errSection = raw.indexOf('\n\n') !== -1 ? raw.substring(raw.indexOf('\n\n')) : raw;
|
||||
// Find the "Ошибки:" section
|
||||
const errIdx = raw.indexOf('Ошибки:');
|
||||
let errorText = errIdx !== -1 ? raw.substring(errIdx + 'Ошибки:'.length).trim() : raw;
|
||||
// Take first block (before first separator line)
|
||||
const lines = errorText.split('\n');
|
||||
const firstBlockLines = [];
|
||||
let inBlock = false;
|
||||
for (const line of lines) {
|
||||
if (separator.test(line)) {
|
||||
if (inBlock) break; // end of first block
|
||||
inBlock = true;
|
||||
continue;
|
||||
}
|
||||
if (inBlock) firstBlockLines.push(line);
|
||||
}
|
||||
const firstBlock = firstBlockLines.join('\n').trim();
|
||||
|
||||
// 7. Close support info and about dialogs (done in finally via closePlatformDialogs)
|
||||
return parseErrorStack(firstBlock || errorText);
|
||||
}
|
||||
@@ -0,0 +1,178 @@
|
||||
// web-test core/helpers v1.21 — private, cross-cutting helpers used by the
|
||||
// public action functions (clickElement/fillFields/selectValue/etc).
|
||||
// Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
|
||||
|
||||
import { page } from './state.mjs';
|
||||
import { dismissPendingErrors, checkForErrors } from './errors.mjs';
|
||||
import { getFormState } from '../forms/state.mjs';
|
||||
import {
|
||||
detectNewFormScript,
|
||||
isInputFocusedScript,
|
||||
isInputFocusedInGridScript,
|
||||
findOpenPopupScript,
|
||||
readEddScript,
|
||||
isEddVisibleScript,
|
||||
clickEddItemViaDispatchScript,
|
||||
clickShowAllInEddScript,
|
||||
} from '../../dom.mjs';
|
||||
|
||||
/**
|
||||
* page.click with the standard "intercepts pointer events" retry ladder:
|
||||
* normal → force → Escape (+ optional dismissPendingErrors) → normal.
|
||||
* Mirrors the three hand-written copies in fillReferenceField, clickElement
|
||||
* and the DLB branch of selectValue.
|
||||
*
|
||||
* @param {string} selector
|
||||
* @param {object} [opts]
|
||||
* @param {number} [opts.timeout] — passed through to page.click
|
||||
* @param {boolean} [opts.dismissErrors=false] — call dismissPendingErrors()
|
||||
* before pressing Escape on the second retry (used in fillReferenceField).
|
||||
*/
|
||||
export async function safeClick(selector, { timeout, dismissErrors = false } = {}) {
|
||||
const baseOpts = timeout != null ? { timeout } : {};
|
||||
try {
|
||||
await page.click(selector, baseOpts);
|
||||
} catch (e) {
|
||||
if (!e.message.includes('intercepts pointer events')) throw e;
|
||||
try {
|
||||
await page.click(selector, { ...baseOpts, force: true });
|
||||
} catch (e2) {
|
||||
if (!e2.message.includes('intercepts pointer events')) throw e2;
|
||||
if (dismissErrors) await dismissPendingErrors();
|
||||
await page.keyboard.press('Escape');
|
||||
await page.waitForTimeout(500);
|
||||
await page.click(selector, baseOpts);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Find a form field's input element id by name. Tries `form{N}_{name}` first,
|
||||
* then `form{N}_{name}_i0` (reference fields use the _i0 suffix). Returns the
|
||||
* element id or null. Used in selectValue's clear/composite-type/F4 fallback
|
||||
* branches.
|
||||
*
|
||||
* @param {number} formNum
|
||||
* @param {string} fieldName
|
||||
* @returns {Promise<string|null>}
|
||||
*/
|
||||
export async function findFieldInputId(formNum, fieldName) {
|
||||
return await page.evaluate(`(() => {
|
||||
const p = 'form${formNum}_';
|
||||
const name = ${JSON.stringify(fieldName)};
|
||||
const el = document.querySelector('[id="' + p + name + '"], [id="' + p + name + '_i0"]');
|
||||
return el ? el.id : null;
|
||||
})()`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Detect a new form opened above the given `prevFormNum`. Two modes:
|
||||
* `{ strict: true }` — only counts visible interactive elements
|
||||
* (`input.editInput[id], a.press[id]`); used by fillReferenceField.
|
||||
* default (broad) — any element with `id^=form{N}_` that's visible
|
||||
* in either dimension; also finds type-dialogs whose a.press buttons
|
||||
* have empty IDs. Used by selectValue / fillTableRow.
|
||||
*
|
||||
* @param {number} prevFormNum
|
||||
* @param {object} [opts]
|
||||
* @param {boolean} [opts.strict=false]
|
||||
* @returns {Promise<number|null>} new form number or null
|
||||
*/
|
||||
export async function detectNewForm(prevFormNum, { strict = false } = {}) {
|
||||
return page.evaluate(detectNewFormScript(prevFormNum, { strict }));
|
||||
}
|
||||
|
||||
/**
|
||||
* Thin wrapper: is the currently focused element an INPUT (or TEXTAREA)?
|
||||
*
|
||||
* @param {object} [opts]
|
||||
* @param {boolean} [opts.allowTextarea=false]
|
||||
*/
|
||||
export async function isInputFocused({ allowTextarea = false } = {}) {
|
||||
return page.evaluate(isInputFocusedScript({ allowTextarea }));
|
||||
}
|
||||
|
||||
/**
|
||||
* Thin wrapper: is the currently focused INPUT/TEXTAREA inside a `.grid`?
|
||||
* Used to verify grid edit-mode. Pass `{ gridSelector }` to scope the check
|
||||
* to a specific grid (when a form has multiple grids).
|
||||
*/
|
||||
export async function isInputFocusedInGrid({ gridSelector } = {}) {
|
||||
return page.evaluate(isInputFocusedInGridScript(gridSelector));
|
||||
}
|
||||
|
||||
/**
|
||||
* Thin wrapper: is calculator (`.calculate`) or calendar (`.frameCalendar`)
|
||||
* popup visible? Returns `'calculator' | 'calendar' | null`.
|
||||
*/
|
||||
export async function findOpenPopup() {
|
||||
return page.evaluate(findOpenPopupScript());
|
||||
}
|
||||
|
||||
/**
|
||||
* Read the `#editDropDown` autocomplete popup. Returns whether it's visible
|
||||
* and, when visible, an array of `.eddText` items with display name and
|
||||
* center coordinates (suitable for page.mouse.click).
|
||||
*
|
||||
* @returns {Promise<{visible: boolean, items?: Array<{name:string, x:number, y:number}>}>}
|
||||
*/
|
||||
export async function readEdd() {
|
||||
return page.evaluate(readEddScript());
|
||||
}
|
||||
|
||||
/**
|
||||
* Thin wrapper: is the EDD popup currently visible?
|
||||
* Lighter than `readEdd` when only presence matters.
|
||||
*/
|
||||
export async function isEddVisible() {
|
||||
return page.evaluate(isEddVisibleScript());
|
||||
}
|
||||
|
||||
/**
|
||||
* Click an EDD item by name via dispatchEvent (bypasses div.surface overlays).
|
||||
* Returns the clicked item's innerText, or `null` if no match.
|
||||
*/
|
||||
export async function clickEddItemViaDispatch(itemName) {
|
||||
return page.evaluate(clickEddItemViaDispatchScript(itemName));
|
||||
}
|
||||
|
||||
/**
|
||||
* Click the "Показать все" / "Show all" link in the EDD footer.
|
||||
* Returns boolean.
|
||||
*/
|
||||
export async function clickShowAllInEdd() {
|
||||
return page.evaluate(clickShowAllInEddScript());
|
||||
}
|
||||
|
||||
/**
|
||||
* Standard "tail" of action functions: fetch current form state, attach
|
||||
* caller-specified extras (e.g. `{ clicked: {...} }`) and the result of
|
||||
* `checkForErrors()` if any. Returns the flat state object.
|
||||
*
|
||||
* Unifies ~15 hand-written copies in clickElement, selectValue, closeForm,
|
||||
* navigation functions, etc. Also closes R1/R2/R3 from the refactor plan —
|
||||
* any caller using this helper gets `state.errors` for free.
|
||||
*
|
||||
* @param {object} [extras] — merged into the state object via Object.assign.
|
||||
* @returns {Promise<object>} form state (flat) with optional `errors`.
|
||||
*/
|
||||
export async function returnFormState(extras = {}) {
|
||||
const state = await getFormState();
|
||||
Object.assign(state, extras);
|
||||
const err = await checkForErrors();
|
||||
if (err) state.errors = err;
|
||||
return state;
|
||||
}
|
||||
|
||||
/**
|
||||
* Mouse click at (x, y) with an optional modifier key held down for the duration.
|
||||
* Supports `'ctrl'` / `'shift'` (used by clickElement for multi-select).
|
||||
* Pass `{ dbl: true }` for double-click.
|
||||
*/
|
||||
export async function modifierClick(x, y, modifier, { dbl = false } = {}) {
|
||||
const modKey = modifier === 'ctrl' ? 'Control' : modifier === 'shift' ? 'Shift' : null;
|
||||
if (modKey) await page.keyboard.down(modKey);
|
||||
if (dbl) await page.mouse.dblclick(x, y);
|
||||
else await page.mouse.click(x, y);
|
||||
if (modKey) await page.keyboard.up(modKey);
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
// web-test core/scroll-horiz v1.0 — horizontal scroll loop helper for grids and spreadsheets.
|
||||
// Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
|
||||
//
|
||||
// 1С scrolls horizontally by shifting absolute X coordinates of cells (not via
|
||||
// scrollLeft). The only reliable way to drive this from outside is to press
|
||||
// ArrowRight / ArrowLeft on a focused cell. Both SpreadsheetDocument and form
|
||||
// grids share this mechanic, so the loop body is identical: press an arrow,
|
||||
// wait, check visibility, bail when the cell stops moving (lost focus / hit edge).
|
||||
//
|
||||
// Callers handle their own focus setup (clicking a visible cell to put keyboard
|
||||
// focus on the grid/spreadsheet), direction selection, and visibility queries.
|
||||
|
||||
/**
|
||||
* Press {direction} key in a loop until the target cell is fully visible or
|
||||
* progress stalls.
|
||||
*
|
||||
* @param {object} opts
|
||||
* @param {import('playwright').Page} opts.page
|
||||
* @param {'ArrowRight'|'ArrowLeft'} opts.direction
|
||||
* @param {() => Promise<boolean>} opts.isFullyVisible — true when target inside viewport
|
||||
* @param {() => Promise<number|null>} opts.getCenterX — current target center X (page coords); null if cell vanished
|
||||
* @param {number} [opts.maxPresses=100]
|
||||
* @param {number} [opts.staleMax=5] — bail when center hasn't moved this many presses in a row
|
||||
* @param {number} [opts.delayMs=50] — wait after each key press
|
||||
* @param {number} [opts.finalDelayMs=200] — wait after the loop completes
|
||||
*/
|
||||
export async function scrollHorizontallyByKey({
|
||||
page, direction,
|
||||
isFullyVisible, getCenterX,
|
||||
maxPresses = 100, staleMax = 5,
|
||||
delayMs = 50, finalDelayMs = 200,
|
||||
}) {
|
||||
let prevCx = await getCenterX();
|
||||
if (prevCx == null) return;
|
||||
let stale = 0;
|
||||
for (let i = 0; i < maxPresses; i++) {
|
||||
await page.keyboard.press(direction);
|
||||
await page.waitForTimeout(delayMs);
|
||||
if (await isFullyVisible()) break;
|
||||
const cx = await getCenterX();
|
||||
if (cx == null) break;
|
||||
if (Math.abs(cx - prevCx) >= 1) stale = 0;
|
||||
else { stale++; if (stale >= staleMax) break; }
|
||||
prevCx = cx;
|
||||
}
|
||||
await page.waitForTimeout(finalDelayMs);
|
||||
}
|
||||
@@ -0,0 +1,404 @@
|
||||
// web-test core/session v1.17 — Browser session lifecycle: connect/disconnect/attach/detach, multi-context registry.
|
||||
// Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
|
||||
|
||||
import { chromium } from 'playwright';
|
||||
import { statSync, mkdirSync, readdirSync, rmSync } from 'fs';
|
||||
import { join as pathJoin } from 'path';
|
||||
import { tmpdir } from 'os';
|
||||
import {
|
||||
browser, page, sessionPrefix, seanceId, recorder, highlightMode,
|
||||
contexts, activeContextName, activeMode, persistentUserDataDir,
|
||||
setBrowser, setPage, setSessionPrefix, setSeanceId, setHighlightMode,
|
||||
setActiveContextName, setActiveMode, setPersistentUserDataDir,
|
||||
isConnected, LOAD_TIMEOUT, INIT_TIMEOUT, EXT_ID,
|
||||
} from './state.mjs';
|
||||
import { closeModals } from './errors.mjs';
|
||||
import { stopRecording } from '../recording/capture.mjs';
|
||||
import { getPageState } from '../nav/navigation.mjs';
|
||||
|
||||
/**
|
||||
* Find the 1C browser extension in Chrome/Edge user profiles.
|
||||
* Returns the path to the latest version, or null if not found.
|
||||
* Can be overridden via extensionPath in .v8-project.json.
|
||||
*/
|
||||
function findExtension(overridePath) {
|
||||
if (overridePath) {
|
||||
try { if (statSync(overridePath).isDirectory()) return overridePath; } catch {}
|
||||
return null;
|
||||
}
|
||||
const localAppData = process.env.LOCALAPPDATA;
|
||||
if (!localAppData) return null;
|
||||
const browsers = [
|
||||
pathJoin(localAppData, 'Google', 'Chrome', 'User Data'),
|
||||
pathJoin(localAppData, 'Microsoft', 'Edge', 'User Data'),
|
||||
];
|
||||
for (const userData of browsers) {
|
||||
try { if (!statSync(userData).isDirectory()) continue; } catch { continue; }
|
||||
let profiles;
|
||||
try { profiles = readdirSync(userData).filter(d => d === 'Default' || d.startsWith('Profile ')); } catch { continue; }
|
||||
for (const profile of profiles) {
|
||||
const extDir = pathJoin(userData, profile, 'Extensions', EXT_ID);
|
||||
try { if (!statSync(extDir).isDirectory()) continue; } catch { continue; }
|
||||
let versions;
|
||||
try { versions = readdirSync(extDir).filter(d => /^\d/.test(d)).sort(); } catch { continue; }
|
||||
if (versions.length > 0) {
|
||||
const best = pathJoin(extDir, versions[versions.length - 1]);
|
||||
try { if (statSync(pathJoin(best, 'manifest.json')).isFile()) return best; } catch {}
|
||||
}
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/* isConnected moved to core/state.mjs */
|
||||
|
||||
/**
|
||||
* Open browser and navigate to 1C web client URL.
|
||||
* Waits for initialization (themesCell_theme_0 selector) and attempts to close startup modals.
|
||||
*/
|
||||
export async function connect(url, { extensionPath } = {}) {
|
||||
if (isConnected()) {
|
||||
await page.goto(url, { waitUntil: 'domcontentloaded', timeout: LOAD_TIMEOUT });
|
||||
} else {
|
||||
const extPath = findExtension(extensionPath);
|
||||
if (extPath) {
|
||||
// Launch with 1C browser extension via persistent context
|
||||
setPersistentUserDataDir(pathJoin(tmpdir(), 'pw-1c-ext-' + Date.now()));
|
||||
mkdirSync(persistentUserDataDir, { recursive: true });
|
||||
const context = await chromium.launchPersistentContext(persistentUserDataDir, {
|
||||
headless: false,
|
||||
args: [
|
||||
'--start-maximized',
|
||||
'--disable-extensions-except=' + extPath,
|
||||
'--load-extension=' + extPath,
|
||||
],
|
||||
viewport: null,
|
||||
permissions: ['clipboard-read', 'clipboard-write'],
|
||||
});
|
||||
setBrowser(context); // persistent context IS the browser
|
||||
setPage(context.pages()[0] || await context.newPage());
|
||||
} else {
|
||||
// Fallback: launch without extension
|
||||
setBrowser(await chromium.launch({ headless: false, args: ['--start-maximized'] }));
|
||||
const context = await browser.newContext({
|
||||
viewport: null,
|
||||
permissions: ['clipboard-read', 'clipboard-write'],
|
||||
});
|
||||
setPage(await context.newPage());
|
||||
}
|
||||
|
||||
// Auto-accept native browser dialogs (confirm/alert from 1C scripts like vis.js)
|
||||
page.on('dialog', dialog => dialog.accept().catch(() => {}));
|
||||
|
||||
// Capture seanceId from network requests for graceful logout
|
||||
setSessionPrefix(null);
|
||||
setSeanceId(null);
|
||||
page.on('request', req => {
|
||||
if (seanceId) return;
|
||||
const m = req.url().match(/^(https?:\/\/[^/]+\/[^/]+\/[^/]+)\/e1cib\/.+[?&]seanceId=([^&]+)/);
|
||||
if (m) { setSessionPrefix(m[1]); setSeanceId(m[2]); }
|
||||
});
|
||||
|
||||
await page.goto(url, { waitUntil: 'domcontentloaded', timeout: LOAD_TIMEOUT });
|
||||
}
|
||||
|
||||
// Wait for 1C to initialize — detect by section panel appearance
|
||||
try {
|
||||
await page.waitForSelector('#themesCell_theme_0', { timeout: INIT_TIMEOUT });
|
||||
} catch {
|
||||
// Fallback: wait fixed time if selector doesn't appear (e.g. login page)
|
||||
await page.waitForTimeout(5000);
|
||||
}
|
||||
|
||||
// Try to close startup modals (Путеводитель etc.)
|
||||
await closeModals();
|
||||
|
||||
return await getPageState();
|
||||
}
|
||||
|
||||
/**
|
||||
* Best-effort POST /e1cib/logout on a slot to release the 1C session license.
|
||||
* Silent — if page is closed or session info missing, just returns.
|
||||
* @param {object} slot { page, sessionPrefix, seanceId } from contexts Map
|
||||
* @param {number} [waitMs=500] pause after logout fetch (gives 1C time to process)
|
||||
*/
|
||||
async function logoutSlot(slot, waitMs = 500) {
|
||||
if (!slot?.page || slot.page.isClosed() || !slot.seanceId || !slot.sessionPrefix) return;
|
||||
try {
|
||||
const logoutUrl = `${slot.sessionPrefix}/e1cib/logout?seanceId=${slot.seanceId}`;
|
||||
await slot.page.evaluate(async (url) => {
|
||||
await fetch(url, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: '{"root":{}}' });
|
||||
}, logoutUrl);
|
||||
await slot.page.waitForTimeout(waitMs);
|
||||
} catch {}
|
||||
}
|
||||
|
||||
/**
|
||||
* Gracefully terminate the 1C session and close the browser.
|
||||
* Sends POST /e1cib/logout to release the license before closing.
|
||||
*/
|
||||
export async function disconnect() {
|
||||
// Multi-context path: stop recording + logout each slot before closing browser
|
||||
if (contexts.size > 0) {
|
||||
saveActiveSlot();
|
||||
// Recorder is global — one stop covers all contexts
|
||||
if (recorder) {
|
||||
try { await stopRecording(); } catch {}
|
||||
}
|
||||
for (const [, slot] of contexts.entries()) {
|
||||
await logoutSlot(slot);
|
||||
}
|
||||
contexts.clear();
|
||||
setActiveContextName(null);
|
||||
setActiveMode(null);
|
||||
}
|
||||
|
||||
// Single-session path (connect): auto-stop recording if active
|
||||
if (recorder) {
|
||||
try { await stopRecording(); } catch {}
|
||||
}
|
||||
|
||||
if (browser) {
|
||||
// Graceful logout — release the 1C license (single-session connect path)
|
||||
await logoutSlot({ page, sessionPrefix, seanceId }, 1000);
|
||||
await browser.close().catch(() => {});
|
||||
setBrowser(null);
|
||||
setPage(null);
|
||||
setSessionPrefix(null);
|
||||
setSeanceId(null);
|
||||
// Clean up persistent user data dir
|
||||
if (persistentUserDataDir) {
|
||||
try { rmSync(persistentUserDataDir, { recursive: true, force: true }); } catch {}
|
||||
setPersistentUserDataDir(null);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Attach to a running browser server via CDP WebSocket.
|
||||
* Sets module state so all functions (getFormState, clickElement, etc.) work.
|
||||
*/
|
||||
export async function attach(wsEndpoint, session = {}) {
|
||||
if (isConnected()) return;
|
||||
setBrowser(await chromium.connect(wsEndpoint));
|
||||
const ctx = browser.contexts()[0];
|
||||
setPage(ctx?.pages()[0]);
|
||||
if (!page) throw new Error('No page found in browser');
|
||||
setSessionPrefix(session.sessionPrefix || null);
|
||||
setSeanceId(session.seanceId || null);
|
||||
}
|
||||
|
||||
/**
|
||||
* Detach from browser without closing it.
|
||||
* Returns session state for persistence.
|
||||
*/
|
||||
export function detach() {
|
||||
const session = { sessionPrefix, seanceId };
|
||||
setBrowser(null);
|
||||
setPage(null);
|
||||
setSessionPrefix(null);
|
||||
setSeanceId(null);
|
||||
return session;
|
||||
}
|
||||
|
||||
/** Get current session state (for saving between reconnections). */
|
||||
export function getSession() {
|
||||
return { sessionPrefix, seanceId };
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// Multi-context support (used by run.mjs cmdTest only)
|
||||
// ============================================================
|
||||
|
||||
/**
|
||||
* Save current module-level state into the active slot before switching.
|
||||
* No-op if no active slot.
|
||||
*/
|
||||
function saveActiveSlot() {
|
||||
if (!activeContextName) return;
|
||||
const slot = contexts.get(activeContextName);
|
||||
if (!slot) return;
|
||||
slot.page = page;
|
||||
slot.sessionPrefix = sessionPrefix;
|
||||
slot.seanceId = seanceId;
|
||||
slot.highlightMode = highlightMode;
|
||||
// Note: `recorder`, `lastCaptions`, `lastRecordingDuration` are intentionally NOT
|
||||
// mirrored per-slot. A multi-context recording produces one continuous output file —
|
||||
// the recorder follows the active page via recorder._attachPage(), not per-slot state.
|
||||
}
|
||||
|
||||
/** Load a slot's state into module-level vars and mark it active. */
|
||||
function activateSlot(name) {
|
||||
const slot = contexts.get(name);
|
||||
if (!slot) throw new Error(`Context "${name}" not found. Create it via createContext() first.`);
|
||||
setPage(slot.page);
|
||||
setSessionPrefix(slot.sessionPrefix);
|
||||
setSeanceId(slot.seanceId);
|
||||
setHighlightMode(slot.highlightMode || false);
|
||||
setActiveContextName(name);
|
||||
}
|
||||
|
||||
/** Attach 1C session listeners to a page, writing into the given slot. */
|
||||
function attachSessionListeners(pg, slot, name) {
|
||||
pg.on('dialog', dialog => dialog.accept().catch(() => {}));
|
||||
pg.on('request', req => {
|
||||
if (slot.seanceId) return;
|
||||
const m = req.url().match(/^(https?:\/\/[^/]+\/[^/]+\/[^/]+)\/e1cib\/.+[?&]seanceId=([^&]+)/);
|
||||
if (m) {
|
||||
slot.sessionPrefix = m[1];
|
||||
slot.seanceId = m[2];
|
||||
if (activeContextName === name) {
|
||||
setSessionPrefix(m[1]);
|
||||
setSeanceId(m[2]);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Create (or navigate) a named browser context.
|
||||
* First call launches Chromium via chromium.launch() (NOT launchPersistentContext) so that
|
||||
* subsequent calls can create additional isolated BrowserContexts in the same process.
|
||||
* Trade-off: 1C browser extension is loaded via --load-extension (process-level) rather than
|
||||
* persistent profile.
|
||||
*
|
||||
* Use this from run.mjs cmdTest only — exec/run/start use connect() and stay on the
|
||||
* legacy persistent-context path.
|
||||
*/
|
||||
export async function createContext(name, url, { extensionPath, isolation = 'tab' } = {}) {
|
||||
if (contexts.has(name)) {
|
||||
await setActiveContext(name);
|
||||
await page.goto(url, { waitUntil: 'domcontentloaded', timeout: LOAD_TIMEOUT });
|
||||
try { await page.waitForSelector('#themesCell_theme_0', { timeout: INIT_TIMEOUT }); }
|
||||
catch { await page.waitForTimeout(5000); }
|
||||
await closeModals();
|
||||
return await getPageState();
|
||||
}
|
||||
|
||||
if (!['tab', 'window'].includes(isolation)) {
|
||||
throw new Error(`createContext: invalid isolation "${isolation}", expected 'tab' or 'window'`);
|
||||
}
|
||||
if (activeMode && activeMode !== isolation) {
|
||||
throw new Error(`createContext: cannot mix isolation modes — first context used "${activeMode}", "${name}" requested "${isolation}". Use the same mode for all contexts in one run.`);
|
||||
}
|
||||
|
||||
// First context: launch browser. Subsequent: reuse existing.
|
||||
let isFirstContext = !browser;
|
||||
if (isFirstContext) {
|
||||
const extPath = findExtension(extensionPath);
|
||||
const launchArgs = ['--start-maximized'];
|
||||
if (extPath) {
|
||||
launchArgs.push('--disable-extensions-except=' + extPath, '--load-extension=' + extPath);
|
||||
}
|
||||
if (isolation === 'tab') {
|
||||
// Persistent context: extension loads reliably, one window with tabs per context
|
||||
setPersistentUserDataDir(pathJoin(tmpdir(), 'pw-1c-test-' + Date.now()));
|
||||
mkdirSync(persistentUserDataDir, { recursive: true });
|
||||
setBrowser(await chromium.launchPersistentContext(persistentUserDataDir, {
|
||||
headless: false,
|
||||
args: launchArgs,
|
||||
viewport: null,
|
||||
permissions: ['clipboard-read', 'clipboard-write'],
|
||||
}));
|
||||
} else {
|
||||
// Window mode: separate BrowserContext per slot, full cookie isolation
|
||||
setBrowser(await chromium.launch({ headless: false, args: launchArgs }));
|
||||
}
|
||||
setActiveMode(isolation);
|
||||
}
|
||||
|
||||
// Save current active before switching
|
||||
saveActiveSlot();
|
||||
|
||||
// Create slot — page differs by mode
|
||||
let newCtx, newPage;
|
||||
if (activeMode === 'tab') {
|
||||
// Reuse the persistent context for all slots; each slot gets its own page (tab)
|
||||
newCtx = browser;
|
||||
if (isFirstContext) {
|
||||
newPage = browser.pages()[0] || await browser.newPage();
|
||||
} else {
|
||||
newPage = await browser.newPage();
|
||||
}
|
||||
} else {
|
||||
// Window mode: each slot owns its BrowserContext + page
|
||||
newCtx = await browser.newContext({
|
||||
viewport: null,
|
||||
permissions: ['clipboard-read', 'clipboard-write'],
|
||||
});
|
||||
newPage = await newCtx.newPage();
|
||||
}
|
||||
|
||||
const slot = {
|
||||
context: newCtx,
|
||||
page: newPage,
|
||||
sessionPrefix: null,
|
||||
seanceId: null,
|
||||
highlightMode: false,
|
||||
};
|
||||
contexts.set(name, slot);
|
||||
|
||||
attachSessionListeners(newPage, slot, name);
|
||||
activateSlot(name);
|
||||
|
||||
await page.goto(url, { waitUntil: 'domcontentloaded', timeout: LOAD_TIMEOUT });
|
||||
try { await page.waitForSelector('#themesCell_theme_0', { timeout: INIT_TIMEOUT }); }
|
||||
catch { await page.waitForTimeout(5000); }
|
||||
await closeModals();
|
||||
|
||||
return await getPageState();
|
||||
}
|
||||
|
||||
/** Switch the active context. Subsequent browser API calls operate on this context's page. */
|
||||
export async function setActiveContext(name) {
|
||||
if (activeContextName === name) return;
|
||||
if (!contexts.has(name)) throw new Error(`Context "${name}" not found. Available: [${[...contexts.keys()].join(', ')}]`);
|
||||
// If a recording is active, flush the outgoing page's last frame so the gap is filled
|
||||
// up to the moment of the switch (avoids a "jump" in video time).
|
||||
if (recorder && recorder._flushFrames) recorder._flushFrames();
|
||||
saveActiveSlot();
|
||||
activateSlot(name);
|
||||
// If the recording is still alive (it lives across slots — we keep the same ffmpeg/output),
|
||||
// re-attach its screencast to the newly active page.
|
||||
if (recorder && recorder._attachPage) {
|
||||
await recorder._attachPage(page);
|
||||
}
|
||||
}
|
||||
|
||||
export function listContexts() {
|
||||
return [...contexts.keys()];
|
||||
}
|
||||
|
||||
export function getActiveContext() {
|
||||
return activeContextName;
|
||||
}
|
||||
|
||||
export function hasContext(name) {
|
||||
return contexts.has(name);
|
||||
}
|
||||
|
||||
/**
|
||||
* Close a named context: logout, close its page (tab mode) or BrowserContext
|
||||
* (window mode), remove from registry. Cannot close the currently active
|
||||
* context — caller must setActiveContext to another first. This keeps the
|
||||
* recorder/page invariants simple: recorder is always attached to the
|
||||
* active slot, which closeContext never touches.
|
||||
*
|
||||
* @throws if name is not registered or equals the active context.
|
||||
*/
|
||||
export async function closeContext(name) {
|
||||
if (!contexts.has(name)) {
|
||||
throw new Error(`Context "${name}" not found. Available: [${[...contexts.keys()].join(', ')}]`);
|
||||
}
|
||||
if (name === activeContextName) {
|
||||
throw new Error(`closeContext: cannot close the active context "${name}". setActiveContext to another context first.`);
|
||||
}
|
||||
const slot = contexts.get(name);
|
||||
await logoutSlot(slot);
|
||||
if (activeMode === 'tab') {
|
||||
try { await slot.page.close(); } catch {}
|
||||
} else {
|
||||
try { await slot.context.close(); } catch {}
|
||||
}
|
||||
contexts.delete(name);
|
||||
}
|
||||
@@ -0,0 +1,113 @@
|
||||
// web-test core/state v1.17 — module-level state for the web-test engine.
|
||||
// Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
|
||||
//
|
||||
// Holds the single browser/page/recorder slot plus the multi-context registry,
|
||||
// constants, and small state-only utilities (ensureConnected, getPage,
|
||||
// resolveProjectPath, normYo). Mutable values are exported as `let` bindings
|
||||
// for live read access from consumer modules; writes go through setters so
|
||||
// imported bindings stay read-only at the import site.
|
||||
|
||||
import { dirname, resolve as pathResolve } from 'path';
|
||||
import { fileURLToPath } from 'url';
|
||||
|
||||
// Project root: 6 levels up from .claude/skills/web-test/scripts/engine/core/state.mjs
|
||||
const __fn_state = fileURLToPath(import.meta.url);
|
||||
export const projectRoot = pathResolve(dirname(__fn_state), '..', '..', '..', '..', '..', '..');
|
||||
|
||||
/** Resolve a user-provided path relative to the project root (not cwd). */
|
||||
export const resolveProjectPath = (p) => pathResolve(projectRoot, p);
|
||||
|
||||
// ──────────────────────────────────────────────────────────────────────────
|
||||
// Mutable single-session state. Importers read via the live binding; writes
|
||||
// must go through the corresponding setter (ESM imports are read-only).
|
||||
// ──────────────────────────────────────────────────────────────────────────
|
||||
|
||||
export let browser = null;
|
||||
export let page = null;
|
||||
export let sessionPrefix = null; // e.g. "http://localhost:8081/bpdemo/ru_RU"
|
||||
export let seanceId = null;
|
||||
export let recorder = null; // { cdp, ffmpeg, startTime, outputPath, ffmpegError, captions }
|
||||
export let lastCaptions = []; // captions from the last completed recording (for addNarration)
|
||||
export let lastRecordingDuration = null; // wall-clock duration of the last recording (seconds)
|
||||
export let highlightMode = false;
|
||||
export let persistentUserDataDir = null; // temp dir for launchPersistentContext, cleaned on disconnect
|
||||
|
||||
// Clipboard preservation: save full clipboard contents (all MIME types) right
|
||||
// before each writeText+Ctrl+V pair, restore right after. Toggled via
|
||||
// setPreserveClipboard() from run.mjs.
|
||||
export let preserveClipboard = true;
|
||||
export let clipboardWarnLogged = false;
|
||||
|
||||
export const setBrowser = (v) => { browser = v; };
|
||||
export const setPage = (v) => { page = v; };
|
||||
export const setSessionPrefix = (v) => { sessionPrefix = v; };
|
||||
export const setSeanceId = (v) => { seanceId = v; };
|
||||
export const setRecorder = (v) => { recorder = v; };
|
||||
export const setLastCaptions = (v) => { lastCaptions = v; };
|
||||
export const setLastRecordingDuration = (v) => { lastRecordingDuration = v; };
|
||||
export const setHighlightMode = (v) => { highlightMode = !!v; };
|
||||
export const setPersistentUserDataDir = (v) => { persistentUserDataDir = v; };
|
||||
export const setPreserveClipboard = (v) => { preserveClipboard = !!v; };
|
||||
export const setClipboardWarnLogged = (v) => { clipboardWarnLogged = !!v; };
|
||||
|
||||
// ──────────────────────────────────────────────────────────────────────────
|
||||
// Multi-context registry: name → { context, page, sessionPrefix, seanceId,
|
||||
// recorder, lastCaptions, lastRecordingDuration, highlightMode }.
|
||||
// Populated by createContext(); module-level vars above mirror the active
|
||||
// slot. connect() does NOT use this Map — it preserves legacy single-session
|
||||
// behavior for exec/run/start.
|
||||
// ──────────────────────────────────────────────────────────────────────────
|
||||
|
||||
export const contexts = new Map();
|
||||
export let activeContextName = null;
|
||||
// Isolation mode for the current cmdTest session — set by the first
|
||||
// createContext call. 'tab': all contexts share one persistent context
|
||||
// (one window, multiple tabs, extension loads reliably). 'window': each
|
||||
// context gets its own BrowserContext (separate window per context, full
|
||||
// cookie isolation, extension may not load).
|
||||
export let activeMode = null;
|
||||
|
||||
export const setActiveContextName = (v) => { activeContextName = v; };
|
||||
export const setActiveMode = (v) => { activeMode = v; };
|
||||
|
||||
// ──────────────────────────────────────────────────────────────────────────
|
||||
// Constants.
|
||||
// ──────────────────────────────────────────────────────────────────────────
|
||||
|
||||
export const LOAD_TIMEOUT = 60000;
|
||||
export const INIT_TIMEOUT = 60000;
|
||||
export const ACTION_WAIT = 2000; // fallback minimum wait
|
||||
export const MAX_WAIT = 10000; // max wait for stability
|
||||
export const POLL_INTERVAL = 200; // polling interval
|
||||
export const STABLE_CYCLES = 3; // consecutive stable cycles needed
|
||||
|
||||
// 1C browser extension ID (stable across versions, defined by key in manifest.json)
|
||||
export const EXT_ID = 'pbhelknnhilelbnhfpcjlcabhmfangik';
|
||||
|
||||
// ──────────────────────────────────────────────────────────────────────────
|
||||
// Utilities that only depend on state.
|
||||
// ──────────────────────────────────────────────────────────────────────────
|
||||
|
||||
/** Normalize ё→е and →space for fuzzy matching. */
|
||||
export const normYo = (s) => s.replace(/ё/gi, 'е').replace(/ /g, ' ');
|
||||
|
||||
/** Check if browser is connected and page is usable. */
|
||||
export function isConnected() {
|
||||
if (!browser || !page || page.isClosed()) return false;
|
||||
// launchPersistentContext returns BrowserContext (no isConnected), launch returns Browser
|
||||
if (typeof browser.isConnected === 'function') return browser.isConnected();
|
||||
// For persistent context, check via context's browser()
|
||||
return browser.browser()?.isConnected() ?? false;
|
||||
}
|
||||
|
||||
export function ensureConnected() {
|
||||
if (!isConnected()) {
|
||||
throw new Error('Browser not connected. Call web_connect first.');
|
||||
}
|
||||
}
|
||||
|
||||
/** Get the raw Playwright page object (for advanced scripting in skill mode). */
|
||||
export function getPage() {
|
||||
ensureConnected();
|
||||
return page;
|
||||
}
|
||||
@@ -0,0 +1,123 @@
|
||||
// web-test core/wait v1.17 — Smart wait helpers: DOM stability polling, JS-expression polling, CDP network monitor.
|
||||
// Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
|
||||
|
||||
import { page, MAX_WAIT, POLL_INTERVAL, STABLE_CYCLES } from './state.mjs';
|
||||
import { detectFormScript } from '../../dom.mjs';
|
||||
|
||||
/**
|
||||
* Smart wait: poll until DOM is stable and no loading indicators are visible.
|
||||
* Checks: form number change, loading indicators, DOM stability.
|
||||
* @param {number|null} previousFormNum — form number before the action (null = don't check)
|
||||
*/
|
||||
export async function waitForStable(previousFormNum = null) {
|
||||
let stableCount = 0;
|
||||
let lastSnapshot = '';
|
||||
const start = Date.now();
|
||||
|
||||
while (Date.now() - start < MAX_WAIT) {
|
||||
await page.waitForTimeout(POLL_INTERVAL);
|
||||
|
||||
// Check for loading indicators
|
||||
const status = await page.evaluate(`(() => {
|
||||
const loading = document.querySelector('.loadingImage, .waitCurtain, .progressBar');
|
||||
const isLoading = loading && loading.offsetWidth > 0;
|
||||
const formCount = document.querySelectorAll('input.editInput[id], a.press[id]').length;
|
||||
return { isLoading, formCount };
|
||||
})()`);
|
||||
|
||||
if (status.isLoading) {
|
||||
stableCount = 0;
|
||||
continue;
|
||||
}
|
||||
|
||||
// Check DOM stability by comparing element count snapshot
|
||||
const snapshot = String(status.formCount);
|
||||
if (snapshot === lastSnapshot) {
|
||||
stableCount++;
|
||||
} else {
|
||||
stableCount = 0;
|
||||
lastSnapshot = snapshot;
|
||||
}
|
||||
|
||||
// If form was expected to change, ensure it did
|
||||
if (previousFormNum !== null && stableCount === 1) {
|
||||
const currentForm = await page.evaluate(detectFormScript());
|
||||
if (currentForm !== previousFormNum) {
|
||||
// Form changed — still wait for stability
|
||||
}
|
||||
}
|
||||
|
||||
if (stableCount >= STABLE_CYCLES) return;
|
||||
}
|
||||
// Fallback: max wait reached
|
||||
}
|
||||
|
||||
/**
|
||||
* Start monitoring network activity via CDP.
|
||||
* Must be called BEFORE the click so it captures all server requests.
|
||||
* Returns a monitor object with waitDone() and cleanup() methods.
|
||||
*/
|
||||
export async function startNetworkMonitor() {
|
||||
const client = await page.context().newCDPSession(page);
|
||||
await client.send('Network.enable');
|
||||
|
||||
let pending = 0;
|
||||
let total = 0;
|
||||
let lastZeroTime = null;
|
||||
const DEBOUNCE = 300;
|
||||
|
||||
client.on('Network.requestWillBeSent', () => {
|
||||
pending++;
|
||||
total++;
|
||||
lastZeroTime = null;
|
||||
});
|
||||
client.on('Network.loadingFinished', () => {
|
||||
if (--pending === 0) lastZeroTime = Date.now();
|
||||
});
|
||||
client.on('Network.loadingFailed', () => {
|
||||
if (--pending === 0) lastZeroTime = Date.now();
|
||||
});
|
||||
|
||||
return {
|
||||
/** Wait until all network requests complete (300ms debounce) or UI element appears. */
|
||||
async waitDone(timeout = 10000) {
|
||||
const start = Date.now();
|
||||
while (Date.now() - start < timeout) {
|
||||
await page.waitForTimeout(50);
|
||||
|
||||
// Check for UI elements (modal, balloon, confirm)
|
||||
const ui = await page.evaluate(`(() => {
|
||||
const modal = document.querySelector('#modalSurface:not([style*="display: none"])');
|
||||
const balloon = document.querySelector('.balloon');
|
||||
const confirm = document.querySelector('.confirm');
|
||||
return !!(modal || balloon || confirm);
|
||||
})()`);
|
||||
if (ui) return;
|
||||
|
||||
// CDP debounce: pending===0 held for DEBOUNCE ms
|
||||
if (total > 0 && pending === 0 && lastZeroTime !== null) {
|
||||
if (Date.now() - lastZeroTime >= DEBOUNCE) return;
|
||||
}
|
||||
}
|
||||
},
|
||||
/** Detach CDP session. Always call this when done. */
|
||||
async cleanup() {
|
||||
await client.send('Network.disable').catch(() => {});
|
||||
await client.detach().catch(() => {});
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Poll until a JS expression returns truthy, or timeout (ms) expires.
|
||||
* Resolves early — typically within 100-300ms instead of fixed delays.
|
||||
*/
|
||||
export async function waitForCondition(evalScript, timeout = 2000) {
|
||||
const start = Date.now();
|
||||
while (Date.now() - start < timeout) {
|
||||
const result = await page.evaluate(evalScript);
|
||||
if (result) return result;
|
||||
await page.waitForTimeout(100);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
@@ -0,0 +1,122 @@
|
||||
// web-test forms/click-form v1.1 — click handler for form-element targets: button, tab, submenu, link, field-focus.
|
||||
// Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
|
||||
//
|
||||
// Called by core/click.mjs dispatcher after target is found.
|
||||
// Owns the CDP network-monitor lifecycle for button clicks (server roundtrip waits),
|
||||
// post-click submenu detection (split buttons like "Создать на основании"),
|
||||
// and confirmation hint propagation in the final state.
|
||||
|
||||
import { page, ACTION_WAIT } from '../core/state.mjs';
|
||||
import {
|
||||
detectFormScript, readSubmenuScript,
|
||||
} from '../../dom.mjs';
|
||||
import { checkForErrors } from '../core/errors.mjs';
|
||||
import { waitForStable, startNetworkMonitor } from '../core/wait.mjs';
|
||||
import { safeClick, returnFormState, isInputFocused } from '../core/helpers.mjs';
|
||||
|
||||
/**
|
||||
* Click a form target (button, tab, submenu, link) using its resolved {kind, id, x, y, name}.
|
||||
* Handles three special concerns:
|
||||
* 1. **netMonitor** for `kind: 'button'` — captures CDP requests started by the click
|
||||
* so we can wait for them (when the form doesn't change) before stabilising.
|
||||
* 2. **Submenu detection** — both pre-click (`kind: 'submenu'` already known) and
|
||||
* post-click (split buttons like "Создать на основании" which open a popup).
|
||||
* Returns `submenu[]` items as a hint for the caller.
|
||||
* 3. **Confirmation propagation** — if a confirmation dialog opens as a result of the
|
||||
* click, surface `confirmation` and `hint` fields on the returned state so the
|
||||
* caller can react with Да/Нет/Отмена on the next call.
|
||||
*/
|
||||
export async function clickFormTarget(target, ctx) {
|
||||
const { formNum, timeout } = ctx;
|
||||
let netMonitor = null;
|
||||
|
||||
try {
|
||||
// CDP network monitor BEFORE the click for buttons — captures all server requests
|
||||
// triggered by the click so we can wait for them after.
|
||||
if (target.kind === 'button') {
|
||||
try { netMonitor = await startNetworkMonitor(); } catch {}
|
||||
}
|
||||
|
||||
// Tabs without ID — use coordinate click to avoid global [data-content] ambiguity
|
||||
if (target.kind === 'tab' && !target.id && target.x && target.y) {
|
||||
await page.mouse.click(target.x, target.y);
|
||||
} else {
|
||||
const selector = `[id="${target.id}"]`;
|
||||
// Use Playwright click for proper mousedown/mouseup events
|
||||
await safeClick(selector, { timeout: 5000 });
|
||||
}
|
||||
|
||||
// Pre-known submenu button — read popup items and return them as hints
|
||||
if (target.kind === 'submenu') {
|
||||
await page.waitForTimeout(ACTION_WAIT);
|
||||
const submenuItems = await page.evaluate(readSubmenuScript());
|
||||
const extras = { clicked: { kind: 'submenu', name: target.name } };
|
||||
if (Array.isArray(submenuItems)) {
|
||||
extras.submenu = submenuItems.map(i => i.name);
|
||||
extras.hint = 'Call web_click again with a submenu item name to select it';
|
||||
}
|
||||
return returnFormState(extras);
|
||||
}
|
||||
|
||||
await waitForStable(formNum);
|
||||
|
||||
// Check if the click opened a popup/submenu (split buttons like "Создать на основании")
|
||||
const openedPopup = await page.evaluate(readSubmenuScript());
|
||||
if (Array.isArray(openedPopup) && openedPopup.length > 0) {
|
||||
return returnFormState({
|
||||
clicked: { kind: 'submenu', name: target.name },
|
||||
submenu: openedPopup.map(i => i.name),
|
||||
hint: 'Call web_click again with a submenu item name to select it',
|
||||
});
|
||||
}
|
||||
|
||||
// For buttons that trigger server-side operations (post, write, etc.),
|
||||
// the DOM may stabilise BEFORE the server response arrives.
|
||||
// The CDP monitor (started before click) lets us wait for all in-flight requests
|
||||
// to complete (300ms debounce) or for a modal/balloon/confirm to appear.
|
||||
// Skip for grid edit mode (e.g. "Добавить" row) — no server round-trip expected.
|
||||
if (target.kind === 'button') {
|
||||
const postForm = await page.evaluate(detectFormScript());
|
||||
if (postForm === formNum) {
|
||||
const inGridEdit = await page.evaluate(`(() => {
|
||||
const f = document.activeElement;
|
||||
if (!f || (f.tagName !== 'INPUT' && f.tagName !== 'TEXTAREA')) return false;
|
||||
let n = f; while (n) { if (n.classList?.contains('grid')) return true; n = n.parentElement; }
|
||||
return false;
|
||||
})()`);
|
||||
if (!inGridEdit && netMonitor) {
|
||||
await netMonitor.waitDone(timeout);
|
||||
await waitForStable();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Build final state with confirmation propagation
|
||||
// (the one custom branch deliberately skipped by Phase 2 — surfaces confirmation
|
||||
// + hint when a save/delete dialog opened as a result of the click).
|
||||
const extras = { clicked: { kind: target.kind, name: target.name } };
|
||||
const err = await checkForErrors();
|
||||
if (err?.confirmation) {
|
||||
extras.confirmation = err.confirmation;
|
||||
extras.hint = 'Call web_click with a button name (e.g. "Да", "Нет", "Отмена") to respond';
|
||||
}
|
||||
return returnFormState(extras);
|
||||
} finally {
|
||||
if (netMonitor) try { await netMonitor.cleanup(); } catch {}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Focus a form input field (last-resort target kind: 'field') by clicking the input itself —
|
||||
* does NOT change its value. Lets the caller then drive focus-dependent shortcuts
|
||||
* (F4 selection form, Shift+F4 clear, etc.) via getPage().keyboard.
|
||||
* Returns flat form state with `focused: { field, id, ok }`; `ok` reflects whether the
|
||||
* input actually received focus (false for disabled/readonly fields). Never throws on ok=false.
|
||||
*/
|
||||
export async function focusFormField(target, ctx) {
|
||||
const selector = `[id="${target.id}"]`;
|
||||
await safeClick(selector, { timeout: 5000 });
|
||||
await waitForStable(ctx.formNum);
|
||||
const ok = await isInputFocused({ allowTextarea: true });
|
||||
return returnFormState({ focused: { field: target.name, id: target.id, ok } });
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
// web-test forms/click-popup v1.0 — click handlers for in-form popups: confirmation dialogs and open submenus.
|
||||
// Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
|
||||
//
|
||||
// Both handlers run BEFORE clickElement's regular target-finding flow:
|
||||
// - clickConfirmationButton intercepts when a pending confirmation dialog is open
|
||||
// - tryClickPopupItem intercepts when a submenu/popup is open from a previous click
|
||||
|
||||
import { page, ACTION_WAIT, normYo } from '../core/state.mjs';
|
||||
import { readSubmenuScript } from '../../dom.mjs';
|
||||
import { waitForStable } from '../core/wait.mjs';
|
||||
import { returnFormState } from '../core/helpers.mjs';
|
||||
|
||||
/**
|
||||
* Click a button in the currently-open confirmation dialog (Да/Нет/Отмена, etc).
|
||||
* Caller is responsible for verifying that a confirmation is actually pending
|
||||
* (via checkForErrors().confirmation) before invoking this handler.
|
||||
*
|
||||
* Throws if no button matching `text` is found in the dialog.
|
||||
*/
|
||||
export async function clickConfirmationButton(text) {
|
||||
const btnResult = await page.evaluate(`(() => {
|
||||
const norm = s => s?.trim().replace(/\\u00a0/g, ' ') || '';
|
||||
const ny = s => s.replace(/ё/gi, 'е').replace(/\\u00a0/g, ' ');
|
||||
const target = ny(${JSON.stringify(text.toLowerCase())});
|
||||
const btns = [...document.querySelectorAll('a.press.pressButton')].filter(el => el.offsetWidth > 0);
|
||||
let best = btns.find(el => ny(norm(el.innerText).toLowerCase()) === target);
|
||||
if (!best) best = btns.find(el => ny(norm(el.innerText).toLowerCase()).includes(target));
|
||||
if (best) {
|
||||
const r = best.getBoundingClientRect();
|
||||
return { name: norm(best.innerText), x: Math.round(r.x + r.width/2), y: Math.round(r.y + r.height/2) };
|
||||
}
|
||||
return { error: 'not_found', available: btns.map(el => norm(el.innerText)).filter(Boolean) };
|
||||
})()`);
|
||||
if (btnResult?.error) {
|
||||
throw new Error(`clickElement: "${text}" not found among confirmation buttons. Available: ${btnResult.available?.join(', ') || 'none'}`);
|
||||
}
|
||||
await page.mouse.click(btnResult.x, btnResult.y);
|
||||
await waitForStable();
|
||||
return returnFormState({ clicked: { kind: 'confirmation', name: btnResult.name } });
|
||||
}
|
||||
|
||||
/**
|
||||
* Try to click an item inside an already-open submenu/popup.
|
||||
*
|
||||
* Returns a form-state result on match (kind: 'popupItem' or 'submenuArrow'),
|
||||
* or `null` if the requested text doesn't match any visible popup item — in
|
||||
* which case the caller should fall through to regular form-element finding.
|
||||
*
|
||||
* @param {string} text — fuzzy-matched against item labels (NBSP/ё-normalised)
|
||||
* @param {Array} popupItems — items already read via readSubmenuScript()
|
||||
*/
|
||||
export async function tryClickPopupItem(text, popupItems) {
|
||||
const target = normYo(text.toLowerCase());
|
||||
let found = popupItems.find(i => normYo(i.name.toLowerCase()) === target);
|
||||
if (!found) found = popupItems.find(i => normYo(i.name.toLowerCase()).includes(target));
|
||||
if (!found) return null;
|
||||
|
||||
// submenuArrow items (group headers like "Создать", "Печать") — hover to expand nested submenu
|
||||
if (found.kind === 'submenuArrow') {
|
||||
// page.hover(selector) is more reliable than page.mouse.move(x,y) —
|
||||
// some submenu groups don't expand with plain mouse.move
|
||||
if (found.id) {
|
||||
await page.hover(`[id="${found.id}"]`);
|
||||
} else {
|
||||
await page.mouse.move(found.x, found.y);
|
||||
}
|
||||
await page.waitForTimeout(ACTION_WAIT);
|
||||
const nestedItems = await page.evaluate(readSubmenuScript());
|
||||
const extras = { clicked: { kind: 'submenuArrow', name: found.name } };
|
||||
if (Array.isArray(nestedItems)) {
|
||||
extras.submenu = nestedItems.map(i => i.name);
|
||||
extras.hint = 'Call web_click again with a submenu item name to select it';
|
||||
}
|
||||
return returnFormState(extras);
|
||||
}
|
||||
|
||||
// Regular submenu/dropdown items — trusted events required.
|
||||
// Use mouse.click(x,y) when in viewport; use :visible selector for clipped items
|
||||
// (same ID can exist hidden in parent cloud AND visible in nested cloud).
|
||||
const vpHeight = await page.evaluate('window.innerHeight');
|
||||
if (found.x && found.y && found.y > 0 && found.y < vpHeight) {
|
||||
await page.mouse.click(found.x, found.y);
|
||||
} else if (found.id) {
|
||||
await page.click(`[id="${found.id}"]:visible`);
|
||||
} else if (found.x && found.y) {
|
||||
await page.mouse.click(found.x, found.y);
|
||||
}
|
||||
await waitForStable();
|
||||
return returnFormState({ clicked: { kind: 'popupItem', name: found.name } });
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
// web-test forms/close v1.18 — Close current form via Escape, handle save-changes confirmation.
|
||||
// Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
|
||||
|
||||
import { page, recorder, ensureConnected } from '../core/state.mjs';
|
||||
import { detectFormScript } from '../../dom.mjs';
|
||||
import { dismissPendingErrors, checkForErrors, detectPlatformDialogs, closePlatformDialogs } from '../core/errors.mjs';
|
||||
import { waitForStable } from '../core/wait.mjs';
|
||||
import { returnFormState } from '../core/helpers.mjs';
|
||||
import { getFormState } from './state.mjs';
|
||||
|
||||
/**
|
||||
* Close the current form/dialog via Escape.
|
||||
* @param {Object} [opts]
|
||||
* @param {boolean} [opts.save] - Handle "Save changes?" confirmation automatically:
|
||||
* true → click "Да" (save and close)
|
||||
* false → click "Нет" (discard and close)
|
||||
* undefined → return confirmation as hint for caller to decide
|
||||
*/
|
||||
export async function closeForm({ save } = {}) {
|
||||
ensureConnected();
|
||||
await dismissPendingErrors();
|
||||
// If platform dialogs are open, close them instead of pressing Escape
|
||||
const pd = await detectPlatformDialogs();
|
||||
if (pd.length) {
|
||||
await closePlatformDialogs();
|
||||
await page.waitForTimeout(300);
|
||||
return returnFormState({ closed: true, closedPlatformDialogs: pd });
|
||||
}
|
||||
const beforeForm = await page.evaluate(detectFormScript());
|
||||
await page.keyboard.press('Escape');
|
||||
await waitForStable(beforeForm);
|
||||
const state = await getFormState();
|
||||
const err = await checkForErrors();
|
||||
if (err?.confirmation) {
|
||||
if (save === true || save === false) {
|
||||
const label = save ? 'Да' : 'Нет';
|
||||
const btnSel = `#form${err.confirmation.formNum}_container a.press.pressButton`;
|
||||
const btns = await page.$$(btnSel);
|
||||
for (const b of btns) {
|
||||
const txt = (await b.textContent()).trim();
|
||||
if (txt === label) {
|
||||
if (recorder) await page.waitForTimeout(500); // show confirmation to viewer during recording
|
||||
await b.click({ force: true });
|
||||
await waitForStable(beforeForm);
|
||||
break;
|
||||
}
|
||||
}
|
||||
const afterForm = await page.evaluate(detectFormScript());
|
||||
return returnFormState({ closed: afterForm !== beforeForm });
|
||||
}
|
||||
state.confirmation = err.confirmation;
|
||||
state.hint = 'Confirmation dialog shown. Click "Да" to confirm or "Нет" to cancel';
|
||||
return state;
|
||||
}
|
||||
return returnFormState({ closed: state.form !== beforeForm });
|
||||
}
|
||||
@@ -0,0 +1,147 @@
|
||||
// web-test forms/fill v1.19 — Fill form fields by name (text/checkbox/date/number/dropdown/reference). Delegates references to selectValue / fillReferenceField.
|
||||
// Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
|
||||
|
||||
import {
|
||||
page, ensureConnected, ACTION_WAIT, highlightMode, normYo,
|
||||
} from '../core/state.mjs';
|
||||
import {
|
||||
detectFormScript, resolveFieldsScript,
|
||||
} from '../../dom.mjs';
|
||||
import { dismissPendingErrors, checkForErrors } from '../core/errors.mjs';
|
||||
import { waitForStable, startNetworkMonitor } from '../core/wait.mjs';
|
||||
import { highlight, unhighlight } from '../recording/highlight.mjs';
|
||||
import {
|
||||
fillReferenceField, selectValue, pickFromSelectionForm,
|
||||
isTypeDialog, pickFromTypeDialog,
|
||||
} from './select-value.mjs';
|
||||
import { pasteText } from '../core/clipboard.mjs';
|
||||
import { returnFormState } from '../core/helpers.mjs';
|
||||
|
||||
/** Fill fields on the current form via Playwright page.fill(). Returns fill results + updated form. */
|
||||
export async function fillFields(fields) {
|
||||
ensureConnected();
|
||||
await dismissPendingErrors();
|
||||
const formNum = await page.evaluate(detectFormScript());
|
||||
if (formNum === null) throw new Error('fillFields: no form found');
|
||||
|
||||
// Resolve field names to element IDs
|
||||
const resolved = await page.evaluate(resolveFieldsScript(formNum, fields));
|
||||
const results = [];
|
||||
|
||||
for (const r of resolved) {
|
||||
if (r.error) {
|
||||
results.push(r);
|
||||
continue;
|
||||
}
|
||||
// Auto-highlight the field input before filling
|
||||
if (highlightMode && r.inputId) {
|
||||
try {
|
||||
await page.evaluate(({ id }) => {
|
||||
const target = document.getElementById(id);
|
||||
if (!target) return;
|
||||
let div = document.getElementById('__web_test_highlight');
|
||||
if (!div) { div = document.createElement('div'); div.id = '__web_test_highlight'; document.body.appendChild(div); }
|
||||
const r = target.getBoundingClientRect();
|
||||
div.style.cssText = 'position:fixed;pointer-events:none;z-index:999998;top:' + (r.y-4) + 'px;left:' + (r.x-4) + 'px;width:' + (r.width+8) + 'px;height:' + (r.height+8) + 'px;outline:3px solid #e74c3c;border-radius:4px;box-shadow:0 0 16px #e74c3c80';
|
||||
}, { id: r.inputId });
|
||||
await page.waitForTimeout(500);
|
||||
await unhighlight();
|
||||
} catch {}
|
||||
}
|
||||
try {
|
||||
// Auto-enable DCS checkbox if resolved via label
|
||||
if (r.dcsCheckbox && !r.dcsCheckbox.checked) {
|
||||
await page.click(`[id="${r.dcsCheckbox.inputId}"]`);
|
||||
await waitForStable();
|
||||
}
|
||||
const selector = `[id="${r.inputId}"]`;
|
||||
// Clear field via Shift+F4 if value is empty (not applicable to checkbox/radio)
|
||||
const rawValue = fields[r.field];
|
||||
const isEmpty = rawValue === '' || rawValue === null || rawValue === undefined;
|
||||
if (isEmpty && !r.isCheckbox && !r.isRadio) {
|
||||
await page.click(selector);
|
||||
await page.waitForTimeout(200);
|
||||
await page.keyboard.press('Shift+F4');
|
||||
await page.waitForTimeout(300);
|
||||
await page.keyboard.press('Tab');
|
||||
await waitForStable();
|
||||
results.push({ field: r.field, ok: true, value: '', method: 'clear' });
|
||||
continue;
|
||||
}
|
||||
if (r.isCheckbox) {
|
||||
// Checkbox: compare desired with current, toggle if mismatch
|
||||
const desired = String(fields[r.field]).toLowerCase();
|
||||
const wantChecked = ['true', '1', 'да', 'yes', 'on'].includes(desired);
|
||||
if (wantChecked !== r.checked) {
|
||||
await page.click(selector);
|
||||
await waitForStable();
|
||||
}
|
||||
results.push({ field: r.field, ok: true, value: String(wantChecked), method: 'toggle' });
|
||||
} else if (r.isRadio) {
|
||||
// Radio button: find option by label (fuzzy match) and click it
|
||||
const desired = normYo(String(fields[r.field]).toLowerCase());
|
||||
const opt = r.options.find(o => normYo(o.label.toLowerCase()) === desired)
|
||||
|| r.options.find(o => normYo(o.label.toLowerCase()).includes(desired));
|
||||
if (opt) {
|
||||
// Option 0 = base element (no suffix), options 1+ = #N#radio
|
||||
const radioId = opt.index === 0 ? r.inputId : `${r.inputId}#${opt.index}#radio`;
|
||||
await page.click(`[id="${radioId}"]`);
|
||||
await waitForStable();
|
||||
results.push({ field: r.field, ok: true, value: opt.label, method: 'radio' });
|
||||
} else {
|
||||
results.push({ field: r.field, error: 'option_not_found', available: r.options.map(o => o.label) });
|
||||
}
|
||||
} else if (r.hasSelect) {
|
||||
// Combobox/reference with DLB: DLB-first, then paste fallback
|
||||
const refResult = await fillReferenceField(selector, r.field, fields[r.field], formNum);
|
||||
results.push(refResult);
|
||||
} else if (r.hasPick && (r.isDate || r.isCalc)) {
|
||||
// Date/time (calendar CB) or numeric (calculator CB) field — use paste:
|
||||
// the pick button is a calendar/calculator widget, not a selection form.
|
||||
await page.click(selector);
|
||||
await page.waitForTimeout(200);
|
||||
await page.keyboard.press('Control+A');
|
||||
await pasteText(fields[r.field]);
|
||||
await page.waitForTimeout(300);
|
||||
await page.keyboard.press('Tab');
|
||||
await waitForStable();
|
||||
results.push({ field: r.field, ok: true, value: String(fields[r.field]), method: 'paste' });
|
||||
} else if (r.hasPick) {
|
||||
// Reference field with CB (non-editable or editable ref): delegate to selectValue (F4 → selection form)
|
||||
const svResult = await selectValue(r.field, String(fields[r.field]));
|
||||
if (svResult?.error) {
|
||||
results.push({ field: r.field, error: svResult.error, message: svResult.message });
|
||||
} else {
|
||||
results.push({ field: r.field, ok: true, value: svResult.value || String(fields[r.field]), method: svResult.method || 'form' });
|
||||
}
|
||||
} else {
|
||||
// Plain field: clipboard paste + Tab to commit
|
||||
// page.fill() sets DOM value but doesn't trigger 1C input events;
|
||||
// clipboard paste (Ctrl+V) is a trusted event that 1C processes correctly.
|
||||
await page.click(selector);
|
||||
await page.waitForTimeout(200);
|
||||
await page.keyboard.press('Control+A');
|
||||
await pasteText(fields[r.field]);
|
||||
await page.waitForTimeout(300);
|
||||
await page.keyboard.press('Tab');
|
||||
await waitForStable();
|
||||
results.push({ field: r.field, ok: true, value: String(fields[r.field]), method: 'paste' });
|
||||
}
|
||||
} catch (e) {
|
||||
results.push({ field: r.field, error: e.message });
|
||||
}
|
||||
if (highlightMode) try { await unhighlight(); } catch {}
|
||||
}
|
||||
|
||||
const failed = results.filter(r => r.error);
|
||||
if (failed.length > 0) {
|
||||
const details = failed.map(f => ` ${f.field}: ${f.message || f.error}${f.available ? ' (available: ' + f.available.join(', ') + ')' : ''}`).join('\n');
|
||||
throw new Error(`fillFields: ${failed.length} of ${results.length} field(s) failed:\n${details}`);
|
||||
}
|
||||
return returnFormState({ filled: results });
|
||||
}
|
||||
|
||||
/** Convenience alias: fill a single field. Same as fillFields({ name: value }). */
|
||||
export async function fillField(name, value) {
|
||||
return fillFields({ [name]: value });
|
||||
}
|
||||
@@ -0,0 +1,825 @@
|
||||
// web-test forms/select-value v1.22 — Reference & composite-type value selection: selectValue, fillReferenceField, selection/type-dialog pickers.
|
||||
// Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
|
||||
|
||||
import {
|
||||
page, ensureConnected, normYo, highlightMode, ACTION_WAIT,
|
||||
} from '../core/state.mjs';
|
||||
import {
|
||||
detectFormScript, findFieldButtonScript, resolveFieldsScript,
|
||||
readSubmenuScript, checkErrorsScript,
|
||||
findSearchInputScript, findNamedButtonScript, findCompareTypeRadioScript, isFormVisibleScript,
|
||||
findPatternInputIdScript, isTypeDialogScript, isNotInListCloudVisibleScript,
|
||||
findChildFormByButtonScript, readTypeDialogVisibleRowsScript,
|
||||
} from '../../dom.mjs';
|
||||
import { scanGridRowsScript } from '../../dom/grid.mjs';
|
||||
import { dismissPendingErrors, checkForErrors } from '../core/errors.mjs';
|
||||
import { waitForStable, waitForCondition } from '../core/wait.mjs';
|
||||
import { highlight, unhighlight } from '../recording/highlight.mjs';
|
||||
import {
|
||||
safeClick, findFieldInputId, readEdd,
|
||||
detectNewForm as helperDetectNewForm,
|
||||
clickEddItemViaDispatch, clickShowAllInEdd, returnFormState,
|
||||
} from '../core/helpers.mjs';
|
||||
import { pasteText } from '../core/clipboard.mjs';
|
||||
import { getFormState } from './state.mjs';
|
||||
|
||||
/**
|
||||
* Scan visible grid rows for a text match (exact → startsWith → includes).
|
||||
* Returns center coords of the matched row, or null if not found.
|
||||
* When searchLower is empty, returns coords of the first row (fallback).
|
||||
*/
|
||||
async function scanGridRows(formNum, searchLower) {
|
||||
return page.evaluate(scanGridRowsScript(formNum, searchLower));
|
||||
}
|
||||
|
||||
/**
|
||||
* Select a row in a selection form via click + Enter, verify it closed.
|
||||
* Uses click + Enter instead of dblclick because dblclick toggles
|
||||
* expand/collapse in tree-style selection forms.
|
||||
* Returns { field, ok: true, method: 'form' } on success,
|
||||
* or { field, ok: false, reason: 'still_open' } if the item couldn't be selected (e.g. group row).
|
||||
*/
|
||||
async function dblclickAndVerify(coords, selFormNum, fieldName) {
|
||||
// Click to highlight the row, then Enter to confirm selection.
|
||||
// This works for both flat grids and tree forms (dblclick would
|
||||
// toggle expand/collapse on tree group rows).
|
||||
await page.mouse.click(coords.x, coords.y);
|
||||
await page.waitForTimeout(200);
|
||||
await page.keyboard.press('Enter');
|
||||
await waitForStable(selFormNum);
|
||||
|
||||
// Verify selection form closed
|
||||
const stillOpen = await page.evaluate(isFormVisibleScript(selFormNum));
|
||||
if (stillOpen) {
|
||||
// Enter didn't select — item is likely a non-selectable group.
|
||||
// Don't Escape here — let the caller decide (may want to try another row).
|
||||
return { field: fieldName, ok: false, reason: 'still_open' };
|
||||
}
|
||||
|
||||
// Check for 1C error modals after selection
|
||||
const err = await page.evaluate(checkErrorsScript());
|
||||
if (err?.modal) {
|
||||
try {
|
||||
const btn = await page.$('a.press.pressDefault');
|
||||
if (btn) { await btn.click(); await page.waitForTimeout(500); }
|
||||
} catch { /* OK */ }
|
||||
}
|
||||
return { field: fieldName, ok: true, method: 'form' };
|
||||
}
|
||||
|
||||
/**
|
||||
* Inline advanced search on a selection form via Alt+F.
|
||||
* Does NOT click any column — FieldSelector auto-populates with main representation.
|
||||
* Switches to "по части строки" (CompareType#1) to avoid composite type issues.
|
||||
* Does not throw — returns silently on failure.
|
||||
*/
|
||||
async function advancedSearchInline(formNum, text) {
|
||||
try {
|
||||
// 1. Open advanced search via Alt+F
|
||||
await page.keyboard.press('Alt+f');
|
||||
await page.waitForTimeout(2000);
|
||||
|
||||
const dialogForm = await page.evaluate(detectFormScript());
|
||||
if (dialogForm === formNum || dialogForm === null) return; // Alt+F didn't open dialog
|
||||
|
||||
// 2. Switch to "по части строки" (CompareType#1)
|
||||
const radioClicked = await page.evaluate(findCompareTypeRadioScript(dialogForm, 1));
|
||||
if (radioClicked && !radioClicked.already) {
|
||||
await page.mouse.click(radioClicked.x, radioClicked.y);
|
||||
await page.waitForTimeout(300);
|
||||
}
|
||||
|
||||
// 3. Fill Pattern field via clipboard paste
|
||||
const patternId = await page.evaluate(findPatternInputIdScript(dialogForm));
|
||||
if (!patternId) {
|
||||
await page.keyboard.press('Escape');
|
||||
await page.waitForTimeout(300);
|
||||
return;
|
||||
}
|
||||
await page.click(`[id="${patternId}"]`);
|
||||
await page.waitForTimeout(200);
|
||||
await page.keyboard.press('Control+A');
|
||||
await pasteText(text);
|
||||
await page.waitForTimeout(300);
|
||||
|
||||
// 4. Click "Найти"
|
||||
const findBtn = await page.evaluate(findNamedButtonScript('Найти'));
|
||||
if (findBtn) {
|
||||
await page.mouse.click(findBtn.x, findBtn.y);
|
||||
await page.waitForTimeout(2000);
|
||||
}
|
||||
|
||||
// 5. Close advanced search dialog
|
||||
for (let attempt = 0; attempt < 3; attempt++) {
|
||||
const dialogVisible = await page.evaluate(isFormVisibleScript(dialogForm));
|
||||
if (!dialogVisible) break;
|
||||
await page.keyboard.press('Escape');
|
||||
await page.waitForTimeout(500);
|
||||
}
|
||||
await waitForStable(formNum);
|
||||
} catch { /* silently fail — caller will re-scan and handle not_found */ }
|
||||
}
|
||||
|
||||
/**
|
||||
* Pick a value from an opened selection form.
|
||||
*
|
||||
* Strategy (escalating):
|
||||
* 1. Scan visible rows for text match (exact → startsWith → includes)
|
||||
* 2. Advanced search (Alt+F, "по части строки") → re-scan
|
||||
* 3. Fallback: simple search (search input + Enter) → re-scan
|
||||
* 4. Not found → Escape → error
|
||||
*
|
||||
* For object search {field: value}: steps 1, then filterList(val, {field}) per entry, then re-scan.
|
||||
* For empty search: pick first visible row.
|
||||
*
|
||||
* @param {number} selFormNum - selection form number
|
||||
* @param {string} fieldName - field being filled (for error messages)
|
||||
* @param {string|Object} search - string for simple search, or { field: value } for per-field search
|
||||
* @param {number} origFormNum - original form number (to verify we returned)
|
||||
* @returns {{ field, ok, method }} or {{ field, error, message }}
|
||||
*/
|
||||
export async function pickFromSelectionForm(selFormNum, fieldName, search, origFormNum) {
|
||||
const searchText = typeof search === 'string'
|
||||
? search : (search ? Object.values(search).join(' ') : '');
|
||||
const searchLower = normYo((searchText || '').toLowerCase());
|
||||
|
||||
// Helper: try to select a row; returns result if ok, null if item wasn't selectable (group).
|
||||
let hadUnselectableMatch = false;
|
||||
async function trySelect(row) {
|
||||
const r = await dblclickAndVerify(row, selFormNum, fieldName);
|
||||
if (r.ok) return r;
|
||||
hadUnselectableMatch = true; // found match but couldn't select (possibly group row or overlay)
|
||||
return null; // form still open, try next step
|
||||
}
|
||||
|
||||
// Step 1: Scan visible rows (no filtering)
|
||||
if (searchLower) {
|
||||
const row = await scanGridRows(selFormNum, searchLower);
|
||||
if (row?.x) {
|
||||
const r = await trySelect(row);
|
||||
if (r) return r;
|
||||
}
|
||||
}
|
||||
|
||||
// Step 2: Advanced search (Alt+F — fast, no overlay issues)
|
||||
if (typeof search === 'object' && search) {
|
||||
// Per-field advanced search via filterList(val, {field})
|
||||
for (const [fld, val] of Object.entries(search)) {
|
||||
try { await filterList(String(val), { field: fld }); } catch { /* proceed */ }
|
||||
}
|
||||
} else if (searchLower) {
|
||||
// Inline advanced search (Alt+F, "по части строки")
|
||||
await advancedSearchInline(selFormNum, searchText);
|
||||
}
|
||||
if (searchLower) {
|
||||
const row = await scanGridRows(selFormNum, searchLower);
|
||||
if (row?.x) {
|
||||
const r = await trySelect(row);
|
||||
if (r) return r;
|
||||
}
|
||||
}
|
||||
|
||||
// Step 3: Fallback — simple search via search input (for forms without Alt+F support)
|
||||
if (typeof search === 'string' && searchLower) {
|
||||
const searchInputInfo = await page.evaluate(findSearchInputScript(selFormNum));
|
||||
if (searchInputInfo) {
|
||||
try {
|
||||
await page.click(`[id="${searchInputInfo.id}"]`);
|
||||
await page.waitForTimeout(200);
|
||||
await page.keyboard.press('Control+A');
|
||||
await pasteText(searchText);
|
||||
await page.waitForTimeout(300);
|
||||
await page.keyboard.press('Enter');
|
||||
await waitForStable(selFormNum);
|
||||
} catch { /* proceed */ }
|
||||
const row = await scanGridRows(selFormNum, searchLower);
|
||||
if (row?.x) {
|
||||
const r = await trySelect(row);
|
||||
if (r) return r;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Step 4: Empty search → pick first row; otherwise not found
|
||||
if (!searchLower) {
|
||||
const row = await scanGridRows(selFormNum, '');
|
||||
if (row?.x) {
|
||||
const r = await trySelect(row);
|
||||
if (r) return r;
|
||||
}
|
||||
}
|
||||
|
||||
await page.keyboard.press('Escape');
|
||||
await waitForStable();
|
||||
const searchDesc = typeof search === 'string' ? '"' + search + '"' : JSON.stringify(search);
|
||||
if (hadUnselectableMatch) {
|
||||
return { field: fieldName, error: 'not_selectable',
|
||||
message: 'Found ' + searchDesc + ' in selection form but it is not selectable (group/folder row)' };
|
||||
}
|
||||
return { field: fieldName, error: 'not_found',
|
||||
message: 'No matches in selection form for ' + searchDesc };
|
||||
}
|
||||
|
||||
/**
|
||||
* Detect whether a form is a type selection dialog ("Выбор типа данных").
|
||||
* Type dialogs appear when selecting a value for a composite-type field.
|
||||
*
|
||||
* Detection signals (any one is sufficient):
|
||||
* - form{N}_OK element exists (selection forms use "Выбрать", not "OK")
|
||||
* - form{N}_ValueList grid exists (specific to type/value list dialogs)
|
||||
* - Window title contains "Выбор типа" (title attr on .toplineBoxTitle)
|
||||
*/
|
||||
export async function isTypeDialog(formNum) {
|
||||
return page.evaluate(isTypeDialogScript(formNum));
|
||||
}
|
||||
|
||||
/**
|
||||
* Select a type from the type selection dialog ("Выбор типа данных")
|
||||
* using Ctrl+F search. The dialog has a virtual grid (~5 visible rows),
|
||||
* so Ctrl+F is the only reliable way to find a type.
|
||||
*
|
||||
* Algorithm: Ctrl+F → paste typeName → Enter (search) → Escape (close Find) →
|
||||
* verify selected row matches → Enter (OK)
|
||||
*
|
||||
* @param {number} formNum - type dialog form number
|
||||
* @param {string} typeName - type name to search for (fuzzy, e.g. "Реализация (акт")
|
||||
* @throws {Error} if type not found
|
||||
*/
|
||||
export async function pickFromTypeDialog(formNum, typeName) {
|
||||
// The type dialog is a modal ValueList grid.
|
||||
// Strategy: scan visible rows first (fast path), fall back to Ctrl+F for large lists.
|
||||
//
|
||||
// Key constraints discovered during testing:
|
||||
// - Grid focus: use evaluate(() => gridBody.focus()), NOT page.click({force:true})
|
||||
// which punches through the modal overlay to the form underneath
|
||||
// - Ctrl+F only opens "Найти" if the GRID is focused (otherwise closes the type dialog)
|
||||
// - Buttons: use page.click({force:true}), NOT evaluate(() => el.click())
|
||||
// because evaluate click doesn't trigger 1C's event chain properly
|
||||
// - Enter/Escape in "Найти" close the ENTIRE dialog chain, not just "Найти"
|
||||
// - Closing "Найти" via Cancel resets the search — verify grid while "Найти" is open
|
||||
|
||||
const typeNorm = normYo(typeName.toLowerCase());
|
||||
|
||||
// Helper: read visible rows and find matching ones
|
||||
async function readVisibleRows() {
|
||||
return page.evaluate(readTypeDialogVisibleRowsScript(formNum, typeNorm));
|
||||
}
|
||||
|
||||
// Helper: dismiss the type-selection dialog (and any child "Найти") on error.
|
||||
// Escape closes the dialog chain, but a blind Escape×3 cascades into the underlying
|
||||
// form. So press Escape only while THIS type dialog is still present, then stop —
|
||||
// leaving the source form (and cell edit mode) for the caller to handle.
|
||||
async function dismissTypeDialog() {
|
||||
for (let i = 0; i < 4; i++) {
|
||||
const stillOpen = await page.evaluate(
|
||||
`!!document.getElementById('form${formNum}_OK') || !!document.getElementById('form${formNum}_ValueList')`);
|
||||
if (!stillOpen) break;
|
||||
await page.keyboard.press('Escape');
|
||||
await page.waitForTimeout(300);
|
||||
}
|
||||
}
|
||||
|
||||
// Exact-match preference: substring search can surface several types that merely CONTAIN the
|
||||
// requested name (e.g. "Контрагент" → "Банковская карта контрагента", "Договор с контрагентом",
|
||||
// …, "Контрагент"). Prefer the row equal to the requested name; only the absence of a single
|
||||
// exact match among multiple substring hits is a genuine ambiguity.
|
||||
function resolveExact(matches) {
|
||||
if (!matches || matches.length === 0) return null;
|
||||
if (matches.length === 1) return matches[0];
|
||||
const exact = matches.filter(m => normYo((m.text || '').toLowerCase()) === typeNorm);
|
||||
return exact.length === 1 ? exact[0] : null;
|
||||
}
|
||||
async function selectRowAndOk(row) {
|
||||
await page.mouse.click(row.x, row.y);
|
||||
await page.waitForTimeout(200);
|
||||
await page.click(`#form${formNum}_OK`, { force: true });
|
||||
await page.waitForTimeout(ACTION_WAIT);
|
||||
}
|
||||
// Focus the grid via evaluate (does NOT punch through the modal overlay like page.click).
|
||||
async function focusGrid() {
|
||||
await page.evaluate(`(() => {
|
||||
const grid = document.getElementById('form${formNum}_ValueList');
|
||||
if (!grid) return;
|
||||
const body = grid.querySelector('.gridBody');
|
||||
if (body) body.focus(); else grid.focus();
|
||||
})()`);
|
||||
}
|
||||
|
||||
// Step 1: Scan visible rows (fast path — no Ctrl+F needed for small lists)
|
||||
const scan = await readVisibleRows();
|
||||
const scanPick = resolveExact(scan.matches);
|
||||
if (scanPick) { await selectRowAndOk(scanPick); return; }
|
||||
if (scan.matches.length > 1) {
|
||||
await dismissTypeDialog();
|
||||
await waitForStable();
|
||||
throw new Error(`selectValue: multiple types match "${typeName}": ${scan.matches.map(m => '"' + m.text + '"').join(', ')}. Specify a more precise type name`);
|
||||
}
|
||||
|
||||
// Step 2: Not in visible rows — Ctrl+F jumps near the match in the large virtual list.
|
||||
await focusGrid();
|
||||
await page.waitForTimeout(300);
|
||||
|
||||
// Ctrl+F to open "Найти" dialog
|
||||
await page.keyboard.press('Control+f');
|
||||
await page.waitForTimeout(1000);
|
||||
|
||||
// Paste search text (focus is on "Что искать" field)
|
||||
await page.keyboard.press('Control+a');
|
||||
await pasteText(typeName);
|
||||
await page.waitForTimeout(300);
|
||||
|
||||
// Find the "Найти" dialog form number (it's > formNum)
|
||||
const findFormNum = await page.evaluate(findChildFormByButtonScript(formNum, 'Find'));
|
||||
|
||||
if (findFormNum === null) {
|
||||
await dismissTypeDialog();
|
||||
await waitForStable();
|
||||
throw new Error('selectValue: Ctrl+F did not open "Найти" dialog in type selection');
|
||||
}
|
||||
|
||||
// Click "Найти" — search is client-side (no server round-trip)
|
||||
await page.click(`#form${findFormNum}_Find`, { force: true });
|
||||
|
||||
// "Найти" positions at the first match; the exact row is at or just below it. Read, and if the
|
||||
// exact match is not yet in view, PageDown a few times (bounded) — virtualised grid, scrollTop
|
||||
// stays 0 but the visible window changes. Poll each window for matches to settle.
|
||||
let resolved = null, lastMatches = [], sawMatches = false;
|
||||
for (let pageStep = 0; pageStep <= 3; pageStep++) {
|
||||
if (pageStep > 0) { await focusGrid(); await page.keyboard.press('PageDown'); }
|
||||
let v = null;
|
||||
for (let w = 0; w < 5; w++) {
|
||||
await page.waitForTimeout(200);
|
||||
v = await readVisibleRows();
|
||||
if (v.matches.length) break;
|
||||
}
|
||||
if (v && v.matches.length) {
|
||||
sawMatches = true;
|
||||
lastMatches = v.matches;
|
||||
resolved = resolveExact(v.matches);
|
||||
if (resolved) break;
|
||||
// matches present but no single exact in this window — scroll to look just below
|
||||
} else if (sawMatches) {
|
||||
break; // scrolled past the matches without finding an exact one
|
||||
}
|
||||
}
|
||||
if (resolved) { await selectRowAndOk(resolved); return; }
|
||||
|
||||
await dismissTypeDialog();
|
||||
await waitForStable();
|
||||
if (!sawMatches) {
|
||||
throw new Error(`selectValue: type "${typeName}" not found in type selection dialog` +
|
||||
`. Visible: ${(scan.visible || []).join(', ')}`);
|
||||
}
|
||||
throw new Error(`selectValue: multiple types match "${typeName}": ${lastMatches.map(m => '"' + m.text + '"').join(', ')}. Specify a more precise type name`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Fill a reference field via clipboard paste + 1C autocomplete.
|
||||
*
|
||||
* Strategy:
|
||||
* 1. Clear field if it has a value (Shift+F4 — native 1C mechanism, no JS errors)
|
||||
* 2. Clipboard paste text (Ctrl+V = trusted event, triggers real 1C autocomplete)
|
||||
* 3. Check editDropDown for autocomplete results → click match or Tab to resolve
|
||||
* 4. Verify result: resolved → ok, not found → clear + error
|
||||
*
|
||||
* Clipboard paste was chosen because:
|
||||
* - Ctrl+V produces trusted browser events that 1C respects for autocomplete
|
||||
* - page.fill() + synthetic keydown/keyup only triggers hints, not real search
|
||||
* - keyboard.type() garbles Cyrillic on some fields
|
||||
*
|
||||
* @returns {{ field, ok?, method?, error?, value?, message?, available? }}
|
||||
*/
|
||||
export async function fillReferenceField(selector, fieldName, value, formNum) {
|
||||
const text = String(value);
|
||||
const escapedSel = selector.replace(/'/g, "\\'");
|
||||
|
||||
// Helper: detect new forms opened above the current one (strict — interactive
|
||||
// elements only; fillReferenceField-specific)
|
||||
const detectNewForm = () => helperDetectNewForm(formNum, { strict: true });
|
||||
|
||||
// Helper: clear the field using Shift+F4 (native 1C mechanism)
|
||||
async function clearField() {
|
||||
try {
|
||||
await page.click(selector, { timeout: 3000 });
|
||||
await page.keyboard.press('Shift+F4');
|
||||
await page.waitForTimeout(300);
|
||||
await page.keyboard.press('Tab');
|
||||
await page.waitForTimeout(300);
|
||||
} catch { /* OK */ }
|
||||
}
|
||||
|
||||
// Helper: check for "not in list" cloud popup (1C shows positioned div with "нет в списке")
|
||||
async function checkNotInListCloud() {
|
||||
return page.evaluate(isNotInListCloudVisibleScript());
|
||||
}
|
||||
|
||||
// 0. Dismiss any leftover error modal from a previous operation
|
||||
await dismissPendingErrors();
|
||||
|
||||
// 0a. Try DLB (DropListButton) first — works cleanly for combobox/enum fields
|
||||
// and also for reference fields that show a dropdown.
|
||||
const inputId = selector.match(/\[id="(.+)"\]/)?.[1];
|
||||
// DLB button ID uses field name without _iN suffix (e.g. form1_Field_DLB, not form1_Field_i0_DLB)
|
||||
const dlbId = inputId.replace(/_i\d+$/, '') + '_DLB';
|
||||
const dlbSelector = `[id="${dlbId}"]`;
|
||||
try {
|
||||
const dlbVisible = await page.evaluate(`document.querySelector('${dlbSelector.replace(/'/g, "\\'")}')?.offsetWidth > 0`);
|
||||
if (dlbVisible) {
|
||||
await page.click(dlbSelector);
|
||||
await page.waitForTimeout(1000);
|
||||
const eddState = await readEdd();
|
||||
if (eddState.visible && eddState.items?.length > 0) {
|
||||
const target = normYo(text.toLowerCase());
|
||||
const candidates = eddState.items.filter(i => !i.name.startsWith('Создать'));
|
||||
let match = candidates.find(i => normYo(i.name.replace(/\s*\([^)]*\)\s*$/, '').toLowerCase()) === target);
|
||||
if (!match) match = candidates.find(i => normYo(i.name.toLowerCase()).includes(target));
|
||||
if (!match) match = candidates.find(i => {
|
||||
const name = normYo(i.name.replace(/\s*\([^)]*\)\s*$/, '').toLowerCase());
|
||||
return name.includes(target) || target.includes(name);
|
||||
});
|
||||
if (match) {
|
||||
await page.mouse.click(match.x, match.y);
|
||||
await waitForStable();
|
||||
await dismissPendingErrors();
|
||||
return { field: fieldName, ok: true, method: 'dropdown',
|
||||
value: match.name.replace(/\s*\([^)]*\)\s*$/, '') };
|
||||
}
|
||||
// No match in DLB dropdown — close and fall through to paste approach
|
||||
await page.keyboard.press('Escape');
|
||||
await page.waitForTimeout(300);
|
||||
} else if (eddState.visible) {
|
||||
// DLB opened a hint popup (no .eddText items) — close it before proceeding
|
||||
await page.keyboard.press('Escape');
|
||||
await page.waitForTimeout(300);
|
||||
}
|
||||
}
|
||||
} catch { /* DLB approach failed — fall through to paste */ }
|
||||
|
||||
// 1. Focus (handle surface/modal overlay from previous interaction)
|
||||
await safeClick(selector, { dismissErrors: true });
|
||||
|
||||
// 2. If field already has a value, clear using Shift+F4 (native 1C mechanism).
|
||||
// This is needed for reference fields — Shift+F4 properly clears the ref link.
|
||||
const currentVal = await page.evaluate(`document.querySelector('${escapedSel}')?.value || ''`);
|
||||
if (currentVal) {
|
||||
await page.keyboard.press('Shift+F4');
|
||||
await page.waitForTimeout(500);
|
||||
await page.keyboard.press('Tab');
|
||||
await page.waitForTimeout(500);
|
||||
// Refocus
|
||||
await page.click(selector);
|
||||
}
|
||||
|
||||
// 3. Paste text via clipboard (trusted event → triggers real 1C autocomplete)
|
||||
await pasteText(text);
|
||||
await page.waitForTimeout(2000);
|
||||
|
||||
// 4. Check editDropDown for autocomplete suggestions
|
||||
const eddState = await readEdd();
|
||||
|
||||
if (eddState.visible && eddState.items?.length > 0) {
|
||||
const target = normYo(text.toLowerCase());
|
||||
// Separate real matches from "Создать:" items
|
||||
const candidates = eddState.items.filter(i => !i.name.startsWith('Создать'));
|
||||
|
||||
if (candidates.length > 0) {
|
||||
// Find best match (items have format "Name (Code)" — match against name part)
|
||||
let match = candidates.find(i => {
|
||||
const name = normYo(i.name.replace(/\s*\([^)]*\)\s*$/, '').toLowerCase());
|
||||
return name === target;
|
||||
});
|
||||
if (!match) match = candidates.find(i => normYo(i.name.toLowerCase()).includes(target));
|
||||
if (!match) match = candidates.find(i => {
|
||||
const name = normYo(i.name.replace(/\s*\([^)]*\)\s*$/, '').toLowerCase());
|
||||
return name.includes(target) || target.includes(name);
|
||||
});
|
||||
|
||||
if (match) {
|
||||
await page.mouse.click(match.x, match.y);
|
||||
await waitForStable();
|
||||
await dismissPendingErrors(); // business logic errors (e.g. СПАРК) may appear async
|
||||
return { field: fieldName, ok: true, method: 'dropdown',
|
||||
value: match.name.replace(/\s*\([^)]*\)\s*$/, '') };
|
||||
}
|
||||
// Candidates exist but none match — report them
|
||||
await page.keyboard.press('Escape');
|
||||
await page.waitForTimeout(300);
|
||||
await clearField();
|
||||
return { field: fieldName, error: 'not_matched',
|
||||
available: candidates.map(i => i.name.replace(/\s*\([^)]*\)\s*$/, '')) };
|
||||
}
|
||||
|
||||
// Only "Создать:" items — no existing matches
|
||||
await page.keyboard.press('Escape');
|
||||
await page.waitForTimeout(300);
|
||||
await clearField();
|
||||
return { field: fieldName, error: 'not_found',
|
||||
message: 'No existing values match "' + text + '"' };
|
||||
}
|
||||
|
||||
// 4b. No edd — check for "not in list" cloud that may have appeared during paste
|
||||
if (await checkNotInListCloud()) {
|
||||
await page.keyboard.press('Escape');
|
||||
await page.waitForTimeout(300);
|
||||
await clearField();
|
||||
return { field: fieldName, error: 'not_found',
|
||||
message: 'Value "' + text + '" not found (not in list)' };
|
||||
}
|
||||
|
||||
// 5. No edd at all — press Tab to trigger direct resolve
|
||||
await page.keyboard.press('Tab');
|
||||
await waitForStable();
|
||||
await dismissPendingErrors();
|
||||
|
||||
// 5x. Check for "not in list" cloud popup after Tab
|
||||
if (await checkNotInListCloud()) {
|
||||
await page.keyboard.press('Escape');
|
||||
await page.waitForTimeout(300);
|
||||
await clearField();
|
||||
return { field: fieldName, error: 'not_found',
|
||||
message: 'Value "' + text + '" not found (not in list)' };
|
||||
}
|
||||
|
||||
// 5a. New form opened? (creation form = value not found)
|
||||
const newForm = await detectNewForm();
|
||||
if (newForm !== null) {
|
||||
await page.keyboard.press('Escape');
|
||||
await waitForStable();
|
||||
await clearField();
|
||||
return { field: fieldName, error: 'not_found',
|
||||
message: 'Value "' + text + '" not found' };
|
||||
}
|
||||
|
||||
// 5b. Dropdown after Tab?
|
||||
const popup = await page.evaluate(readSubmenuScript());
|
||||
if (Array.isArray(popup) && popup.length > 0) {
|
||||
const realItems = popup.filter(i => !i.name.startsWith('Создать'));
|
||||
await page.keyboard.press('Escape');
|
||||
await page.waitForTimeout(300);
|
||||
await clearField();
|
||||
if (realItems.length > 0) {
|
||||
return { field: fieldName, error: 'ambiguous',
|
||||
message: 'Multiple matches for "' + text + '"',
|
||||
available: realItems.map(i => i.name.replace(/\s*\([^)]*\)\s*$/, '')) };
|
||||
}
|
||||
return { field: fieldName, error: 'not_found',
|
||||
message: 'Value "' + text + '" not found' };
|
||||
}
|
||||
|
||||
// 5c. Check final value
|
||||
const finalVal = await page.evaluate(`document.querySelector('${escapedSel}')?.value || ''`);
|
||||
if (!finalVal) {
|
||||
// 6. Last resort: try F4 to open selection form and pick from there
|
||||
try {
|
||||
await page.click(selector);
|
||||
await page.waitForTimeout(300);
|
||||
} catch { /* OK — field may be unfocused */ }
|
||||
await page.keyboard.press('F4');
|
||||
await page.waitForTimeout(ACTION_WAIT);
|
||||
|
||||
const selFormNum = await detectNewForm();
|
||||
if (selFormNum !== null) {
|
||||
const pickResult = await pickFromSelectionForm(selFormNum, fieldName, text, formNum);
|
||||
if (pickResult.ok) return pickResult;
|
||||
// pickFromSelectionForm already closed the form on error
|
||||
}
|
||||
|
||||
return { field: fieldName, error: 'not_found',
|
||||
message: 'Value "' + text + '" not found (field is empty)' };
|
||||
}
|
||||
|
||||
return { field: fieldName, ok: true, method: 'typeahead', value: finalVal };
|
||||
}
|
||||
|
||||
/**
|
||||
* Select a value from a reference field (compound operation).
|
||||
* Handles three patterns:
|
||||
* A) DLB opens an inline dropdown popup — click matching item
|
||||
* B) DLB opens dropdown with history — click "Показать все" or F4 to open selection form
|
||||
* C) DLB opens a separate selection form directly — search + dblclick in grid
|
||||
*/
|
||||
export async function selectValue(fieldName, searchText, { type } = {}) {
|
||||
ensureConnected();
|
||||
await dismissPendingErrors();
|
||||
const formNum = await page.evaluate(detectFormScript());
|
||||
if (formNum === null) throw new Error(`selectValue: no form found`);
|
||||
|
||||
// Detect any new form opened above this one (broad — includes type dialogs).
|
||||
// Hoisted to the top so the composite-type branch can call it before its
|
||||
// original declaration site further below.
|
||||
const detectNewForm = () => helperDetectNewForm(formNum);
|
||||
|
||||
// 1. Find DLB button (fallback to CB — ERP uses Choose Button instead of DLB for some fields)
|
||||
let btn = await page.evaluate(findFieldButtonScript(formNum, fieldName, 'DLB'));
|
||||
if (btn?.error === 'button_not_found') {
|
||||
btn = await page.evaluate(findFieldButtonScript(formNum, fieldName, 'CB'));
|
||||
}
|
||||
if (btn?.error) return btn;
|
||||
if (highlightMode) try { await highlight(fieldName); await page.waitForTimeout(500); await unhighlight(); } catch {}
|
||||
try {
|
||||
|
||||
// === CLEAR FIELD if searchText is empty/null ===
|
||||
if (!searchText && searchText !== 0) {
|
||||
const inputId = await findFieldInputId(formNum, btn.fieldName);
|
||||
if (inputId) {
|
||||
await page.click(`[id="${inputId}"]`);
|
||||
await page.waitForTimeout(200);
|
||||
await page.keyboard.press('Shift+F4');
|
||||
await page.waitForTimeout(300);
|
||||
await page.keyboard.press('Tab');
|
||||
await waitForStable();
|
||||
}
|
||||
if (highlightMode) try { await unhighlight(); } catch {}
|
||||
return returnFormState({ selected: { field: fieldName, search: null, method: 'clear' } });
|
||||
}
|
||||
|
||||
// === COMPOSITE TYPE HANDLING ===
|
||||
// When `type` is specified, clear the field first to reset cached type,
|
||||
// then open type selection dialog, pick the type, then pick the value.
|
||||
if (type) {
|
||||
// Find and focus the field input
|
||||
const inputId = await findFieldInputId(formNum, btn.fieldName);
|
||||
if (!inputId) throw new Error(`selectValue: field "${btn.fieldName}" input not found`);
|
||||
|
||||
// Clear cached type + value with Shift+F4
|
||||
await page.click(`[id="${inputId}"]`);
|
||||
await page.waitForTimeout(300);
|
||||
await page.keyboard.press('Shift+F4');
|
||||
await page.waitForTimeout(500);
|
||||
|
||||
// Re-focus and press F4 to open type selection dialog
|
||||
await page.click(`[id="${inputId}"]`);
|
||||
await page.waitForTimeout(300);
|
||||
await page.keyboard.press('F4');
|
||||
await page.waitForTimeout(ACTION_WAIT);
|
||||
await waitForStable(formNum);
|
||||
|
||||
const newFormNum = await detectNewForm();
|
||||
if (newFormNum === null) {
|
||||
throw new Error(`selectValue: F4 for composite field "${btn.fieldName}" did not open type selection dialog`);
|
||||
}
|
||||
|
||||
if (await isTypeDialog(newFormNum)) {
|
||||
// Pick type from the dialog
|
||||
await pickFromTypeDialog(newFormNum, type);
|
||||
await waitForStable(newFormNum);
|
||||
|
||||
// After type selection, the actual selection form should open
|
||||
const selFormNum = await detectSelectionForm();
|
||||
if (selFormNum === null) {
|
||||
throw new Error(`selectValue: after selecting type "${type}", no selection form opened for "${btn.fieldName}"`);
|
||||
}
|
||||
|
||||
const pickResult = await pickFromSelectionForm(selFormNum, btn.fieldName, searchText || '', formNum);
|
||||
const state = await getFormState();
|
||||
state.selected = { field: btn.fieldName, search: searchText || null, type, method: 'form' };
|
||||
if (pickResult.error) state.selected.error = pickResult.error;
|
||||
if (pickResult.message) state.selected.message = pickResult.message;
|
||||
const err = await checkForErrors();
|
||||
if (err) state.errors = err;
|
||||
return state;
|
||||
} else {
|
||||
// Not a type dialog — field is not composite type, proceed with normal selection
|
||||
const pickResult = await pickFromSelectionForm(newFormNum, btn.fieldName, searchText || '', formNum);
|
||||
const state = await getFormState();
|
||||
state.selected = { field: btn.fieldName, search: searchText || null, method: 'form' };
|
||||
if (pickResult.error) state.selected.error = pickResult.error;
|
||||
if (pickResult.message) state.selected.message = pickResult.message;
|
||||
const err = await checkForErrors();
|
||||
if (err) state.errors = err;
|
||||
return state;
|
||||
}
|
||||
}
|
||||
// === END COMPOSITE TYPE HANDLING ===
|
||||
|
||||
// Auto-enable DCS checkbox if resolved via label
|
||||
if (btn.dcsCheckbox) {
|
||||
const cbSel = `[id="${btn.dcsCheckbox.inputId}"]`;
|
||||
const isChecked = await page.$eval(cbSel, el =>
|
||||
el.classList.contains('checked') || el.classList.contains('checkboxOn') || el.classList.contains('select'));
|
||||
if (!isChecked) { await page.click(cbSel); await waitForStable(); }
|
||||
}
|
||||
|
||||
// Helper: detect selection form (form number > formNum, strict mode)
|
||||
async function detectSelectionForm() {
|
||||
return helperDetectNewForm(formNum, { strict: true });
|
||||
}
|
||||
|
||||
// detectNewForm is hoisted at the top of selectValue (see above).
|
||||
|
||||
// Helper: open selection form and pick value
|
||||
async function openFormAndPick() {
|
||||
await waitForStable(formNum);
|
||||
const selFormNum = await detectSelectionForm();
|
||||
if (selFormNum !== null) {
|
||||
const pickResult = await pickFromSelectionForm(selFormNum, btn.fieldName, searchText || '', formNum);
|
||||
const selected = { field: btn.fieldName, search: searchText || null, method: 'form' };
|
||||
if (pickResult.error) selected.error = pickResult.error;
|
||||
if (pickResult.message) selected.message = pickResult.message;
|
||||
return returnFormState({ selected });
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
// Locals → dom-scripts in helpers.mjs (see clickEddItemViaDispatch / clickShowAllInEdd)
|
||||
const clickEddItem = clickEddItemViaDispatch;
|
||||
const clickShowAll = clickShowAllInEdd;
|
||||
|
||||
// 2. Click DLB (handle funcPanel / surface overlay intercept)
|
||||
const dlbSel = `[id="${btn.buttonId}"]`;
|
||||
await safeClick(dlbSel, { timeout: 5000 });
|
||||
await page.waitForTimeout(ACTION_WAIT);
|
||||
|
||||
// 3A. Check if a dropdown popup appeared (inline quick selection)
|
||||
const popupItems = await page.evaluate(readSubmenuScript());
|
||||
if (Array.isArray(popupItems) && popupItems.length > 0) {
|
||||
const regularItems = popupItems.filter(i => i.kind !== 'showAll');
|
||||
const showAllItem = popupItems.find(i => i.kind === 'showAll');
|
||||
|
||||
if (searchText) {
|
||||
const target = normYo(searchText.toLowerCase());
|
||||
// Try to find match among regular dropdown items
|
||||
let match = regularItems.find(i => normYo(i.name.toLowerCase()) === target);
|
||||
if (!match) match = regularItems.find(i => normYo(i.name.toLowerCase()).includes(target));
|
||||
if (!match) match = regularItems.find(i => {
|
||||
const name = normYo(i.name.replace(/\s*\([^)]*\)\s*$/, '').toLowerCase());
|
||||
return name === target || name.includes(target) || target.includes(name);
|
||||
});
|
||||
|
||||
if (match) {
|
||||
// Click via evaluate to bypass div.surface overlay
|
||||
await clickEddItem(match.name);
|
||||
await waitForStable();
|
||||
return returnFormState({ selected: { field: btn.fieldName, search: searchText, method: 'dropdown' } });
|
||||
}
|
||||
|
||||
// No match in dropdown — try "Показать все" to open selection form
|
||||
if (showAllItem) {
|
||||
await clickShowAll();
|
||||
const formResult = await openFormAndPick();
|
||||
if (formResult) return formResult;
|
||||
}
|
||||
|
||||
// No "Показать все" — close dropdown, try F4
|
||||
await page.keyboard.press('Escape');
|
||||
await page.waitForTimeout(500);
|
||||
|
||||
// Focus the field input and press F4 to open selection form
|
||||
const inputId = await findFieldInputId(formNum, btn.fieldName);
|
||||
if (inputId) {
|
||||
await page.click(`[id="${inputId}"]`);
|
||||
await page.waitForTimeout(300);
|
||||
}
|
||||
await page.keyboard.press('F4');
|
||||
await page.waitForTimeout(ACTION_WAIT);
|
||||
|
||||
const formResult = await openFormAndPick();
|
||||
if (formResult) return formResult;
|
||||
|
||||
// Still nothing — report available items from original dropdown
|
||||
throw new Error(`selectValue: "${searchText}" not found for field "${btn.fieldName}". Available: ${regularItems.map(i => i.name).join(', ') || 'none'}`);
|
||||
}
|
||||
|
||||
// No search text — click first regular item
|
||||
if (regularItems.length > 0) {
|
||||
await clickEddItem(regularItems[0].name);
|
||||
await waitForStable();
|
||||
return returnFormState({ selected: { field: btn.fieldName, search: null, picked: regularItems[0].name, method: 'dropdown' } });
|
||||
}
|
||||
}
|
||||
|
||||
// 3B. Check if a new selection form opened directly (use broad detection to also catch type dialogs)
|
||||
const selFormNum = await detectNewForm();
|
||||
if (selFormNum !== null) {
|
||||
// Auto-detect type selection dialog when `type` was not specified
|
||||
if (await isTypeDialog(selFormNum)) {
|
||||
await page.keyboard.press('Escape');
|
||||
await waitForStable();
|
||||
throw new Error(`selectValue: field "${btn.fieldName}" opened a type selection dialog — this is a composite-type field. Specify the type: selectValue('${btn.fieldName}', '${searchText || ''}', { type: 'ИмяТипа' })`);
|
||||
}
|
||||
const pickResult = await pickFromSelectionForm(selFormNum, btn.fieldName, searchText || '', formNum);
|
||||
const selected = { field: btn.fieldName, search: searchText || null, method: 'form' };
|
||||
if (pickResult.error) selected.error = pickResult.error;
|
||||
if (pickResult.message) selected.message = pickResult.message;
|
||||
return returnFormState({ selected });
|
||||
}
|
||||
|
||||
// 3C. Neither popup nor form — try F4 as last resort
|
||||
await page.keyboard.press('Escape');
|
||||
await page.waitForTimeout(300);
|
||||
|
||||
const inputId = await findFieldInputId(formNum, btn.fieldName);
|
||||
if (inputId) {
|
||||
await page.click(`[id="${inputId}"]`);
|
||||
await page.waitForTimeout(300);
|
||||
}
|
||||
await page.keyboard.press('F4');
|
||||
await page.waitForTimeout(ACTION_WAIT);
|
||||
|
||||
const formResult = await openFormAndPick();
|
||||
if (formResult) return formResult;
|
||||
|
||||
throw new Error(`selectValue: DLB click for "${btn.fieldName}" did not open a popup or selection form`);
|
||||
|
||||
} finally { if (highlightMode) try { await unhighlight(); } catch {} }
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
// web-test engine/forms/state v1.17 — central form-state reader.
|
||||
// Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
|
||||
//
|
||||
// getFormState — the canonical "what's on the screen right now" call. Combines:
|
||||
// 1. DOM script (getFormStateScript) → form structure (fields, buttons, tables, openForms, ...)
|
||||
// 2. checkForErrors → state.errors + state.confirmation hint
|
||||
// 3. detectPlatformDialogs → state.platformDialogs (About / Support Info / Error Report)
|
||||
//
|
||||
// Returned by virtually every action-function as the "after" snapshot.
|
||||
|
||||
import { page, ensureConnected } from '../core/state.mjs';
|
||||
import { getFormStateScript } from '../../dom.mjs';
|
||||
import { checkForErrors, detectPlatformDialogs } from '../core/errors.mjs';
|
||||
|
||||
/** Read current form state. Single evaluate call via combined script. */
|
||||
export async function getFormState() {
|
||||
ensureConnected();
|
||||
const state = await page.evaluate(getFormStateScript());
|
||||
const err = await checkForErrors();
|
||||
if (err) {
|
||||
state.errors = err;
|
||||
if (err.confirmation) {
|
||||
state.confirmation = err.confirmation;
|
||||
state.hint = 'Call web_click with a button name (e.g. "Да", "Нет", "Отмена") to respond';
|
||||
}
|
||||
}
|
||||
// Detect platform-level dialogs (About, Support Info, Error Report)
|
||||
// These are NOT 1C forms — invisible to detectForms() and not closeable via Escape.
|
||||
const pd = await detectPlatformDialogs();
|
||||
if (pd.length) state.platformDialogs = pd;
|
||||
return state;
|
||||
}
|
||||
@@ -0,0 +1,253 @@
|
||||
// web-test nav/navigation v1.17 — Section navigation, openCommand, switchTab, navigateLink (Shift+F11), openFile.
|
||||
// Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
|
||||
|
||||
import {
|
||||
page, ensureConnected, highlightMode, resolveProjectPath,
|
||||
} from '../core/state.mjs';
|
||||
import {
|
||||
readSectionsScript, readTabsScript, readCommandsScript,
|
||||
navigateSectionScript, openCommandScript, switchTabScript,
|
||||
detectFormScript,
|
||||
} from '../../dom.mjs';
|
||||
import { dismissPendingErrors, checkForErrors } from '../core/errors.mjs';
|
||||
import { waitForStable, waitForCondition } from '../core/wait.mjs';
|
||||
import { highlight, unhighlight } from '../recording/highlight.mjs';
|
||||
import { returnFormState } from '../core/helpers.mjs';
|
||||
// Static import — ESM cycle that resolves at call time.
|
||||
import { pasteText } from '../core/clipboard.mjs';
|
||||
import { getFormState } from '../forms/state.mjs';
|
||||
|
||||
/**
|
||||
* Get current page state: active section, tabs.
|
||||
* Combined into a single evaluate call.
|
||||
*/
|
||||
export async function getPageState() {
|
||||
ensureConnected();
|
||||
const { sections, tabs } = await page.evaluate(`({
|
||||
sections: ${readSectionsScript()},
|
||||
tabs: ${readTabsScript()}
|
||||
})`);
|
||||
const activeSection = sections.find(s => s.active)?.name || null;
|
||||
const activeTab = tabs.find(t => t.active)?.name || null;
|
||||
return { activeSection, activeTab, sections, tabs };
|
||||
}
|
||||
|
||||
/** Read section panel + commands in a single evaluate call. */
|
||||
export async function getSections() {
|
||||
ensureConnected();
|
||||
const { sections, commands } = await page.evaluate(`({
|
||||
sections: ${readSectionsScript()},
|
||||
commands: ${readCommandsScript()}
|
||||
})`);
|
||||
const activeSection = sections.find(s => s.active)?.name || null;
|
||||
return { activeSection, sections, commands };
|
||||
}
|
||||
|
||||
/** Navigate to a section by name. Returns new state with commands. */
|
||||
export async function navigateSection(name) {
|
||||
ensureConnected();
|
||||
await dismissPendingErrors();
|
||||
if (highlightMode) try { await highlight(name); await page.waitForTimeout(500); await unhighlight(); } catch {}
|
||||
const result = await page.evaluate(navigateSectionScript(name));
|
||||
if (result?.error) {
|
||||
const avail = result.available?.filter(Boolean);
|
||||
if (avail?.length === 0) throw new Error(`navigateSection: "${name}" not found. Section panel is in icon-only mode — text labels are hidden. Switch to "Text" or "Picture and text" display mode in 1C settings (View → Section Panel → Display Mode)`);
|
||||
throw new Error(`navigateSection: "${name}" not found. Available: ${avail?.join(', ') || 'none'}`);
|
||||
}
|
||||
|
||||
await waitForStable();
|
||||
const { sections, commands } = await page.evaluate(`({
|
||||
sections: ${readSectionsScript()},
|
||||
commands: ${readCommandsScript()}
|
||||
})`);
|
||||
return returnFormState({ navigated: result, sections, commands });
|
||||
}
|
||||
|
||||
/** Read commands of the current section. */
|
||||
export async function getCommands() {
|
||||
ensureConnected();
|
||||
return await page.evaluate(readCommandsScript());
|
||||
}
|
||||
|
||||
/** Open a command from function panel by name. Returns new form state. */
|
||||
export async function openCommand(name) {
|
||||
ensureConnected();
|
||||
await dismissPendingErrors();
|
||||
if (highlightMode) try { await highlight(name); await page.waitForTimeout(500); await unhighlight(); } catch {}
|
||||
const formBefore = await page.evaluate(detectFormScript());
|
||||
const result = await page.evaluate(openCommandScript(name));
|
||||
if (result?.error) throw new Error(`openCommand: "${name}" not found. Available: ${result.available?.join(', ') || 'none'}`);
|
||||
|
||||
await waitForStable(formBefore);
|
||||
return await returnFormState();
|
||||
}
|
||||
|
||||
/** Switch to an open tab by name (fuzzy match). Returns updated form state. */
|
||||
export async function switchTab(name) {
|
||||
ensureConnected();
|
||||
const result = await page.evaluate(switchTabScript(name));
|
||||
if (result?.error) throw new Error(`switchTab: "${name}" not found. Available: ${result.available?.join(', ') || 'none'}`);
|
||||
await waitForStable();
|
||||
return returnFormState();
|
||||
}
|
||||
|
||||
// English → Russian metadata type mapping for e1cib navigation links
|
||||
const E1CIB_TYPE_MAP = {
|
||||
'catalog': 'Справочник', 'catalogs': 'Справочник',
|
||||
'document': 'Документ', 'documents': 'Документ',
|
||||
'commonmodule': 'ОбщийМодуль',
|
||||
'enum': 'Перечисление', 'enums': 'Перечисление',
|
||||
'dataprocessor': 'Обработка', 'dataprocessors': 'Обработка',
|
||||
'report': 'Отчет', 'reports': 'Отчет',
|
||||
'accumulationregister': 'РегистрНакопления',
|
||||
'informationregister': 'РегистрСведений',
|
||||
'accountingregister': 'РегистрБухгалтерии',
|
||||
'calculationregister': 'РегистрРасчета',
|
||||
'chartofaccounts': 'ПланСчетов',
|
||||
'chartofcharacteristictypes': 'ПланВидовХарактеристик',
|
||||
'chartofcalculationtypes': 'ПланВидовРасчета',
|
||||
'businessprocess': 'БизнесПроцесс',
|
||||
'task': 'Задача',
|
||||
'exchangeplan': 'ПланОбмена',
|
||||
'constant': 'Константа',
|
||||
};
|
||||
|
||||
// Types that open via e1cib/app/ (reports and data processors have their own app forms)
|
||||
const E1CIB_APP_TYPES = new Set(['Отчет', 'Обработка']);
|
||||
|
||||
function normalizeE1cibUrl(url) {
|
||||
// Already a full e1cib link
|
||||
if (url.startsWith('e1cib/')) return url;
|
||||
// "ТипОбъекта.Имя" or "EnglishType.Имя" — translate type, pick list/ or app/ prefix
|
||||
const dot = url.indexOf('.');
|
||||
if (dot > 0) {
|
||||
const typePart = url.substring(0, dot);
|
||||
const namePart = url.substring(dot + 1);
|
||||
const ruType = E1CIB_TYPE_MAP[typePart.toLowerCase()] || typePart;
|
||||
const prefix = E1CIB_APP_TYPES.has(ruType) ? 'e1cib/app' : 'e1cib/list';
|
||||
return `${prefix}/${ruType}.${namePart}`;
|
||||
}
|
||||
return `e1cib/list/${url}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Open an external data processor or report (EPF/ERF) via File → Open menu.
|
||||
* Handles the security confirmation dialog on first open.
|
||||
* @param {string} filePath - path to EPF/ERF file (absolute or relative to cwd)
|
||||
* @returns {Promise<object>} form state of the opened processor/report
|
||||
*/
|
||||
export async function openFile(filePath) {
|
||||
ensureConnected();
|
||||
await dismissPendingErrors();
|
||||
const absPath = resolveProjectPath(filePath.replace(/\\/g, '/'));
|
||||
|
||||
const MAX_ATTEMPTS = 2; // 1st may trigger security dialog, 2nd is the real open
|
||||
for (let attempt = 0; attempt < MAX_ATTEMPTS; attempt++) {
|
||||
const formBefore = await page.evaluate(detectFormScript());
|
||||
|
||||
// 1. Ctrl+O opens 1C's "Выбор файлов" dialog
|
||||
await page.keyboard.press('Control+o');
|
||||
|
||||
// 2. Wait for the file selection dialog
|
||||
const dialogOk = await waitForCondition(`(() => {
|
||||
const ok = document.querySelector('#fileSelectDialogOk');
|
||||
return ok && ok.offsetWidth > 0 ? true : false;
|
||||
})()`, 3000);
|
||||
if (!dialogOk) throw new Error("File selection dialog did not open (Ctrl+O)");
|
||||
|
||||
// 3. Click "выберите с диска" to trigger the native OS file picker
|
||||
let fileChooser;
|
||||
try {
|
||||
[fileChooser] = await Promise.all([
|
||||
page.waitForEvent('filechooser', { timeout: 5000 }),
|
||||
page.click('a.underline.pointer'),
|
||||
]);
|
||||
} catch (e) {
|
||||
// Try closing the dialog before throwing
|
||||
await page.keyboard.press('Escape');
|
||||
throw new Error(`File chooser did not appear: ${e.message}`);
|
||||
}
|
||||
|
||||
// 4. Set the file path and click OK
|
||||
await fileChooser.setFiles(absPath);
|
||||
await page.waitForTimeout(500);
|
||||
await page.click('#fileSelectDialogOk');
|
||||
await waitForStable(formBefore);
|
||||
|
||||
// 5. Check for security dialog
|
||||
const err = await checkForErrors();
|
||||
if (err?.confirmation) {
|
||||
// Security confirmation — click the positive button (Продолжить/Да/OK)
|
||||
const positiveBtn = err.confirmation.buttons.find(b =>
|
||||
/продолжить|да|ok|yes|открыть/i.test(b)
|
||||
) || err.confirmation.buttons[0];
|
||||
if (positiveBtn) {
|
||||
const btns = await page.$$(`#form${err.confirmation.formNum}_container a.press.pressButton`);
|
||||
for (const b of btns) {
|
||||
const txt = (await b.textContent())?.trim();
|
||||
if (txt === positiveBtn) { await b.click(); break; }
|
||||
}
|
||||
await waitForStable(formBefore);
|
||||
}
|
||||
// After confirmation, check if EPF form appeared or a follow-up dialog showed.
|
||||
// Check form change FIRST — avoids confusing a small EPF form with a modal dialog.
|
||||
const formAfter = await page.evaluate(detectFormScript());
|
||||
if (formAfter != null && formAfter !== formBefore) {
|
||||
// New form appeared — but is it the EPF or an informational dialog?
|
||||
// Informational "re-open" dialogs are tiny (< 20 elements).
|
||||
const elCount = await page.evaluate(`document.querySelectorAll('[id^="form${formAfter}_"]').length`);
|
||||
if (elCount < 20) {
|
||||
// Likely an info dialog — check and dismiss
|
||||
const err2 = await checkForErrors();
|
||||
if (err2?.modal) {
|
||||
await dismissPendingErrors();
|
||||
await waitForStable(formBefore);
|
||||
continue; // retry open cycle
|
||||
}
|
||||
}
|
||||
// It's the real EPF form
|
||||
return returnFormState({ opened: { file: absPath, attempt: attempt + 1 } });
|
||||
}
|
||||
// Form didn't appear — retry
|
||||
continue;
|
||||
}
|
||||
|
||||
// No security dialog — check if form appeared
|
||||
if (err?.modal) {
|
||||
throw new Error(`Error opening file: ${err.modal.message}`);
|
||||
}
|
||||
const formAfter = await page.evaluate(detectFormScript());
|
||||
if (formAfter != null && formAfter !== formBefore) {
|
||||
const state = await getFormState();
|
||||
state.opened = { file: absPath, attempt: attempt + 1 };
|
||||
return state;
|
||||
}
|
||||
}
|
||||
|
||||
throw new Error(`Form did not open after ${MAX_ATTEMPTS} attempts for: ${absPath}`);
|
||||
}
|
||||
|
||||
/** Navigate to a 1C navigation link via Shift+F11 dialog. Returns new form state. */
|
||||
export async function navigateLink(url) {
|
||||
ensureConnected();
|
||||
await dismissPendingErrors();
|
||||
const link = normalizeE1cibUrl(url);
|
||||
const formBefore = await page.evaluate(detectFormScript());
|
||||
|
||||
// Copy link to clipboard, press Shift+F11 (opens "Go to link" dialog with clipboard content)
|
||||
await pasteText(link, { confirm: 'Shift+F11', postDelay: 200 });
|
||||
await waitForStable();
|
||||
|
||||
// Click "Перейти" in the navigation dialog
|
||||
const dialog = await page.evaluate(detectFormScript());
|
||||
if (dialog != null && dialog !== formBefore) {
|
||||
const btns = await page.$$(`#form${dialog}_container a.press`);
|
||||
for (const b of btns) {
|
||||
const txt = (await b.textContent())?.trim();
|
||||
if (txt === 'Перейти') { await b.click(); break; }
|
||||
}
|
||||
}
|
||||
|
||||
await waitForStable(formBefore);
|
||||
return await returnFormState();
|
||||
}
|
||||
@@ -0,0 +1,292 @@
|
||||
// web-test recording/captions v1.17 — Overlay primitives: captions, title slides, image overlays.
|
||||
// Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
|
||||
|
||||
import { existsSync as fsExistsSync, readFileSync } from 'fs';
|
||||
import { extname } from 'path';
|
||||
import {
|
||||
page, recorder, lastCaptions, ensureConnected, resolveProjectPath,
|
||||
} from '../core/state.mjs';
|
||||
|
||||
/**
|
||||
* Show a text caption overlay on the page (visible in recording).
|
||||
* Calling again updates the text without creating a new element.
|
||||
* @param {string} text — caption text
|
||||
* @param {object} [opts]
|
||||
* @param {'top'|'bottom'} [opts.position='bottom'] — vertical position
|
||||
* @param {number} [opts.fontSize=24] — font size in pixels
|
||||
* @param {string} [opts.background='rgba(0,0,0,0.7)'] — background color
|
||||
* @param {string} [opts.color='#fff'] — text color
|
||||
* @param {string|false} [opts.speech] — TTS narration text. Omit to use displayed text,
|
||||
* pass a string for custom narration, or false to skip narration for this caption.
|
||||
*/
|
||||
export async function showCaption(text, opts = {}) {
|
||||
ensureConnected();
|
||||
|
||||
// Collect caption for TTS narration if recording
|
||||
let smartWaitMs = 0;
|
||||
if (recorder && (text.trim() || typeof opts.speech === 'string') && opts.speech !== false) {
|
||||
const speech = typeof opts.speech === 'string' ? opts.speech : text;
|
||||
// Use video timeline position (accounts for frame duplication) instead of wall-clock
|
||||
recorder.captions.push({ text: text || speech, speech, time: Math.round(recorder.videoTimeMs), ...(opts.voice ? { voice: opts.voice } : {}) });
|
||||
// Estimate TTS duration and wait so the video has enough screen time for voiceover
|
||||
smartWaitMs = Math.max(2000, speech.length * (recorder.speechRate || 70));
|
||||
}
|
||||
const position = opts.position || 'bottom';
|
||||
const fontSize = opts.fontSize || 24;
|
||||
const bg = opts.background || 'rgba(0,0,0,0.7)';
|
||||
const color = opts.color || '#fff';
|
||||
|
||||
await page.evaluate(({ text, position, fontSize, bg, color }) => {
|
||||
let el = document.getElementById('__web_test_caption');
|
||||
if (!el) {
|
||||
el = document.createElement('div');
|
||||
el.id = '__web_test_caption';
|
||||
el.style.cssText = `
|
||||
position: fixed; left: 0; right: 0; z-index: 99999;
|
||||
text-align: center; padding: 12px 24px;
|
||||
font-family: Arial, sans-serif; pointer-events: none;
|
||||
`;
|
||||
document.body.appendChild(el);
|
||||
}
|
||||
el.style[position === 'top' ? 'top' : 'bottom'] = '20px';
|
||||
el.style[position === 'top' ? 'bottom' : 'top'] = 'auto';
|
||||
el.style.fontSize = fontSize + 'px';
|
||||
el.style.background = bg;
|
||||
el.style.color = color;
|
||||
el.textContent = text;
|
||||
}, { text, position, fontSize, bg, color });
|
||||
|
||||
// Smart TTS wait: pause for estimated speech duration so video has enough screen time.
|
||||
// Split into chunks and flush frames periodically — CDP doesn't send screencast frames
|
||||
// for static pages, so we must write duplicate frames to keep video timeline in sync.
|
||||
if (smartWaitMs > 0) {
|
||||
let remaining = smartWaitMs;
|
||||
while (remaining > 0) {
|
||||
const chunk = Math.min(remaining, 1000);
|
||||
await page.waitForTimeout(chunk);
|
||||
remaining -= chunk;
|
||||
if (recorder?._flushFrames) recorder._flushFrames();
|
||||
}
|
||||
recorder.captionCredit = { waitedMs: smartWaitMs, at: Date.now() };
|
||||
}
|
||||
}
|
||||
|
||||
/** Remove the caption overlay from the page. */
|
||||
export async function hideCaption() {
|
||||
ensureConnected();
|
||||
await page.evaluate(() => {
|
||||
const el = document.getElementById('__web_test_caption');
|
||||
if (el) el.remove();
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Get captions collected during the current or last recording.
|
||||
* @returns {Array<{text: string, speech: string, time: number}>}
|
||||
*/
|
||||
export function getCaptions() {
|
||||
if (recorder) return [...recorder.captions];
|
||||
return [...lastCaptions];
|
||||
}
|
||||
|
||||
/**
|
||||
* Show a full-screen title slide overlay (for video recordings).
|
||||
* Repeated calls update the content. Use hideTitleSlide() to remove.
|
||||
* @param {string} text Title text (\n → line break)
|
||||
* @param {object} [opts]
|
||||
* @param {string} [opts.subtitle] Smaller text below the title
|
||||
* @param {string} [opts.background] CSS background (default: dark gradient)
|
||||
* @param {string} [opts.color] Text color (default: '#fff')
|
||||
* @param {number} [opts.fontSize] Title font size in px (default: 36)
|
||||
*/
|
||||
export async function showTitleSlide(text, opts = {}) {
|
||||
ensureConnected();
|
||||
const {
|
||||
subtitle = '',
|
||||
background = 'linear-gradient(135deg, #1a1a2e 0%, #16213e 50%, #0f3460 100%)',
|
||||
color = '#fff',
|
||||
fontSize = 36,
|
||||
speech,
|
||||
} = opts;
|
||||
|
||||
// Collect caption for TTS narration if recording
|
||||
let smartWaitMs = 0;
|
||||
if (recorder && speech && speech !== false) {
|
||||
const captionText = typeof speech === 'string' ? speech : text.replace(/\n/g, ' ');
|
||||
if (captionText) {
|
||||
recorder.captions.push({ text: captionText, speech: captionText, time: Math.round(recorder.videoTimeMs), ...(opts.voice ? { voice: opts.voice } : {}) });
|
||||
smartWaitMs = Math.max(2000, captionText.length * (recorder.speechRate || 70));
|
||||
}
|
||||
}
|
||||
|
||||
await page.evaluate(({ text, subtitle, background, color, fontSize }) => {
|
||||
let div = document.getElementById('__web_test_title');
|
||||
if (!div) {
|
||||
div = document.createElement('div');
|
||||
div.id = '__web_test_title';
|
||||
document.body.appendChild(div);
|
||||
}
|
||||
div.style.cssText = [
|
||||
'position:fixed', 'top:0', 'left:0', 'width:100%', 'height:100%',
|
||||
`background:${background}`,
|
||||
'display:flex', 'align-items:center', 'justify-content:center',
|
||||
'z-index:999999', 'pointer-events:none',
|
||||
].join(';');
|
||||
// Remove other overlays to prevent flash between slides
|
||||
const img = document.getElementById('__web_test_image');
|
||||
if (img) img.remove();
|
||||
const esc = s => s.replace(/&/g, '&').replace(/</g, '<').replace(/\n/g, '<br>');
|
||||
let html = `<div style="font-size:${fontSize}px;font-weight:600;line-height:1.4;">${esc(text)}</div>`;
|
||||
if (subtitle) {
|
||||
html += `<div style="font-size:${Math.round(fontSize * 0.5)}px;margin-top:16px;opacity:0.7;">${esc(subtitle)}</div>`;
|
||||
}
|
||||
div.innerHTML = `<div style="text-align:center;max-width:70%;color:${color};font-family:'Segoe UI',Arial,sans-serif;">${html}</div>`;
|
||||
}, { text, subtitle, background, color, fontSize });
|
||||
|
||||
// Smart TTS wait (same pattern as showCaption/showImage)
|
||||
if (smartWaitMs > 0) {
|
||||
let remaining = smartWaitMs;
|
||||
while (remaining > 0) {
|
||||
const chunk = Math.min(remaining, 1000);
|
||||
await page.waitForTimeout(chunk);
|
||||
remaining -= chunk;
|
||||
if (recorder?._flushFrames) recorder._flushFrames();
|
||||
}
|
||||
recorder.captionCredit = { waitedMs: smartWaitMs, at: Date.now() };
|
||||
}
|
||||
}
|
||||
|
||||
/** Remove the title slide overlay. */
|
||||
export async function hideTitleSlide() {
|
||||
ensureConnected();
|
||||
await page.evaluate(() => {
|
||||
const el = document.getElementById('__web_test_title');
|
||||
if (el) el.remove();
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Show a full-screen image overlay (e.g. presentation slide screenshot).
|
||||
* Reads the image file, base64-encodes it, and renders as a fixed overlay
|
||||
* on the page — captured by CDP screencast automatically.
|
||||
*
|
||||
* Style presets:
|
||||
* - 'blur' (default) — blurred+dimmed copy as background, image centered with shadow
|
||||
* - 'dark' — dark background (#2a2a2a) with shadow
|
||||
* - 'light' — white background with shadow
|
||||
* - 'full' — image covers entire screen, no padding/shadow
|
||||
*
|
||||
* Custom background overrides the preset (e.g. background: '#003366').
|
||||
*
|
||||
* @param {string} imagePath — path to the image file (PNG, JPG, etc.)
|
||||
* @param {object} [opts]
|
||||
* @param {'blur'|'dark'|'light'|'full'} [opts.style='blur'] — display style preset
|
||||
* @param {string} [opts.background] — custom background color/gradient (overrides style preset)
|
||||
* @param {boolean} [opts.shadow] — show drop shadow (default: true for blur/dark/light, false for full)
|
||||
* @param {string|false} [opts.speech] — TTS narration text while image is shown.
|
||||
* Pass a string for narration, or false to skip. Omit to skip (no auto-text for images).
|
||||
*/
|
||||
export async function showImage(imagePath, opts = {}) {
|
||||
ensureConnected();
|
||||
const style = opts.style || 'blur';
|
||||
const speech = opts.speech;
|
||||
|
||||
// Style presets
|
||||
const presets = {
|
||||
blur: { bg: '#222', fit: 'contain', shadow: true, blur: true },
|
||||
dark: { bg: '#2a2a2a', fit: 'contain', shadow: true, blur: false },
|
||||
light: { bg: '#ffffff', fit: 'contain', shadow: true, blur: false },
|
||||
full: { bg: '#000', fit: 'contain', shadow: false, blur: false },
|
||||
};
|
||||
const preset = presets[style] || presets.blur;
|
||||
|
||||
const bg = opts.background || preset.bg;
|
||||
const fit = preset.fit;
|
||||
const shadow = opts.shadow !== undefined ? opts.shadow : preset.shadow;
|
||||
const useBlur = opts.background ? false : preset.blur;
|
||||
|
||||
// Read image and base64-encode
|
||||
const absPath = resolveProjectPath(imagePath);
|
||||
if (!fsExistsSync(absPath)) {
|
||||
throw new Error(`showImage: file not found: ${absPath}`);
|
||||
}
|
||||
const buf = readFileSync(absPath);
|
||||
const ext = extname(absPath).toLowerCase().replace('.', '');
|
||||
const mime = ext === 'jpg' || ext === 'jpeg' ? 'image/jpeg'
|
||||
: ext === 'png' ? 'image/png'
|
||||
: ext === 'gif' ? 'image/gif'
|
||||
: ext === 'webp' ? 'image/webp'
|
||||
: ext === 'svg' ? 'image/svg+xml'
|
||||
: 'image/png';
|
||||
const dataUrl = `data:${mime};base64,${buf.toString('base64')}`;
|
||||
|
||||
// Collect caption for TTS narration if recording
|
||||
let smartWaitMs = 0;
|
||||
if (recorder && speech && speech !== false) {
|
||||
const captionText = typeof speech === 'string' ? speech : '';
|
||||
if (captionText) {
|
||||
recorder.captions.push({ text: captionText, speech: captionText, time: Math.round(recorder.videoTimeMs), ...(opts.voice ? { voice: opts.voice } : {}) });
|
||||
smartWaitMs = Math.max(2000, captionText.length * (recorder.speechRate || 70));
|
||||
}
|
||||
}
|
||||
|
||||
// Padding: full style uses 100%, others use 92% for breathing room
|
||||
const isFull = style === 'full';
|
||||
const maxSize = isFull ? '100%' : '92%';
|
||||
|
||||
await page.evaluate(({ dataUrl, fit, bg, useBlur, shadow, maxSize, isFull }) => {
|
||||
let div = document.getElementById('__web_test_image');
|
||||
if (!div) {
|
||||
div = document.createElement('div');
|
||||
div.id = '__web_test_image';
|
||||
document.body.appendChild(div);
|
||||
}
|
||||
// Remove other overlays to prevent flash between slides
|
||||
const title = document.getElementById('__web_test_title');
|
||||
if (title) title.remove();
|
||||
|
||||
div.style.cssText = [
|
||||
'position:fixed', 'top:0', 'left:0', 'width:100%', 'height:100%',
|
||||
`background:${bg}`,
|
||||
'display:flex', 'align-items:center', 'justify-content:center',
|
||||
'z-index:999999', 'pointer-events:none', 'overflow:hidden'
|
||||
].join(';');
|
||||
|
||||
let html = '';
|
||||
|
||||
// Blurred background layer: the same image stretched to cover, blurred and dimmed
|
||||
if (useBlur) {
|
||||
html += `<img src="${dataUrl}" style="position:absolute;top:0;left:0;width:100%;height:100%;object-fit:cover;filter:blur(30px) brightness(0.5);transform:scale(1.1);" />`;
|
||||
}
|
||||
|
||||
// Main image
|
||||
const shadowCss = shadow ? 'box-shadow:0 4px 40px rgba(0,0,0,0.5);' : '';
|
||||
const sizeCss = isFull
|
||||
? `width:100%;height:100%;object-fit:${fit};`
|
||||
: `max-width:${maxSize};max-height:${maxSize};min-width:50%;min-height:50%;object-fit:${fit};`;
|
||||
html += `<img src="${dataUrl}" style="position:relative;${sizeCss}${shadowCss}" />`;
|
||||
|
||||
div.innerHTML = html;
|
||||
}, { dataUrl, fit, bg, useBlur, shadow, maxSize, isFull });
|
||||
|
||||
// Smart TTS wait (same pattern as showCaption)
|
||||
if (smartWaitMs > 0) {
|
||||
let remaining = smartWaitMs;
|
||||
while (remaining > 0) {
|
||||
const chunk = Math.min(remaining, 1000);
|
||||
await page.waitForTimeout(chunk);
|
||||
remaining -= chunk;
|
||||
if (recorder?._flushFrames) recorder._flushFrames();
|
||||
}
|
||||
recorder.captionCredit = { waitedMs: smartWaitMs, at: Date.now() };
|
||||
}
|
||||
}
|
||||
|
||||
/** Remove the image overlay. */
|
||||
export async function hideImage() {
|
||||
ensureConnected();
|
||||
await page.evaluate(() => {
|
||||
const el = document.getElementById('__web_test_image');
|
||||
if (el) el.remove();
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,243 @@
|
||||
// web-test recording/capture v1.17 — Recording lifecycle (CDP screencast + ffmpeg pipe), screenshot, wait helpers.
|
||||
// Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
|
||||
|
||||
import { spawn } from 'child_process';
|
||||
import { mkdirSync, statSync, writeFileSync } from 'fs';
|
||||
import { dirname } from 'path';
|
||||
import {
|
||||
page, recorder, lastCaptions,
|
||||
setRecorder, setLastCaptions, setLastRecordingDuration,
|
||||
resolveProjectPath, ensureConnected,
|
||||
} from '../core/state.mjs';
|
||||
import { resolveFfmpeg } from './tts.mjs';
|
||||
|
||||
// Imported lazily inside wait() to avoid initialization-time circular deps.
|
||||
|
||||
/** Take a screenshot. Returns PNG buffer. */
|
||||
export async function screenshot() {
|
||||
ensureConnected();
|
||||
return await page.screenshot({ type: 'png' });
|
||||
}
|
||||
|
||||
/** Wait for a specified number of seconds. */
|
||||
export async function wait(seconds) {
|
||||
ensureConnected();
|
||||
let ms = seconds * 1000;
|
||||
// Credit system: if showCaption already waited for TTS, subtract that time
|
||||
if (recorder && recorder.captionCredit) {
|
||||
const elapsed = Date.now() - recorder.captionCredit.at;
|
||||
const credit = Math.max(0, recorder.captionCredit.waitedMs - elapsed);
|
||||
ms = Math.max(0, ms - credit);
|
||||
recorder.captionCredit = null;
|
||||
}
|
||||
if (ms > 0) {
|
||||
// During recording, split long waits into chunks and flush frames
|
||||
// to keep video timeline in sync (CDP may not send frames for static pages)
|
||||
if (recorder?._flushFrames && ms > 1000) {
|
||||
let remaining = ms;
|
||||
while (remaining > 0) {
|
||||
const chunk = Math.min(remaining, 1000);
|
||||
await page.waitForTimeout(chunk);
|
||||
remaining -= chunk;
|
||||
recorder._flushFrames();
|
||||
}
|
||||
} else {
|
||||
await page.waitForTimeout(ms);
|
||||
}
|
||||
}
|
||||
const { getFormState } = await import('../forms/state.mjs');
|
||||
return await getFormState();
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// Video recording — CDP screencast + ffmpeg
|
||||
// ============================================================
|
||||
|
||||
/** Check if video recording is active. */
|
||||
export function isRecording() {
|
||||
return recorder !== null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Start video recording via CDP screencast + ffmpeg.
|
||||
* Frames are captured as JPEG and piped to ffmpeg for MP4 encoding.
|
||||
* @param {string} outputPath — output .mp4 file path
|
||||
* @param {object} [opts]
|
||||
* @param {number} [opts.fps=25] — target framerate
|
||||
* @param {number} [opts.quality=80] — JPEG quality (1-100)
|
||||
* @param {string} [opts.ffmpegPath] — explicit path to ffmpeg binary
|
||||
*/
|
||||
export async function startRecording(outputPath, opts = {}) {
|
||||
ensureConnected();
|
||||
if (recorder) {
|
||||
if (opts.force) {
|
||||
try { await stopRecording(); } catch {}
|
||||
} else {
|
||||
throw new Error('Already recording. Call stopRecording() first, or use { force: true }.');
|
||||
}
|
||||
}
|
||||
setLastCaptions([]);
|
||||
setLastRecordingDuration(null);
|
||||
|
||||
const fps = opts.fps || 25;
|
||||
const quality = opts.quality || 80;
|
||||
const ffmpegPath = resolveFfmpeg(opts.ffmpegPath);
|
||||
|
||||
// Ensure output directory exists
|
||||
const resolvedPath = resolveProjectPath(outputPath);
|
||||
mkdirSync(dirname(resolvedPath), { recursive: true });
|
||||
|
||||
// Spawn ffmpeg process — single output file across context switches
|
||||
const ffmpeg = spawn(ffmpegPath, [
|
||||
'-y', // overwrite output
|
||||
'-f', 'image2pipe', // input: piped images
|
||||
'-framerate', String(fps), // input framerate
|
||||
'-i', '-', // read from stdin
|
||||
'-c:v', 'libx264', // H.264 codec
|
||||
'-preset', 'fast', // good quality/speed balance
|
||||
'-crf', '23', // default quality (good for screen content)
|
||||
'-vf', 'scale=in_range=full:out_range=limited', // JPEG full→H.264 limited range
|
||||
'-pix_fmt', 'yuv420p', // broad compatibility
|
||||
'-color_range', 'tv', // limited range (16-235) — standard for H.264 players
|
||||
'-movflags', '+faststart', // web-friendly MP4
|
||||
resolvedPath
|
||||
], { stdio: ['pipe', 'ignore', 'pipe'] });
|
||||
|
||||
ffmpeg.on('error', err => { if (recorder) recorder.ffmpegError += err.message; });
|
||||
|
||||
const frameDuration = 1000 / fps;
|
||||
const speechRate = opts.speechRate || 70; // ms per character for smart TTS wait
|
||||
|
||||
// Frame handler shared across CDP sessions (lives in recorder, not closure):
|
||||
// when the active context switches, we attach a new CDP session and route its
|
||||
// frames to the same ffmpeg pipe — preserving a single continuous timeline.
|
||||
const frameHandler = async ({ data, sessionId }, cdp) => {
|
||||
if (!recorder) return;
|
||||
const buf = Buffer.from(data, 'base64');
|
||||
const now = Date.now();
|
||||
if (!ffmpeg.stdin.destroyed) {
|
||||
let framesWritten = 0;
|
||||
if (recorder.lastFrameTime && recorder.lastFrameBuf) {
|
||||
const gap = now - recorder.lastFrameTime;
|
||||
const dupes = Math.round(gap / frameDuration) - 1;
|
||||
for (let i = 0; i < dupes && i < fps * 30; i++) {
|
||||
ffmpeg.stdin.write(recorder.lastFrameBuf);
|
||||
framesWritten++;
|
||||
}
|
||||
}
|
||||
ffmpeg.stdin.write(buf);
|
||||
framesWritten++;
|
||||
recorder.videoTimeMs += framesWritten * frameDuration;
|
||||
}
|
||||
recorder.lastFrameTime = now;
|
||||
recorder.lastFrameBuf = buf;
|
||||
try { await cdp.send('Page.screencastFrameAck', { sessionId }); } catch {}
|
||||
};
|
||||
|
||||
// Duplicate the last frame to fill wall-clock gaps (static periods, context switches).
|
||||
const _flushFrames = () => {
|
||||
if (!recorder || !recorder.lastFrameBuf || !recorder.lastFrameTime || ffmpeg.stdin.destroyed) return;
|
||||
const now = Date.now();
|
||||
const gap = now - recorder.lastFrameTime;
|
||||
const dupes = Math.round(gap / frameDuration);
|
||||
for (let i = 0; i < dupes; i++) {
|
||||
ffmpeg.stdin.write(recorder.lastFrameBuf);
|
||||
recorder.videoTimeMs += frameDuration;
|
||||
}
|
||||
if (dupes > 0) recorder.lastFrameTime = now;
|
||||
};
|
||||
|
||||
// Attach screencast to a specific page. Stops the old CDP first (if any).
|
||||
// Called by startRecording for the initial page, and by setActiveContext when
|
||||
// the active context changes mid-recording.
|
||||
const _attachPage = async (targetPage) => {
|
||||
if (recorder.cdp) {
|
||||
_flushFrames(); // freeze the last frame of the outgoing page up to "now"
|
||||
try { await recorder.cdp.send('Page.stopScreencast'); } catch {}
|
||||
try { await recorder.cdp.detach(); } catch {}
|
||||
recorder.cdp = null;
|
||||
}
|
||||
const cdp = await targetPage.context().newCDPSession(targetPage);
|
||||
cdp.on('Page.screencastFrame', (ev) => frameHandler(ev, cdp));
|
||||
await cdp.send('Page.startScreencast', { format: 'jpeg', quality, everyNthFrame: 1 });
|
||||
recorder.cdp = cdp;
|
||||
recorder.activePage = targetPage;
|
||||
};
|
||||
|
||||
setRecorder({
|
||||
cdp: null,
|
||||
activePage: null,
|
||||
ffmpeg,
|
||||
startTime: Date.now(),
|
||||
outputPath: resolvedPath,
|
||||
ffmpegError: '',
|
||||
captions: [],
|
||||
videoTimeMs: 0,
|
||||
frameDuration,
|
||||
lastFrameTime: null,
|
||||
lastFrameBuf: null,
|
||||
_flushFrames,
|
||||
_attachPage,
|
||||
speechRate,
|
||||
});
|
||||
ffmpeg.stderr.on('data', d => { recorder.ffmpegError += d.toString(); });
|
||||
|
||||
await _attachPage(page);
|
||||
}
|
||||
|
||||
/**
|
||||
* Stop video recording. Finalizes the MP4 file.
|
||||
* @returns {{ file: string, duration: number, size: number }}
|
||||
*/
|
||||
export async function stopRecording() {
|
||||
if (!recorder) return { file: null, duration: 0, size: 0 };
|
||||
|
||||
const { cdp, ffmpeg, startTime, outputPath } = recorder;
|
||||
|
||||
// Final frame flush: write remaining frames to cover the gap since the last screencast frame
|
||||
if (recorder._flushFrames) recorder._flushFrames();
|
||||
|
||||
// Stop CDP screencast
|
||||
try { await cdp.send('Page.stopScreencast'); } catch {}
|
||||
try { await cdp.detach(); } catch {}
|
||||
|
||||
// Close ffmpeg stdin and wait for encoding to finish
|
||||
await new Promise((resolve, reject) => {
|
||||
const timeout = setTimeout(() => {
|
||||
ffmpeg.kill('SIGKILL');
|
||||
reject(new Error('ffmpeg timed out after 30s'));
|
||||
}, 30000);
|
||||
|
||||
ffmpeg.on('close', (code) => {
|
||||
clearTimeout(timeout);
|
||||
if (code === 0) resolve();
|
||||
else reject(new Error(`ffmpeg exited with code ${code}: ${recorder?.ffmpegError || ''}`));
|
||||
});
|
||||
ffmpeg.on('error', (err) => {
|
||||
clearTimeout(timeout);
|
||||
reject(err);
|
||||
});
|
||||
|
||||
ffmpeg.stdin.end();
|
||||
});
|
||||
|
||||
const duration = (Date.now() - startTime) / 1000;
|
||||
const stats = statSync(outputPath);
|
||||
|
||||
// Preserve captions for addNarration()
|
||||
setLastCaptions(recorder.captions || []);
|
||||
setLastRecordingDuration(duration);
|
||||
if (lastCaptions.length) {
|
||||
const captionsPath = outputPath.replace(/\.[^.]+$/, '.captions.json');
|
||||
const captionsData = { recordingDuration: duration, videoTimestamps: true, captions: lastCaptions };
|
||||
writeFileSync(captionsPath, JSON.stringify(captionsData, null, 2), 'utf-8');
|
||||
}
|
||||
|
||||
setRecorder(null);
|
||||
|
||||
return {
|
||||
file: outputPath,
|
||||
duration: Math.round(duration * 10) / 10,
|
||||
size: stats.size,
|
||||
captions: lastCaptions.length
|
||||
};
|
||||
@@ -0,0 +1,340 @@
|
||||
// web-test recording/highlight v1.17 — Visual highlight overlay (single + auto-mode for clickElement/fillFields/selectValue).
|
||||
// Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
|
||||
|
||||
import {
|
||||
page, highlightMode, ensureConnected, normYo,
|
||||
setHighlightMode,
|
||||
} from '../core/state.mjs';
|
||||
import {
|
||||
readSubmenuScript, detectFormScript, resolveGridScript,
|
||||
findClickTargetScript, resolveFieldsScript,
|
||||
} from '../../dom.mjs';
|
||||
|
||||
/**
|
||||
* Highlight an element on the page (visual accent for video recordings).
|
||||
* Uses overlay div for visibility (not clipped by overflow:hidden), with
|
||||
* requestAnimationFrame tracking so it follows layout shifts (async banners etc).
|
||||
* @param {string} text Element text/label (fuzzy match, same as clickElement/fillFields)
|
||||
* @param {object} [opts]
|
||||
* @param {string} [opts.color] Outline color (default: '#e74c3c')
|
||||
* @param {number} [opts.padding] Extra padding around element (default: 4)
|
||||
*/
|
||||
export async function highlight(text, opts = {}) {
|
||||
ensureConnected();
|
||||
const { color = '#e74c3c', padding = 4, table } = opts;
|
||||
|
||||
// Remove previous highlight first
|
||||
await unhighlight();
|
||||
|
||||
let elId = null;
|
||||
|
||||
// 0. Open submenu/popup — highest priority (submenu overlays the form,
|
||||
// so form search would match grid rows behind the popup)
|
||||
const popupItems = await page.evaluate(readSubmenuScript());
|
||||
if (Array.isArray(popupItems) && popupItems.length > 0) {
|
||||
const target = normYo(text.toLowerCase());
|
||||
let found = popupItems.find(i => normYo(i.name.toLowerCase()) === target);
|
||||
if (!found) found = popupItems.find(i => normYo(i.name.toLowerCase()).startsWith(target));
|
||||
if (!found) found = popupItems.find(i => normYo(i.name.toLowerCase()).includes(target));
|
||||
if (found) {
|
||||
// 1C duplicates IDs in clouds — getElementById returns the hidden copy.
|
||||
// Use elementFromPoint to find the visible element and get its actual rect.
|
||||
await page.evaluate(({ x, y, color, padding }) => {
|
||||
const el = document.elementFromPoint(x, y);
|
||||
if (!el) return;
|
||||
const block = el.closest('.submenuBlock') || el.closest('a.press') || el;
|
||||
const r = block.getBoundingClientRect();
|
||||
let div = document.getElementById('__web_test_highlight');
|
||||
if (!div) {
|
||||
div = document.createElement('div');
|
||||
div.id = '__web_test_highlight';
|
||||
document.body.appendChild(div);
|
||||
}
|
||||
div.style.cssText = [
|
||||
'position:fixed', 'pointer-events:none', 'z-index:999998',
|
||||
`top:${r.y - padding}px`, `left:${r.x - padding}px`,
|
||||
`width:${r.width + padding * 2}px`, `height:${r.height + padding * 2}px`,
|
||||
`outline:3px solid ${color}`, 'border-radius:4px',
|
||||
`box-shadow:0 0 16px ${color}80`,
|
||||
].join(';');
|
||||
}, { x: found.x, y: found.y, color, padding });
|
||||
return; // overlay placed, done
|
||||
}
|
||||
}
|
||||
|
||||
// 1. Visible commands on the function panel (cmd_XXX_txt elements)
|
||||
// Must be checked BEFORE form search: when the section content panel
|
||||
// is showing, the form behind it is hidden but detectFormScript still
|
||||
// finds it, and form buttons match before commands.
|
||||
if (!elId) {
|
||||
elId = await page.evaluate(`(() => {
|
||||
const norm = s => (s?.trim().replace(/\\u00a0/g, ' ') || '').replace(/ё/gi, 'е');
|
||||
const target = ${JSON.stringify(normYo(text.toLowerCase()))};
|
||||
const cmds = [...document.querySelectorAll('[id^="cmd_"][id$="_txt"]')].filter(e => e.offsetWidth > 0);
|
||||
if (cmds.length === 0) return null;
|
||||
let el = cmds.find(e => norm(e.innerText).toLowerCase() === target);
|
||||
if (!el) el = cmds.find(e => norm(e.innerText).toLowerCase().startsWith(target));
|
||||
if (!el) el = cmds.find(e => norm(e.innerText).toLowerCase().includes(target));
|
||||
return el ? el.id : null;
|
||||
})()`);
|
||||
}
|
||||
|
||||
// 1b. Command group headers on the function panel (eAccentColor labels).
|
||||
// Match header text, then highlight the header + commands below it
|
||||
// until the next spacer/header/end.
|
||||
if (!elId) {
|
||||
const groupDone = await page.evaluate(({ target, color, padding }) => {
|
||||
const container = document.querySelector('#funcPanel_container');
|
||||
if (!container) return false;
|
||||
const norm = s => (s?.trim().replace(/\u00a0/g, ' ') || '').replace(/ё/gi, 'е').toLowerCase();
|
||||
const headers = [...container.querySelectorAll('.eAccentColor')].filter(e => e.offsetWidth > 0);
|
||||
if (!headers.length) return false;
|
||||
|
||||
let headerEl = headers.find(h => norm(h.textContent) === target);
|
||||
if (!headerEl) headerEl = headers.find(h => norm(h.textContent).startsWith(target));
|
||||
if (!headerEl) headerEl = headers.find(h => norm(h.textContent).includes(target));
|
||||
if (!headerEl) return false;
|
||||
|
||||
// Collect header + following cmd siblings until next spacer/header
|
||||
const parent = headerEl.parentElement;
|
||||
const children = [...parent.children];
|
||||
const startIdx = children.indexOf(headerEl);
|
||||
const groupEls = [headerEl];
|
||||
for (let i = startIdx + 1; i < children.length; i++) {
|
||||
const el = children[i];
|
||||
if (el.classList.contains('eAccentColor')) break;
|
||||
if (!el.id && !el.classList.contains('functionItem') && el.getBoundingClientRect().width < 10) break;
|
||||
groupEls.push(el);
|
||||
}
|
||||
|
||||
// Bounding box
|
||||
let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity;
|
||||
for (const el of groupEls) {
|
||||
const r = el.getBoundingClientRect();
|
||||
if (r.width === 0 && r.height === 0) continue;
|
||||
minX = Math.min(minX, r.left); minY = Math.min(minY, r.top);
|
||||
maxX = Math.max(maxX, r.right); maxY = Math.max(maxY, r.bottom);
|
||||
}
|
||||
if (minX === Infinity) return false;
|
||||
|
||||
let div = document.getElementById('__web_test_highlight');
|
||||
if (!div) { div = document.createElement('div'); div.id = '__web_test_highlight'; document.body.appendChild(div); }
|
||||
div.style.cssText = [
|
||||
'position:fixed', 'pointer-events:none', 'z-index:999998',
|
||||
`top:${minY - padding}px`, `left:${minX - padding}px`,
|
||||
`width:${maxX - minX + padding * 2}px`, `height:${maxY - minY + padding * 2}px`,
|
||||
`outline:3px solid ${color}`, 'border-radius:4px',
|
||||
`box-shadow:0 0 16px ${color}80`,
|
||||
].join(';');
|
||||
return true;
|
||||
}, { target: normYo(text.toLowerCase()), color, padding });
|
||||
if (groupDone) return;
|
||||
}
|
||||
|
||||
// 2. Form groups/panels — checked BEFORE buttons/fields because group names
|
||||
// often collide with command bar buttons (e.g. "БизнесПроцессы" is both a
|
||||
// panel and a command bar element). Includes _container and _div elements
|
||||
// but skips logicGroupContainer (Representation=None, height=0).
|
||||
if (!elId) {
|
||||
const formNum = await page.evaluate(detectFormScript());
|
||||
if (formNum !== null) {
|
||||
elId = await page.evaluate(`(() => {
|
||||
const norm = s => (s?.trim().replace(/\\u00a0/g, ' ') || '').replace(/ё/gi, 'е');
|
||||
const target = ${JSON.stringify(normYo(text.toLowerCase()))};
|
||||
const p = 'form' + ${formNum} + '_';
|
||||
// Group containers: _container or _div, but skip logicGroupContainer (invisible groups)
|
||||
const groups = [...document.querySelectorAll('[id^="' + p + '"][id$="_container"], [id^="' + p + '"][id$="_div"]')]
|
||||
.filter(el => el.offsetWidth > 0 && el.offsetHeight > 0 && !el.classList.contains('logicGroupContainer'));
|
||||
const items = groups.map(el => {
|
||||
const idName = el.id.replace(p, '').replace(/_(container|div)$/, '');
|
||||
const titleEl = document.getElementById(p + idName + '#title_text')
|
||||
|| document.getElementById(p + idName + '_title_text');
|
||||
const label = norm(titleEl?.innerText || '').toLowerCase();
|
||||
const name = norm(idName).toLowerCase();
|
||||
const big = el.offsetWidth >= 100 && el.offsetHeight >= 50;
|
||||
return { id: el.id, name, label, big };
|
||||
});
|
||||
let found = items.find(i => i.label === target);
|
||||
if (!found) found = items.find(i => i.name === target);
|
||||
// Fuzzy match: only large groups (min 100x50) to avoid matching command bars
|
||||
if (!found) found = items.filter(i => i.big).find(i => i.label.startsWith(target) || i.name.startsWith(target));
|
||||
if (!found && target.length >= 4) found = items.filter(i => i.big).find(i => i.label.includes(target) || i.name.includes(target));
|
||||
return found ? found.id : null;
|
||||
})()`);
|
||||
}
|
||||
}
|
||||
|
||||
// 3. Form-scoped search (buttons, links, fields, grid rows)
|
||||
if (!elId) {
|
||||
const formNum = await page.evaluate(detectFormScript());
|
||||
if (formNum !== null) {
|
||||
// 3a. Try button/link/tab/gridRow via findClickTargetScript
|
||||
let gridSelector;
|
||||
if (table) {
|
||||
const resolved = await page.evaluate(resolveGridScript(formNum, table));
|
||||
if (!resolved.error) gridSelector = resolved.gridSelector;
|
||||
}
|
||||
const target = await page.evaluate(findClickTargetScript(formNum, text, table ? { tableName: table, gridSelector } : undefined));
|
||||
if (target && !target.error) {
|
||||
if (target.id) {
|
||||
elId = target.id;
|
||||
} else if (target.x && target.y) {
|
||||
// Grid row — find the gridLine element and tag it
|
||||
elId = await page.evaluate(`(() => {
|
||||
const p = ${JSON.stringify(`form${formNum}_`)};
|
||||
const grid = document.querySelector('[id^="' + p + '"].grid');
|
||||
if (!grid) return null;
|
||||
const body = grid.querySelector('.gridBody');
|
||||
if (!body) return null;
|
||||
const norm = s => (s?.trim().replace(/\\u00a0/g, ' ') || '').replace(/ё/gi, 'е');
|
||||
const target = ${JSON.stringify(normYo(text.toLowerCase()))};
|
||||
for (const line of body.querySelectorAll('.gridLine')) {
|
||||
const cells = [...line.querySelectorAll('.gridBoxText')].filter(b => b.offsetWidth > 0);
|
||||
const rowText = cells.map(b => b.innerText?.trim() || '').join(' ').toLowerCase().replace(/ё/gi, 'е');
|
||||
if (rowText.includes(target)) {
|
||||
if (!line.id) line.id = '__wt_hl_tmp';
|
||||
return line.id;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
})()`);
|
||||
}
|
||||
}
|
||||
|
||||
// 3b. If not found as button — try as field via resolveFieldsScript
|
||||
if (!elId) {
|
||||
const dummyFields = { [text]: '' };
|
||||
const resolved = await page.evaluate(resolveFieldsScript(formNum, dummyFields));
|
||||
if (resolved?.length > 0 && !resolved[0].error && resolved[0].inputId) {
|
||||
elId = resolved[0].inputId;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 4. Fallback: sections (sidebar navigation)
|
||||
if (!elId) {
|
||||
elId = await page.evaluate(`(() => {
|
||||
const norm = s => (s?.trim().replace(/\\u00a0/g, ' ') || '').replace(/ё/gi, 'е');
|
||||
const target = ${JSON.stringify(normYo(text.toLowerCase()))};
|
||||
const secs = [...document.querySelectorAll('[id^="themesCell_theme_"]')];
|
||||
let el = secs.find(e => norm(e.innerText).toLowerCase() === target);
|
||||
if (!el) el = secs.find(e => norm(e.innerText).toLowerCase().startsWith(target));
|
||||
if (!el) el = secs.find(e => norm(e.innerText).toLowerCase().includes(target));
|
||||
return el ? el.id : null;
|
||||
})()`);
|
||||
}
|
||||
|
||||
if (!elId) {
|
||||
// Collect available elements to help the caller fix the name
|
||||
const available = await page.evaluate(`(() => {
|
||||
const norm = s => (s?.trim().replace(/\\u00a0/g, ' ') || '').replace(/ё/gi, 'е');
|
||||
const result = {};
|
||||
// Commands
|
||||
const cmds = [...document.querySelectorAll('[id^="cmd_"][id$="_txt"]')].filter(e => e.offsetWidth > 0).map(e => norm(e.innerText));
|
||||
if (cmds.length) result.commands = cmds;
|
||||
// Command group headers
|
||||
const fp = document.querySelector('#funcPanel_container');
|
||||
if (fp) {
|
||||
const gh = [...fp.querySelectorAll('.eAccentColor')].filter(e => e.offsetWidth > 0).map(e => norm(e.textContent));
|
||||
if (gh.length) result.commandGroups = gh;
|
||||
}
|
||||
// Sections
|
||||
const secs = [...document.querySelectorAll('[id^="themesCell_theme_"]')].map(e => norm(e.innerText)).filter(Boolean);
|
||||
if (secs.length) result.sections = secs;
|
||||
// Form elements
|
||||
${(() => {
|
||||
// Detect form inline to avoid extra evaluate round-trip
|
||||
return `
|
||||
const forms = {};
|
||||
document.querySelectorAll('[id^="form"]').forEach(el => {
|
||||
const m = el.id.match(/^form(\\d+)_/);
|
||||
if (m) forms[m[1]] = (forms[m[1]] || 0) + 1;
|
||||
});
|
||||
let formNum = null, maxCount = 0;
|
||||
for (const [n, c] of Object.entries(forms)) {
|
||||
if (parseInt(n) > 0 && c > maxCount) { maxCount = c; formNum = n; }
|
||||
}
|
||||
if (formNum !== null) {
|
||||
const p = 'form' + formNum + '_';
|
||||
// Groups (_container or _div, skip logicGroupContainer, min 100x50)
|
||||
const groups = [...document.querySelectorAll('[id^="' + p + '"][id$="_container"], [id^="' + p + '"][id$="_div"]')]
|
||||
.filter(el => el.offsetWidth >= 100 && el.offsetHeight >= 50 && !el.classList.contains('logicGroupContainer'))
|
||||
.map(el => {
|
||||
const idName = el.id.replace(p, '').replace(/_(container|div)$/, '');
|
||||
const titleEl = document.getElementById(p + idName + '#title_text') || document.getElementById(p + idName + '_title_text');
|
||||
return norm(titleEl?.innerText || '') || idName;
|
||||
}).filter(Boolean);
|
||||
if (groups.length) result.groups = groups;
|
||||
// Buttons/links
|
||||
const btns = [...document.querySelectorAll('[id^="' + p + '"].btnText, [id^="' + p + '"] .btnText, [id^="' + p + '"].hplnk')]
|
||||
.filter(el => el.offsetWidth > 0).map(el => norm(el.innerText)).filter(Boolean);
|
||||
if (btns.length) result.buttons = [...new Set(btns)];
|
||||
}`;
|
||||
})()}
|
||||
return result;
|
||||
})()`);
|
||||
const parts = [];
|
||||
for (const [cat, items] of Object.entries(available)) {
|
||||
parts.push(` ${cat}: ${items.join(', ')}`);
|
||||
}
|
||||
const hint = parts.length ? `\nAvailable:\n${parts.join('\n')}` : '';
|
||||
throw new Error(`highlight: "${text}" not found${hint}`);
|
||||
}
|
||||
|
||||
// Overlay div + rAF tracking loop (not clipped by overflow:hidden, follows layout shifts)
|
||||
await page.evaluate(({ elId, color, padding }) => {
|
||||
const target = document.getElementById(elId);
|
||||
if (!target) return;
|
||||
let div = document.getElementById('__web_test_highlight');
|
||||
if (!div) {
|
||||
div = document.createElement('div');
|
||||
div.id = '__web_test_highlight';
|
||||
document.body.appendChild(div);
|
||||
}
|
||||
function sync() {
|
||||
const r = target.getBoundingClientRect();
|
||||
div.style.cssText = [
|
||||
'position:fixed', 'pointer-events:none', 'z-index:999998',
|
||||
`top:${r.y - padding}px`, `left:${r.x - padding}px`,
|
||||
`width:${r.width + padding * 2}px`, `height:${r.height + padding * 2}px`,
|
||||
`outline:3px solid ${color}`, 'border-radius:4px',
|
||||
`box-shadow:0 0 16px ${color}80`,
|
||||
].join(';');
|
||||
}
|
||||
sync();
|
||||
// Track position changes via rAF
|
||||
function tick() {
|
||||
if (!document.getElementById('__web_test_highlight')) return; // stopped
|
||||
sync();
|
||||
requestAnimationFrame(tick);
|
||||
}
|
||||
requestAnimationFrame(tick);
|
||||
}, { elId, color, padding });
|
||||
}
|
||||
|
||||
/** Remove the highlight overlay. */
|
||||
export async function unhighlight() {
|
||||
ensureConnected();
|
||||
await page.evaluate(() => {
|
||||
const el = document.getElementById('__web_test_highlight');
|
||||
if (el) el.remove(); // also stops rAF loop (id check)
|
||||
// Clean up temp ID from grid rows
|
||||
const tmp = document.getElementById('__wt_hl_tmp');
|
||||
if (tmp) tmp.removeAttribute('id');
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Toggle auto-highlight mode. When enabled, clickElement/fillFields/selectValue
|
||||
* automatically highlight the target element before acting.
|
||||
* @param {boolean} on true to enable, false to disable
|
||||
*/
|
||||
export function setHighlight(on) {
|
||||
setHighlightMode(!!on);
|
||||
}
|
||||
|
||||
/** @returns {boolean} Whether auto-highlight mode is active. */
|
||||
export function isHighlightMode() {
|
||||
return highlightMode;
|
||||
}
|
||||
@@ -0,0 +1,196 @@
|
||||
// web-test recording/narration v1.17 — Post-process: generate TTS audio for captions and merge with recorded video.
|
||||
// Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
|
||||
|
||||
import { execFileSync } from 'child_process';
|
||||
import { existsSync as fsExistsSync, mkdirSync, readFileSync, rmSync, statSync } from 'fs';
|
||||
import { extname, join as pathJoin } from 'path';
|
||||
import { tmpdir } from 'os';
|
||||
import {
|
||||
lastCaptions, lastRecordingDuration, resolveProjectPath,
|
||||
} from '../core/state.mjs';
|
||||
import {
|
||||
resolveFfmpeg, getTtsProvider, getAudioDuration, generateSilence,
|
||||
} from './tts.mjs';
|
||||
|
||||
/**
|
||||
* Add TTS narration to a recorded video.
|
||||
* Generates speech from captions and merges audio with the video.
|
||||
* @param {string} videoPath — path to the recorded MP4 file
|
||||
* @param {object} [opts]
|
||||
* @param {Array<{text: string, speech: string, time: number, voice?: string}>} [opts.captions] — explicit captions (default: from last recording or .captions.json). Each caption may include a `voice` field to override the global voice for that segment
|
||||
* @param {string} [opts.provider='edge'] — TTS provider: 'edge' or 'openai'
|
||||
* @param {string} [opts.voice] — voice name (provider-specific)
|
||||
* @param {string} [opts.apiKey] — API key (for openai provider)
|
||||
* @param {string} [opts.apiUrl] — API endpoint (for openai provider)
|
||||
* @param {string} [opts.model] — model name (for openai provider, default: 'tts-1')
|
||||
* @param {string} [opts.ffmpegPath] — path to ffmpeg binary
|
||||
* @param {string} [opts.outputPath] — output file path (default: video-narrated.mp4)
|
||||
* @returns {{ file: string, duration: number, size: number, captions: number, warnings?: string[] }}
|
||||
*/
|
||||
export async function addNarration(videoPath, opts = {}) {
|
||||
if (!videoPath) return { file: null, duration: 0, size: 0, captions: 0 };
|
||||
videoPath = resolveProjectPath(videoPath);
|
||||
const ffmpegPath = resolveFfmpeg(opts.ffmpegPath);
|
||||
const ttsProvider = getTtsProvider(opts.provider || 'edge');
|
||||
const ttsOpts = { voice: opts.voice, apiKey: opts.apiKey, apiUrl: opts.apiUrl, model: opts.model };
|
||||
|
||||
// Resolve captions: explicit > lastCaptions > .captions.json
|
||||
let captions = opts.captions;
|
||||
let videoTimestamps = true; // new recordings use video-time timestamps (no scaling needed)
|
||||
let recordingDuration = null; // wall-clock duration (for legacy scaling fallback)
|
||||
if (!captions || !captions.length) {
|
||||
if (lastCaptions.length) {
|
||||
captions = [...lastCaptions];
|
||||
recordingDuration = lastRecordingDuration;
|
||||
// Runtime captions always use video timestamps (set in showCaption)
|
||||
}
|
||||
}
|
||||
if (!captions || !captions.length) {
|
||||
const captionsJsonPath = videoPath.replace(/\.[^.]+$/, '.captions.json');
|
||||
if (fsExistsSync(captionsJsonPath)) {
|
||||
const raw = JSON.parse(readFileSync(captionsJsonPath, 'utf-8'));
|
||||
// Support formats: array (old), { recordingDuration, captions } (v2), { videoTimestamps, captions } (v3)
|
||||
if (Array.isArray(raw)) {
|
||||
captions = raw;
|
||||
videoTimestamps = false;
|
||||
} else {
|
||||
captions = raw.captions;
|
||||
videoTimestamps = !!raw.videoTimestamps;
|
||||
recordingDuration = raw.recordingDuration || null;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (!captions || !captions.length) {
|
||||
throw new Error('No captions available. Record with showCaption() first, or pass opts.captions.');
|
||||
}
|
||||
|
||||
const videoDuration = getAudioDuration(videoPath, ffmpegPath);
|
||||
|
||||
// Legacy fallback: scale wall-clock timestamps to video duration
|
||||
// (only for old captions without videoTimestamps flag)
|
||||
if (!videoTimestamps && recordingDuration && recordingDuration > 0) {
|
||||
const timeScale = videoDuration / recordingDuration;
|
||||
if (Math.abs(timeScale - 1) > 0.005) {
|
||||
captions = captions.map(c => ({ ...c, time: Math.round(c.time * timeScale) }));
|
||||
}
|
||||
}
|
||||
|
||||
// Output path
|
||||
const ext = extname(videoPath);
|
||||
const base = videoPath.slice(0, -ext.length);
|
||||
const outputPath = opts.outputPath || `${base}-narrated${ext}`;
|
||||
|
||||
// Temp directory
|
||||
const tempDir = pathJoin(tmpdir(), `web-test-tts-${Date.now()}`);
|
||||
mkdirSync(tempDir, { recursive: true });
|
||||
|
||||
const warnings = [];
|
||||
|
||||
try {
|
||||
// Phase 1: Generate TTS audio for each caption
|
||||
const ttsFiles = [];
|
||||
const BATCH_SIZE = (opts.provider === 'elevenlabs') ? 2 : 5;
|
||||
for (let batchStart = 0; batchStart < captions.length; batchStart += BATCH_SIZE) {
|
||||
const batch = captions.slice(batchStart, batchStart + BATCH_SIZE);
|
||||
const promises = batch.map(async (cap, batchIdx) => {
|
||||
const idx = batchStart + batchIdx;
|
||||
const ttsFile = pathJoin(tempDir, `tts_${idx}.mp3`);
|
||||
const capTtsOpts = cap.voice ? { ...ttsOpts, voice: cap.voice } : ttsOpts;
|
||||
try {
|
||||
await ttsProvider(cap.speech, ttsFile, capTtsOpts);
|
||||
} catch (err) {
|
||||
// Retry once
|
||||
try {
|
||||
await ttsProvider(cap.speech, ttsFile, capTtsOpts);
|
||||
} catch (retryErr) {
|
||||
warnings.push(`TTS failed for caption ${idx}: ${retryErr.message || retryErr.cause?.message || String(retryErr)}`);
|
||||
// Generate 1s silence as placeholder
|
||||
generateSilence(ttsFile, 1, ffmpegPath);
|
||||
}
|
||||
}
|
||||
return ttsFile;
|
||||
});
|
||||
const results = await Promise.all(promises);
|
||||
ttsFiles.push(...results);
|
||||
}
|
||||
|
||||
// Phase 2+3: Place each TTS at its exact timestamp using adelay + amix
|
||||
// This avoids MP3 frame quantization drift from silence-file concatenation
|
||||
const ffmpegInputs = [];
|
||||
const filterParts = [];
|
||||
const mixLabels = [];
|
||||
|
||||
for (let i = 0; i < captions.length; i++) {
|
||||
const captionTimeMs = Math.round(captions[i].time);
|
||||
const ttsFile = ttsFiles[i];
|
||||
const ttsDuration = getAudioDuration(ttsFile, ffmpegPath);
|
||||
|
||||
ffmpegInputs.push('-i', ttsFile);
|
||||
const filters = [];
|
||||
|
||||
// Speed up TTS slightly if it's longer than gap to next caption (max 1.3x)
|
||||
if (i < captions.length - 1) {
|
||||
const maxDuration = (captions[i + 1].time - captions[i].time) / 1000;
|
||||
if (ttsDuration > maxDuration && maxDuration > 0.1) {
|
||||
const tempo = ttsDuration / maxDuration;
|
||||
if (tempo <= 1.3) {
|
||||
filters.push(`atempo=${tempo.toFixed(4)}`);
|
||||
} else {
|
||||
// Too fast — let audio overlap instead of distorting
|
||||
warnings.push(`Caption ${i + 1}/${captions.length}: TTS ${ttsDuration.toFixed(1)}s > gap ${maxDuration.toFixed(1)}s (need ${Math.round(ttsDuration - maxDuration)}s more pause)`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Delay to exact caption timestamp (milliseconds)
|
||||
if (captionTimeMs > 0) {
|
||||
filters.push(`adelay=${captionTimeMs}|${captionTimeMs}`);
|
||||
}
|
||||
|
||||
const label = `a${i}`;
|
||||
mixLabels.push(`[${label}]`);
|
||||
// Input indices are shifted by 1 because silence reference is input [0]
|
||||
filterParts.push(`[${i + 1}]${filters.length ? filters.join(',') : 'acopy'}[${label}]`);
|
||||
}
|
||||
|
||||
// Generate a silence reference track as input [0] so amix runs for full video duration
|
||||
const silencePath = pathJoin(tempDir, 'silence.mp3');
|
||||
generateSilence(silencePath, Math.ceil(videoDuration), ffmpegPath);
|
||||
|
||||
const filterComplex = filterParts.join(';') + ';' +
|
||||
`[0]${mixLabels.join('')}amix=inputs=${captions.length + 1}:normalize=0:duration=first`;
|
||||
|
||||
const narrationPath = pathJoin(tempDir, 'narration.mp3');
|
||||
execFileSync(ffmpegPath, [
|
||||
'-y', '-i', silencePath, ...ffmpegInputs,
|
||||
'-filter_complex', filterComplex,
|
||||
'-t', String(Math.ceil(videoDuration)),
|
||||
'-c:a', 'libmp3lame', '-b:a', '128k', narrationPath,
|
||||
], { stdio: 'pipe', timeout: 120000 });
|
||||
|
||||
// Phase 4: Merge video + narration audio
|
||||
execFileSync(ffmpegPath, [
|
||||
'-y', '-i', videoPath, '-i', narrationPath,
|
||||
'-c:v', 'copy', '-c:a', 'aac', '-b:a', '128k',
|
||||
'-map', '0:v:0', '-map', '1:a:0',
|
||||
'-t', String(Math.ceil(videoDuration)),
|
||||
'-movflags', '+faststart', outputPath,
|
||||
], { stdio: 'pipe', timeout: 120000 });
|
||||
|
||||
const stats = statSync(outputPath);
|
||||
const duration = getAudioDuration(outputPath, ffmpegPath);
|
||||
|
||||
const result = {
|
||||
file: outputPath,
|
||||
duration: Math.round(duration * 10) / 10,
|
||||
size: stats.size,
|
||||
captions: captions.length,
|
||||
};
|
||||
if (warnings.length) result.warnings = warnings;
|
||||
return result;
|
||||
|
||||
} finally {
|
||||
// Cleanup temp directory
|
||||
try { rmSync(tempDir, { recursive: true, force: true }); } catch {}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,175 @@
|
||||
// web-test recording/tts v1.17 — TTS providers (edge/openai/elevenlabs) and ffmpeg/ffprobe helpers.
|
||||
// Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
|
||||
|
||||
import { execFileSync, spawn } from 'child_process';
|
||||
import { existsSync as fsExistsSync, writeFileSync } from 'fs';
|
||||
import { resolve as pathResolve } from 'path';
|
||||
import { pathToFileURL } from 'url';
|
||||
import { projectRoot } from '../core/state.mjs';
|
||||
|
||||
/** Resolve ffmpeg binary path. */
|
||||
export function resolveFfmpeg(explicit) {
|
||||
// 1. Explicit path
|
||||
if (explicit) {
|
||||
try { execFileSync(explicit, ['-version'], { stdio: 'ignore', timeout: 5000 }); return explicit; }
|
||||
catch { throw new Error(`ffmpeg not found at: ${explicit}`); }
|
||||
}
|
||||
|
||||
// 2. FFMPEG_PATH env var
|
||||
const envPath = process.env.FFMPEG_PATH;
|
||||
if (envPath) {
|
||||
try { execFileSync(envPath, ['-version'], { stdio: 'ignore', timeout: 5000 }); return envPath; }
|
||||
catch { /* fall through */ }
|
||||
}
|
||||
|
||||
// 3. System PATH
|
||||
try { execFileSync('ffmpeg', ['-version'], { stdio: 'ignore', timeout: 5000 }); return 'ffmpeg'; }
|
||||
catch { /* fall through */ }
|
||||
|
||||
// 4. tools/ffmpeg/bin/ffmpeg.exe relative to project root
|
||||
const localPath = pathResolve(projectRoot, 'tools', 'ffmpeg', 'bin', 'ffmpeg.exe');
|
||||
if (fsExistsSync(localPath)) {
|
||||
try { execFileSync(localPath, ['-version'], { stdio: 'ignore', timeout: 5000 }); return localPath; }
|
||||
catch { /* fall through */ }
|
||||
}
|
||||
|
||||
// 5. Error with instructions
|
||||
throw new Error(
|
||||
'ffmpeg not found. Install it:\n' +
|
||||
' - Download from https://www.gyan.dev/ffmpeg/builds/ (essentials build)\n' +
|
||||
' - Add to PATH, or set FFMPEG_PATH env var, or place in tools/ffmpeg/bin/\n' +
|
||||
' - Or pass ffmpegPath option to startRecording()'
|
||||
);
|
||||
}
|
||||
|
||||
// ── TTS providers ──────────────────────────────────────────────────────────
|
||||
|
||||
/** Resolve node-edge-tts module: global install → tools/tts/ → error with instructions. */
|
||||
let _edgeTtsModule = null;
|
||||
export async function resolveEdgeTts() {
|
||||
if (_edgeTtsModule) return _edgeTtsModule;
|
||||
|
||||
// 1. Global/project-level install (standard Node resolution)
|
||||
try {
|
||||
_edgeTtsModule = await import('node-edge-tts');
|
||||
return _edgeTtsModule;
|
||||
} catch { /* fall through */ }
|
||||
|
||||
// 2. tools/tts/ relative to project root
|
||||
const localPath = pathResolve(projectRoot, 'tools', 'tts', 'node_modules', 'node-edge-tts', 'dist', 'edge-tts.js');
|
||||
if (fsExistsSync(localPath)) {
|
||||
try {
|
||||
_edgeTtsModule = await import(pathToFileURL(localPath).href);
|
||||
return _edgeTtsModule;
|
||||
} catch { /* fall through */ }
|
||||
}
|
||||
|
||||
// 3. Error with instructions
|
||||
throw new Error(
|
||||
'node-edge-tts not found. Install it:\n' +
|
||||
' - npm install --prefix tools/tts node-edge-tts\n' +
|
||||
' - or: npm install node-edge-tts (global/project-level)'
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Edge TTS provider (free, no API key). Uses node-edge-tts package.
|
||||
* @param {string} text — text to synthesize
|
||||
* @param {string} outputPath — path for the output mp3 file
|
||||
* @param {object} opts — { voice }
|
||||
*/
|
||||
export async function edgeTtsProvider(text, outputPath, opts = {}) {
|
||||
const { EdgeTTS } = await resolveEdgeTts();
|
||||
const voice = opts.voice || 'ru-RU-DmitryNeural';
|
||||
const tts = new EdgeTTS({ voice });
|
||||
await Promise.race([
|
||||
tts.ttsPromise(text, outputPath),
|
||||
new Promise((_, reject) => setTimeout(() => reject(new Error('Edge TTS timeout (30s)')), 30000)),
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* OpenAI-compatible TTS provider. Requires apiKey.
|
||||
* @param {string} text — text to synthesize
|
||||
* @param {string} outputPath — path for the output mp3 file
|
||||
* @param {object} opts — { apiKey, apiUrl, voice, model }
|
||||
*/
|
||||
export async function openaiTtsProvider(text, outputPath, opts = {}) {
|
||||
const apiUrl = opts.apiUrl || 'https://api.openai.com/v1/audio/speech';
|
||||
if (!opts.apiKey) throw new Error('OpenAI TTS requires apiKey');
|
||||
const resp = await fetch(apiUrl, {
|
||||
method: 'POST',
|
||||
headers: { 'Authorization': `Bearer ${opts.apiKey}`, 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
model: opts.model || 'tts-1',
|
||||
input: text,
|
||||
voice: opts.voice || 'alloy',
|
||||
response_format: 'mp3',
|
||||
}),
|
||||
});
|
||||
if (!resp.ok) throw new Error(`OpenAI TTS error ${resp.status}: ${await resp.text()}`);
|
||||
const buf = Buffer.from(await resp.arrayBuffer());
|
||||
writeFileSync(outputPath, buf);
|
||||
}
|
||||
|
||||
/**
|
||||
* ElevenLabs TTS provider. Requires apiKey.
|
||||
* @param {string} text — text to synthesize
|
||||
* @param {string} outputPath — path for the output mp3 file
|
||||
* @param {object} opts — { apiKey, apiUrl, voice, model }
|
||||
*/
|
||||
export async function elevenlabsTtsProvider(text, outputPath, opts = {}) {
|
||||
const voiceId = opts.voice || 'JBFqnCBsd6RMkjVDRZzb'; // George
|
||||
const apiUrl = opts.apiUrl || `https://api.elevenlabs.io/v1/text-to-speech/${voiceId}`;
|
||||
if (!opts.apiKey) throw new Error('ElevenLabs TTS requires apiKey');
|
||||
const resp = await fetch(apiUrl, {
|
||||
method: 'POST',
|
||||
headers: { 'xi-api-key': opts.apiKey, 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
text,
|
||||
model_id: opts.model || 'eleven_multilingual_v2',
|
||||
}),
|
||||
});
|
||||
if (!resp.ok) throw new Error(`ElevenLabs TTS error ${resp.status}: ${await resp.text()}`);
|
||||
const buf = Buffer.from(await resp.arrayBuffer());
|
||||
writeFileSync(outputPath, buf);
|
||||
}
|
||||
|
||||
/** Get TTS provider function by name. */
|
||||
export function getTtsProvider(name) {
|
||||
switch (name) {
|
||||
case 'openai': return openaiTtsProvider;
|
||||
case 'elevenlabs': return elevenlabsTtsProvider;
|
||||
case 'edge': default: return edgeTtsProvider;
|
||||
}
|
||||
}
|
||||
|
||||
// ── TTS audio helpers ──────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Get audio duration in seconds using ffprobe.
|
||||
* @param {string} filePath — path to audio file
|
||||
* @param {string} ffmpegPath — path to ffmpeg binary (ffprobe is found next to it)
|
||||
* @returns {number} duration in seconds
|
||||
*/
|
||||
export function getAudioDuration(filePath, ffmpegPath) {
|
||||
const ffprobePath = ffmpegPath.replace(/ffmpeg(\.exe)?$/i, 'ffprobe$1');
|
||||
const out = execFileSync(ffprobePath, [
|
||||
'-v', 'error', '-show_entries', 'format=duration',
|
||||
'-of', 'default=noprint_wrappers=1:nokey=1', filePath,
|
||||
], { encoding: 'utf8', timeout: 10000 }).trim();
|
||||
return parseFloat(out) || 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a silence mp3 file of given duration.
|
||||
* @param {string} outputPath — path for the output mp3 file
|
||||
* @param {number} seconds — duration in seconds
|
||||
* @param {string} ffmpegPath — path to ffmpeg binary
|
||||
*/
|
||||
export function generateSilence(outputPath, seconds, ffmpegPath) {
|
||||
execFileSync(ffmpegPath, [
|
||||
'-y', '-f', 'lavfi', '-i', `anullsrc=r=24000:cl=mono`,
|
||||
'-t', String(seconds), '-c:a', 'libmp3lame', '-b:a', '32k', outputPath,
|
||||
], { stdio: 'pipe', timeout: 10000 });
|
||||
}
|
||||
@@ -0,0 +1,560 @@
|
||||
// web-test spreadsheet v1.19 — readSpreadsheet + helpers for SpreadsheetDocument (отчёты, печатные формы).
|
||||
// Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
|
||||
|
||||
import { page, ensureConnected } from '../core/state.mjs';
|
||||
import { detectFormScript } from '../../dom.mjs';
|
||||
import { waitForStable } from '../core/wait.mjs';
|
||||
import { getFormState } from '../forms/state.mjs';
|
||||
import { returnFormState } from '../core/helpers.mjs';
|
||||
import { scrollHorizontallyByKey } from '../core/scroll-horiz.mjs';
|
||||
|
||||
// --- Spreadsheet helpers (shared by readSpreadsheet and clickElement) ---
|
||||
|
||||
/**
|
||||
* Scan spreadsheet iframes for the current form and collect all cells.
|
||||
* Returns { allCells: Map<'r_c', {r,c,t}>, frameMap: Map<'r_c', frameIndex> }
|
||||
* where frameIndex is the Playwright frames[] index (1-based, 0 = main).
|
||||
*/
|
||||
async function scanSpreadsheetCells(formNum) {
|
||||
const prefix = `form${formNum ?? 0}_`;
|
||||
const iframeHandles = await page.$$('iframe');
|
||||
|
||||
const allCells = new Map();
|
||||
const frameMap = new Map(); // key 'r_c' → Playwright Frame object
|
||||
|
||||
for (const handle of iframeHandles) {
|
||||
const ok = await handle.evaluate((f, pfx) => {
|
||||
if (f.offsetWidth < 100) return false;
|
||||
let el = f.parentElement;
|
||||
for (let d = 0; el && d < 30; d++, el = el.parentElement) {
|
||||
if (el.id && el.id.startsWith(pfx)) return true;
|
||||
}
|
||||
return false;
|
||||
}, prefix);
|
||||
if (!ok) continue;
|
||||
|
||||
const frame = await handle.contentFrame();
|
||||
if (!frame) continue;
|
||||
|
||||
try {
|
||||
const cells = await frame.evaluate(`(() => {
|
||||
const cells = [];
|
||||
document.querySelectorAll('div[x]').forEach(d => {
|
||||
const span = d.querySelector('span');
|
||||
const text = span?.innerText?.replace(/\\n/g, ' ')?.trim() || '';
|
||||
if (!text) return;
|
||||
const rowDiv = d.parentElement;
|
||||
const row = rowDiv?.getAttribute('y') || rowDiv?.className?.match(/R(\\d+)/)?.[1] || null;
|
||||
const col = d.getAttribute('x');
|
||||
if (row != null && col != null) cells.push({ r: parseInt(row), c: parseInt(col), t: text });
|
||||
});
|
||||
return cells;
|
||||
})()`);
|
||||
for (const cell of cells) {
|
||||
const key = `${cell.r}_${cell.c}`;
|
||||
if (!allCells.has(key) || cell.t.length > allCells.get(key).t.length) {
|
||||
allCells.set(key, cell);
|
||||
frameMap.set(key, frame);
|
||||
}
|
||||
}
|
||||
} catch { /* skip inaccessible frames */ }
|
||||
}
|
||||
return { allCells, frameMap };
|
||||
}
|
||||
|
||||
/**
|
||||
* Build structured mapping from raw cells: headers, column map, data/totals row indices.
|
||||
* Returns { rows, sortedRows, maxCol, colNames, headerRowIdx, dataStartIdx, totalsRowIdx, rowMap }
|
||||
* or null if header detection fails.
|
||||
*/
|
||||
function buildSpreadsheetMapping(allCells) {
|
||||
const rowMap = new Map();
|
||||
let maxCol = 0;
|
||||
for (const cell of allCells.values()) {
|
||||
if (!rowMap.has(cell.r)) rowMap.set(cell.r, new Map());
|
||||
rowMap.get(cell.r).set(cell.c, cell.t);
|
||||
if (cell.c > maxCol) maxCol = cell.c;
|
||||
}
|
||||
|
||||
const sortedRows = [...rowMap.keys()].sort((a, b) => a - b);
|
||||
const rows = sortedRows.map(r => {
|
||||
const cm = rowMap.get(r);
|
||||
const arr = [];
|
||||
for (let c = 0; c <= maxCol; c++) arr.push(cm.get(c) || '');
|
||||
return arr;
|
||||
});
|
||||
|
||||
// Generic numeric check: digits with optional spaces/commas, excludes codes like "68/78"
|
||||
// Accepts bare integers (e.g. account codes "50", "84") — used for hasNumber / totals classification.
|
||||
const isNumericVal = (c) => {
|
||||
if (!c || !/\d/.test(c)) return false;
|
||||
const s = c.replace(/^[-\s\u00a0]+/, '').replace(/[\s\u00a0]/g, '');
|
||||
return /^\d[\d,]*$/.test(s);
|
||||
};
|
||||
// Data-formatted numeric value: requires a formatting signal (grouping space, decimal comma, or leading minus).
|
||||
// Used as the anchor for first data row — avoids false positives on bare account codes like "50", "51".
|
||||
const isDataNumericVal = (c) => {
|
||||
if (!isNumericVal(c)) return false;
|
||||
return /[\s\u00a0,]/.test(c) || /^-/.test(c);
|
||||
};
|
||||
const hasNumber = (row) => row.some(c => isNumericVal(c));
|
||||
const nonEmpty = (row) => row.filter(c => c !== '').length;
|
||||
|
||||
// Build a rich mapping (group/super/DCS) anchored at a known detailIdx + firstDataIdx.
|
||||
// Shared by Level 1 (DCS-code anchor) and Level 2 (formatted-number anchor).
|
||||
const buildRichMapping = (detailIdx, firstDataIdx) => {
|
||||
let groupIdx = -1;
|
||||
if (detailIdx > 0 && nonEmpty(rows[detailIdx - 1]) >= 2) groupIdx = detailIdx - 1;
|
||||
|
||||
const detailRow = rows[detailIdx];
|
||||
const groupRow = groupIdx >= 0 ? rows[groupIdx] : null;
|
||||
|
||||
// Detect optional third header level above group row (bounds carry-forward)
|
||||
let superRow = null;
|
||||
if (groupIdx > 0 && nonEmpty(rows[groupIdx - 1]) >= 2) {
|
||||
superRow = rows[groupIdx - 1];
|
||||
}
|
||||
|
||||
// Build column names (group + detail merge)
|
||||
const groupFilled = new Array(maxCol + 1).fill('');
|
||||
if (groupRow) {
|
||||
let cur = '';
|
||||
for (let c = 0; c <= maxCol; c++) {
|
||||
if (groupRow[c]) {
|
||||
cur = groupRow[c];
|
||||
} else if (superRow && superRow[c]) {
|
||||
// New top-level header starts here — stop carry-forward
|
||||
cur = '';
|
||||
}
|
||||
groupFilled[c] = cur;
|
||||
}
|
||||
}
|
||||
|
||||
const detailCounts = {};
|
||||
for (let c = 0; c <= maxCol; c++) {
|
||||
const n = detailRow[c];
|
||||
if (n) detailCounts[n] = (detailCounts[n] || 0) + 1;
|
||||
}
|
||||
|
||||
// Detect DCS column codes (К1, К2, ...) — always prefix with group when present
|
||||
const detailNonEmpty = detailRow.filter(c => c);
|
||||
const isDcsCodeRow = detailNonEmpty.length >= 2 && detailNonEmpty.every(c => /^К\d+$/.test(c));
|
||||
|
||||
const colNames = [];
|
||||
for (let c = 0; c <= maxCol; c++) {
|
||||
const detail = detailRow[c];
|
||||
const group = groupFilled[c];
|
||||
const sup = superRow ? superRow[c] : '';
|
||||
if (detail) {
|
||||
// Prefer group prefix; fall back to superRow for DCS code columns without sub-group
|
||||
const prefix = group && group !== detail ? group : (isDcsCodeRow && sup ? sup : '');
|
||||
const needPrefix = prefix && (isDcsCodeRow || detailCounts[detail] > 1 || (groupRow && groupRow[c] === ''));
|
||||
colNames.push(needPrefix ? `${prefix} / ${detail}` : detail);
|
||||
} else if (group) {
|
||||
colNames.push(group);
|
||||
} else if (sup) {
|
||||
colNames.push(sup);
|
||||
} else {
|
||||
colNames.push(null);
|
||||
}
|
||||
}
|
||||
|
||||
const colMap = new Map();
|
||||
for (let c = 0; c < colNames.length; c++) {
|
||||
if (colNames[c]) colMap.set(colNames[c], c);
|
||||
}
|
||||
|
||||
// Classify data rows: separate data indices and totals index
|
||||
const dataRowIndices = [];
|
||||
let totalsRowIdx = -1;
|
||||
for (let i = firstDataIdx; i < rows.length; i++) {
|
||||
if (!hasNumber(rows[i]) && nonEmpty(rows[i]) === 0) continue;
|
||||
const first = rows[i][0]?.trim().toLowerCase();
|
||||
if (first === 'итого' || first === 'всего') {
|
||||
totalsRowIdx = i;
|
||||
} else {
|
||||
dataRowIndices.push(i);
|
||||
}
|
||||
}
|
||||
|
||||
const superRowIdx = superRow ? groupIdx - 1 : -1;
|
||||
|
||||
return {
|
||||
rows, sortedRows, maxCol, colNames, colMap,
|
||||
headerRowIdx: detailIdx, groupRowIdx: groupIdx, superRowIdx,
|
||||
dataStartIdx: firstDataIdx, dataRowIndices, totalsRowIdx,
|
||||
rowMap, hasNumber, nonEmpty,
|
||||
};
|
||||
};
|
||||
|
||||
// --- Level 1: DCS-code row anchor ---
|
||||
// ФСД / СКД-отчёты всегда содержат строку "К1, К2, ..." — rock-solid structural marker.
|
||||
// Якорение через неё — детерминированное, работает даже если все данные — голые целые (отчёт в "тыс.руб").
|
||||
for (let i = 0; i < rows.length; i++) {
|
||||
const detailNonEmpty = rows[i].filter(c => c);
|
||||
if (detailNonEmpty.length >= 2 && detailNonEmpty.every(c => /^К\d+$/.test(c))) {
|
||||
// Find first non-empty row after the К-codes row as data start
|
||||
let firstDataIdx = rows.length;
|
||||
for (let j = i + 1; j < rows.length; j++) {
|
||||
if (nonEmpty(rows[j]) > 0) { firstDataIdx = j; break; }
|
||||
}
|
||||
return buildRichMapping(i, firstDataIdx);
|
||||
}
|
||||
}
|
||||
|
||||
// --- Level 2: formatted-number anchor (heuristic for reports without DCS codes) ---
|
||||
let firstDataIdx = rows.length;
|
||||
for (let i = 0; i < rows.length; i++) {
|
||||
if (rows[i].filter(c => isDataNumericVal(c)).length >= 2) { firstDataIdx = i; break; }
|
||||
}
|
||||
if (firstDataIdx === rows.length) {
|
||||
for (let i = 0; i < rows.length; i++) {
|
||||
if (rows[i].some(c => isDataNumericVal(c))) { firstDataIdx = i; break; }
|
||||
}
|
||||
}
|
||||
|
||||
if (firstDataIdx < rows.length) {
|
||||
let detailIdx = -1;
|
||||
for (let i = firstDataIdx - 1; i >= 0; i--) {
|
||||
if (nonEmpty(rows[i]) >= Math.min(3, maxCol + 1)) { detailIdx = i; break; }
|
||||
}
|
||||
if (detailIdx !== -1) return buildRichMapping(detailIdx, firstDataIdx);
|
||||
}
|
||||
|
||||
// --- Level 3: single-row header fallback (text-only data, query console) ---
|
||||
// First "wide" row (nonEmpty >= 2) = headers, rest = data. No multi-level composition.
|
||||
let headerIdx = -1;
|
||||
for (let i = 0; i < rows.length; i++) {
|
||||
if (nonEmpty(rows[i]) >= 2) { headerIdx = i; break; }
|
||||
}
|
||||
// Single-column tables: accept nonEmpty >= 1
|
||||
if (headerIdx === -1 && maxCol === 0) {
|
||||
for (let i = 0; i < rows.length; i++) {
|
||||
if (nonEmpty(rows[i]) >= 1) { headerIdx = i; break; }
|
||||
}
|
||||
}
|
||||
if (headerIdx === -1) return null; // truly empty — top-level fallback to { rows, total }
|
||||
|
||||
const detailRow = rows[headerIdx];
|
||||
const colNames = [];
|
||||
for (let c = 0; c <= maxCol; c++) colNames.push(detailRow[c] || null);
|
||||
const colMap = new Map();
|
||||
for (let c = 0; c < colNames.length; c++) {
|
||||
if (colNames[c]) colMap.set(colNames[c], c);
|
||||
}
|
||||
|
||||
const dataRowIndices = [];
|
||||
let totalsRowIdx = -1;
|
||||
for (let i = headerIdx + 1; i < rows.length; i++) {
|
||||
if (!hasNumber(rows[i]) && nonEmpty(rows[i]) === 0) continue;
|
||||
const first = rows[i][0]?.trim().toLowerCase();
|
||||
if (first === 'итого' || first === 'всего') {
|
||||
totalsRowIdx = i;
|
||||
} else {
|
||||
dataRowIndices.push(i);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
rows, sortedRows, maxCol, colNames, colMap,
|
||||
headerRowIdx: headerIdx, groupRowIdx: -1, superRowIdx: -1,
|
||||
dataStartIdx: headerIdx + 1, dataRowIndices, totalsRowIdx,
|
||||
rowMap, hasNumber, nonEmpty,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Scroll SpreadsheetDocument to make a cell visible using arrow keys.
|
||||
* Uses native platform scroll — keeps headers, data, and scrollbar synchronized.
|
||||
*
|
||||
* How it works:
|
||||
* 1. Check target cell visibility via Playwright boundingBox (page-level coords).
|
||||
* 2. Click a fully-visible cell via page.mouse.click through the mxlCurrBody overlay.
|
||||
* This is the same native click that clickSpreadsheetCell uses — it gives keyboard
|
||||
* focus to the spreadsheet and keeps headers/data/scrollbar in sync.
|
||||
* (frame.locator().click() bypasses overlay → desyncs frozen headers;
|
||||
* page.mouse.click() + frameEl.focus() doesn't transfer keyboard focus.)
|
||||
* 3. Press ArrowRight/ArrowLeft until the target cell is fully within the viewport.
|
||||
*
|
||||
* @param {Frame} frame - Playwright Frame containing the spreadsheet cells
|
||||
* @param {number} physRow - physical row (y attribute) in the frame
|
||||
* @param {number} physCol - physical column (x attribute) in the frame
|
||||
* @param {Locator} cellLoc - Playwright locator for the target cell (from caller)
|
||||
*/
|
||||
async function scrollSpreadsheetToCell(frame, physRow, physCol, cellLoc) {
|
||||
const pageVw = await page.evaluate('window.innerWidth');
|
||||
// Get iframe bounds — the actual visible region on page.
|
||||
// The iframe may extend behind the section panel on the left, so cells with
|
||||
// x >= 0 but x < iframeBox.x are behind the panel. Clicking them hits the panel.
|
||||
const frameElm = await frame.frameElement();
|
||||
const frameBox = await frameElm.boundingBox();
|
||||
const visLeft = frameBox ? frameBox.x : 0;
|
||||
const visRight = frameBox ? Math.min(frameBox.x + frameBox.width, pageVw) : pageVw;
|
||||
|
||||
const getBox = async () => {
|
||||
try { return await cellLoc.boundingBox({ timeout: 500 }); }
|
||||
catch { return null; }
|
||||
};
|
||||
const isFullyVisible = (box) => box && box.x >= visLeft && (box.x + box.width) <= visRight;
|
||||
|
||||
let box = await getBox();
|
||||
if (!box) return; // cell not in DOM
|
||||
if (isFullyVisible(box)) return;
|
||||
|
||||
const direction = (box.x + box.width) > pageVw ? 'ArrowRight' : 'ArrowLeft';
|
||||
|
||||
// Find a fully-visible cell to click for focus.
|
||||
// Prefer cells in the target row (scrollable area), fall back to any row.
|
||||
const targetRowSel = `div[y="${physRow}"] div[x]`;
|
||||
const anyRowSel = 'div[x]';
|
||||
let focusClicked = false;
|
||||
for (const sel of [targetRowSel, anyRowSel]) {
|
||||
const locs = frame.locator(sel);
|
||||
const count = await locs.count();
|
||||
const candidates = [];
|
||||
for (let ci = 0; ci < count; ci++) {
|
||||
const b = await locs.nth(ci).boundingBox();
|
||||
if (b && b.width > 5 && b.x >= visLeft && (b.x + b.width) <= visRight) {
|
||||
candidates.push({ ci, box: b });
|
||||
}
|
||||
}
|
||||
if (candidates.length === 0) continue;
|
||||
candidates.sort((a, b) => a.box.x - b.box.x);
|
||||
// ArrowRight → rightmost fully-visible (each press scrolls right immediately)
|
||||
// ArrowLeft → leftmost fully-visible (each press scrolls left immediately)
|
||||
const pick = direction === 'ArrowRight'
|
||||
? candidates[candidates.length - 1]
|
||||
: candidates[0];
|
||||
// Native click through overlay — gives keyboard focus + no header desync.
|
||||
await page.mouse.click(pick.box.x + pick.box.width / 2, pick.box.y + pick.box.height / 2);
|
||||
await page.waitForTimeout(100);
|
||||
focusClicked = true;
|
||||
break;
|
||||
}
|
||||
if (!focusClicked) return; // no visible cells — can't scroll
|
||||
|
||||
await scrollHorizontallyByKey({
|
||||
page, direction,
|
||||
isFullyVisible: async () => {
|
||||
const b = await getBox();
|
||||
return !!b && isFullyVisible(b);
|
||||
},
|
||||
getCenterX: async () => {
|
||||
const b = await getBox();
|
||||
return b ? b.x + b.width / 2 : null;
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Click a cell in SpreadsheetDocument by logical coordinates.
|
||||
* target: { row: number|'totals'|{colName: value}, column: string }
|
||||
* Internal helper — called from clickElement when first arg is an object.
|
||||
*/
|
||||
export async function clickSpreadsheetCell(target, { dblclick: dbl, modifier } = {}) {
|
||||
ensureConnected();
|
||||
const formNum = await page.evaluate(detectFormScript());
|
||||
const { allCells, frameMap } = await scanSpreadsheetCells(formNum);
|
||||
if (allCells.size === 0) throw new Error('clickElement: no SpreadsheetDocument found on current form.');
|
||||
|
||||
const mapping = buildSpreadsheetMapping(allCells);
|
||||
if (!mapping) throw new Error('clickElement: could not detect spreadsheet headers. Use readSpreadsheet() to check report structure.');
|
||||
|
||||
const { rows, sortedRows, colNames, colMap, dataRowIndices, totalsRowIdx } = mapping;
|
||||
|
||||
// Resolve column (exact → endsWith " / X" → includes)
|
||||
let colName = target.column;
|
||||
if (!colMap.has(colName)) {
|
||||
const available = colNames.filter(n => n);
|
||||
const suffix = ' / ' + colName;
|
||||
const match = available.find(n => n.endsWith(suffix)) || available.find(n => n.includes(colName));
|
||||
if (!match) throw new Error(`clickElement: column "${colName}" not found. Available: ${available.join(', ')}`);
|
||||
colName = match;
|
||||
}
|
||||
const physCol = colMap.get(colName);
|
||||
|
||||
// Resolve row → index into rows[] array
|
||||
let rowIdx;
|
||||
const row = target.row;
|
||||
if (row === 'totals') {
|
||||
if (totalsRowIdx === -1) throw new Error('clickElement: no totals row found in spreadsheet.');
|
||||
rowIdx = totalsRowIdx;
|
||||
} else if (typeof row === 'number') {
|
||||
if (row < 0 || row >= dataRowIndices.length) throw new Error(`clickElement: row index ${row} out of range (0..${dataRowIndices.length - 1}).`);
|
||||
rowIdx = dataRowIndices[row];
|
||||
} else if (typeof row === 'object') {
|
||||
// Filter: { colName: value } — find first data row where column matches
|
||||
const filterEntries = Object.entries(row);
|
||||
const norm = s => s?.replace(/\u00a0/g, ' ').trim().toLowerCase() || '';
|
||||
const resolveCol = (name) => {
|
||||
if (colMap.has(name)) return colMap.get(name);
|
||||
const suffix = ' / ' + name;
|
||||
const available = colNames.filter(n => n);
|
||||
const m = available.find(n => n.endsWith(suffix)) || available.find(n => n.includes(name));
|
||||
return m ? colMap.get(m) : null;
|
||||
};
|
||||
rowIdx = dataRowIndices.find(i => {
|
||||
return filterEntries.every(([fCol, fVal]) => {
|
||||
const fColIdx = resolveCol(fCol);
|
||||
if (fColIdx == null) return false;
|
||||
const cellText = norm(rows[i][fColIdx]);
|
||||
const search = norm(fVal);
|
||||
return cellText === search || cellText.includes(search);
|
||||
});
|
||||
});
|
||||
if (rowIdx == null) throw new Error(`clickElement: no row matching ${JSON.stringify(row)} found in spreadsheet data.`);
|
||||
} else {
|
||||
throw new Error('clickElement: row must be a number, "totals", or { colName: value } filter object.');
|
||||
}
|
||||
|
||||
// Map rows[] index → physical row number
|
||||
const physRow = sortedRows[rowIdx];
|
||||
const cellKey = `${physRow}_${physCol}`;
|
||||
const frame = frameMap.get(cellKey);
|
||||
if (!frame) {
|
||||
// Cell exists in mapping but might be empty — try clicking anyway
|
||||
throw new Error(`clickElement: cell at row=${JSON.stringify(target.row)}, column="${colName}" is empty or not rendered.`);
|
||||
}
|
||||
// Use [y]+[x] attributes — CSS class RxCy uses different numbering than y/x attrs.
|
||||
const cellDiv = frame.locator(`div[y="${physRow}"] div[x="${physCol}"]`).first();
|
||||
// Scroll cell into view using arrow keys — the only reliable way to scroll
|
||||
// 1C SpreadsheetDocument without desynchronizing headers, data, and scrollbar.
|
||||
await scrollSpreadsheetToCell(frame, physRow, physCol, cellDiv);
|
||||
const box = await cellDiv.boundingBox();
|
||||
if (!box) throw new Error(`clickElement: cell y=${physRow} x=${physCol} not visible (no bounding box).`);
|
||||
|
||||
const x = box.x + box.width / 2;
|
||||
const y = box.y + box.height / 2;
|
||||
const modKey = modifier === 'ctrl' ? 'Control' : modifier === 'shift' ? 'Shift' : null;
|
||||
if (modKey) await page.keyboard.down(modKey);
|
||||
if (dbl) {
|
||||
await page.mouse.dblclick(x, y);
|
||||
} else {
|
||||
await page.mouse.click(x, y);
|
||||
}
|
||||
if (modKey) await page.keyboard.up(modKey);
|
||||
|
||||
await waitForStable();
|
||||
return returnFormState({ clicked: { kind: 'spreadsheetCell', row: target.row, column: colName, ...(dbl ? { dblclick: true } : {}) } });
|
||||
}
|
||||
|
||||
/**
|
||||
* Search spreadsheet iframes for a cell matching text (for text fallback in clickElement).
|
||||
* Returns { frameIndex, physRow, physCol, box } or null if not found.
|
||||
*/
|
||||
export async function findSpreadsheetCellByText(formNum, searchText) {
|
||||
const { allCells, frameMap } = await scanSpreadsheetCells(formNum);
|
||||
if (allCells.size === 0) return null;
|
||||
|
||||
const norm = s => s?.replace(/\u00a0/g, ' ').trim().toLowerCase() || '';
|
||||
const target = norm(searchText);
|
||||
|
||||
// Exact match first, then includes
|
||||
let found = null;
|
||||
for (const [key, cell] of allCells) {
|
||||
if (norm(cell.t) === target) { found = { key, cell }; break; }
|
||||
}
|
||||
if (!found) {
|
||||
for (const [key, cell] of allCells) {
|
||||
if (norm(cell.t).includes(target)) { found = { key, cell }; break; }
|
||||
}
|
||||
}
|
||||
if (!found) return null;
|
||||
|
||||
const frame = frameMap.get(found.key);
|
||||
if (!frame) return null;
|
||||
|
||||
// Scroll cell into view using native arrow-key mechanism
|
||||
const cellDiv = frame.locator(`div[y="${found.cell.r}"] div[x="${found.cell.c}"]`).first();
|
||||
await scrollSpreadsheetToCell(frame, found.cell.r, found.cell.c, cellDiv);
|
||||
const box = await cellDiv.boundingBox();
|
||||
if (!box) return null;
|
||||
|
||||
return { frame, physRow: found.cell.r, physCol: found.cell.c, text: found.cell.t, box };
|
||||
}
|
||||
|
||||
/**
|
||||
* Read report output (SpreadsheetDocumentField) rendered in iframes.
|
||||
* 1C renders spreadsheet documents as absolutely-positioned div cells inside iframes.
|
||||
* Each cell is a div[x] inside a row div[y], text content in <span>.
|
||||
*
|
||||
* Returns structured data:
|
||||
* { title, headers, data: [{col: val}], totals: {col: val}, total }
|
||||
* If header detection fails, falls back to { rows: string[][], total }.
|
||||
*/
|
||||
export async function readSpreadsheet() {
|
||||
ensureConnected();
|
||||
const formNum = await page.evaluate(detectFormScript());
|
||||
|
||||
const { allCells } = await scanSpreadsheetCells(formNum);
|
||||
|
||||
if (allCells.size === 0) {
|
||||
// Check for state window messages (info bar) that explain why the report is empty
|
||||
const err = await checkForErrors();
|
||||
const hint = err?.stateText?.length ? err.stateText.join('; ') : '';
|
||||
throw new Error('readSpreadsheet: no SpreadsheetDocument found.' + (hint ? ' State: ' + hint : ' Report may not be generated yet.'));
|
||||
}
|
||||
|
||||
const mapping = buildSpreadsheetMapping(allCells);
|
||||
if (!mapping) {
|
||||
// Fallback: return raw rows
|
||||
const rowMap = new Map();
|
||||
let maxCol = 0;
|
||||
for (const cell of allCells.values()) {
|
||||
if (!rowMap.has(cell.r)) rowMap.set(cell.r, new Map());
|
||||
rowMap.get(cell.r).set(cell.c, cell.t);
|
||||
if (cell.c > maxCol) maxCol = cell.c;
|
||||
}
|
||||
const sortedRows = [...rowMap.keys()].sort((a, b) => a - b);
|
||||
const rows = sortedRows.map(r => {
|
||||
const cm = rowMap.get(r);
|
||||
const arr = [];
|
||||
for (let c = 0; c <= maxCol; c++) arr.push(cm.get(c) || '');
|
||||
return arr;
|
||||
});
|
||||
return { rows, total: rows.length };
|
||||
}
|
||||
|
||||
const { rows, colNames, dataStartIdx, maxCol, groupRowIdx, headerRowIdx, superRowIdx, hasNumber, nonEmpty } = mapping;
|
||||
|
||||
// Convert data rows to objects
|
||||
const data = [];
|
||||
let totals = null;
|
||||
const toObj = (row) => {
|
||||
const obj = {};
|
||||
for (let c = 0; c < colNames.length; c++) {
|
||||
if (colNames[c] && row[c]) obj[colNames[c]] = row[c];
|
||||
}
|
||||
return obj;
|
||||
};
|
||||
|
||||
for (let i = dataStartIdx; i < rows.length; i++) {
|
||||
if (!hasNumber(rows[i]) && nonEmpty(rows[i]) === 0) continue;
|
||||
const first = rows[i][0]?.trim().toLowerCase();
|
||||
if (first === 'итого' || first === 'всего') {
|
||||
totals = toObj(rows[i]);
|
||||
} else {
|
||||
data.push(toObj(rows[i]));
|
||||
}
|
||||
}
|
||||
|
||||
// Meta: title, params, filters from rows before header (superRow is part of header, not meta)
|
||||
const metaEnd = superRowIdx >= 0 ? superRowIdx : (groupRowIdx >= 0 ? groupRowIdx : headerRowIdx);
|
||||
let title = '';
|
||||
const meta = [];
|
||||
for (let i = 0; i < metaEnd; i++) {
|
||||
const parts = rows[i].filter(c => c);
|
||||
if (!parts.length) continue;
|
||||
if (!title) { title = parts.join(' '); continue; }
|
||||
meta.push(parts.join(' '));
|
||||
}
|
||||
|
||||
return {
|
||||
title: title || undefined,
|
||||
meta: meta.length ? meta : undefined,
|
||||
headers: colNames.filter(n => n),
|
||||
data,
|
||||
totals: totals || undefined,
|
||||
total: data.length,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,235 @@
|
||||
// web-test table/click-cell v1.4 — click a cell in a form grid by (row, column).
|
||||
// Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
|
||||
//
|
||||
// Routed from core/click.mjs when the user calls clickElement({row, column}) and
|
||||
// the form has no SpreadsheetDocument (or `table` matches a grid).
|
||||
//
|
||||
// Key behaviors:
|
||||
// - `row` can be a number (index in current DOM window) or `{col: value}` filter.
|
||||
// - `scroll: true | number` enables reveal-loop via PageDown when a filter row
|
||||
// isn't visible. End detected by snapshot stability between PageDowns.
|
||||
// - Horizontal scroll mirrors SpreadsheetDocument: focus a visible cell in the
|
||||
// target row, press ArrowRight/Left until the target column is in viewport.
|
||||
//
|
||||
// 1С virtualization quirks worth knowing:
|
||||
// - DOM holds a window of ~N visible rows. PageDown's first press moves the
|
||||
// cursor inside the window; subsequent presses swap the window contents.
|
||||
// - scrollTop/scrollLeft are always 0; absolute X of cells shifts on horizontal
|
||||
// scroll. So scroll progress must be inferred from cell coordinates / snapshot
|
||||
// diffs, never from scrollTop/Height.
|
||||
// - Frozen columns (.gridBoxFix) stay pinned at the left, overlap with scrolled
|
||||
// cells — DOM scripts handle the partition; engine just consumes their results.
|
||||
|
||||
import { page } from '../core/state.mjs';
|
||||
import { waitForStable } from '../core/wait.mjs';
|
||||
import { modifierClick, returnFormState, isInputFocusedInGrid } from '../core/helpers.mjs';
|
||||
import { scrollHorizontallyByKey } from '../core/scroll-horiz.mjs';
|
||||
import {
|
||||
findGridCellScript, findFocusCellScript, snapshotGridScript,
|
||||
} from '../../dom.mjs';
|
||||
|
||||
const REVEAL_DEFAULT_LIMIT = 50;
|
||||
const PD_WAIT_MS = 300;
|
||||
const FOCUS_WAIT_MS = 150;
|
||||
|
||||
/**
|
||||
* Guard: a 'pic:N' filter value is a readTable picture token, not real cell text.
|
||||
* Picture cells render an icon (no text), so they can't select a row — fail fast
|
||||
* with guidance instead of a confusing 'row_not_found'.
|
||||
*/
|
||||
function assertNotPictureFilter(filter) {
|
||||
for (const [k, v] of Object.entries(filter)) {
|
||||
if (typeof v === 'string' && /^pic:\d+$/.test(v.trim())) {
|
||||
throw new Error(`clickElement: "${v}" is a readTable picture value (column "${k}"), not selectable text — it can't be used as a row filter. Filter by a text column (e.g. name/number) instead.`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve a `{ col: value }` row filter to a numeric index into the grid's current
|
||||
* DOM window (`body.querySelectorAll('.gridLine')`). Reused by fillTableRow so it
|
||||
* can target an existing row by cell values, mirroring clickElement.
|
||||
*
|
||||
* The filter matches across ALL columns (AND). `findGridCellScript` requires a
|
||||
* `column`, so we pass the first filter key as a placeholder — it only affects the
|
||||
* returned coordinates (which we ignore), not row selection. The matched row
|
||||
* guarantees that key's cell is in the DOM, so no `cell_not_in_dom` for it.
|
||||
*
|
||||
* @param {object} args
|
||||
* @param {number} args.formNum
|
||||
* @param {string} [args.gridSelector] - CSS selector for the target grid (same grid the caller edits)
|
||||
* @param {object} args.filter - `{ col: value }` (one or more columns)
|
||||
* @param {string} [args.gridName] - for diagnostics in error messages
|
||||
* @param {boolean|number} [args.scroll] - reveal-loop beyond the DOM window (true = 50 PageDowns, number = limit)
|
||||
* @returns {Promise<number>} resolved row index
|
||||
*/
|
||||
export async function resolveRowIndexByFilter({ formNum, gridSelector, filter, gridName, scroll }) {
|
||||
assertNotPictureFilter(filter);
|
||||
const target = { row: filter, column: Object.keys(filter)[0] };
|
||||
let cell = await page.evaluate(findGridCellScript(formNum, gridSelector, target));
|
||||
if (cell?.error === 'row_not_found' && scroll) {
|
||||
cell = await revealAndFindCell({ formNum, gridSelector, target, scroll });
|
||||
}
|
||||
if (cell?.error) throw cellError(cell, target, gridName, scroll, 'fillTableRow');
|
||||
return cell.rowIdx;
|
||||
}
|
||||
|
||||
/**
|
||||
* Click a cell in a form grid by (row, column). Called from core/click.mjs.
|
||||
*
|
||||
* @param {object} target - { row: number|{col:value}, column: string }
|
||||
* @param {object} ctx
|
||||
* @param {number} ctx.formNum
|
||||
* @param {string} ctx.gridSelector - CSS selector for the target grid
|
||||
* @param {string} [ctx.gridName] - for diagnostics
|
||||
* @param {string} [ctx.modifier] - 'ctrl' | 'shift' for multi-select
|
||||
* @param {boolean} [ctx.dblclick]
|
||||
* @param {boolean|number} [ctx.scroll] - true = up to 50 PageDowns, number = exact limit
|
||||
*/
|
||||
export async function clickGridCell(target, ctx) {
|
||||
const { formNum, gridSelector, gridName, modifier, dblclick, scroll } = ctx;
|
||||
|
||||
if (target?.row && typeof target.row === 'object') assertNotPictureFilter(target.row);
|
||||
|
||||
// 1. Try to find the cell in current DOM window.
|
||||
let cell = await page.evaluate(findGridCellScript(formNum, gridSelector, target));
|
||||
|
||||
// 2. Reveal loop: only for filter-based row search with scroll opt-in.
|
||||
if (cell?.error === 'row_not_found' && scroll && target.row && typeof target.row === 'object') {
|
||||
cell = await revealAndFindCell({ formNum, gridSelector, target, scroll });
|
||||
}
|
||||
|
||||
if (cell?.error) throw cellError(cell, target, gridName, scroll);
|
||||
|
||||
// 3. Horizontal scroll if cell is off-viewport.
|
||||
if (!cell.visible) {
|
||||
await scrollGridToCell({ formNum, gridSelector, target, cell });
|
||||
cell = await page.evaluate(findGridCellScript(formNum, gridSelector, target));
|
||||
if (cell?.error) {
|
||||
throw new Error(`clickElement: cell vanished after horizontal scroll: ${cell.error}`);
|
||||
}
|
||||
if (!cell.visible) {
|
||||
// Scroll loop bailed out before reaching the target. Don't silently click
|
||||
// at off-screen coordinates — that would report a false success.
|
||||
const ctxMsg = gridName ? ` in table "${gridName}"` : '';
|
||||
throw new Error(`clickElement: horizontal scroll could not reach column "${cell.columnText}"${ctxMsg} (cell still at x=${cell.cellX}, viewport ends at ${cell.gridRight}).`);
|
||||
}
|
||||
}
|
||||
|
||||
// 4. Click.
|
||||
await modifierClick(cell.x, cell.y, modifier, { dbl: !!dblclick });
|
||||
await waitForStable();
|
||||
return returnFormState({
|
||||
clicked: {
|
||||
kind: 'gridCell',
|
||||
row: target.row,
|
||||
column: cell.columnText,
|
||||
...(dblclick ? { dblclick: true } : {}),
|
||||
...(modifier ? { modifier } : {}),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function cellError(cell, target, gridName, scroll, who = 'clickElement') {
|
||||
const ctxMsg = gridName ? ` in table "${gridName}"` : '';
|
||||
if (cell.error === 'row_not_found') {
|
||||
const hint = scroll
|
||||
? ' (reveal-loop exhausted)'
|
||||
: ' — pass { scroll: true } to scan beyond the current DOM window';
|
||||
return new Error(`${who}: row matching ${JSON.stringify(target.row)} not found${ctxMsg}${hint}.`);
|
||||
}
|
||||
if (cell.error === 'column_not_found' || cell.error === 'filter_column_not_found') {
|
||||
return new Error(`${who}: column "${cell.column}" not found${ctxMsg}. Available: ${(cell.available || []).join(', ')}`);
|
||||
}
|
||||
if (cell.error === 'row_out_of_range') {
|
||||
return new Error(`${who}: row index ${cell.row} out of range${ctxMsg} (loaded: ${cell.loaded}). Note: row index is into current DOM window, not absolute — long lists are virtualized.`);
|
||||
}
|
||||
return new Error(`${who}: cannot resolve cell ${JSON.stringify(target)}${ctxMsg}: ${cell.error}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Press PageDown in a loop, scanning DOM each iteration for the target row.
|
||||
* Bail when the row is found, snapshots stop changing (end of list), or limit hit.
|
||||
* page.mouse.click on a safe cell first — PageDown needs keyboard focus on gridBody.
|
||||
*/
|
||||
async function revealAndFindCell({ formNum, gridSelector, target, scroll }) {
|
||||
const limit = typeof scroll === 'number' ? scroll : REVEAL_DEFAULT_LIMIT;
|
||||
|
||||
const focusPt = await page.evaluate(findFocusCellScript(gridSelector));
|
||||
if (!focusPt) return { error: 'no_focusable_cell' };
|
||||
await page.mouse.click(focusPt.x, focusPt.y);
|
||||
await page.waitForTimeout(FOCUS_WAIT_MS);
|
||||
// Click on a Number/Date cell auto-enters edit mode in 1С; PageDown there
|
||||
// is a no-op. Exit edit mode before driving the reveal loop.
|
||||
if (await isInputFocusedInGrid({ gridSelector })) {
|
||||
await page.keyboard.press('Escape');
|
||||
await page.waitForTimeout(150);
|
||||
}
|
||||
|
||||
let prevSnap = await page.evaluate(snapshotGridScript(gridSelector));
|
||||
for (let i = 0; i < limit; i++) {
|
||||
await page.keyboard.press('PageDown');
|
||||
await page.waitForTimeout(PD_WAIT_MS);
|
||||
|
||||
const cell = await page.evaluate(findGridCellScript(formNum, gridSelector, target));
|
||||
if (!cell?.error) return cell;
|
||||
|
||||
const snap = await page.evaluate(snapshotGridScript(gridSelector));
|
||||
// Reached the end of the list. Primary signal: nothing remains below
|
||||
// (`hasBelow === false`) — the reliable cross-grid-type signal. Content
|
||||
// stability is only a fallback when hasBelow is unknown: it compares the
|
||||
// full-row text (snapshotGridScript joins every cell), so a low-cardinality
|
||||
// first column (e.g. all "Товар 0X") can't look "stable" mid-scroll.
|
||||
const reachedEnd = snap && (
|
||||
snap.hasBelow === false
|
||||
|| (snap.hasBelow == null
|
||||
&& snap.firstText === prevSnap?.firstText
|
||||
&& snap.lastText === prevSnap?.lastText
|
||||
&& snap.selIdx === prevSnap?.selIdx
|
||||
&& snap.lineCount === prevSnap?.lineCount)
|
||||
);
|
||||
if (reachedEnd) return { error: 'row_not_found', filter: target.row };
|
||||
prevSnap = snap;
|
||||
}
|
||||
return { error: 'row_not_found', filter: target.row };
|
||||
}
|
||||
|
||||
/**
|
||||
* Scroll the grid horizontally so the target cell falls inside the viewport.
|
||||
* Focuses an edge cell in the target row (rightmost-visible for ArrowRight,
|
||||
* leftmost-visible for ArrowLeft) so the next arrow key immediately scrolls.
|
||||
*
|
||||
* Frozen columns (gridBoxFix) are excluded from focus candidates — they don't
|
||||
* drive the scrollable viewport. The DOM script handles that detail.
|
||||
*/
|
||||
async function scrollGridToCell({ formNum, gridSelector, target, cell }) {
|
||||
const direction = cell.cellX > cell.gridRight ? 'ArrowRight'
|
||||
: cell.cellRight < cell.gridX ? 'ArrowLeft'
|
||||
: (cell.cellRight > cell.gridRight ? 'ArrowRight' : 'ArrowLeft');
|
||||
|
||||
const focusPt = await page.evaluate(
|
||||
findFocusCellScript(gridSelector, { rowIdx: cell.rowIdx, direction })
|
||||
);
|
||||
if (!focusPt) throw new Error('clickElement: no visible cell to focus for horizontal scroll');
|
||||
await page.mouse.click(focusPt.x, focusPt.y);
|
||||
await page.waitForTimeout(FOCUS_WAIT_MS);
|
||||
// Click on a Number/Date cell auto-enters edit mode in 1С; arrow keys there
|
||||
// navigate text inside the input rather than scrolling the viewport. Exit first.
|
||||
if (await isInputFocusedInGrid({ gridSelector })) {
|
||||
await page.keyboard.press('Escape');
|
||||
await page.waitForTimeout(150);
|
||||
}
|
||||
|
||||
await scrollHorizontallyByKey({
|
||||
page,
|
||||
direction,
|
||||
isFullyVisible: async () => {
|
||||
const c = await page.evaluate(findGridCellScript(formNum, gridSelector, target));
|
||||
return !!c && !c.error && c.visible;
|
||||
},
|
||||
getCenterX: async () => {
|
||||
const c = await page.evaluate(findGridCellScript(formNum, gridSelector, target));
|
||||
return c && !c.error ? c.x : null;
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
// web-test table/click-row v1.0 — click handlers for grid row targets: gridGroup, gridTreeNode, gridRow.
|
||||
// Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
|
||||
//
|
||||
// All handlers are called by core/click.mjs dispatcher after target is found.
|
||||
// Each takes (target, ctx) where ctx = { formNum, modifier, dblclick, toggle, expand, ... }
|
||||
// and returns a form state with `clicked: { kind, name, ... }`.
|
||||
|
||||
import { waitForStable } from '../core/wait.mjs';
|
||||
import { modifierClick, returnFormState } from '../core/helpers.mjs';
|
||||
import { getGridToggleIcon, shouldClickToggle } from './grid-toggle.mjs';
|
||||
|
||||
/**
|
||||
* Click handler for gridGroup / gridParent targets (hierarchy mode).
|
||||
* With `expand`/`toggle` — click the level-indicator icon to expand/collapse the group.
|
||||
* Without — dblclick the row to enter the group / go up to parent.
|
||||
*/
|
||||
export async function clickGridGroupTarget(target, ctx) {
|
||||
const { formNum, modifier, toggle, expand } = ctx;
|
||||
if (expand != null || toggle) {
|
||||
// Expand/collapse group — click the triangle icon (.gridListH/.gridListV).
|
||||
// expand=true: only expand (skip if already expanded), expand=false: only collapse, toggle: always click.
|
||||
const levelIconInfo = await getGridToggleIcon(target, formNum, {
|
||||
iconSelector: '.gridListH, .gridListV',
|
||||
isExpandedExpr: "icon.classList.contains('gridListV')",
|
||||
});
|
||||
const shouldClick = shouldClickToggle(levelIconInfo, expand, toggle);
|
||||
if (shouldClick) {
|
||||
if (levelIconInfo) {
|
||||
await modifierClick(levelIconInfo.x, levelIconInfo.y, modifier);
|
||||
} else {
|
||||
// Fallback: dblclick (standard hierarchy navigation)
|
||||
await modifierClick(target.x, target.y, modifier, { dbl: true });
|
||||
}
|
||||
}
|
||||
await waitForStable(formNum);
|
||||
return returnFormState({
|
||||
clicked: { kind: target.kind, name: target.name, toggled: shouldClick, ...(modifier ? { modifier } : {}) },
|
||||
hint: shouldClick ? 'Group toggled. Use readTable to see updated list.' : 'Group already in desired state.',
|
||||
});
|
||||
}
|
||||
// Default: dblclick to enter group / go up to parent
|
||||
await modifierClick(target.x, target.y, modifier, { dbl: true });
|
||||
await waitForStable(formNum);
|
||||
return returnFormState({ clicked: { kind: target.kind, name: target.name, ...(modifier ? { modifier } : {}) } });
|
||||
}
|
||||
|
||||
/**
|
||||
* Click handler for gridTreeNode targets (tree-style grid).
|
||||
* With `expand`/`toggle` — click the tree icon to expand/collapse.
|
||||
* Without — single-click to select the row (no expand).
|
||||
*/
|
||||
export async function clickGridTreeNodeTarget(target, ctx) {
|
||||
const { formNum, modifier, toggle, expand } = ctx;
|
||||
if (expand != null || toggle) {
|
||||
// Expand/collapse tree node — click the tree icon [tree="true"].
|
||||
const treeIconInfo = await getGridToggleIcon(target, formNum, {
|
||||
iconSelector: '.gridBoxImg [tree="true"]',
|
||||
isExpandedExpr: '(icon.style.backgroundImage || "").includes("gx=0")',
|
||||
});
|
||||
const shouldClick = shouldClickToggle(treeIconInfo, expand, toggle);
|
||||
if (shouldClick) {
|
||||
if (treeIconInfo) {
|
||||
await modifierClick(treeIconInfo.x, treeIconInfo.y, modifier);
|
||||
} else {
|
||||
// Fallback: dblclick on row (works for trees without clickable +/- icons)
|
||||
await modifierClick(target.x, target.y, modifier, { dbl: true });
|
||||
}
|
||||
}
|
||||
await waitForStable(formNum);
|
||||
return returnFormState({
|
||||
clicked: { kind: 'gridTreeNode', name: target.name, toggled: shouldClick, ...(modifier ? { modifier } : {}) },
|
||||
hint: shouldClick ? 'Tree node toggled. Use readTable to see updated tree.' : 'Tree node already in desired state.',
|
||||
});
|
||||
}
|
||||
// Default: select row (click text, no expand/collapse)
|
||||
await modifierClick(target.x, target.y, modifier);
|
||||
await waitForStable(formNum);
|
||||
return returnFormState({
|
||||
clicked: { kind: 'gridTreeNode', name: target.name, ...(modifier ? { modifier } : {}) },
|
||||
hint: 'Row selected. Use { expand: true } to expand/collapse.',
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Click handler for gridRow targets (flat list row).
|
||||
* Single click selects the row; `dblclick: true` opens the item.
|
||||
*/
|
||||
export async function clickGridRowTarget(target, ctx) {
|
||||
const { modifier, dblclick } = ctx;
|
||||
await modifierClick(target.x, target.y, modifier, { dbl: !!dblclick });
|
||||
await waitForStable();
|
||||
return returnFormState({
|
||||
clicked: { kind: 'gridRow', name: target.name, ...(dblclick ? { dblclick: true } : {}), ...(modifier ? { modifier } : {}) },
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,248 @@
|
||||
// web-test table/filter v1.19 — filterList / unfilterList — simple search + advanced-column filter badges.
|
||||
// Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
|
||||
|
||||
import { page, ensureConnected, normYo, highlightMode, ACTION_WAIT } from '../core/state.mjs';
|
||||
import {
|
||||
detectFormScript, readSubmenuScript,
|
||||
findSearchInputScript, findNamedButtonScript, findCompareTypeRadioScript, isFormVisibleScript,
|
||||
findFirstGridCellCoordsScript, findColumnFirstCellCoordsScript,
|
||||
readFieldSelectorInfoScript, pickFieldInSelectorDropdownScript,
|
||||
readFilterDialogInfoScript, findFilterBadgeCloseScript, findFirstFilterBadgeCloseScript,
|
||||
} from '../../dom.mjs';
|
||||
import { dismissPendingErrors, checkForErrors } from '../core/errors.mjs';
|
||||
import { waitForStable, waitForCondition } from '../core/wait.mjs';
|
||||
import { highlight, unhighlight } from '../recording/highlight.mjs';
|
||||
import { safeClick, returnFormState } from '../core/helpers.mjs';
|
||||
import { selectValue, fillReferenceField } from '../forms/select-value.mjs';
|
||||
import { pasteText } from '../core/clipboard.mjs';
|
||||
import { getFormState } from '../forms/state.mjs';
|
||||
import { clickElement } from '../core/click.mjs';
|
||||
|
||||
/**
|
||||
* Filter the current list by field value, or search via search bar.
|
||||
*
|
||||
* Without field: simple search via the search bar (filters by all columns, no badge).
|
||||
* With field: advanced search — clicks target column cell to auto-populate FieldSelector,
|
||||
* opens dialog (Alt+F), fills Pattern, clicks Найти. Creates a real filter badge.
|
||||
* Handles text, reference (with Tab autocomplete), and date fields automatically.
|
||||
* Multiple filters can be chained by calling filterList multiple times.
|
||||
*
|
||||
* @param {string} text - Search text or date (e.g. "Мишка", "КП00", "10.03.2016")
|
||||
* @param {object} [opts]
|
||||
* @param {string} [opts.field] - Column name for advanced search (e.g. "Наименование", "Получатель", "Дата")
|
||||
* @param {boolean} [opts.exact] - Exact match (text fields only; dates/numbers/refs always exact)
|
||||
*/
|
||||
export async function filterList(text, { field, exact } = {}) {
|
||||
ensureConnected();
|
||||
await dismissPendingErrors();
|
||||
const formNum = await page.evaluate(detectFormScript());
|
||||
if (formNum === null) throw new Error('filterList: no form found');
|
||||
|
||||
if (!field) {
|
||||
// --- Simple search: fill search input + Enter ---
|
||||
const searchInfo = await page.evaluate(findSearchInputScript(formNum));
|
||||
|
||||
if (searchInfo) {
|
||||
await page.click(`[id="${searchInfo.id}"]`);
|
||||
await page.waitForTimeout(200);
|
||||
await page.keyboard.press('Control+A');
|
||||
await pasteText(text);
|
||||
await page.waitForTimeout(300);
|
||||
await page.keyboard.press('Enter');
|
||||
await waitForStable(formNum);
|
||||
|
||||
return returnFormState({ filtered: { type: 'search', text } });
|
||||
}
|
||||
|
||||
// No search input — Ctrl+F opens advanced search on such forms.
|
||||
// Click first grid cell then fall through to advanced search path below.
|
||||
const firstCell = await page.evaluate(findFirstGridCellCoordsScript(formNum));
|
||||
if (!firstCell) throw new Error('filterList: no search input and no grid found on this form');
|
||||
await page.mouse.click(firstCell.x, firstCell.y);
|
||||
await page.waitForTimeout(300);
|
||||
field = ''; // fall through to advanced search, skip DLB (empty field = keep auto-selected)
|
||||
}
|
||||
|
||||
// --- Advanced search: click target column cell → Alt+F → fill Pattern → Найти ---
|
||||
// Clicking a cell in the target column makes it active, so when Alt+F opens the
|
||||
// advanced search dialog, FieldSelector is auto-populated with the correct field name.
|
||||
// This avoids changing FieldSelector programmatically (which can cause errors).
|
||||
const isDateValue = /^\d{2}\.\d{2}\.\d{4}$/.test(text.trim());
|
||||
|
||||
// 1. Click a cell in the target column to activate it (auto-populates FieldSelector).
|
||||
// If the column isn't visible in the grid, click any cell and use DLB fallback later.
|
||||
let needDlb = false;
|
||||
const gridEl = await page.evaluate(findColumnFirstCellCoordsScript(formNum, field));
|
||||
if (gridEl.error) throw new Error(`filterList: ${gridEl.error}`);
|
||||
needDlb = !!gridEl.needDlb;
|
||||
await page.mouse.click(gridEl.x, gridEl.y);
|
||||
await page.waitForTimeout(500);
|
||||
|
||||
// 2. Open advanced search dialog via Alt+F (with fallback to Еще menu)
|
||||
await page.keyboard.press('Alt+f');
|
||||
await page.waitForTimeout(2000);
|
||||
|
||||
let dialogForm = await page.evaluate(detectFormScript());
|
||||
if (dialogForm === formNum) {
|
||||
// Alt+F didn't open dialog — fallback to Еще → Расширенный поиск
|
||||
await clickElement('Еще');
|
||||
await page.waitForTimeout(500);
|
||||
const menu = await page.evaluate(readSubmenuScript());
|
||||
const searchItem = Array.isArray(menu) && menu.find(i =>
|
||||
i.name.replace(/ /g, ' ').toLowerCase().includes('расширенный поиск'));
|
||||
if (!searchItem) {
|
||||
await page.keyboard.press('Escape');
|
||||
throw new Error('filterList: advanced search dialog could not be opened');
|
||||
}
|
||||
await page.mouse.click(searchItem.x, searchItem.y);
|
||||
await page.waitForTimeout(2000);
|
||||
dialogForm = await page.evaluate(detectFormScript());
|
||||
if (dialogForm === formNum) {
|
||||
throw new Error('filterList: advanced search dialog did not open');
|
||||
}
|
||||
}
|
||||
|
||||
// 2b. If column wasn't in the grid, change FieldSelector via DLB dropdown
|
||||
// Skip DLB when field is empty (fallback from no-search-input path — keep auto-selected field)
|
||||
if (needDlb && field) {
|
||||
const fsInfo = await page.evaluate(readFieldSelectorInfoScript(dialogForm));
|
||||
|
||||
if (normYo(fsInfo.current.toLowerCase()) !== normYo(field.toLowerCase())) {
|
||||
await page.mouse.click(fsInfo.dlbX, fsInfo.dlbY);
|
||||
await page.waitForTimeout(1500);
|
||||
|
||||
const ddResult = await page.evaluate(pickFieldInSelectorDropdownScript(field));
|
||||
|
||||
if (ddResult.error) {
|
||||
await page.keyboard.press('Escape');
|
||||
await page.waitForTimeout(500);
|
||||
await page.keyboard.press('Escape');
|
||||
await page.waitForTimeout(500);
|
||||
throw new Error(`filterList: field "${field}" not found in FieldSelector. Available: ${ddResult.available?.join(', ') || 'none'}`);
|
||||
}
|
||||
await page.mouse.click(ddResult.x, ddResult.y);
|
||||
await page.waitForTimeout(3000);
|
||||
}
|
||||
}
|
||||
|
||||
// 3. Read dialog state and fill Pattern
|
||||
// Detect field type by Pattern's sibling buttons:
|
||||
// - iCalendB → date field (Home+Shift+End+Ctrl+V to replace date value)
|
||||
// - iDLB on Pattern → reference field (paste + Tab for autocomplete)
|
||||
// - neither → plain text field (just paste)
|
||||
const dialogInfo = await page.evaluate(readFilterDialogInfoScript(dialogForm));
|
||||
|
||||
if (dialogInfo.isDate) {
|
||||
// Date field: fill via Home → Shift+End (select all) → Ctrl+V (paste)
|
||||
if (isDateValue && dialogInfo.patternValue !== text.trim()) {
|
||||
await page.click(`[id="${dialogInfo.patternId}"]`);
|
||||
await page.waitForTimeout(200);
|
||||
await page.keyboard.press('Home');
|
||||
await page.waitForTimeout(100);
|
||||
await page.keyboard.press('Shift+End');
|
||||
await page.waitForTimeout(100);
|
||||
await pasteText(text);
|
||||
await page.waitForTimeout(500);
|
||||
}
|
||||
} else {
|
||||
// Text or reference field: fill Pattern via clipboard paste
|
||||
await page.click(`[id="${dialogInfo.patternId}"]`);
|
||||
await page.waitForTimeout(200);
|
||||
await page.keyboard.press('Control+A');
|
||||
await pasteText(text);
|
||||
await page.waitForTimeout(300);
|
||||
|
||||
if (dialogInfo.isRef) {
|
||||
// Reference field: Tab triggers autocomplete to resolve text → reference value
|
||||
await page.keyboard.press('Tab');
|
||||
await page.waitForTimeout(2000);
|
||||
}
|
||||
}
|
||||
|
||||
// 3b. Switch CompareType if exact match requested (text fields only).
|
||||
// Date/number: always exact, CompareType disabled. Reference: default exact (selects ref).
|
||||
if (exact && !dialogInfo.isDate && !dialogInfo.isRef) {
|
||||
const exactRadio = await page.evaluate(findCompareTypeRadioScript(dialogForm, 2));
|
||||
if (exactRadio && !exactRadio.already) {
|
||||
await page.mouse.click(exactRadio.x, exactRadio.y);
|
||||
await page.waitForTimeout(300);
|
||||
}
|
||||
}
|
||||
|
||||
// 4. Click "Найти" via mouse.click (dialog is modal — page.click may be blocked)
|
||||
const findBtnCoords = await page.evaluate(findNamedButtonScript('Найти'));
|
||||
if (findBtnCoords) {
|
||||
await page.mouse.click(findBtnCoords.x, findBtnCoords.y);
|
||||
} else {
|
||||
await clickElement('Найти');
|
||||
}
|
||||
await page.waitForTimeout(2000);
|
||||
|
||||
// 5. Close advanced search dialog if it stayed open (some forms keep it open after Найти).
|
||||
// Check the specific dialog form — not generic modalSurface — to avoid closing parent modals
|
||||
// (e.g. a selection form that opened this advanced search).
|
||||
for (let attempt = 0; attempt < 3; attempt++) {
|
||||
const dialogVisible = await page.evaluate(isFormVisibleScript(dialogForm));
|
||||
if (!dialogVisible) break;
|
||||
await page.keyboard.press('Escape');
|
||||
await page.waitForTimeout(500);
|
||||
}
|
||||
await waitForStable(formNum);
|
||||
|
||||
return returnFormState({ filtered: { type: 'advanced', field, text, exact: !!exact } });
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove active filters/search from the current list.
|
||||
*
|
||||
* Without field: clears ALL filters (Ctrl+Q for advanced search + clear search field).
|
||||
* With field: clicks the × button on the specific filter badge (selective removal).
|
||||
*
|
||||
* @param {object} [opts]
|
||||
* @param {string} [opts.field] - Remove only the filter for this field (clicks badge ×)
|
||||
*/
|
||||
export async function unfilterList({ field } = {}) {
|
||||
ensureConnected();
|
||||
await dismissPendingErrors();
|
||||
const formNum = await page.evaluate(detectFormScript());
|
||||
if (formNum === null) throw new Error('unfilterList: no form found');
|
||||
|
||||
if (field) {
|
||||
// --- Selective: click × on specific filter badge ---
|
||||
const closeBtn = await page.evaluate(findFilterBadgeCloseScript(formNum, field));
|
||||
|
||||
if (closeBtn?.error) throw new Error(`unfilterList: filter badge "${field}" not found. Available: ${closeBtn.available?.join(', ') || 'none'}`);
|
||||
await page.mouse.click(closeBtn.x, closeBtn.y);
|
||||
await waitForStable(formNum);
|
||||
|
||||
return returnFormState({ unfiltered: { field: closeBtn.field } });
|
||||
}
|
||||
|
||||
// --- Clear ALL filters ---
|
||||
|
||||
// 1. Remove all advanced filter badges (.trainItem × buttons)
|
||||
for (let attempt = 0; attempt < 20; attempt++) {
|
||||
const badge = await page.evaluate(findFirstFilterBadgeCloseScript(formNum));
|
||||
if (!badge) break;
|
||||
await page.mouse.click(badge.x, badge.y);
|
||||
await waitForStable(formNum);
|
||||
}
|
||||
|
||||
// 2. Cancel active search via Ctrl+Q
|
||||
await page.keyboard.press('Control+q');
|
||||
await waitForStable(formNum);
|
||||
|
||||
// 3. Clear simple search field if it has a value
|
||||
const searchInfo = await page.evaluate(findSearchInputScript(formNum));
|
||||
|
||||
if (searchInfo?.value) {
|
||||
await page.click(`[id="${searchInfo.id}"]`);
|
||||
await page.waitForTimeout(200);
|
||||
await page.keyboard.press('Control+A');
|
||||
await page.keyboard.press('Delete');
|
||||
await page.keyboard.press('Enter');
|
||||
await waitForStable(formNum);
|
||||
}
|
||||
|
||||
return returnFormState({ unfiltered: true });
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
// web-test table/grid-toggle v1.17 — shared icon-detection for grid expand/
|
||||
// collapse toggles. Used by clickElement's gridGroup/gridParent and
|
||||
// gridTreeNode branches; the actual mouse click stays in the caller because
|
||||
// it depends on the caller-local modifier-key handling.
|
||||
// Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
|
||||
|
||||
import { page } from '../core/state.mjs';
|
||||
|
||||
/**
|
||||
* Locate the toggle icon for the grid row at `target.y`. Inspects the row
|
||||
* under that Y-coordinate inside the resolved grid, returns the icon's
|
||||
* center coordinates and current expanded state — or `null` if no toggle
|
||||
* icon is present (e.g. leaf node or detached row).
|
||||
*
|
||||
* @param {{y:number, gridId?:string}} target
|
||||
* @param {number} formNum
|
||||
* @param {object} opts
|
||||
* @param {string} opts.iconSelector — CSS selector inside .gridLine
|
||||
* (e.g. '.gridListH, .gridListV' for groups, '.gridBoxImg [tree="true"]' for tree nodes)
|
||||
* @param {string} opts.isExpandedExpr — JS expression evaluated in browser
|
||||
* context where `icon` is the matched element; must yield a boolean
|
||||
* (e.g. "icon.classList.contains('gridListV')" or "(icon.style.backgroundImage || '').includes('gx=0')")
|
||||
* @returns {Promise<{x:number, y:number, isExpanded:boolean}|null>}
|
||||
*/
|
||||
export async function getGridToggleIcon(target, formNum, { iconSelector, isExpandedExpr }) {
|
||||
return await page.evaluate(`(() => {
|
||||
const p = ${JSON.stringify(`form${formNum}_`)};
|
||||
const gridSel = ${JSON.stringify(target.gridId ? '#' + target.gridId : null)};
|
||||
const grid = gridSel ? document.querySelector(gridSel) : document.querySelector('[id^="' + p + '"].grid');
|
||||
const body = grid?.querySelector('.gridBody');
|
||||
if (!body) return null;
|
||||
const targetY = ${target.y};
|
||||
const lines = [...body.querySelectorAll('.gridLine')];
|
||||
for (const line of lines) {
|
||||
const lr = line.getBoundingClientRect();
|
||||
if (targetY < lr.top || targetY > lr.bottom) continue;
|
||||
const icon = line.querySelector(${JSON.stringify(iconSelector)});
|
||||
if (icon) {
|
||||
const r = icon.getBoundingClientRect();
|
||||
const isExpanded = ${isExpandedExpr};
|
||||
return { x: Math.round(r.x + r.width / 2), y: Math.round(r.y + r.height / 2), isExpanded };
|
||||
}
|
||||
}
|
||||
return null;
|
||||
})()`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Standard expand/toggle decision: should we click the toggle icon?
|
||||
* - `toggle:true` → always click.
|
||||
* - `expand:true` → click only if not already expanded.
|
||||
* - `expand:false` → click only if currently expanded.
|
||||
* - If no icon found (`iconInfo` is null) → click anyway (caller falls back to dblclick).
|
||||
*
|
||||
* @param {{isExpanded:boolean}|null} iconInfo
|
||||
* @param {boolean|undefined} expand
|
||||
* @param {boolean|undefined} toggle
|
||||
* @returns {boolean}
|
||||
*/
|
||||
export function shouldClickToggle(iconInfo, expand, toggle) {
|
||||
return toggle || !iconInfo
|
||||
|| (expand === true && !iconInfo.isExpanded)
|
||||
|| (expand === false && iconInfo.isExpanded);
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
// web-test table/grid v1.20 — Form-grid operations: read table rows, fill rows, delete rows.
|
||||
// Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
|
||||
//
|
||||
// "Grid" в терминах 1С — таблица на форме (.gridLine/.gridBody/.grid в DOM):
|
||||
// табличные части документов, формы списков, ТЧ настроек и т.п.
|
||||
// Отдельно от SpreadsheetDocument (engine/spreadsheet/spreadsheet.mjs).
|
||||
|
||||
import { page, ensureConnected } from '../core/state.mjs';
|
||||
import { detectFormScript, readTableScript, resolveGridScript } from '../../dom.mjs';
|
||||
import { findDeleteRowCoordsScript, countGridRowsScript } from '../../dom/grid.mjs';
|
||||
import { isInputFocusedInGrid } from '../core/helpers.mjs';
|
||||
import { dismissPendingErrors } from '../core/errors.mjs';
|
||||
import { waitForStable } from '../core/wait.mjs';
|
||||
import { clickElement } from '../core/click.mjs';
|
||||
import { returnFormState } from '../core/helpers.mjs';
|
||||
|
||||
/** Read structured table data with pagination. Returns columns, rows, total count. */
|
||||
export async function readTable({ maxRows = 20, offset = 0, table } = {}) {
|
||||
ensureConnected();
|
||||
const formNum = await page.evaluate(detectFormScript());
|
||||
if (formNum === null) throw new Error('readTable: no form found');
|
||||
let gridSelector;
|
||||
if (table) {
|
||||
const resolved = await page.evaluate(resolveGridScript(formNum, table));
|
||||
if (resolved.error) throw new Error(`readTable: ${resolved.message || resolved.error}. Available: ${resolved.available?.map(a => a.name).join(', ') || 'none'}`);
|
||||
gridSelector = resolved.gridSelector;
|
||||
}
|
||||
return await page.evaluate(readTableScript(formNum, { maxRows, offset, gridSelector }));
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete a row from the current table part.
|
||||
* Single click to select the row, then Delete key to remove it.
|
||||
*
|
||||
* @param {number} row - 0-based row index to delete
|
||||
* @param {Object} [options]
|
||||
* @param {string} [options.tab] - Switch to this form tab before operating
|
||||
* @returns {object} form state with { deleted, rowsBefore, rowsAfter }
|
||||
*/
|
||||
export async function deleteTableRow(row, { tab, table } = {}) {
|
||||
ensureConnected();
|
||||
await dismissPendingErrors();
|
||||
const formNum = await page.evaluate(detectFormScript());
|
||||
if (formNum === null) throw new Error('deleteTableRow: no form found');
|
||||
|
||||
// Pre-resolve grid when table is specified
|
||||
let gridSelector;
|
||||
if (table) {
|
||||
const resolved = await page.evaluate(resolveGridScript(formNum, table));
|
||||
if (resolved.error) throw new Error(`deleteTableRow: table "${table}" not found. Available: ${resolved.available?.map(a => a.name).join(', ') || 'none'}`);
|
||||
gridSelector = resolved.gridSelector;
|
||||
}
|
||||
|
||||
// 1. Switch tab if requested
|
||||
if (tab) {
|
||||
await clickElement(tab);
|
||||
await page.waitForTimeout(500);
|
||||
}
|
||||
|
||||
// 2. Find the target row and click to select it
|
||||
const cellCoords = await page.evaluate(findDeleteRowCoordsScript(gridSelector, row));
|
||||
|
||||
if (cellCoords.error) throw new Error(`deleteTableRow: ${cellCoords.error}${cellCoords.total ? ' (total rows: ' + cellCoords.total + ')' : ''}`);
|
||||
|
||||
const rowsBefore = cellCoords.total;
|
||||
|
||||
// Pre-click Escape: leftover edit-mode from a prior fillTableRow Tab-navigation.
|
||||
// Without it the next mouse click may not select the row reliably (the active
|
||||
// edit input intercepts the event timing).
|
||||
if (await isInputFocusedInGrid({ gridSelector })) {
|
||||
await page.keyboard.press('Escape');
|
||||
await page.waitForTimeout(150);
|
||||
}
|
||||
|
||||
// Single click to select the row
|
||||
await page.mouse.click(cellCoords.x, cellCoords.y);
|
||||
await page.waitForTimeout(300);
|
||||
|
||||
// Post-click Escape: clicking a Number/Date cell auto-enters edit mode in 1С.
|
||||
// Delete in edit mode clears the cell buffer instead of deleting the row, so
|
||||
// we exit edit first. The row remains selected after Escape — Delete acts on it.
|
||||
if (await isInputFocusedInGrid({ gridSelector })) {
|
||||
await page.keyboard.press('Escape');
|
||||
await page.waitForTimeout(150);
|
||||
}
|
||||
|
||||
// 3. Press Delete to remove the row
|
||||
await page.keyboard.press('Delete');
|
||||
await waitForStable();
|
||||
|
||||
// 4. Count rows after deletion
|
||||
const rowsAfter = await page.evaluate(countGridRowsScript(gridSelector));
|
||||
|
||||
return returnFormState({ deleted: row, rowsBefore, rowsAfter });
|
||||
}
|
||||
@@ -0,0 +1,903 @@
|
||||
// web-test table/row-fill v1.22 — fillTableRow — заполнение строки табличной части/списка через Tab-навигацию и попутный выбор значений.
|
||||
// Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
|
||||
|
||||
import {
|
||||
page, ensureConnected, normYo, highlightMode, ACTION_WAIT,
|
||||
} from '../core/state.mjs';
|
||||
import {
|
||||
detectFormScript, resolveGridScript, readTableScript,
|
||||
countGridRowsScript, isTreeGridScript, findGridHeadCenterCoordsScript,
|
||||
getSelectedOrLastRowIndexScript,
|
||||
isNotInListCloudVisibleScript, clickShowAllInNotInListCloudScript,
|
||||
sortFieldKeysByColindexScript, findCellCoordsByFieldsScript,
|
||||
findNextCellCoordsByKeyScript, findCheckboxAtPointScript,
|
||||
findRowCommitClickCoordsScript, getGridEditCheckScript,
|
||||
readActiveGridCellScript, getElementCenterCoordsByIdScript,
|
||||
} from '../../dom.mjs';
|
||||
import { dismissPendingErrors, checkForErrors } from '../core/errors.mjs';
|
||||
import { waitForStable, waitForCondition, startNetworkMonitor } from '../core/wait.mjs';
|
||||
import { highlight, unhighlight } from '../recording/highlight.mjs';
|
||||
import {
|
||||
safeClick, findFieldInputId, returnFormState,
|
||||
detectNewForm as helperDetectNewForm,
|
||||
isInputFocused, isInputFocusedInGrid, findOpenPopup,
|
||||
readEdd, isEddVisible, clickEddItemViaDispatch,
|
||||
} from '../core/helpers.mjs';
|
||||
import { clickElement } from '../core/click.mjs';
|
||||
import { resolveRowIndexByFilter } from './click-cell.mjs';
|
||||
import {
|
||||
pickFromSelectionForm, isTypeDialog, pickFromTypeDialog,
|
||||
fillReferenceField, selectValue,
|
||||
} from '../forms/select-value.mjs';
|
||||
import { pasteText } from '../core/clipboard.mjs';
|
||||
|
||||
/**
|
||||
* Fill a choice cell (_CB iCB, buttonKind==='choice') whose INPUT is already focused.
|
||||
*
|
||||
* Two kinds of cell carry the same choice button and are INDISTINGUISHABLE in the DOM
|
||||
* (both `editInput`, readOnly:false):
|
||||
* (a) editable value cell (Произвольный/примитив, РедактированиеТекста=Истина) — typed text sticks;
|
||||
* (b) pick-from-list cell (НачалоВыбора / РедактированиеТекста=Ложь) — typed text is rejected.
|
||||
* The only reliable discriminator is behavioral: paste and watch the input value.
|
||||
* stuck → editable cell → leave value in the INPUT (caller's Tab/commit persists it), method 'direct';
|
||||
* rejected → F4 → form: isTypeDialog ? pickFromTypeDialog ('choice') : pickFromSelectionForm ('form').
|
||||
*
|
||||
* Does NOT navigate between cells — caller owns Tab/dblclick/row-commit.
|
||||
*
|
||||
* @param {number} formNum base form number (for new-form detection)
|
||||
* @param {string} text value to fill
|
||||
* @param {Object} [opts]
|
||||
* @param {string|null} [opts.type] explicit type for composite/value-list pick
|
||||
* @param {string} [opts.fieldLabel] field name for diagnostics / selection-form search
|
||||
* @returns {{ ok, method, error?, message?, value? }}
|
||||
*/
|
||||
async function fillChoiceCell(formNum, text, { type = null, fieldLabel = '' } = {}) {
|
||||
const norm = (s) => normYo((s || '').toLowerCase());
|
||||
const before = await page.evaluate(`document.activeElement?.value || ''`);
|
||||
// Re-fill guard: cell already holds the target (paste wouldn't change it → false "rejected").
|
||||
if (before && norm(before).includes(norm(text))) {
|
||||
return { ok: true, method: 'skip', value: before };
|
||||
}
|
||||
// Try direct input; poll for the input value to settle on the pasted text (editable cell).
|
||||
await pasteText(text, { confirm: ['Control+a', 'Control+v'] });
|
||||
let after = before, stuck = false;
|
||||
for (let i = 0; i < 6; i++) {
|
||||
await page.waitForTimeout(100);
|
||||
after = await page.evaluate(`document.activeElement?.value || ''`);
|
||||
if (after !== before && norm(after).includes(norm(text))) { stuck = true; break; }
|
||||
}
|
||||
if (stuck) return { ok: true, method: 'direct', value: text };
|
||||
|
||||
// Text rejected (pick-from-list cell) — nothing typed to clear (field is not text-editable).
|
||||
// Dismiss any autocomplete hint, then open the choice form via F4.
|
||||
if (await isEddVisible()) { await page.keyboard.press('Escape'); await page.waitForTimeout(200); }
|
||||
await page.keyboard.press('F4');
|
||||
let choiceForm = null;
|
||||
for (let cw = 0; cw < 8; cw++) {
|
||||
await page.waitForTimeout(200);
|
||||
choiceForm = await helperDetectNewForm(formNum);
|
||||
if (choiceForm !== null) break;
|
||||
}
|
||||
if (choiceForm === null) {
|
||||
return { ok: false, error: 'no_selection_form', message: `Cell "${fieldLabel || text}": F4 did not open a choice form` };
|
||||
}
|
||||
if (await isTypeDialog(choiceForm)) {
|
||||
try {
|
||||
await pickFromTypeDialog(choiceForm, type || text);
|
||||
} catch (e) {
|
||||
return { ok: false, error: 'not_found', message: e.message };
|
||||
}
|
||||
await waitForStable(formNum);
|
||||
// A value form opened after the type pick → composite-value cell needs { value, type }.
|
||||
const valForm = await helperDetectNewForm(formNum);
|
||||
if (valForm !== null) {
|
||||
await page.keyboard.press('Escape'); await page.waitForTimeout(300);
|
||||
return { ok: false, error: 'type_required', message: `Cell "${fieldLabel || text}" expects { value, type }` };
|
||||
}
|
||||
return { ok: true, method: 'choice', value: text };
|
||||
}
|
||||
const pr = await pickFromSelectionForm(choiceForm, fieldLabel || text, text, formNum);
|
||||
return pr.ok ? { ok: true, method: 'form' } : { ok: false, error: pr.error, message: pr.message };
|
||||
}
|
||||
|
||||
/**
|
||||
* Fill cells in the current table row via Tab navigation.
|
||||
* Grid cells are only accessible sequentially (Tab) — no random access.
|
||||
*
|
||||
* After "Добавить", 1C enters inline edit mode on the first cell.
|
||||
* All inputs in the row are created hidden (offsetWidth=0); only the active one is visible.
|
||||
* Tab moves through cells in a fixed order determined by the form configuration.
|
||||
*
|
||||
* @param {Object} fields - { fieldName: value } map (fuzzy match: "Номенклатура" → "ТоварыНоменклатура")
|
||||
* @param {Object} [options]
|
||||
* @param {string} [options.tab] - Switch to this form tab before operating
|
||||
* @param {boolean} [options.add] - Click "Добавить" to create a new row first
|
||||
* @param {number|Object} [options.row] - Edit existing row: 0-based DOM-window index, or
|
||||
* a `{ col: value }` filter (one or more columns, AND-matched) to locate the row by cell values
|
||||
* @param {boolean|number} [options.scroll] - When `row` is a filter, scan beyond the current
|
||||
* DOM window via PageDown (true = up to 50 presses, number = exact limit)
|
||||
* @returns {{ filled[], notFilled[]?, form }}
|
||||
*/
|
||||
export async function fillTableRow(fields, { tab, add, row, table, scroll } = {}) {
|
||||
ensureConnected();
|
||||
await dismissPendingErrors();
|
||||
const formNum = await page.evaluate(detectFormScript());
|
||||
if (formNum === null) throw new Error('fillTableRow: no form found');
|
||||
|
||||
// Pre-resolve grid when table is specified
|
||||
let gridSelector;
|
||||
if (table) {
|
||||
const resolved = await page.evaluate(resolveGridScript(formNum, table));
|
||||
if (resolved.error) throw new Error(`fillTableRow: table "${table}" not found. Available: ${resolved.available?.map(a => a.name).join(', ') || 'none'}`);
|
||||
gridSelector = resolved.gridSelector;
|
||||
}
|
||||
|
||||
try {
|
||||
// 1. Switch tab if requested
|
||||
if (tab) {
|
||||
await clickElement(tab);
|
||||
}
|
||||
|
||||
// 1b. Resolve a { col: value } row filter to a numeric DOM-window index (mirrors
|
||||
// clickElement). After this, `row` is a number and all downstream code/recursion
|
||||
// works unchanged. Filter targets an EXISTING row — incompatible with `add`.
|
||||
if (row != null && typeof row === 'object') {
|
||||
row = await resolveRowIndexByFilter({ formNum, gridSelector, filter: row, gridName: table, scroll });
|
||||
}
|
||||
|
||||
// 2. Add new row if requested
|
||||
let addedRowIdx = -1;
|
||||
if (add) {
|
||||
// Count rows before add — new row will be appended at this index
|
||||
addedRowIdx = await page.evaluate(countGridRowsScript(gridSelector));
|
||||
await clickElement('Добавить', { table });
|
||||
// Poll for edit mode (INPUT inside grid) instead of fixed 1000ms wait
|
||||
for (let aw = 0; aw < 6; aw++) {
|
||||
await page.waitForTimeout(150);
|
||||
if (await isInputFocusedInGrid()) break;
|
||||
}
|
||||
}
|
||||
|
||||
// 2b. Enter edit mode on existing row by dblclick
|
||||
if (row != null) {
|
||||
// Sort fields by colindex (leftmost first) so Tab traversal covers all fields left-to-right
|
||||
const sortedKeys = await page.evaluate(
|
||||
sortFieldKeysByColindexScript(gridSelector, Object.keys(fields).map(k => k.toLowerCase())));
|
||||
if (sortedKeys) {
|
||||
// Rebuild fields in sorted order
|
||||
const sortedFields = {};
|
||||
for (const kl of sortedKeys) {
|
||||
const origKey = Object.keys(fields).find(k => k.toLowerCase() === kl);
|
||||
if (origKey) sortedFields[origKey] = fields[origKey];
|
||||
}
|
||||
// Add any keys not matched in header (preserve original order for those)
|
||||
for (const k of Object.keys(fields)) {
|
||||
if (!(k in sortedFields)) sortedFields[k] = fields[k];
|
||||
}
|
||||
fields = sortedFields;
|
||||
}
|
||||
|
||||
const cellCoords = await page.evaluate(
|
||||
findCellCoordsByFieldsScript(gridSelector, row, Object.keys(fields).map(k => k.toLowerCase())));
|
||||
|
||||
if (cellCoords.error) throw new Error(`fillTableRow: ${cellCoords.error}${cellCoords.total ? ' (total rows: ' + cellCoords.total + ')' : ''}`);
|
||||
|
||||
// Skip if cell already contains the desired value (single-field optimization)
|
||||
const firstKey0 = Object.keys(fields)[0];
|
||||
const rawFirstVal = fields[firstKey0];
|
||||
const firstVal0 = rawFirstVal === null || rawFirstVal === undefined || rawFirstVal === ''
|
||||
? '' : (typeof rawFirstVal === 'object' ? rawFirstVal.value : String(rawFirstVal));
|
||||
let firstFieldSkipped = false;
|
||||
if (cellCoords.currentText && firstVal0 &&
|
||||
cellCoords.currentText.toLowerCase().includes(firstVal0.toLowerCase())) {
|
||||
firstFieldSkipped = true;
|
||||
if (Object.keys(fields).length === 1) {
|
||||
return returnFormState({ filled: [{ field: firstKey0, ok: true, method: 'skip', value: cellCoords.currentText }] });
|
||||
}
|
||||
}
|
||||
|
||||
// Click first (tree grids enter edit on single click; dblclick toggles expand/collapse).
|
||||
// Then escalate: dblclick → F4 if needed.
|
||||
await page.mouse.click(cellCoords.x, cellCoords.y);
|
||||
|
||||
// Clear cell via Shift+F4 if value is empty
|
||||
if (firstVal0 === '') {
|
||||
await page.waitForTimeout(500);
|
||||
// Check if click opened a selection form — close it first
|
||||
let openedForm = await helperDetectNewForm(formNum);
|
||||
if (openedForm !== null) {
|
||||
await page.keyboard.press('Escape');
|
||||
await page.waitForTimeout(500);
|
||||
} else {
|
||||
// No form opened — need to enter edit mode first (dblclick), then close any form that opens
|
||||
await page.mouse.dblclick(cellCoords.x, cellCoords.y);
|
||||
await page.waitForTimeout(500);
|
||||
openedForm = await helperDetectNewForm(formNum);
|
||||
if (openedForm !== null) {
|
||||
await page.keyboard.press('Escape');
|
||||
await page.waitForTimeout(500);
|
||||
}
|
||||
}
|
||||
await page.keyboard.press('Shift+F4');
|
||||
await page.waitForTimeout(300);
|
||||
const results = [{ field: firstKey0, ok: true, method: 'clear', value: '' }];
|
||||
// If more fields remain, process them on the same row
|
||||
const remaining = { ...fields };
|
||||
delete remaining[firstKey0];
|
||||
if (Object.keys(remaining).length > 0) {
|
||||
const more = await fillTableRow(remaining, { row, table });
|
||||
results.push(...more.filled);
|
||||
}
|
||||
return returnFormState({ filled: results });
|
||||
}
|
||||
|
||||
// Check if clicked cell is a checkbox (toggle-on-click, no edit mode)
|
||||
const checkboxInfo = await page.evaluate(findCheckboxAtPointScript(cellCoords.x, cellCoords.y));
|
||||
if (checkboxInfo !== null) {
|
||||
// Checkbox cell found — click directly on the checkbox icon (not cell center)
|
||||
const desired = ['true', 'да', '1', 'yes'].includes(String(firstVal0).toLowerCase().trim());
|
||||
if (checkboxInfo.checked !== desired) {
|
||||
await page.mouse.click(checkboxInfo.x, checkboxInfo.y);
|
||||
await page.waitForTimeout(300);
|
||||
}
|
||||
const results = [{ field: firstKey0, ok: true, method: 'toggle', value: desired }];
|
||||
await waitForStable(formNum);
|
||||
// If more fields remain, process them on the same row
|
||||
const remaining = { ...fields };
|
||||
delete remaining[firstKey0];
|
||||
if (Object.keys(remaining).length > 0) {
|
||||
const more = await fillTableRow(remaining, { row, table });
|
||||
results.push(...more.filled);
|
||||
}
|
||||
return returnFormState({ filled: results });
|
||||
}
|
||||
|
||||
let inEdit = false;
|
||||
let directEditForm = null;
|
||||
for (let dw = 0; dw < 4; dw++) {
|
||||
await page.waitForTimeout(150);
|
||||
inEdit = await isInputFocused();
|
||||
if (inEdit) break;
|
||||
directEditForm = await helperDetectNewForm(formNum);
|
||||
if (directEditForm !== null) break;
|
||||
}
|
||||
// Click didn't enter edit — try dblclick (works for flat grids)
|
||||
if (!inEdit && directEditForm === null) {
|
||||
await page.mouse.dblclick(cellCoords.x, cellCoords.y);
|
||||
for (let dw = 0; dw < 4; dw++) {
|
||||
await page.waitForTimeout(150);
|
||||
inEdit = await isInputFocused();
|
||||
if (inEdit) break;
|
||||
directEditForm = await helperDetectNewForm(formNum);
|
||||
if (directEditForm !== null) break;
|
||||
}
|
||||
}
|
||||
// Still nothing — try F4 (opens selection for direct-edit cells)
|
||||
if (!inEdit && directEditForm === null) {
|
||||
await page.keyboard.press('F4');
|
||||
for (let fw = 0; fw < 8; fw++) {
|
||||
await page.waitForTimeout(200);
|
||||
inEdit = await isInputFocused();
|
||||
if (inEdit) break;
|
||||
directEditForm = await helperDetectNewForm(formNum);
|
||||
if (directEditForm !== null) break;
|
||||
}
|
||||
}
|
||||
|
||||
// When click entered INPUT mode but no selection form yet — try F4 only for tree grids
|
||||
// (tree grid ref fields need F4 to open selection form; flat grids work via Tab-loop)
|
||||
if (inEdit && directEditForm === null) {
|
||||
const isTreeGrid = await page.evaluate(isTreeGridScript(gridSelector));
|
||||
if (isTreeGrid) {
|
||||
await page.keyboard.press('F4');
|
||||
for (let fw = 0; fw < 8; fw++) {
|
||||
await page.waitForTimeout(200);
|
||||
directEditForm = await helperDetectNewForm(formNum);
|
||||
if (directEditForm !== null) break;
|
||||
}
|
||||
// If F4 didn't open a selection form, fall through to Tab loop
|
||||
}
|
||||
}
|
||||
|
||||
// Direct-edit mode: selection form opened on dblclick/F4 (e.g. tree grid with immediate editing).
|
||||
// Handle each field by picking from selection form, then dblclick next cell.
|
||||
if (directEditForm !== null) {
|
||||
const pending = new Map();
|
||||
for (const [key, val] of Object.entries(fields)) {
|
||||
if (val && typeof val === 'object' && 'value' in val) {
|
||||
pending.set(key, { value: String(val.value), type: val.type || null, filled: false });
|
||||
} else {
|
||||
pending.set(key, { value: String(val), type: null, filled: false });
|
||||
}
|
||||
}
|
||||
const results = [];
|
||||
|
||||
// Helper: handle type dialog + pick from selection form
|
||||
async function directEditPick(openedForm, key, info) {
|
||||
let selForm = openedForm;
|
||||
// Check if opened form is a type selection dialog (composite type field)
|
||||
if (await isTypeDialog(selForm)) {
|
||||
if (info.type) {
|
||||
await pickFromTypeDialog(selForm, info.type);
|
||||
await waitForStable(selForm);
|
||||
// After type selection, detect the actual selection form
|
||||
selForm = await helperDetectNewForm(formNum);
|
||||
if (selForm === null) {
|
||||
return { field: key, ok: false, error: 'no_selection_after_type', message: `Type selected but no selection form opened for "${key}"` };
|
||||
}
|
||||
} else {
|
||||
// No type given — treat as a choice cell: the value IS the list item
|
||||
// ("Выбрать тип"). Pick it; if a value form follows, it was genuinely a
|
||||
// composite-value cell that needs {value, type}.
|
||||
try {
|
||||
await pickFromTypeDialog(selForm, info.value);
|
||||
} catch (e) {
|
||||
return { field: key, ok: false, error: 'not_found', message: e.message };
|
||||
}
|
||||
await waitForStable(formNum);
|
||||
const after = await helperDetectNewForm(formNum);
|
||||
if (after !== null) {
|
||||
await page.keyboard.press('Escape');
|
||||
await page.waitForTimeout(300);
|
||||
return { field: key, ok: false, error: 'type_required', message: `Cell "${key}" expects { value, type }` };
|
||||
}
|
||||
return { field: key, ok: true, method: 'choice' };
|
||||
}
|
||||
}
|
||||
const pr = await pickFromSelectionForm(selForm, key, info.value, formNum);
|
||||
return pr.ok ? { field: key, ok: true, method: 'form' } : { field: key, ok: false, error: pr.error, message: pr.message };
|
||||
}
|
||||
|
||||
// First field: selection form is already open from the dblclick above
|
||||
const firstKey = Object.keys(fields)[0];
|
||||
const firstInfo = pending.get(firstKey);
|
||||
if (firstFieldSkipped) {
|
||||
firstInfo.filled = true;
|
||||
results.push({ field: firstKey, ok: true, method: 'skip', value: cellCoords.currentText });
|
||||
// Close the selection form that opened from the click
|
||||
await page.keyboard.press('Escape');
|
||||
await waitForStable(formNum);
|
||||
} else {
|
||||
const pickResult = await directEditPick(directEditForm, firstKey, firstInfo);
|
||||
firstInfo.filled = true;
|
||||
results.push(pickResult);
|
||||
}
|
||||
|
||||
// Remaining fields: dblclick on each column cell individually
|
||||
for (const [key, info] of pending) {
|
||||
if (info.filled) continue;
|
||||
// Find column for this key and dblclick on it
|
||||
const nextCoords = await page.evaluate(findNextCellCoordsByKeyScript(gridSelector, row, key));
|
||||
if (!nextCoords) {
|
||||
info.filled = true;
|
||||
results.push({ field: key, ok: false, error: 'column_not_found', message: `Column for "${key}" not found` });
|
||||
continue;
|
||||
}
|
||||
// Skip if cell already contains the desired value
|
||||
if (nextCoords.currentText && info.value &&
|
||||
nextCoords.currentText.toLowerCase().includes(info.value.toLowerCase())) {
|
||||
info.filled = true;
|
||||
results.push({ field: key, ok: true, method: 'skip', value: nextCoords.currentText });
|
||||
continue;
|
||||
}
|
||||
await page.mouse.dblclick(nextCoords.x, nextCoords.y);
|
||||
await page.waitForTimeout(300);
|
||||
// Check if dblclick entered INPUT mode (plain text/numeric field) — before F4 which may open calculator
|
||||
const inInputAfterDblclick = await isInputFocusedInGrid();
|
||||
// Also check if a selection form already appeared
|
||||
let selForm = await helperDetectNewForm(formNum);
|
||||
if (selForm === null && inInputAfterDblclick) {
|
||||
// Choice cell (bare _CB iCB) — editable value (text sticks) or pick-from-list
|
||||
// (text rejected → F4 form). fillChoiceCell discriminates; row commit persists 'direct'.
|
||||
const activeCell = await page.evaluate(readActiveGridCellScript());
|
||||
if (activeCell.buttonKind === 'choice') {
|
||||
const r = await fillChoiceCell(formNum, info.value, { type: info.type, fieldLabel: key });
|
||||
info.filled = true;
|
||||
results.push(r.ok
|
||||
? { field: key, ok: true, method: r.method, ...(r.value !== undefined ? { value: r.value } : {}) }
|
||||
: { field: key, ok: false, error: r.error, message: r.message });
|
||||
continue;
|
||||
}
|
||||
// Plain text/numeric field — fill via clipboard paste
|
||||
await pasteText(info.value, { confirm: ['Control+a', 'Control+v'] });
|
||||
await page.waitForTimeout(400);
|
||||
// Dismiss EDD autocomplete if it appeared
|
||||
if (await isEddVisible()) {
|
||||
await page.keyboard.press('Escape');
|
||||
await page.waitForTimeout(200);
|
||||
}
|
||||
info.filled = true;
|
||||
results.push({ field: key, ok: true, method: 'paste' });
|
||||
continue;
|
||||
}
|
||||
// Poll for selection form (with F4 fallback if dblclick didn't open it)
|
||||
if (selForm === null) {
|
||||
for (let attempt = 0; attempt < 2 && selForm === null; attempt++) {
|
||||
if (attempt === 1) await page.keyboard.press('F4'); // F4 fallback
|
||||
for (let sw = 0; sw < 6; sw++) {
|
||||
await page.waitForTimeout(200);
|
||||
selForm = await helperDetectNewForm(formNum);
|
||||
if (selForm !== null) break;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (selForm === null) {
|
||||
info.filled = true;
|
||||
results.push({ field: key, ok: false, error: 'no_selection_form', message: `Dblclick on "${key}" did not open selection form` });
|
||||
continue;
|
||||
}
|
||||
const pr = await directEditPick(selForm, key, info);
|
||||
info.filled = true;
|
||||
results.push(pr);
|
||||
}
|
||||
// Commit the edit: click on a different row (Escape cancels in tree grids).
|
||||
// Find the first visible row that is NOT the edited row and click it.
|
||||
const commitCoords = await page.evaluate(findRowCommitClickCoordsScript(gridSelector, row));
|
||||
if (commitCoords) {
|
||||
await page.mouse.click(commitCoords.x, commitCoords.y);
|
||||
} else {
|
||||
await page.keyboard.press('Escape');
|
||||
}
|
||||
await waitForStable(formNum);
|
||||
return returnFormState({ filled: results });
|
||||
}
|
||||
|
||||
if (!inEdit) throw new Error(`fillTableRow: click on row ${row} did not enter edit mode`);
|
||||
} else {
|
||||
// No row specified — verify we're in grid edit mode (active INPUT inside a .grid or .gridContent)
|
||||
const editCheck = await page.evaluate(getGridEditCheckScript());
|
||||
|
||||
if (!editCheck.inEdit) {
|
||||
throw new Error('fillTableRow: not in grid edit mode. Use add:true or click a cell first.');
|
||||
}
|
||||
}
|
||||
|
||||
// 4. Prepare pending fields for fuzzy matching
|
||||
const pending = new Map();
|
||||
for (const [key, val] of Object.entries(fields)) {
|
||||
if (val === null || val === undefined || val === '') {
|
||||
pending.set(key, { value: '', type: null, filled: false });
|
||||
} else if (val && typeof val === 'object' && 'value' in val) {
|
||||
const innerVal = val.value;
|
||||
pending.set(key, {
|
||||
value: innerVal === null || innerVal === undefined || innerVal === '' ? '' : String(innerVal),
|
||||
type: val.type || null, filled: false
|
||||
});
|
||||
} else {
|
||||
pending.set(key, { value: String(val), type: null, filled: false });
|
||||
}
|
||||
}
|
||||
|
||||
const results = [];
|
||||
const MAX_ITER = 40;
|
||||
let prevCellId = null;
|
||||
let nonInputCount = 0;
|
||||
let firstCellId = null;
|
||||
|
||||
for (let iter = 0; iter < MAX_ITER; iter++) {
|
||||
// Read focused element (INPUT or TEXTAREA inside grid = editable cell)
|
||||
const cell = await page.evaluate(readActiveGridCellScript());
|
||||
|
||||
if (cell.tag !== 'INPUT' || !cell.fullName) {
|
||||
// Not in an editable grid cell — Tab past (ERP has DIV focus between cells)
|
||||
nonInputCount++;
|
||||
// If only checkbox fields remain unfilled, stop Tab'ing to avoid creating extra rows
|
||||
const onlyCheckboxLeft = [...pending.values()].every(p => p.filled ||
|
||||
['true', 'false', 'да', 'нет', '1', '0', 'yes', 'no'].includes(p.value.toLowerCase().trim()));
|
||||
if (nonInputCount > 3 || onlyCheckboxLeft) break;
|
||||
await page.keyboard.press('Tab');
|
||||
await page.waitForTimeout(300);
|
||||
continue;
|
||||
}
|
||||
nonInputCount = 0;
|
||||
|
||||
// Track first cell to detect wrap-around (Tab looped back to row start)
|
||||
if (firstCellId === null) firstCellId = cell.id;
|
||||
else if (cell.id === firstCellId) break; // wrapped around — all cells visited
|
||||
|
||||
// Stuck detection: same cell twice in a row → force Tab
|
||||
if (cell.id === prevCellId) {
|
||||
await page.keyboard.press('Tab');
|
||||
await page.waitForTimeout(500);
|
||||
prevCellId = null;
|
||||
continue;
|
||||
}
|
||||
prevCellId = cell.id;
|
||||
|
||||
// Fuzzy match cell name to user field: exact → suffix → includes → no-space includes
|
||||
const cellLower = cell.fullName.toLowerCase();
|
||||
let matchedKey = null;
|
||||
for (const [key, info] of pending) {
|
||||
if (info.filled) continue;
|
||||
const kl = key.toLowerCase();
|
||||
if (cellLower === kl || cellLower.endsWith(kl) || cellLower.includes(kl)) {
|
||||
matchedKey = key;
|
||||
break;
|
||||
}
|
||||
// CamelCase cell names have no spaces/dashes — try matching without spaces and dashes
|
||||
const klNoSpace = kl.replace(/[\s\-]+/g, '');
|
||||
if (klNoSpace && (cellLower.endsWith(klNoSpace) || cellLower.includes(klNoSpace))) {
|
||||
matchedKey = key;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback: match by column header text (handles metadata typos in cell id)
|
||||
if (!matchedKey && cell.headerText) {
|
||||
const htLower = cell.headerText.toLowerCase();
|
||||
for (const [key, info] of pending) {
|
||||
if (info.filled) continue;
|
||||
const kl = key.toLowerCase();
|
||||
if (htLower === kl || htLower.endsWith(kl) || htLower.includes(kl)) {
|
||||
matchedKey = key;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!matchedKey) {
|
||||
// Skip this cell
|
||||
await page.keyboard.press('Tab');
|
||||
await page.waitForTimeout(300);
|
||||
continue;
|
||||
}
|
||||
|
||||
const info = pending.get(matchedKey);
|
||||
const text = info.value;
|
||||
|
||||
// Clear cell if value is empty (Shift+F4 = native 1C clear)
|
||||
if (text === '') {
|
||||
await page.keyboard.press('Shift+F4');
|
||||
await page.waitForTimeout(300);
|
||||
info.filled = true;
|
||||
results.push({ field: matchedKey, cell: cell.fullName, ok: true, method: 'clear', value: '' });
|
||||
if ([...pending.values()].every(p => p.filled)) break;
|
||||
await page.keyboard.press('Tab');
|
||||
await page.waitForTimeout(500);
|
||||
continue;
|
||||
}
|
||||
|
||||
// If user specified a type, always clear and use type selection flow
|
||||
if (info.type) {
|
||||
await page.keyboard.press('Shift+F4'); // Clear cell to reset any inherited type
|
||||
await page.waitForTimeout(300);
|
||||
await page.keyboard.press('F4');
|
||||
// Poll for type dialog form to appear
|
||||
let typeForm = null;
|
||||
for (let tw = 0; tw < 6; tw++) {
|
||||
await page.waitForTimeout(200);
|
||||
typeForm = await helperDetectNewForm(formNum);
|
||||
if (typeForm !== null) break;
|
||||
}
|
||||
if (typeForm !== null && await isTypeDialog(typeForm)) {
|
||||
await pickFromTypeDialog(typeForm, info.type);
|
||||
await waitForStable(typeForm);
|
||||
// After type selection, check if a selection form opened (ref types)
|
||||
const selForm = await helperDetectNewForm(formNum);
|
||||
if (selForm === null) {
|
||||
// Primitive type — poll for calculator/calendar popup or settle on INPUT
|
||||
let hasPopup = null;
|
||||
for (let pw = 0; pw < 5; pw++) {
|
||||
await page.waitForTimeout(200);
|
||||
hasPopup = await findOpenPopup();
|
||||
if (hasPopup) break;
|
||||
}
|
||||
if (hasPopup) {
|
||||
await page.keyboard.press('Escape');
|
||||
// Poll for popup to disappear
|
||||
for (let dw = 0; dw < 4; dw++) {
|
||||
await page.waitForTimeout(150);
|
||||
if (!(await findOpenPopup())) break;
|
||||
}
|
||||
}
|
||||
// Ensure we are in an editable INPUT for this cell
|
||||
const inInput = await isInputFocused({ allowTextarea: true });
|
||||
if (!inInput) {
|
||||
const cellRect = await page.evaluate(getElementCenterCoordsByIdScript(cell.id));
|
||||
if (cellRect) {
|
||||
await page.mouse.dblclick(cellRect.x, cellRect.y);
|
||||
// Poll for INPUT focus
|
||||
for (let fw = 0; fw < 4; fw++) {
|
||||
await page.waitForTimeout(150);
|
||||
if (await isInputFocused({ allowTextarea: true })) break;
|
||||
}
|
||||
}
|
||||
}
|
||||
await pasteText(text, { confirm: ['Control+a', 'Control+v'] });
|
||||
await page.waitForTimeout(400);
|
||||
await page.keyboard.press('Tab');
|
||||
await page.waitForTimeout(300);
|
||||
info.filled = true;
|
||||
results.push({ field: matchedKey, cell: cell.fullName, ok: true, method: 'type-direct', type: info.type });
|
||||
continue;
|
||||
}
|
||||
const pickResult = await pickFromSelectionForm(selForm, matchedKey, text, formNum);
|
||||
info.filled = true;
|
||||
results.push(pickResult.ok
|
||||
? { field: matchedKey, cell: cell.fullName, ok: true, method: 'form', type: info.type }
|
||||
: { field: matchedKey, cell: cell.fullName, ok: false,
|
||||
error: pickResult.error, message: pickResult.message });
|
||||
continue;
|
||||
}
|
||||
// F4 opened something but not a type dialog — close and report
|
||||
if (typeForm !== null) {
|
||||
await page.keyboard.press('Escape');
|
||||
await page.waitForTimeout(300);
|
||||
}
|
||||
info.filled = true;
|
||||
results.push({ field: matchedKey, cell: cell.fullName, ok: false,
|
||||
error: 'type_dialog_failed',
|
||||
message: `Cell "${matchedKey}": F4 did not open type dialog for type "${info.type}"` });
|
||||
await page.keyboard.press('Tab');
|
||||
await page.waitForTimeout(500);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Choice cell (_CB iCB): either an editable value cell (text sticks → direct input) or a
|
||||
// pick-from-list cell (НачалоВыбора / РедактированиеТекста=Ложь → text rejected → F4 form).
|
||||
// fillChoiceCell discriminates behaviorally; both kinds are indistinguishable in the DOM.
|
||||
if (cell.buttonKind === 'choice') {
|
||||
const r = await fillChoiceCell(formNum, text, { type: info.type, fieldLabel: matchedKey });
|
||||
info.filled = true;
|
||||
results.push(r.ok
|
||||
? { field: matchedKey, cell: cell.fullName, ok: true, method: r.method, ...(r.value !== undefined ? { value: r.value } : {}) }
|
||||
: { field: matchedKey, cell: cell.fullName, ok: false, error: r.error, message: r.message });
|
||||
// 'direct' leaves text in the INPUT — caller's Tab (or end-of-row commit on the last field) persists it.
|
||||
if ([...pending.values()].every(p => p.filled)) break;
|
||||
await page.keyboard.press('Tab'); await page.waitForTimeout(500);
|
||||
continue;
|
||||
}
|
||||
|
||||
// === Fill this cell: clipboard paste (trusted event) ===
|
||||
await page.keyboard.press('Control+A');
|
||||
await pasteText(text);
|
||||
await page.waitForTimeout(1500);
|
||||
|
||||
// Check if paste was rejected (composite-type cell blocks text input until type is selected)
|
||||
const inputAfterPaste = await page.evaluate(`document.activeElement?.value || ''`);
|
||||
if (!inputAfterPaste && text) {
|
||||
// No type specified — can't fill this composite-type cell
|
||||
info.filled = true;
|
||||
results.push({ field: matchedKey, cell: cell.fullName, ok: false,
|
||||
error: 'type_required',
|
||||
message: `Cell "${matchedKey}" rejected text input (composite-type). Use { value: '...', type: 'Тип' } syntax` });
|
||||
await page.keyboard.press('Tab');
|
||||
await page.waitForTimeout(500);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Check for EDD autocomplete (indicates reference field)
|
||||
const edd = await readEdd();
|
||||
const eddItems = edd.visible ? edd.items.map(i => i.name) : null;
|
||||
|
||||
if (eddItems && eddItems.length > 0) {
|
||||
// Reference field with autocomplete — click best match
|
||||
// Filter out reference field "create" actions (Создать элемент, Создать группу, Создать: ...)
|
||||
// but keep standalone enum values like "Создать" (no space/colon after)
|
||||
const realItems = eddItems.filter(i => !/^Создать[\s:]/.test(i));
|
||||
|
||||
if (realItems.length > 0) {
|
||||
const tgt = normYo(text.toLowerCase());
|
||||
let pick = realItems.find(i =>
|
||||
normYo(i.replace(/\s*\([^)]*\)\s*$/, '').toLowerCase()) === tgt);
|
||||
if (!pick) pick = realItems.find(i => normYo(i.toLowerCase()).includes(tgt));
|
||||
if (!pick) pick = realItems[0];
|
||||
|
||||
// Click EDD item via dispatchEvent (bypasses div.surface overlay)
|
||||
await clickEddItemViaDispatch(pick);
|
||||
await waitForStable();
|
||||
info.filled = true;
|
||||
results.push({ field: matchedKey, cell: cell.fullName, ok: true,
|
||||
method: 'dropdown', value: pick.replace(/\s*\([^)]*\)\s*$/, '') });
|
||||
} else {
|
||||
// Only "Создать:" items — value not found in autocomplete
|
||||
await page.keyboard.press('Escape');
|
||||
await page.waitForTimeout(300);
|
||||
info.filled = true;
|
||||
results.push({ field: matchedKey, cell: cell.fullName, ok: false,
|
||||
error: 'not_found', message: `No match for "${text}"` });
|
||||
}
|
||||
|
||||
// Done? If so, don't Tab (avoids creating a new row after last cell)
|
||||
if ([...pending.values()].every(p => p.filled)) break;
|
||||
// Tab to move to next cell
|
||||
await page.keyboard.press('Tab');
|
||||
await page.waitForTimeout(500);
|
||||
continue;
|
||||
}
|
||||
|
||||
// No EDD — press Tab to commit the value
|
||||
await page.keyboard.press('Tab');
|
||||
await page.waitForTimeout(1000);
|
||||
|
||||
// Check for "нет в списке" cloud popup (reference field, value not found)
|
||||
const notInList = await page.evaluate(isNotInListCloudVisibleScript());
|
||||
|
||||
if (notInList) {
|
||||
// Cloud has "Показать все" link — try to open selection form via it
|
||||
const clickedShowAll = await page.evaluate(clickShowAllInNotInListCloudScript());
|
||||
|
||||
if (clickedShowAll) {
|
||||
await waitForStable(formNum);
|
||||
// Check if selection form opened
|
||||
const selForm = await helperDetectNewForm(formNum, { strict: true });
|
||||
|
||||
if (selForm !== null) {
|
||||
const pickResult = await pickFromSelectionForm(selForm, matchedKey, text, formNum);
|
||||
info.filled = true;
|
||||
if (pickResult.ok) {
|
||||
results.push({ field: matchedKey, cell: cell.fullName, ok: true, method: 'form' });
|
||||
continue;
|
||||
}
|
||||
// Not found in selection form — fall through to clear + skip
|
||||
results.push({ field: matchedKey, cell: cell.fullName, ok: false,
|
||||
error: pickResult.error, message: pickResult.message });
|
||||
} else {
|
||||
info.filled = true;
|
||||
results.push({ field: matchedKey, cell: cell.fullName, ok: false,
|
||||
error: 'not_found', message: `Value "${text}" not in list` });
|
||||
}
|
||||
} else {
|
||||
info.filled = true;
|
||||
results.push({ field: matchedKey, cell: cell.fullName, ok: false,
|
||||
error: 'not_found', message: `Value "${text}" not in list` });
|
||||
}
|
||||
|
||||
// 1C won't let us Tab away from an invalid ref value.
|
||||
// Must clear the field first, then Tab to move on.
|
||||
// Escape dismisses the cloud; Ctrl+A + Delete clears the text.
|
||||
await page.keyboard.press('Escape');
|
||||
await page.waitForTimeout(300);
|
||||
await page.keyboard.press('Control+A');
|
||||
await page.keyboard.press('Delete');
|
||||
await page.waitForTimeout(300);
|
||||
await page.keyboard.press('Tab');
|
||||
await page.waitForTimeout(500);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Check for a new form (broad detection — also catches type dialogs whose buttons lack IDs)
|
||||
const newForm = await helperDetectNewForm(formNum);
|
||||
|
||||
if (newForm !== null) {
|
||||
if (await isTypeDialog(newForm)) {
|
||||
// Composite-type cell — need type to proceed
|
||||
if (info.type) {
|
||||
await pickFromTypeDialog(newForm, info.type);
|
||||
await waitForStable(newForm);
|
||||
// After type selection, the actual selection form should open
|
||||
const selForm = await helperDetectNewForm(formNum);
|
||||
if (selForm === null) {
|
||||
// Primitive type — poll for calculator/calendar popup or settle on INPUT
|
||||
let hasPopup = null;
|
||||
for (let pw = 0; pw < 5; pw++) {
|
||||
await page.waitForTimeout(200);
|
||||
hasPopup = await findOpenPopup();
|
||||
if (hasPopup) break;
|
||||
}
|
||||
if (hasPopup) {
|
||||
await page.keyboard.press('Escape');
|
||||
for (let dw = 0; dw < 4; dw++) {
|
||||
await page.waitForTimeout(150);
|
||||
if (!(await findOpenPopup())) break;
|
||||
}
|
||||
}
|
||||
const inInput = await isInputFocused({ allowTextarea: true });
|
||||
if (!inInput) {
|
||||
const cellRect = await page.evaluate(getElementCenterCoordsByIdScript(cell.id));
|
||||
if (cellRect) {
|
||||
await page.mouse.dblclick(cellRect.x, cellRect.y);
|
||||
for (let fw = 0; fw < 4; fw++) {
|
||||
await page.waitForTimeout(150);
|
||||
if (await isInputFocused({ allowTextarea: true })) break;
|
||||
}
|
||||
}
|
||||
}
|
||||
await pasteText(text, { confirm: ['Control+a', 'Control+v'] });
|
||||
await page.waitForTimeout(400);
|
||||
await page.keyboard.press('Tab');
|
||||
await page.waitForTimeout(300);
|
||||
info.filled = true;
|
||||
results.push({ field: matchedKey, cell: cell.fullName, ok: true, method: 'type-direct', type: info.type });
|
||||
continue;
|
||||
}
|
||||
const pickResult = await pickFromSelectionForm(selForm, matchedKey, text, formNum);
|
||||
info.filled = true;
|
||||
results.push(pickResult.ok
|
||||
? { field: matchedKey, cell: cell.fullName, ok: true, method: 'form', type: info.type }
|
||||
: { field: matchedKey, cell: cell.fullName, ok: false,
|
||||
error: pickResult.error, message: pickResult.message });
|
||||
continue;
|
||||
} else {
|
||||
// No type specified — close dialog, clear cell, report error
|
||||
await page.keyboard.press('Escape');
|
||||
await page.waitForTimeout(300);
|
||||
await page.keyboard.press('Control+A');
|
||||
await page.keyboard.press('Delete');
|
||||
await page.waitForTimeout(300);
|
||||
await page.keyboard.press('Tab');
|
||||
await page.waitForTimeout(500);
|
||||
info.filled = true;
|
||||
results.push({ field: matchedKey, cell: cell.fullName, ok: false,
|
||||
error: 'type_required',
|
||||
message: `Cell "${matchedKey}" opened a type selection dialog. Use { value: '...', type: 'Тип' } syntax` });
|
||||
continue;
|
||||
}
|
||||
}
|
||||
// Not a type dialog — normal selection form
|
||||
const pickResult = await pickFromSelectionForm(newForm, matchedKey, text, formNum);
|
||||
info.filled = true;
|
||||
results.push(pickResult.ok
|
||||
? { field: matchedKey, cell: cell.fullName, ok: true, method: 'form' }
|
||||
: { field: matchedKey, cell: cell.fullName, ok: false,
|
||||
error: pickResult.error, message: pickResult.message });
|
||||
continue;
|
||||
}
|
||||
|
||||
// Plain field — value committed via Tab
|
||||
info.filled = true;
|
||||
results.push({ field: matchedKey, cell: cell.fullName, ok: true, method: 'direct' });
|
||||
|
||||
// All done?
|
||||
if ([...pending.values()].every(p => p.filled)) break;
|
||||
// Tab already pressed — we're on next cell
|
||||
}
|
||||
|
||||
// Commit the new row: click on the grid header to exit edit mode.
|
||||
// Clicking a different data row would re-enter edit mode on that row.
|
||||
// Without this commit click, the row stays in "uncommitted add" state
|
||||
// and a subsequent Escape (e.g. from closeForm) would cancel the entire row.
|
||||
const commitTarget = await page.evaluate(findGridHeadCenterCoordsScript(gridSelector));
|
||||
if (commitTarget) {
|
||||
await page.mouse.click(commitTarget.x, commitTarget.y);
|
||||
await page.waitForTimeout(500);
|
||||
} else {
|
||||
// Fallback: Tab out of the last cell to commit the row
|
||||
await page.keyboard.press('Tab');
|
||||
await page.waitForTimeout(500);
|
||||
}
|
||||
|
||||
// Dismiss any leftover error modals
|
||||
const err = await checkForErrors();
|
||||
if (err?.modal) {
|
||||
try {
|
||||
const btn = await page.$('a.press.pressDefault');
|
||||
if (btn) { await btn.click(); await page.waitForTimeout(500); }
|
||||
} catch { /* OK */ }
|
||||
}
|
||||
|
||||
const notFilled = [...pending].filter(([_, info]) => !info.filled).map(([key]) => key);
|
||||
|
||||
// Retry unfilled checkbox fields via direct click (Tab skips checkbox cells)
|
||||
if (notFilled.length > 0) {
|
||||
const checkboxFields = {};
|
||||
for (const key of notFilled) {
|
||||
const val = String(pending.get(key).value).toLowerCase().trim();
|
||||
if (['true', 'false', 'да', 'нет', '1', '0', 'yes', 'no'].includes(val)) {
|
||||
checkboxFields[key] = pending.get(key).value;
|
||||
}
|
||||
}
|
||||
if (Object.keys(checkboxFields).length > 0) {
|
||||
// Use row index: addedRowIdx (from add mode) or fallback to selected row
|
||||
const currentRow = addedRowIdx >= 0 ? addedRowIdx : (row != null ? row : await page.evaluate(getSelectedOrLastRowIndexScript(gridSelector))
|
||||
);
|
||||
if (currentRow >= 0) {
|
||||
const more = await fillTableRow(checkboxFields, { row: currentRow, table });
|
||||
results.push(...more.filled);
|
||||
for (const key of Object.keys(checkboxFields)) {
|
||||
const idx = notFilled.indexOf(key);
|
||||
if (idx >= 0) notFilled.splice(idx, 1);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const extras = { filled: results };
|
||||
if (notFilled.length > 0) extras.notFilled = notFilled;
|
||||
return returnFormState(extras);
|
||||
|
||||
} catch (e) {
|
||||
if (e.message.startsWith('fillTableRow:')) throw e;
|
||||
throw new Error(`fillTableRow: ${e.message}`);
|
||||
}
|
||||
}
|
||||
+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,65 @@
|
||||
#!/usr/bin/env node
|
||||
// web-test run v1.17 — CLI entry-point (распилено по cli/)
|
||||
// Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
|
||||
/**
|
||||
* 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 run <url> <file|-> — autonomous: connect, execute script, disconnect
|
||||
* 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
|
||||
* node src/run.mjs test [url] <dir|file> — run regression tests
|
||||
*
|
||||
* Внутренности живут в cli/: util, session, exec-context, server,
|
||||
* commands/{start,run,exec,shot,stop,status,test}, test-runner/*.
|
||||
*/
|
||||
import * as browser from './browser.mjs';
|
||||
import { usage } from './cli/util.mjs';
|
||||
import { cmdStart } from './cli/commands/start.mjs';
|
||||
import { cmdRun } from './cli/commands/run.mjs';
|
||||
import { cmdExec } from './cli/commands/exec.mjs';
|
||||
import { cmdShot } from './cli/commands/shot.mjs';
|
||||
import { cmdStop } from './cli/commands/stop.mjs';
|
||||
import { cmdStatus } from './cli/commands/status.mjs';
|
||||
import { cmdTest } from './cli/commands/test.mjs';
|
||||
|
||||
const [,, cmd, ...rawArgs] = process.argv;
|
||||
const flags = {
|
||||
noRecord: rawArgs.includes('--no-record'),
|
||||
execTimeoutMs: parseExecTimeoutMs(rawArgs),
|
||||
};
|
||||
const args = rawArgs.filter(a => !a.startsWith('--'));
|
||||
|
||||
// Clipboard preservation: default ON. Disabled by --no-preserve-clipboard CLI flag
|
||||
// or WEB_TEST_PRESERVE_CLIPBOARD=0 env. cmdTest may further disable via config.
|
||||
const preserveClipboard = !rawArgs.includes('--no-preserve-clipboard')
|
||||
&& process.env.WEB_TEST_PRESERVE_CLIPBOARD !== '0';
|
||||
browser.setPreserveClipboard(preserveClipboard);
|
||||
|
||||
function parseExecTimeoutMs(argv) {
|
||||
const DEFAULT_MS = 30 * 60 * 1000;
|
||||
const flagMs = argv.find(a => a.startsWith('--timeout='));
|
||||
if (flagMs) return Math.max(1, Number(flagMs.slice('--timeout='.length))) || DEFAULT_MS;
|
||||
const flagMin = argv.find(a => a.startsWith('--timeout-min='));
|
||||
if (flagMin) return Math.max(1, Number(flagMin.slice('--timeout-min='.length))) * 60 * 1000 || DEFAULT_MS;
|
||||
const env = process.env.WEB_TEST_EXEC_TIMEOUT_MS;
|
||||
if (env) return Math.max(1, Number(env)) || DEFAULT_MS;
|
||||
return DEFAULT_MS;
|
||||
}
|
||||
|
||||
switch (cmd) {
|
||||
case 'start': await cmdStart(args[0]); break;
|
||||
case 'run': await cmdRun(args[0], args[1]); break;
|
||||
case 'exec': await cmdExec(args[0], flags); break;
|
||||
case 'shot': await cmdShot(args[0]); break;
|
||||
case 'stop': await cmdStop(); break;
|
||||
case 'status': cmdStatus(); break;
|
||||
case 'test': await cmdTest(rawArgs); break;
|
||||
default: usage();
|
||||
}
|
||||
Reference in New Issue
Block a user