Files
Nick Shirokov f424d2ac70 feat(web-test): фокус на поле ввода через clickElement (fallback)
clickElement как последний fallback (без table) фокусирует одноимённое
поле ввода, не меняя значение — возвращает focused:{field,id,ok}.
Закрывает пробел: клавиши F4/Shift+F4 требовали сфокусированного поля,
но штатного примитива фокуса не было.

- dom/forms.mjs: резолв input.editInput/textarea по имени/заголовку
  последним шагом findClickTargetScript; имена полей в available
- click-form.mjs: focusFormField (клик по инпуту + isInputFocused → ok)
- click.mjs: ветка диспетчера kind === field
- SKILL.md + docs/web-test-guide.md: focused в extras, пример focus→F4
- tests: 19-focus-field.test.mjs (focus/F4/регресс/негатив)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-05-29 22:02:12 +03:00

412 lines
30 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# Тестирование через веб-клиент 1С
Навык `/web-test` автоматизирует действия в веб-клиенте 1С через Playwright — навигация по разделам, заполнение форм, чтение таблиц и отчётов, фильтрация списков. Замыкает цикл: правка исходников → загрузка → обновление → публикация → **автоматическое тестирование**.
## Навык
| Навык | Скрипт | Описание |
|-------|:------:|----------|
| `/web-test` | `.mjs` (Node.js) | Автоматизация 1С через браузер — навигация, формы, таблицы, отчёты |
## Предусловия
- База опубликована через Apache (`/web-publish`)
- Node.js 18+ установлен
- Зависимости установлены: `cd .claude/skills/web-test/scripts && npm install`
## Рабочий цикл
```
/web-publish → /web-test → результат
↑ |
└── правки → /db-load-xml → /db-update ──┘
```
## Сценарии использования
### Навигация и чтение данных
```
> Открой базу erp в браузере, перейди в раздел Склад и покажи какие команды там есть
```
Claude откроет браузер, перейдёт в раздел и покажет список команд.
```
> Открой список поступлений товаров и покажи первые 10 строк
```
Claude откроет список и прочитает таблицу.
### Поиск и открытие элементов
```
> Найди в списке номенклатуры товар "Вентилятор" и открой его карточку
```
Claude отфильтрует список, откроет найденный элемент двойным кликом и прочитает реквизиты формы.
```
> Открой справочник Контрагенты и найди "Торговый дом"
```
Claude может работать с иерархическими справочниками — поиск автоматически сглаживает иерархию.
### Создание документа
```
> Создай заказ клиента: организация "Андромеда Плюс", контрагент "Торговый дом Комплексный",
> добавь строку: номенклатура "Вентилятор", количество 5
```
Claude откроет форму создания, заполнит шапку и добавит строку в табличную часть.
### Работа с отчётами
```
> Открой отчёт "Остатки и доступность товаров",
> установи отбор Склад = "Склад бытовой техники", сформируй и прочитай результат
```
Claude заполнит фильтры отчёта по человекочитаемым именам (не надо знать технические имена DCS), нажмёт "Сформировать" и прочитает структурированные данные: заголовки, строки, итоги.
### Сравнение данных
```
> Сформируй отчёт по остаткам для "Склад бытовой техники" и "Западный склад",
> сравни итоги по доступным товарам
```
Claude напишет сценарий, который сформирует отчёт дважды с разными фильтрами и сравнит результаты.
### Проверка после загрузки расширения
```
> Загрузи расширение ТестОшибки и проверь через браузер, что при создании заказа клиента
> появляется ошибка "Тестовая ошибка из расширения"
```
Claude загрузит расширение через `/db-load-xml`, затем через `/web-test` откроет форму и проверит ожидаемое поведение.
### Открытие внешней обработки
```
> Открой обработку build/РедакторДвижений.epf в веб-клиенте и покажи что на форме
```
Claude откроет EPF через Ctrl+O, автоматически обработает диалог безопасности (если есть) и прочитает форму.
### Пошаговая отладка
```
> Запусти браузер на базе erp
> Перейди в раздел Продажи
> Посмотри что на форме
> Открой первый заказ
> Какие реквизиты заполнены?
```
Claude будет выполнять команды по одной, показывая состояние формы между шагами.
## Режимы работы
### Автономный режим (run)
Одна команда: открывает браузер → логинится → выполняет сценарий → закрывает браузер → завершает процесс. Не оставляет висящих процессов.
```bash
RUN=".claude/skills/web-test/scripts/run.mjs"
node $RUN run http://localhost:8081/erp scenario.js
```
Claude пишет `.js` файл со сценарием и запускает его. Ответ — JSON:
```json
{ "ok": true, "output": "...console.log output...", "elapsed": 12.3 }
```
При ошибке — автоматический скриншот (пока модалка ещё видна) и стек вызова:
```json
{ "ok": false, "error": "Тестовая проверка: запись запрещена", "screenshot": "error-shot.png",
"stack": { "raw": "...", "entries": [{"location": "Модуль(4)", "code": "ВызватьИсключение..."}] } }
```
Стек извлекается автоматически — через OpenReport (платформенные исключения) или "О программе" → "Информация для техподдержки" (ВызватьИсключение).
### Интерактивный режим (start/exec/stop)
Браузер остаётся открытым между командами. Состояние (открытые вкладки, формы) сохраняется.
```bash
node $RUN start http://localhost:8081/erp # запустить сессию (фоновый процесс)
cat <<'SCRIPT' | node $RUN exec - # выполнить скрипт
await navigateSection('Продажи');
SCRIPT
node $RUN shot current-state.png # скриншот
node $RUN stop # завершить сессию
```
### Когда какой
| Режим | Когда использовать |
|-------|-------------------|
| Автономный (`run`) | Готовый сценарий целиком, субагенты, CI |
| Интерактивный (`start/exec`) | Пошаговое исследование, отладка, разговор с пользователем |
## Пример: автономный сценарий
Сравнение остатков по двум складам — один файл, один запуск:
```js
// scenario-compare-stocks.js
// 1. Открыть отчёт
await navigateSection('Склад и доставка');
await openCommand('Отчеты по складу');
await clickElement('Остатки и доступность товаров', { dblclick: true });
// 2. Первый склад
await fillFields({ 'Склад': 'Склад бытовой техники' });
await clickElement('Сформировать');
await wait(5);
const report1 = await readSpreadsheet();
console.log('=== Склад бытовой техники ===');
console.log('Строк:', report1.data?.length, '| Доступно:', report1.totals?.['Доступно']);
// 3. Второй склад
await fillFields({ 'Склад': 'Западный склад' });
await clickElement('Сформировать');
await wait(5);
const report2 = await readSpreadsheet();
console.log('=== Западный склад ===');
console.log('Строк:', report2.data?.length, '| Доступно:', report2.totals?.['Доступно']);
// 4. Сравнение
const parse = s => parseFloat((s || '0').replace(/\s/g, '').replace(',', '.'));
const diff = parse(report1.totals?.['Доступно']) - parse(report2.totals?.['Доступно']);
console.log('Разница:', diff.toFixed(0));
await closeForm({ save: false });
```
Запуск: `node $RUN run http://localhost:8081/erp scenario-compare-stocks.js`
### Расшифровка отчёта
```js
// 1. Сформировать отчёт
await clickElement('Сформировать');
await wait(5);
const report = await readSpreadsheet();
// 2. Двойной клик по ячейке → диалог "Выбор поля"
await clickElement({ row: 0, column: 'К6' }, { dblclick: true });
// 3. Выбрать поле расшифровки
await clickElement('Регистратор');
await clickElement('Выбрать');
await wait(10);
// 4. Прочитать результат
const drilldown = await readSpreadsheet();
console.log('Расшифровка:', JSON.stringify(drilldown.rows));
```
## API
Все функции доступны как глобальные переменные в скриптах. `console.log()` выводит данные в ответ.
### Навигация
| Функция | Описание | Возвращает |
|---------|----------|------------|
| `navigateSection(name)` | Перейти в раздел (fuzzy match) | form state с `navigated`, `sections`, `commands` |
| `openCommand(name)` | Открыть команду из панели функций | form state |
| `navigateLink(path)` | Открыть по пути метаданных (`Документ.ЗаказКлиента`) | form state |
| `openFile(path)` | Открыть внешнюю обработку/отчёт (EPF/ERF) через «Файл → Открыть» | form state |
| `switchTab(name)` | Переключить открытую вкладку | form state |
### Чтение
| Функция | Описание | Возвращает |
|---------|----------|------------|
| `getFormState()` | Структура формы: поля, кнопки, таблица, фильтры, состояние окон | `{ form, formCount, openForms, fields, buttons, tabs, table, filters, reportSettings? }` |
| `readTable({ maxRows?, offset? })` | Данные таблицы с пагинацией | `{ columns, rows: [{col: val}], total }` |
| `readSpreadsheet()` | Результат отчёта | `{ title?, headers?, data?, totals?, total }` |
| `getSections()` | Разделы и команды | `{ activeSection, sections, commands }` |
| `getPageState()` | Разделы + открытые вкладки | `{ activeSection, activeTab, sections, tabs }` |
#### getFormState — подробнее
Основной способ «увидеть» что на экране:
- **form** — номер активной формы, `null` когда ничего не открыто (десктоп)
- **formCount** — количество открытых форм. `0` = десктоп. Работает даже если панель открытых окон скрыта
- **openForms** — `[0, 1, 2]` — номера всех открытых форм в DOM
- **modal** — `true` когда активная форма — модальный диалог, блокирующий интерфейс
- **openTabs** — `[{ name, active? }]` из панели открытых окон (только когда панель включена в настройках 1С)
- **fields** — `[{ name, value, label?, actions?, required? }]`. `actions` = select/clear/open. `required: true` = незаполненное обязательное поле
- **table** — `{ name, columns, rowCount }` (метаданные; для данных — `readTable()`)
- **reportSettings** — DCS-фильтры в читаемом виде: `[{ name: "Склад", enabled: true, value: "..." }]`
- **errorModal** — 1С показала ошибку
- **confirmation** — диалог Да/Нет, вызовите `clickElement('Да')` или `clickElement('Нет')`
- **platformDialogs** — `[{ type, title }]` — платформенные диалоги (О программе, Информация для техподдержки). Невидимы для обычного определения форм, но блокируют интерфейс. `closeForm()` закрывает их. Автоочистка через `dismissPendingErrors` перед каждым action
#### readTable — подробнее
Каждая строка — объект `{ columnName: value }`. Специальные поля для иерархии и дерева:
- `_kind: 'group'` — группа в иерархическом списке
- `_tree: 'expanded'|'collapsed'` — состояние узла дерева
- `_level: N` — уровень вложенности
- `_selected: true` — строка выделена (подсвечена). Используйте с `clickElement({ modifier: 'ctrl'|'shift' })` для проверки мультиселекции
- На объекте результата: `hierarchical: true`, `viewMode: 'tree'`
**Виртуализация и `hasMore`.** 1С виртуализирует и динамические списки, и табличные части — в DOM лежит только окно видимых строк. Поля `total` / `shown` — это размер DOM-окна, а **не** размер коллекции. Чтобы понять, есть ли строки за пределами окна, используйте `hasMore`:
```js
const t = await readTable();
// t.hasMore = { above: false, below: true } — открыли список, есть строки ниже
// t.hasMore = { above: true, below: false } — пролистали в конец
// t.hasMore = { above: false, below: false } — всё помещается / нет страниц
```
`hasMore.below` присутствует всегда. `hasMore.above` тоже обычно есть — определяется по кнопкам пагинации (`vertButtonScroll`, есть у большинства дин-списков) или треку скроллбара (у табчастей). Отсутствует только в редких случаях, когда у грида нет ни кнопок, ни видимого скроллбара — тогда трактуйте отсутствие как «неизвестно».
**Колонки-картинки.** Ячейки, где выводится иконка (статусы, этапы, индикатор ЭДО, скрепка «есть файл»), читаются как `'pic:<N>'` при наличии иконки (`N` — индекс кадра/состояния) и `''` при её отсутствии. Присутствие читается как truthy, разные иконки различаются по индексу:
```js
const t = await readTable();
if (t.rows[0]['Присоединенные файлы']) { /* у строки есть прикреплённый файл */ }
t.rows[0]['ЭДО'] === 'pic:1'; // подключён к 1С-ЭДО ('pic:0' = нет)
```
Колонки без текста в заголовке (одна иконка) тоже попадают в `columns`, именуются по тултипу заголовка или `'(picture)'` — служебное имя колонки 1С в браузер не передаёт. Картиночные значения — **только для чтения и ассертов**: отбирать/фильтровать строки по `'pic:N'` нельзя (фильтр по такому значению бросает понятную ошибку, расширенный поиск 1С такое поле не покажет). Для выбора строки фильтруйте по текстовой колонке; кликать по картиночной ячейке можно по индексу строки.
#### clickElement — клик по ячейке (spreadsheet или грид формы)
Первый аргумент `clickElement` принимает объект `{ row, column }` вместо текста. Маршрутизация автоматическая: если на форме отрисован SpreadsheetDocument (отчёт) — кликаем туда (drill-down), иначе — по ячейке грида (табчасть, список). Параметр `table: 'ИмяГрида'` принудительно указывает грид, если на форме одновременно есть отчёт и таблицы.
**SpreadsheetDocument (drill-down отчёта).** Координаты соответствуют выводу `readSpreadsheet()`:
```js
const report = await readSpreadsheet();
// report.data[0] = { 'К1': 'Материалы строительные', 'К6': '150 000' }
await clickElement({ row: 0, column: 'К6' }, { dblclick: true }); // по индексу
await clickElement({ row: { 'К1': 'Материалы' }, column: 'К6' }, { dblclick: true }); // по фильтру
await clickElement({ row: 'totals', column: 'К6' }, { dblclick: true }); // итоги
await clickElement('150 000', { dblclick: true }); // fallback: по тексту в iframe'ах
```
**Грид формы (табчасть документа, список каталога/журнала).** Колонка вне viewport — авто-скролл по горизонтали (с учётом frozen-колонок). `scroll: true | number` включает reveal-loop через PageDown для filter-row за пределами DOM-окна:
```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, лимит по умолчанию 50
);
```
**Подводные камни:**
- `row: <число>` — индекс в **текущем DOM-окне**, не абсолютный (1С виртуализирует длинные списки). Для произвольной строки в длинном списке — `row: { col: val }` + `scroll: true`.
- `scroll: true` идёт только **вниз** (PageDown). Для вверх — `page.keyboard.press('Home')` через `getPage()` или сначала `filterList`.
- На дубликаты при фильтре — первая подходящая строка. Уточняйте фильтр для disambiguation.
### Действия
Все action-функции возвращают **плоский form state** (как `getFormState()`) с action-specific extras (`clicked`, `focused`, `selected`, `filled`, `notFilled`, `closed`, `opened`, `navigated`, `deleted`, `filtered`, `unfiltered`). Errors всегда на верхнем уровне `.errors` — exec-wrapper автоматически throw'ает на soft validation errors (`modal`/`balloon`).
| Функция | Описание | Возвращает |
|---------|----------|------------|
| `clickElement(text, {dblclick?, modifier?, table?, scroll?})` | Клик по кнопке/ссылке/строке. `{dblclick: true}` для открытия, `{modifier: 'ctrl'\|'shift'}` для мультиселекции. Первый аргумент может быть `{row, column}` для клика по ячейке spreadsheet или грида формы (`table` форсит грид; `scroll: true \| number` включает reveal-loop через PageDown — см. выше). Если `text` не совпал ни с одним контролом и `table` не задан — как последний fallback фокусирует одноимённое поле ввода (без изменения значения), см. раздел про клавиши | form state (`clicked` / `focused` / `submenu`) |
| `fillFields({name: value})` | Заполнить поля (текст, чекбокс, радио, ссылки, DCS-фильтры). Пустое значение (`''`/`null`) = очистка | form state с `filled` |
| `selectValue(field, search, opts?)` | Выбрать из справочника. search: текст, `{поле: значение}` или `''`/`null` для очистки. `{ type }` для составного типа | form state с `selected` |
| `fillTableRow(fields, {tab?, add?, row?})` | Заполнить строку. Значение: строка, `{ value, type }` для составного типа, `''`/`null` для очистки | form state с `filled` (per-field ошибки как items `ok: false`, см. ниже) + `notFilled?` |
| `deleteTableRow(row, {tab?})` | Удалить строку по индексу | form state |
| `closeForm({save?})` | Закрыть форму. `save: false` = "Нет", `save: true` = "Да". Возвращает `closed: true/false` | form state с `closed` |
| `filterList(text, {field?, exact?})` | Фильтр списка. Без field = все колонки, с field = расширенный поиск | form state |
| `unfilterList({field?})` | Снять фильтры (все или конкретный) | form state |
#### fillFields — типы полей
| Значение | Тип поля | Метод |
|----------|---------|--------|
| `'Андромеда Плюс'` | Ссылочное | clipboard paste + typeahead |
| `'5000'` | Текст | clipboard paste |
| `'true'` / `'да'` | Чекбокс | toggle |
| `'Оплата поставщику'` | Радио | fuzzy match по меткам |
| `'Склад бытовой техники'` (DCS) | Фильтр отчёта | авто-включение чекбокса + заполнение |
| `''` / `null` | Любое (кроме чекбокс/радио) | очистка через Shift+F4 |
### Утилиты
| Функция | Описание |
|---------|----------|
| `screenshot()` | Скриншот (PNG Buffer) |
| `wait(seconds)` | Пауза, возвращает form state |
| `getPage()` | Сырой Playwright Page для горячих клавиш и нестандартных операций |
| `startRecording(path, opts?)` | Начать запись видео (CDP screencast → ffmpeg → MP4) |
| `stopRecording()` | Остановить запись, вернуть `{ file, duration, size }` |
| `showCaption(text, opts?)` | Текстовая подпись поверх страницы (`speech` — текст озвучки) |
| `hideCaption()` | Убрать подпись |
| `showTitleSlide(text, opts?)` | Полноэкранный титульный слайд (`subtitle`, `background`, `speech`) |
| `hideTitleSlide()` | Убрать титульный слайд |
| `showImage(path, opts?)` | Полноэкранное изображение (`style`: blur/dark/light/full, `speech`) |
| `hideImage()` | Убрать изображение |
| `addNarration(videoPath, opts?)` | Озвучка видео по субтитрам (Edge TTS / ElevenLabs / OpenAI) |
| `getCaptions()` | Субтитры из текущей/последней записи |
| `isRecording()` | Идёт ли запись (boolean) |
| `setHighlight(on)` | Включить/выключить авто-выделение элементов при действиях |
| `isHighlightMode()` | Активен ли режим авто-выделения (boolean) |
| `highlight(text)` | Ручное выделение элемента (по имени, fuzzy match) |
| `unhighlight()` | Снять выделение |
## Клавиатурные сочетания
Чтобы клавиша применилась к нужному полю, его сперва надо сфокусировать. `clickElement('ИмяПоля')` (без `table`) ставит фокус, ничего не меняя, и возвращает `focused: { field, id, ok }` — после этого жмём клавишу через `getPage()`:
```js
await clickElement('Контрагент'); // фокус на ссылочное поле (focused.ok)
const page = await getPage();
await page.keyboard.press('F4'); // открыть форму выбора
```
| Клавиша | Контекст | Действие |
|---------|----------|----------|
| `F8` | Ссылочное поле | Создать новый элемент (может требовать прав/настройки в 1С) |
| `Shift+F4` | Любое поле | Очистить значение (автоматизировано: `fillFields({ поле: '' })`) |
| `F4` | Ссылочное поле | Форма выбора |
| `Alt+F` | Список/таблица | Расширенный поиск |
## Типичные ошибки
Большинство функций бросают исключение при ошибке. Сценарий прерывается на проблемном шаге с информативным сообщением. В интерактиве — `try/catch` для обработки.
**Исключение — `fillTableRow`**: на per-field ошибках не throws, а возвращает их в `filled[]` как items с `ok: false` (`{ field, ok: false, error: 'code', message: '...' }`). Это позволяет частичное восстановление: например при `error: 'composite_type'` модель может retry'нуть конкретную ячейку с `{ value, type }` синтаксисом, не перезаполняя всю строку. Проверка — `r.filled.filter(f => !f.ok)`. Жёсткие ошибки (нет формы, table не найдена) и soft validation errors от 1С (balloon/modal) — всё равно throws.
| Проблема | Решение |
|----------|---------|
| `no form found` — форма не открыта | Добавьте `await wait(2)` после навигации |
| `not found. Available: ...` — элемент не найден | Проверьте имя через `getFormState()`, используйте вариант из Available |
| `fillFields: N of M field(s) failed` | Текст ошибки содержит список проблемных полей и доступные варианты |
| `fillTableRow` вернул item с `ok: false` | См. поле `error``composite_type` → retry с `{value, type}`; `column_not_found` → проверьте имя поля через `readTable`; `not_found` → уточните значение поиска |
| Пустой `readSpreadsheet()` | Увеличьте `await wait(N)` перед чтением |
## Особенности
- **Headed mode** — 1С требует видимый браузер, headless не поддерживается
- **Время запуска** — первое подключение к 1С занимает 30-60 секунд (ожидание встроено)
- **Fuzzy matching** — все поиски: точное совпадение → начало строки → вхождение. Буквы ё и е считаются эквивалентными
- **Clipboard paste** — поля заполняются через Ctrl+V (корректно триггерит события 1С)
- **Неразрывные пробелы** — 1С использует `\u00a0`, внутри API нормализация автоматическая
- **Ошибки** — все функции бросают исключение при ошибке (сценарий прерывается), `try/catch` для обработки
- **Панель разделов** — `navigateSection()` работает при любом расположении панели (сбоку, сверху), но требует режим «Картинка и текст» или «Текст». Режим «Только картинки» не поддерживается — API не может прочитать имена разделов из иконок
## Связанные навыки
- [Запись видеоинструкций](web-test-recording-guide.md) — запись видео, субтитры, подсветка, TTS-озвучка
- [Веб-публикация](web-guide.md) — `/web-publish`, `/web-info`, `/web-stop`, `/web-unpublish`
- [Базы данных](db-guide.md) — `/db-load-xml`, `/db-update`, `/db-run`
- [Расширения](cfe-guide.md) — `/cfe-init`, `/cfe-borrow`, `/cfe-patch-method`