Compare commits

..

98 Commits

Author SHA1 Message Date
Nick Shirokov 7fa279c354 feat(skd-edit): clear-conditionalAppearance + multiline patch-query (доки)
- Новая операция clear-conditionalAppearance в стиле clear-selection/
  order/filter. Закрывает потребность "заменить набор правил оформления"
  через clear + re-add.
- patch-query: многострочные подстроки уже работали (string.Replace
  корректно обрабатывает \n). Зафиксировано в SKILL.md.
- add-total: shorthand-шаблон с тремя случаями (Func, Func(expr),
  identity-выражение) — после fix Bug 6 поведение нужно явно объяснить.
- Косметика: убрана утечка XML-внутренностей в комментарии примера
  set-field-role @period.
- Пример patch-query @once заменён на более типовой случай уникальной
  подстроки (КАК ВТ_СтароеИмя вместо ЛЕВОЕ СОЕДИНЕНИЕ).

Регресс: 33/33 PS, 33/33 PY, 33/33 платформенный verify.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-15 17:39:24 +03:00
Nick Shirokov 28a2a34c84 fix(skd-edit): add-total identity expression для не-аггрегатных функций
Раньше "DataPath: X" всегда заворачивалось в X(DataPath). Если X не
аггрегатная функция (например, имя другого ресурса или сам DataPath),
получалось некорректное выражение типа Проверка(Проверка).

Зеркалю логику из skd-compile: whitelist аггрегатных функций
(Сумма, Количество, Минимум, Максимум, Среднее + EN-варианты).
Для остального — identity (использовать funcPart как есть).

Сообщение [OK] теперь показывает фактически записанный expression.

Регресс: 32/32 PS, 32/32 PY, 32/32 платформенный verify.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-15 16:16:27 +03:00
Nick Shirokov f0f1e88aaa feat(skd-edit): patch-query @once — assert ровно одно вхождение
Защищает от случайных замен в комментариях/совпадениях имён:

  "ЛЕВОЕ СОЕДИНЕНИЕ => ВНУТРЕННЕЕ СОЕДИНЕНИЕ @once"
  # fail, если в запросе 0 или 2+ вхождений

Без флага default — replace-all (как раньше, обратная совместимость).

При успехе сообщение содержит фактическое число вхождений
"(N occurrence(s))", помогает заметить неожиданную множественность
без явного @once.

Регресс: 31/31 PS, 31/31 PY, 31/31 платформенный verify.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-15 16:00:55 +03:00
Nick Shirokov e7cbf306a0 feat(skd-edit): availableValue — список с replace-семантикой и в add-parameter
- Единый list-синтаксис: availableValue=v1[: p1], v2[: p2], ...
  Элементы через запятую, представление после двоеточия.
- Запятые/двоеточия внутри значений и представлений — в одинарных кавычках:
  availableValue=Окр1: 'руб., коп.', Окр1000: руб.
- add-parameter теперь принимает availableValue= и создаёт начальный список
  в одном вызове (раньше требовался последующий modify-parameter).
- modify-parameter availableValue=... ЗАМЕНЯЕТ весь список (раньше
  append). Согласуется с остальными modify-* для одиночных свойств.
- SKILL.md: добавлен shorthand-шаблон для modify-parameter,
  расширен для add-parameter [availableValue=список].

Существующие тесты мигрированы со старого ;;-batch на новый list-синтаксис.
Снапшоты сохранились (тесты стартовали с пустого списка — semantics
совпадает для greenfield).

Регресс: 29/29 PS, 29/29 PY, 29/29 платформенный verify.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-15 15:37:56 +03:00
Nick Shirokov 610720334b feat(skd-info): kv-параметры роли в детализации поля
В -Mode fields -Name <field> к сводке Role добавляются не-bool
параметры роли (balanceGroupName, balanceType, parentDimension,
accountTypeExpression и т.д.) в формате name=value.

Bool-флаги (@balance, @dimension, ...) отображаются как раньше.
False-значения по-прежнему скрыты.

Регресс: 6/6 PS, 6/6 PY (существующие snapshots не задеты).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-15 14:54:03 +03:00
Nick Shirokov 5090deb5bc feat(skd-edit): set-field-role — управление ролями поля
Новая операция: полная замена <role>-блока поля dataSet.

- Shorthand: "<dataPath> [@флаги] [kv=значение]"
- Флаги (зеркало skd-compile): @balance, @dimension, @account, @period,
  @required, @autoOrder, @ignoreNullValues
- KV: balanceGroupName, balanceType, parentDimension, accountTypeExpression,
  orderType, expression, periodNumber, periodType
- Пустой spec (только dataPath) — снимает роль целиком
- Поддерживает пакетный режим

Закрывает потребность временного toggle off/on роли при отладке
(было: ручной Edit XML), а также корректировку balance/dimension
после add-total.

Регресс: 27/27 PS, 27/27 PY, 27/27 платформенный verify.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-15 14:53:55 +03:00
Nick Shirokov 8b0bcf0194 feat(skd-edit): флаги @hidden и @always для параметров
- @hidden — скрывает параметр от пользовательских настроек
  (useRestriction=true + availableAsField=false). Для констант-параметров.
- @always — параметр всегда подставляется в запрос (use=Always).
  Используется самостоятельно для видимых обязательных параметров.
- Композируются: @hidden @always одной строкой даёт типовой паттерн
  "скрытая константа всегда применяется".
- Поддержка в add-parameter и modify-parameter, идемпотентны.

Регресс: 25/25 PS, 25/25 PY, 25/25 платформенный verify.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-15 14:14:19 +03:00
Nick Shirokov 529a5cacae feat(skd-edit): modify-structure + фиксы set-structure/parameter/patch-query
- modify-structure: новая операция, меняет groupItems группы по @name=,
  сохраняя Selection/order/filter/conditionalAppearance (Bug 1)
- set-structure: shorthand поддерживает запятую для нескольких полей
  в одном уровне группировки (Bug 2)
- set-structure: @name= с обрамляющими кавычками (двойными/одинарными)
  снимает их при записи в <dcsset:name> (Bug 3)
- add-parameter: ссылочные типы (CatalogRef, ChartOfAccountsRef, …)
  пишут <value xsi:type="dcscor:DesignTimeValue">, не xs:string (Bug 4a)
- modify-parameter: namespace-aware lookup существующих свойств
  — обновляет inplace, не плодит дубли (Bug 4b)
- modify-parameter value=…: пересборка <value> с корректным xsi:type
  из <valueType> (попутно лечит ранее битый XML)
- patch-query: батч ;;-сегментов триммится по краям (Bug 5)
- skd-compile: симметричный фикс ссылочных типов в emit_value

Регресс: 23/23 PS, 23/23 PY (skd-edit), 21/21 PS+PY (skd-compile),
23/23 платформенный verify-snapshots.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-15 13:40:49 +03:00
Nick Shirokov 8b0f55f1cc feat(form-validate): silent-skip числовых и UUID-DataPath в Check 5
В реальных выгрузках ERP/БП встречаются непрозрачные платформенные
DataPath, которые невозможно проверить из одного Form.xml:
- bare numeric ("10", "1000003") — внутренние индексы платформы
- "N/M:<uuid>" — ссылка на метаданные по UUID

Раньше Check 5 ругался на них "attribute not found". Теперь такие
пути пропускаются без счёта в paths checked и без ошибки.

Реалистичные пользовательские опечатки (кириллица в имени атрибута)
продолжают ловиться обычной проверкой attrMap.

Добавлен тест-кейс datapath-opaque-refs, версия v1.5 → v1.6.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-15 11:40:27 +03:00
Nick Shirokov 54cbc69a59 feat(form-validate): резолв Items.<Table>.CurrentData.* и ~<Attr>.* в DataPath
В Check 5 раньше брался первый сегмент DataPath и искался в attrMap,
из-за чего ложно ругались реальные формы ERP/БП с путями вида
Items.<TableName>.CurrentData.<Field> (подвалы, инфо-панели) и
~<DynamicListAttr>.<Field> (текущая строка списка).

Теперь:
- ведущий ~ стрипается перед разбором сегментов;
- для Items.<Table>.CurrentData.* находим элемент-таблицу по name,
  берём её <DataPath> (атрибут DynamicList/TableSection) и проверяем
  его в attrMap. Если таблицы нет — Error; если третий сегмент не
  CurrentData — Warn.

Добавлен тест-кейс datapath-currentdata, версия скриптов v1.4 → v1.5.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-15 11:38:56 +03:00
Nick Shirokov ac3047cf55 docs(readme): ссылка на гайд регрессионного тестирования
Добавлена строка в таблицу навыков и в перечень docs/ для нового
docs/web-test-regression-guide.md (был забыт при первичном коммите
регресс-гайда).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-13 20:41:33 +03:00
Nick Shirokov 5da154adea Merge feature/web-test-runner into dev
web-test regression runner: M5-pre синтетика + M6 автономный стенд +
M7 testInfo/contexts/testlevel-хуки + M8 per-context lifecycle +
Allure-форматтер с auto-suite/severity + _allure/categories.json +
пользовательский гайд регресса (docs/web-test-regression-guide.md) +
skill-инструкция regress.md.
2026-05-13 20:28:39 +03:00
Nick Shirokov f4748d76af docs(web-test): пользовательский гайд регресса + skill-инструкция regress.md
- docs/web-test-regression-guide.md — пользовательские сценарии работы
  с моделью для покрытия прикладного решения регрессом (русский, по
  аналогии с web-test-recording-guide.md): структура tests/<app-name>/,
  диалоги с моделью, пример организации покрытия, отчёты Allure +
  categories.json.
- .claude/skills/web-test/regress.md — инструкция модели по написанию
  регрессионного набора: разведка (метаданные + живой проход через exec),
  layout по фичам, готовые шаблоны (CRUD/document/DCS/multi-user/repro),
  severity, anti-patterns, failure triage, _allure/ конвенция.
- SKILL.md — указатель на regress.md в конце файла (рядом с recording).
- docs/web-test-runner-spec.md → upload/ (был внутренним планом
  разработки, не пользовательской документацией).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-13 20:25:44 +03:00
Nick Shirokov b992cd11c5 feat(web-test): _allure/ конвенция + categories.json для триажа падений
run.mjs:
- syncAllureExtras(testDir, reportDir) копирует все файлы из
  <testDir>/_allure/ в reportDir перед генерацией отчёта. Underscore
  в имени параллелен _hooks.mjs (инфра, не тест) — discovery его
  пропускает.
- Вызов после writeAllure при --format=allure.

tests/web-test/_allure/categories.json — 7 правил классификации падений
по нашему 1С-домену:
  1. License pool exhausted (1C) — известный multi-context flake.
  2. 1C application error (modal) — exception modal через fetchErrorStack.
  3. Section panel icon-only — деградация состояния стенда.
  4. Navigation lookup miss — navigateSection/openCommand/navigateLink/switchTab.
  5. Element not found — clickElement/fillField/selectValue/closeForm/fillTableRow/deleteTableRow.
  6. Test timeout — Timeout (Nms) от раннера.
  7. Assertion failure — наши createAssertions + 1С-specific (formHasField/tableHasRow/noErrors).

spec §9: раздел «Доп. файлы Allure через <testDir>/_allure/» с таблицей
поддерживаемых типов (categories.json / environment.properties /
executor.json) и минимальным примером.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-13 18:53:09 +03:00
Nick Shirokov fc76407877 feat(web-test): auto-suite + severity-резолвер для Allure
run.mjs:
- buildSeverityIndex(config) — валидация config.severity (inverted map
  «уровень → [теги]») при загрузке: ключи только из blocker|critical|
  normal|minor|trivial, теги не дублируются между bucket'ами,
  defaultSeverity тоже валидируется. fail-fast через die.
- resolveSeverity(t, severityIndex):
  1. mod.severity если задан и валидный — выигрывает.
  2. max-rank среди тегов (стандартные имена severity или маппинг).
  3. config.defaultSeverity или 'normal'.
  Rank: blocker(5) > critical(4) > normal(3) > minor(2) > trivial(1).
  Max-wins инвариантен к порядку тегов.
- writeAllure: добавлены labels suite (= dirname(t.file) или 'root') +
  severity. Тег `tag` остался как раньше.
- testResult пробрасывает t.severity (для passed/failed веток).
- SEVERITY_RANK/LEVELS объявлены в модульной шапке (top-level await на
  cmdTest начинается до конца тела модуля, TDZ-аккуратность).

webtest.config.mjs: severity policy для нашего сьюта (smoke +
multi-context → critical, recording → minor, defaultSeverity = normal).

spec.md §7: раздел про severity-policy в конфиге с валидацией.
spec.md §9: «Авто-эмиссия label-ов» — tag/suite/severity + правила резолва.

Регресс 19/19 ✓ (9m 7.6s). Распределение по уровням после исправления
'record' → 'recording' в маппинге: 13 critical / 5 normal / 1 minor.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-13 18:37:58 +03:00
Nick Shirokov a55195ab66 docs(web-test): §16.1 — вложенные каталоги (что работает, что нет)
Зафиксирована конвенция:
- Discovery рекурсивный, путь попадает в отчёт.
- Per-folder hooks/config/context-default НЕ поддерживаются (by design).
- Группировку в отчётах делать через tags, не через путь.
- Сортировка по полному пути (`warehouse/01-x` после `sales/02-y`) —
  для глобального порядка нужны 3-значные префиксы или теги-фазы.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-13 17:33:10 +03:00
Nick Shirokov 1eff62de42 docs(web-test): полный sync спеки + contexts[] в testResult
spec.md v0.2 (последний sync 2026-05-13):

§1 CLI: добавлены --report-dir и `--` separator в таблицу флагов.
§1 «Режим выполнения»: убрана несуществующая «группировка по контексту»,
  описана реальная алфавитная модель + lazy ensureContext.
§2 пример multi-context: latin ID контекстов вместо кириллицы (clerk/manager)
  + showcase closeContext в финальном шаге.
§3 список API расширен: контексты (createContext/closeContext/setActive/
  listContexts/hasContext/getActiveContext), overlay-helpers (hideTitleSlide/
  hideImage/setHighlight/isHighlightMode), error-helpers (dismissPendingErrors/
  fetchErrorStack).
§6 пример _hooks.mjs: убран mock-навигация в beforeAll, добавлены примеры
  afterOpenContext/beforeCloseContext, afterEach показывает testResult.
§8 переписан раздел «Реализация в browser.mjs» (мульти-контекст уже live)
  + новая таблица режимов изоляции tab/window.
§9 JSON example: поле "context" → "contexts": [...] (массив).
§10: убрано упоминание несуществующего verbose-режима.
§13 «Параметризация»: убран статус «будущее», описана реальная семантика
  T6 (template name, param 2-м аргументом, testInfo.param).
§14 buildContext: переписан под done-состояние + scoped-вариант.
§16 каталог тест-кейсов: 13 → 19 файлов (multi-context, recording,
  errors-stack, tree-form, misc, hooks).
§17 дорожная карта: 10 → 18 пунктов, M4–M8 включены.

run.mjs:
- testResult получил поле contexts: [...names] во всех ветках
  (passed/failed/skipped/context-setup-failed). Раннер передаёт
  declaredContexts из единой точки до if(skip), чтобы skip-результаты
  тоже несли структурную информацию.

Регресс 19/19 ✓ (9m 8.7s) после --rebuild-stand.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-13 17:11:51 +03:00
Nick Shirokov eb87be5c04 feat(web-test): M8 — per-context lifecycle (closeContext + afterOpenContext/beforeCloseContext)
browser.mjs:
- + closeContext(name): logout slot + close page (tab) или context (window),
  удаление из реестра. Throw если name неактивен (рулило: nicht den aktiven
  closen, recorder always attached к active → invariant простой).
- _logoutSlot(slot, waitMs) — извлечён из disconnect, переиспользуется в
  closeContext.

run.mjs:
- ensureContext() после createContext вызывает hooks.afterOpenContext(ctx, name, spec).
- wrapCloseContextHook() оборачивает ctx.closeContext (и каждую scoped-обёртку)
  чтобы перед browser.closeContext fir'ить hooks.beforeCloseContext.
- Финальный teardown в finally: для всех живых контекстов кроме первого
  (survivor) — beforeCloseContext + closeContext; для survivor только хук,
  его закрывает disconnect().

_hooks.mjs v0.5:
- afterOpenContext инжектит persistent DOM-badge с displayName в правый
  верхний угол page — в записанном видео всегда видно, какой контекст.
- beforeCloseContext counter-only.
- _state расширен полями afterOpenContext / beforeCloseContext.

15-multi-context-handover.test.mjs:
- +2 шага: closeContext('b') после handover, попытка closeContext(active)
  ловится throw'ом с проверкой message.

00-hooks.test.mjs:
- +1 ассерт: afterOpenContext >= 1 (default уже создан), beforeCloseContext === 0
  в теле первого теста.

spec §6:
- Раздел «Контекстный уровень» (afterOpenContext / beforeCloseContext + правила closeContext).
- ASCII-диаграмма порядка хуков обновлена с per-context lifecycle.

Регресс 19/19 ✓ (9m 16.8s).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-13 16:07:45 +03:00
Nick Shirokov 43ed9ba142 feat(web-test): M7.5 — title slide в beforeEach для --record
_hooks.mjs v0.4: beforeEach под условием ctx.isRecording() показывает
title slide с testInfo.name + displayName первичного контекста как
subtitle, ждёт 1.5с через ctx.wait() и убирает.

В обычном регрессе (без --record) — ветка скипается, overhead ноль.
Под --record: 01-navigation 12.1s → 13.9s (+1.8с на слайд).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-13 14:45:14 +03:00
Nick Shirokov 588382cec1 feat(web-test): M7.4 — testlevel-хуки + 00-hooks индикатор
_hooks.mjs v0.3: добавлены beforeAll/afterAll/beforeEach/afterEach
(counter-only) и shared `_state` (счётчики + events log).

tests/web-test/00-hooks.test.mjs (новый, 4 шага, 0s) — индикатор
порядка вызовов: проверяет beforeAll===1, beforeEach для текущего
теста, доступность ctx.testInfo, afterEach < beforeEach.

Multi-context хуки оставлены one-shot. Разведка beforeAll:
navigateSection не нужен, 1С после входа уже на дефолтной секции.

Регресс 19/19 ✓ (9m 12.7s).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-13 14:35:20 +03:00
Nick Shirokov e0197683e1 feat(web-test): M7.1+M7.2 — ctx.testInfo + проброс custom-полей контекстов
- ctx.testInfo (name/file/filePath/tags/timeout/attempt/maxAttempts/param/contexts/primaryContext)
  выставляется перед каждой попыткой, доступен в beforeEach/test/afterEach
- ctx.testResult (status/duration/attempts/error/steps) доступен в afterEach
- run.mjs:411 spread полного contextSpec (был whitelist {url, isolation});
  CLI --url override сохраняет custom-поля через merge
- webtest.config.mjs: displayName для a/b
- spec §3 — подраздел «Метаданные теста», §6 — availability testInfo/testResult,
  §7 — рекомендация латинский ID + кириллический displayName
- Full regression 18/18 ✓ (9m 9.8s)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-13 12:44:07 +03:00
Nick Shirokov 96dad75b2f feat(web-test): M6-MVP follow-up — 13-misc setup + URL webtest-runner
13-misc.test.mjs: setup-шаг упрощён до `assert.ok(existsSync(epfPath))`.
EPF-сборку (epf-init → form-add → form-compile → epf-build) забрал
_hooks.mjs.prepare() — здесь только проверка артефакта с понятной
ошибкой при отсутствии: «запустите раннер с `-- --rebuild-epf`».

webtest.config.mjs: URL обоих контекстов переключён на
`/webtest-runner/ru_RU` — отдельная публикация автономного стенда,
не конфликтует с интерактивной разведкой через `/webtest` на 8081.
2026-05-12 20:25:54 +03:00
Nick Shirokov 5c734202b6 feat(web-test): M6-MVP — автономный стенд через _hooks.mjs
Новый tests/web-test/_hooks.mjs v0.2 с prepare()/cleanup().
prepare() поднимает изолированный стенд:
- Hash-locks `tests/skills/.cache/webtest-stand/{config,epf}.lock`
  на sha256 от build-steps и EPF_SPEC — автоматический skip
  пересборки при отсутствии изменений.
- Слои конфиг XML / БД / EPF пересобираются независимо. Триггер
  ручной — флаги `--rebuild-config`/`--reload-data`/`--rebuild-epf`/
  `--rebuild-stand` (через `-- ...` после CLI раннера).
- Smart Apache: web-stop+web-publish выполняются только когда
  пересоздаём БД (нужно освободить блокировку). Иначе probe-first:
  жив (200) → no-op; мёртв → publish + probeReady. На warm-старте
  prepare сводится к чтению локов и одному probe (~200ms).
- web-publish на собственном AppName `webtest-runner` :9191 — не
  пересекается с интерактивной публикацией `webtest`.
- Кросс-платформенно: env WEBTEST_HOOKS_RUNTIME=python переключает
  на зеркальные py-порты скиллов (для не-Windows стендов).

cleanup() пока stub — оставляем стенд поднятым между прогонами,
для full-shutdown ручной /web-stop или `-- --rebuild-stand`.

E2E-проверено: cold-start `--rebuild-stand` поднимает стенд за
~38s; warm-старт prepare = 0.0s; полный регресс 18/18 зелёный
за 9m 7.1s (включая оба multi-context-теста, которые исторически
флапали).
2026-05-12 20:25:47 +03:00
Nick Shirokov a92bce05fb feat(web-test): runner v1.11 — -- separator + spec §6.1
В CLI раннера всё после `--` собирается в массив hookArgs и
передаётся в инфра-хуки prepare/cleanup без интерпретации со
стороны раннера. Сигнатура расширена до { hookArgs, log, config }:
log — структурированный вывод раннера, config — разобранный
webtest.config.mjs. Шаблон «всё после `--` принадлежит вложенному
инструменту» — стандартная shell-конвенция (npm, cargo, pytest).

Спека §6 обновлена под новую сигнатуру, §6.1 закрепляет контракт
`--` ↔ hookArgs с примером. Help-строка раннера упоминает
разделитель.
2026-05-12 20:25:33 +03:00
Nick Shirokov b8ebbf6a6f feat(build-webtest-db): v0.2 — dual-mode CLI + module exports
Извлечены exports: getProjectInfo, resolveScript, execSkill,
replacePlaceholders, runSteps, platformLoadSteps, loadBuildSteps.
CLI-режим сохранён через import.meta.url-guard. Подготовка к
переиспользованию из tests/web-test/_hooks.mjs без дублирования
exec-логики и pipeline-шагов.
2026-05-12 20:25:25 +03:00
Nick Shirokov 43ba6ce16c feat(web-test): M5-pre #4b — 09-filter/unfilter-specific (multi-badge)
Раньше шаг был deferred с комментарием «требует список с видимой
filter-панелью». На самом деле существующая абстракция работает:
два advanced filterList на разных колонках Контрагентов создают
два badge'а в state.filters[], а unfilterList({field}) снимает
конкретный — оставляя остальные.

Новый шаг 09-filter/unfilter-specific (~14s):
- filterList('ООО', {field:'Наименование'}) + filterList('123', {field:'ИНН'})
  → state.filters = [{field:'Наименование',value:'ООО'}, {field:'ИНН',value:'123'}]
- unfilterList({field:'ИНН'}) → остался только Наименование badge
- unfilterList() → пусто

Старый комментарий «defer to filter-panel synthetic» удалён —
оказался устаревшим (видимо unfilterList({field}) уже умел работать
с advanced-filter badge'ами на синтетических списках).

timeout 09-filter поднят с 60000 → 120000ms (8 шагов теперь, +14s
для unfilter-specific).

Регресс: 16/18 зелёных. Два multi-context-теста (14/15) упали на
лицензионном пределе 1С — known environmental issue, не связано с
этим коммитом.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-12 18:01:00 +03:00
Nick Shirokov 51e37f9874 feat(web-test): M5-pre #4a — Менеджер (choiceHistoryOnInput=Auto) + selectValue/auto-history
Реквизит шапки ПриходнаяНакладная.Менеджер типа CatalogRef.Контрагенты
с дефолтным choiceHistoryOnInput=Auto. Существующий Контрагент в той же
шапке имеет DontUse, что даёт парный контраст для тестирования влияния
флага на selectValue.

Новый шаг 04-selectvalue/auto-history:
- selectValue('Менеджер', 'ООО Юг') → method='dropdown' (typeahead активен,
  префиксный поиск по Description находит «ООО Юг» в catalogue).
- Парный 04-selectvalue/direct-form (existing): selectValue('Контрагент',
  'Север') → method='form' (typeahead подавлен DontUse → форма выбора).

Тест покрывает существующее ветвление selectValue по флагу
choiceHistoryOnInput без engine-доработок. Истории на сервере писать
заранее не нужно: typeahead использует prefix-match по Description,
а не статистику истории.

Полный регресс **18/18 зелёный** (8m 47.3s).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-12 16:56:00 +03:00
Nick Shirokov 62e864e474 feat(web-test): M5-pre #3 — textEdit:false поле + 03-fillfields/direct-edit-form
Расширение синтетики: реквизит Поставщик типа CatalogRef.Контрагенты
добавлен в шапку ПриходнаяНакладная. Элемент формы Поставщик скомпилирован
с textEdit:false (новый DSL ключ form-compile v1.21 из коммита 32bf9c1):
ручной ввод запрещён, селект-кнопки нет, выбор только через форму выбора
по pick-кнопке.

Новый шаг 03-fillfields/direct-edit-form (~7s) — fillFields на Поставщик
('ООО Юг') возвращает method:'form', минуя обычные paste/typeahead/dropdown
ветки. fillFields внутренне детектит textEdit:false и сразу идёт через
форму выбора (selectValue path).

Полный регресс **18/18 зелёный** (8m 40.6s).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-12 16:11:46 +03:00
Nick Shirokov ddebd7b6df feat(web-test): M5-pre #2 — составной тип Источник + 03-fillfields/composite
Расширение синтетики: реквизит Источник составного типа
(CatalogRef.Контрагенты + CatalogRef.Номенклатура + CatalogRef.Организации)
добавлен в шапку ПриходнаяНакладная и в ТЧ Товары. meta-compile принимает
составной тип через строковый синтаксис `A + B + C` (см. SKILL.md:56) —
эмитит три `<v8:Type>` элемента с правильным `d5p1:` префиксом.

Элемент ТЧ-колонки переименован в ИсточникТЧ (path/title оставлены
оригинальные) — иначе form-compile генерирует одинаковые companion-имена
(`ИсточникКонтекстноеМеню`) для шапки и ТЧ, и платформа отказывает в
открытии формы документа: "К сожалению, возникла непредвиденная ошибка"
(server-side, без полезного stack). TODO в form-compile-bugs.md: учитывать
путь поля при генерации companion-имён, чтобы избежать конфликта.

Новый шаг 03-fillfields/composite (~25s) — покрывает selectValue с
параметром `{type}` на составном поле:
- Шапка: selectValue('Источник', 'ООО Север', {type:'Контрагенты'})
  → method:'form', type:'Контрагенты', выбор через каталог-форму.
- ТЧ: fillTableRow({Источник: {value:'Альфа', type:'Организации'}},
  {row:0}) → method:'form', type:'Организации' (quickChoice=true →
  без формы выбора, прямой dropdown).

fillFields на composite без type выбрасывает понятную ошибку
с инструкцией «specify the type: selectValue(...,{type:'ИмяТипа'})» —
поведение API стабильно.

timeout 03-fillfields поднят с 60000 → 120000ms (6 шагов суммарно
~63s, новый composite step добавляет ~25s).

Полный регресс **18/18 зелёный** (8m 28.7s).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-12 15:51:41 +03:00
Nick Shirokov 3d16e35e80 feat(web-test): M5-pre #1 — ValueTree + ДеревоНоменклатуры + tree-form smoke
Расширение синтетики: новая обработка ДеревоНоменклатуры с реквизитом
формы Дерево типа ДеревоЗначений и колонками Номенклатура (ссылка,
read-only) + Цена (Number, editable). ПриСозданииНаСервере рекурсивно
обходит Справочник.Номенклатура и заполняет дерево, отражая иерархию
групп/элементов из справочника.

Обработка зарегистрирована в подсистеме Администрирование и в роли
Администратор (Use+View).

Новый smoke 16-tree-form.test.mjs (5 шагов, 17.1s) — покрывает
05-table/edit-form (fillTableRow method:'direct' на FormDataTree-колонке)
и 08-hierarchy/tree-edit (expand узла + правка Цены через index-row):
- setup: navigateLink('Обработка.ДеревоНоменклатуры'), таблица Дерево
- read-roots: 2 корневые группы (_kind:'group'), columns=Номенклатура,Цена
- expand: clickElement('Товары',{expand:true}) → 16 строк (1 + 15)
- tree-edit: fillTableRow({Цена:1500},{row:1}) → method:'direct',
  Цена становится '1 500,00' (с non-breaking space 1С)
- cleanup: closeForm

Гэп: fillTableRow с row-by-name ('Товар 01') ловит SyntaxError в JS
eval. Использую row-by-index (TODO в web-test-bugs).

Полный регресс **18/18 зелёный** (8m 9.8s) на порту 9191.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-12 14:03:28 +03:00
Nick Shirokov 56822c4533 test(web-test): switch webtest publication to port 9191
Чтобы не конфликтовать с интерактивной разработкой на основном
Apache (8081, занят сторонним проектом), регрессионный регресс
теперь использует отдельный httpd-процесс на порту 9191. Тот же
httpd запускает /web-publish webtest -Port 9191 -V8Path 8.3.24.

Один процесс Apache → собственный пул лицензий 1С. На 8081 другие
проекты — наши тесты их не блокируют и наоборот.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-12 14:03:15 +03:00
Nick Shirokov 32bf9c1a3f feat(form-compile): textEdit key for InputField (TextEdit=false)
v1.20 → v1.21 (ps1 + py).

Добавлен ключ DSL `textEdit` для элемента input. Эмитит
`<TextEdit>false</TextEdit>` после AutoMarkIncomplete (значение
true — дефолт платформы, не эмитируется). Закрывает блокер для
03-fillfields/direct-edit-form в синтетике web-test: поле с
запрещённым ручным вводом → выбор только через pick-кнопку/F4.

Snapshot-тест: tests/skills/cases/form-compile/text-edit-flag.json
(2 поля, проверяет наличие TextEdit только на втором). 30/30
form-compile зелёные обоих runtime'ов.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-12 12:56:35 +03:00
Nick Shirokov c94f86a9cd test(web-test): M4.D2 — openFile EPF + security confirm
Новый 13-misc.test.mjs (3 шага, 11s) — покрытие openFile() для
внешних обработок с автоматической обработкой security confirmation.

- setup: автономный билд EPF (идемпотентный) через epf-init →
  form-add → form-compile (с текстовой декорацией) → epf-build.
  child_process.spawnSync для вызова PowerShell скриптов.
- openFile: проверки state.form, activeTab='Тест открытия',
  state.texts[] содержит декорацию с ожидаемым value,
  opened.attempt>=1, security confirm modal не пробивается.
- cleanup: closeForm + soft-проверка activeTab (между тестами в
  desktop могут оставаться формы от других тестов — не настаиваем
  на formCount=0).

Артефакты в test-tmp/13-openfile/ (.gitignore). Полный регресс
17/17 зелёный (8m 8s).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 18:27:22 +03:00
Nick Shirokov 8b5fed98e0 test(web-test): M4.E — hierarchy + tree-grid (Номенклатура)
Новый 08-hierarchy.test.mjs (7 шагов, 24s) — покрывает группы и
tree-grid режима «Дерево» на форме списка Номенклатуры через UI
переключение viewMode. Без расширения синтетики.

- setup: явное переключение в «Иерархический список» через Ещё →
  Режим просмотра (viewMode сохраняется между сессиями и НЕ
  сбрасывается «Установить стандартные настройки»).
- read-groups (P1): readTable возвращает 2 группы (_kind=group).
- group-expand (P1): clickElement({expand:true}) развёртывает группу,
  внутри 15 элементов.
- switch-tree: «Ещё → Режим просмотра → Дерево» → viewMode='tree'.
- read-tree (P2): readTable.rows[]._tree (collapsed|expanded) — проверка
  только наличия поля (состояние сохраняется между сессиями).
- tree-expand (P1): defensive свёртка через {expand:false} если узел
  expanded, затем {expand:true} → kind='gridTreeNode' toggled=true,
  видны 15 элементов под Товарами.
- cleanup: восстановить иерархический список.

Замечание: clickElement({expand:true}) — только развернуть (no-op для
expanded), {expand:false} — только свернуть, {toggle:true} —
безусловно переключить.

05-table/direct-edit-form, edit-dblclick остаются deferred — нужен
документ с иерархической ТЧ. Полный регресс 16/16 зелёный (7m 53s).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 17:43:31 +03:00
Nick Shirokov 9e677cfc61 test(web-test): M4.F — recording smoke (video + captions + TTS + overlays)
Новый 15-recording.test.mjs (5 шагов, 20.7s) — покрытие полного
публичного API recording.md.

- record + captions: startRecording → 2× showCaption → stopRecording.
  Проверки isRecording, duration/size, mp4 на диске, .captions.json,
  getCaptions с правильными text и time.
- narration: addNarration через Edge TTS (ru-RU-DmitryNeural), narrated
  mp4 больше исходного (добавлен аудио-трек).
- title-slide: showTitleSlide/hideTitleSlide — overlay fullscreen
  (w==innerWidth, h==innerHeight).
- image-overlay: showImage/hideImage с тестовой картинкой из screenshot.
- highlight: setHighlight toggles isHighlightMode, manual highlight на
  кнопке «Создать» создаёт overlay позиционированный на элементе.

Артефакты в test-tmp/recording-smoke/ (.gitignore), идемпотентный.
Полный регресс 15/15 зелёный (7m 27s).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 17:20:13 +03:00
Nick Shirokov 211a4726d6 test(web-test): M4.C+D — drill-down + submenu-read
11-report/drill-down: dblclick по ячейке Номенклатуры сформированного
DCS-отчёта открывает форму элемента (DCS auto-drill). После Сформировать
ищется первая строка с заполненной номенклатурой, проверяется что после
clickElement({row,column},{dblclick:true}) form изменился и есть кнопка
«Записать».

02-crud/more-menu усилен под P2 submenu-read: добавлены явные проверки
clicked.kind='submenu', наличия типовых пунктов «Создать», «Изменить»,
«Расширенный поиск» (length>=5).

Покрыто 2 P2-кейса coverage matrix (11-report/drill-down,
02-crud/submenu-read). Полный регресс 14/14 зелёный (7m 1.6s).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 16:58:28 +03:00
Nick Shirokov 91b39b758b test(web-test): M4.B+G — subordinate-nav + platform dialogs в 12-formstate
Расширены тесты getFormState: проверка ветвей navigation[] и
platformDialogs[] возвращаемой структуры.

- subordinate-nav: форма элемента Контрагент → state.navigation содержит
  «Основное» (active) и «Контактные лица» (подчинённый каталог).
- platform-dialogs: открытый через hamburger «О программе…» виден в
  state.platformDialogs[{type:'about'}].
- platform-dialog-close: closeForm закрывает платформенный диалог,
  массив становится пустым.

Покрыто 3 P2-кейса coverage matrix (12-formstate/subordinate-nav,
platform-dialogs, platform-dialog-close). Полный регресс 14/14 зелёный.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 16:30:51 +03:00
Nick Shirokov 4af69f1600 test(web-test): M4.A — validation messages + exception modal + error stack
10-validation.test.mjs (3 шага): Сообщить() → state.errors.messages,
ВызватьИсключение → onecError.errors.modal с автоматическим закрытием
fetchErrorStack.

14-errors-stack.test.mjs (3 шага): Path 1 OpenReport автоматически фетчит
стек для серверных исключений (entries[] содержит кадр ОбщиеФункции);
оставленная error modal через raw page.click закрывается closeForm;
платформенный диалог «О программе» виден в state.platformDialogs и
закрывается closeForm.

Покрыто 4 P2-кейса coverage matrix: 10-validation/messages,
10-validation/exception-modal, 14-errors/path1, 14-errors/dismiss-platform
+ бонус dismiss-modal. Открытие обработки ТестовыеОшибки через
navigateLink('Обработка.ТестовыеОшибки') — стандартные команды у
DataProcessor отключены.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 15:21:11 +03:00
Nick Shirokov 8c7c442705 feat(meta-compile): DSL choiceHistoryOnInput для аттрибутов
meta-compile v1.12 (ps1 + py): Parse-AttributeShorthand принимает поле
choiceHistoryOnInput в object-форме аттрибута, Emit-Attribute эмитит его
вместо хардкода Auto. Покрывает атрибуты Catalog/Document/TabularSection
(Emit-Attribute, единственная точка эмиссии в работе). Другие контексты
(register dimensions, resources, etc.) пока эмитят Auto — расширим
при необходимости.

build-webtest-config: реквизит Документ.ПриходнаяНакладная.Контрагент
получил choiceHistoryOnInput='DontUse'. Это убирает 1С-историю выбора
для поля и фиксит pre-existing flake 04-selectvalue/direct-form:
после 03 значение «ООО Север» оставалось в истории и selectValue
выбирал его через dropdown вместо ожидаемой формы выбора.

Live: полный регресс 12/12 впервые зелёный (5m 28s).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 13:47:50 +03:00
Nick Shirokov c541d51f33 fix(web-test): resetState не закрывал form 0 + error screenshot снимался после reset
run.mjs:

1. resetState проверял `if (!state.form) break`. form === 0 (фоновая
   форма 1С, которую detectForm может вернуть) рассматривался как
   "форм нет" → cleanup прерывался, форма оставалась → следующий тест
   получал грязное состояние. Замена на `state.form == null` корректно
   различает null (desktop) и 0 (реальная фоновая форма).

2. Error screenshot в catch-блоке cmdTest снимался ПОСЛЕ resetState,
   который уже закрывал все формы → скрин показывал пустой рабочий
   стол вместо места падения. Перенёс снимок в начало catch (до
   teardown/afterEach/resetState).

Эффекты:
- 15-multi-context-handover теперь стабильно проходит в полном прогоне
  (раньше падал когда предыдущий тест оставлял form=0).
- 04-selectvalue/direct-form остался pre-existing flake (история
  выбора 1С после 03 — отдельная задача в синтетике).
- Скриншоты падения теперь показывают реальный UI на момент исключения.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 11:52:22 +03:00
Nick Shirokov a650325baf fix(web-test): убрать стуб showCaption/hideCaption в cmdTest
run.mjs v1.10: cmdTest больше не передаёт noRecord:true в buildContext.
Тестам доступен полный API browser.mjs (showCaption, hideCaption,
startRecording, stopRecording, addNarration).

Изначальный стуб с noRecord:true прятал showCaption/hideCaption тестов
вместе с recording-функциями. Это блокировало визуальные оверлеи в
мульти-контекстных тестах: a.showCaption() тихо превращался в no-op,
баннер никогда не отображался даже под --record.

Smart wait внутри showCaption и так гейтится на наличие recorder
(`if (recorder && ...)`), поэтому без --record тесты остаются быстрыми
(никаких 2-секундных пауз на каждый вызов).

startRecording/stopRecording/addNarration теперь тоже доступны тестам.
При попытке вызвать startRecording в момент активной runner-записи
browser.startRecording бросает "Already recording" — loud failure
лучше silent no-op.

Регресс: 15-multi-context-handover один проходит за 19.9s. Полный
прогон 10/12 (04 и 15 флапают независимо в последовательности).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 11:37:42 +03:00
Nick Shirokov 6c19846051 feat(web-test): T4.6 — гибридные режимы изоляции контекстов (tab default, window opt-in)
browser.mjs v1.12 + run.mjs v1.9: createContext принимает isolation параметр.
По умолчанию 'tab' — все контексты живут в одном launchPersistentContext, каждый
слот получает свою Page (вкладку). Преимущества: 1С extension грузится
надёжно (через --load-extension в persistent profile), один процесс Chromium,
дешёвая память. Cookies делятся между вкладками, но скоупятся по URL-path —
для модели «разные пользователи через разные vrd-публикации» это естественно
и достаточно.

isolation: 'window' (opt-in) — старый путь chromium.launch() + newContext():
полная изоляция cookies, отдельный BrowserContext (и окно) на каждый слот,
но extension может не подняться. Использовать когда нужна изоляция auth
внутри одного URL.

Смешивать режимы в одном прогоне нельзя — createContext бросает явную
ошибку (первый createContext устанавливает activeMode, остальные обязаны
совпадать).

Конфиг tests/web-test/webtest.config.mjs: добавлен комментарий с описанием
обоих режимов. По умолчанию tab — синтетика и наши smoke-тесты идут им.

Live: 11/12 в полном прогоне (default tab) + 3/3 sanity-check в window mode
(01-navigation + 14 + 15). Видеозапись из T4.5 работает в обоих режимах.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 18:34:44 +03:00
Nick Shirokov eef4f4bcea feat(web-test): T4.5 — мульти-контекстная запись видео
browser.mjs v1.11: recorder стал глобальным (не per-slot) — один ffmpeg,
один mp4 на тест с любым числом переключений контекста.

Frame state (lastFrameBuf/lastFrameTime/handler) переехал в поля recorder.
Добавлен recorder._attachPage(targetPage) — стопает старый CDP screencast,
заводит новый на нужной странице, route'ит фреймы в тот же ffmpeg pipe.

setActiveContext: при активной записи делает _flushFrames (замораживает
хвост уходящего окна), затем _attachPage(page) после _activateSlot. Видео
получается непрерывным с плавным сюжетом — пока активен a, видно a; пока
активен b, видно b.

_saveActiveSlot/_activateSlot больше не трогают recorder/lastCaptions/
lastRecordingDuration — recorder следует за активной страницей через
_attachPage, не через slot mirror.

disconnect: убрал leftover из T4.1, который пытался итерировать slot.recorder.

Live: 15-multi-context-handover с --record → 17.84s mp4, 446 кадров @ 25fps,
извлечённые кадры показывают переключение между окнами a (1920x1042) и
b (982x546). Полный регресс 11/12 (04-selectvalue — pre-existing flake).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 17:58:31 +03:00
Nick Shirokov 2c553fee98 feat(web-test): T4 — мульти-контекст BrowserContext
browser.mjs v1.10: createContext/setActiveContext/listContexts/getActiveContext/
hasContext. Несколько изолированных BrowserContext в одном Chromium-процессе через
chromium.launch() + newContext(). Module-level page/sessionPrefix/seanceId/recorder
зеркалят активный слот (атомарный своп через _saveActiveSlot/_activateSlot).
connect() оставлен для exec/run/start без изменений (launchPersistentContext).

run.mjs v1.8: ensureContext(name) + ленивое создание. Single-routing через
export const context = 'name'. Multi через export const contexts = ['a','b'] +
buildScopedContext(name) строит ctx.a/ctx.b — каждое действие префиксится
setActiveContext. Reset state после теста по всем активным контекстам.

Конфиг tests/web-test/webtest.config.mjs: два контекста a/b на одну webtest
публикацию (изолированные cookies через newContext).

Smoke-тесты:
- 14-multi-context-routing.test.mjs — single routing в b (2.6s)
- 15-multi-context-handover.test.mjs — ctx.a создаёт Контрагента, ctx.b в
  независимой сессии видит запись через filterList, ctx.a cleanup (14.5s, 4/4)

Live: 11/12 в полном прогоне. 04-selectvalue/direct-form флапает —
pre-existing, воспроизводится на baseline 95e4674 (03→04 sequence).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 17:24:24 +03:00
Nick Shirokov 95e4674825 test(01-navigation): M3 P1 — section/command/switchTab errors + navigateLink
section-error / command-error / switchTab error: проверка throw для
несуществующих имён.

navigateLink: link-type (Catalog.Контрагенты) + e1cib URL (с soft-skip
для платформ без поддержки e1cib через Shift+F11).

Live на webtest: 10/10 passed.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 16:29:54 +03:00
Nick Shirokov 9751840cc8 test(09-filter): M3 P1 — exact, hidden-field, date, reference, unfilter-all
exact: filterList exact:true строго 1 совпадение.
hidden-field: filterList по неотображённому реквизиту через FieldSelector
DLB (КодКПП в синтетике нет — soft-skip).
date: filterList по колонке Дата поступления (синтетика выводит её в форму
списка Номенклатуры).
reference: filterList по ссылочной колонке Контрагент (форма списка ПН).
unfilter-all: unfilterList() полностью восстанавливает список.

unfilter-specific отложен — требует списка с видимой filter-панелью,
synthetic списки фильтруют без создания badge.
cancel-search/clear-input семантически дубликаты unfilter-all через
публичный API.
show-all-form требует quickChoice=true каталога с количеством > порога
(в синтетике нет).

Live на webtest: все 7 шагов passed.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 16:28:09 +03:00
Nick Shirokov f257bb428c test(12-formstate): M3 P1 — modal + tabs
modal: F4 на ref-поле открывает модальную форму выбора Контрагентов,
state.modal=true, formCount=2.

tabs: форма элемента Номенклатуры с двумя табами (Основное/Дополнительно)
возвращает state.tabs[].

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 15:43:53 +03:00
Nick Shirokov 71e3691cf1 test(web-test): M3 P1 batch 1 — confirm-save-no/pending, more-menu, clear/ref-form, table checkbox/clear
02-crud: confirm-save-no (rollback при save:false), confirm-pending
(closeForm() без решения возвращает confirmation), more-menu (clickElement
'Ещё' возвращает submenu).

03-fillfields: clear (Shift+F4 через пустое значение), reference-non-quickchoice
(fillFields на quickChoice=false поле — method=dropdown через DLB; чистый
form-path требует hasPick && !hasSelect, такого поля в синтетике нет).

04-selectvalue: clear (selectValue '' → Shift+F4). show-all-form отложен —
требует quickChoice=true каталога с количеством > порога dropdown
(в синтетике нет).

05-table: checkbox (fillTableRow с Boolean), clear (Shift+F4 на ref-ячейке +
восстановление для последующего delete).

Live на webtest: все шаги проходят.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 15:40:27 +03:00
Nick Shirokov 1af318325d test(05-table): добавить явный tab-loop step с двумя числовыми полями
fillTableRow({Количество, Цена}, {row:1}) — purpose-built проверка inEdit
multi-cell tab-loop. method='direct' для обоих полей, значения
подставляются корректно (live на webtest).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 15:23:03 +03:00
Nick Shirokov 986480748e Merge branch 'dev' into feature/web-test-runner 2026-05-10 15:10:38 +03:00
Nick Shirokov 7561faf736 test(web-test): покрыть Tumbler через clickElement в radio-шаге
Tumbler-представление RadioButtonField не парсится fillFields, но варианты
видны в state.buttons[] и кликаются через clickElement. Уточнили шаг radio:
- RadioButtons (КатегорияЦены) → fillFields с method=radio
- Tumbler (СпособУчёта) → проверка наличия в buttons[] + clickElement('ФИФО')

Семантика Tumbler через fillFields остаётся как баг web-test/browser.mjs
(см. upload/web-test-bugs.md пункт 5), но рабочий путь интеракции есть.

10/10 smoke зелёные после рестарта Apache.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-04 18:19:42 +03:00
Nick Shirokov 2849087fd9 test(web-test): покрытие quickChoice + radio (RadioButtons)
03-fillfields:
- reference-dropdown: переведён с Контрагент на Организация
  (после смены quickChoice Контрагенты идут через форму выбора)
- новый шаг radio: КатегорияЦены через method=radio (RadioButtons)

04-selectvalue:
- dropdown: переведён на Организация (quickChoice=true)
- новый шаг direct-form: Контрагент (quickChoice=false), method=form

Закрывает selectValue#3 dropdown (P0), selectValue#6 direct-form (P1),
fillFields#3 radio (P1) из coverage matrix.

Tumbler-представление радио (СпособУчёта) пока не покрыто — getFormState
не возвращает Tumbler в fields[]. Зафиксировано в upload/web-test-bugs.md
пункт 5.

10/10 smoke зелёные на webtest базе.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-04 16:17:19 +03:00
Nick Shirokov 105171cdc2 test(webtest-config): Организации/quickChoice + radio (RadioButtons+Tumbler)
Расширение синтетики под новые возможности meta-compile/form-compile,
закрывает три ветки coverage matrix:
- Catalog.Организации (quickChoice: true) → selectValue#3 dropdown (P0)
- Catalog.Контрагенты (дефолт quickChoice: false) → selectValue#6 direct-form (P1)
- form-compile radio с видами RadioButtons (КатегорияЦены) и Tumbler
  (СпособУчёта) → fillFields#3 radio (P1)

В шапку ПриходнаяНакладная добавлен реквизит Организация (dropdown ветка),
Контрагент остаётся на форме выбора. Фикстура ЗаполнитьОрганизации создаёт
2 организации (Альфа, Бета); первая подставляется в документы.

Платформенная верификация: build-webtest-db (45 шагов, 30.3s) зелёная,
db-create + db-load-xml + db-update проходят. Функциональный прогон
runner.mjs integration/build-webtest — 42 шага зелёные.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-04 14:58:27 +03:00
Nick Shirokov c9cd0d62ab Merge branch 'dev' into feature/web-test-runner 2026-05-04 13:15:49 +03:00
Nick Shirokov c1a0a54971 feat(web-test): --record и export const params
Раннер v1.7.

T5 --record: startRecording перед каждым тестом, stopRecording
после (и в passed, и в failed ветке). Файл
{reportDir}/{testIdx}-{slug}.mp4. testResult.video содержит путь.
В Allure — attachment типа video/mp4. config.record читается
тоже. Использует существующую инфраструктуру browser.mjs.

T6 export const params: материализация в N тестов на этапе
discovery. Имя через {key}-шаблон в mod.name (например
'demo {type}'); если шаблона нет — суффикс [index]. Тест-функция
получает param как второй аргумент: default(ctx, param).
В отчёте каждый набор — отдельная test entry с собственным uuid
в Allure / testcase в JUnit.

Live-проверка:
- params: 2 теста с именами demo A / demo B из шаблона.
- record: mp4 91KB на 6-секундном тесте, путь в JSON и
  Allure attachment video/mp4.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-03 16:19:52 +03:00
Nick Shirokov 927c0827f3 feat(web-test): --format=allure и --format=junit
Раннер v1.6. Реализованы оба формата отчётов из spec §9.

allure: {reportDir}/{uuid}-result.json на каждый тест. uuid через
randomUUID, labels из tags, steps рекурсивно с attachments из
step.screenshot, statusDetails для упавших шагов и тестов.
Пропускает skipped (нет start/stop).

junit: один XML в --report=path.xml. Валидация: --format=junit
требует --report=. xmlEscape для name/message/trace. <failure>
для упавших, <skipped/> для пропущенных, <system-out> со ссылкой
на screenshot.

Валидация формата (json|allure|junit) на старте cmdTest.
testResult теперь хранит start/stop в мс — нужно для Allure
и полезно в JSON-отчёте.

Live-проверка: 01-navigation в Allure (5 шагов с attachments,
все ссылки на существующие PNG); JUnit с passed и forced-fail
(спецсимволы корректно экранированы).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-03 16:03:31 +03:00
Nick Shirokov 56cd18a6b4 feat(web-test): --screenshot=on-failure|every-step|off + --report-dir
Раннер v1.5. Парсит --screenshot и --report-dir, мерж с config.screenshot.
- every-step: после успешного step() пишет {reportDir}/{testIdx}-{stepIdx}-{slug}.png,
  путь в step.screenshot.
- off: ни пошаговых, ни error-shot.
- on-failure (default): error-shot уехал из .claude/skills/web-test/
  в {reportDir}/error-{testIdx}-{slug}.png.

reportDir фоллбэчит: --report-dir → dirname(--report) → testDir.

Известная нестыковка: error-shot из buildContext/executeScript остаётся в
.claude/skills/web-test/error-shot.png — затронем при T2 (Allure).

Live-проверка: 01-navigation с every-step (5 PNG), off (пусто),
default on-failure на стуб-failing тесте (error-shot в reportDir).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-03 15:54:38 +03:00
Nick Shirokov 3ac1d425cd test(11-report): DCS-отчёт ОстаткиТоваров + smoke с быстрым фильтром
Синтетика: добавлен template-add ОсновнаяСхемаКомпоновкиДанных к отчёту
(без него skd-compile писал Template.xml в незарегистрированный путь),
переписан DSL skd-compile — fields внутри dataSets, типы полей, totalFields,
явный settingsVariants со structure и быстрым отбором по Номенклатуре
(@off @user @quickAccess).

Тест 11-report покрывает: регистрацию команды в подсистеме, открытие формы
отчёта с дефолтной кнопкой Сформировать, видимость и структуру быстрого
DCS-фильтра, формирование отчёта, применение фильтра через selectValue
(auto-enable чекбокса + значение), пересчёт с фильтром, снятие фильтра
через fillFields toggle off с восстановлением исходных данных.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-03 15:22:22 +03:00
Nick Shirokov 3c596f4550 test(12-formstate): smoke базовых полей getFormState
Покрывает форму списка (form, formCount, openForms, tables, buttons)
и форму элемента (fields с label и value, проверка по конкретному
полю Наименование).
2026-05-02 20:15:20 +03:00
Nick Shirokov 36d29a51a9 test(09-filter): smoke filterList simple-search и advanced-column
Покрывает:
- filterList('Север') — поиск по всем колонкам списка Контрагенты
- filterList('Север', { field: 'Наименование' }) — фильтр по
  конкретной колонке через расширенный поиск
- unfilterList — восстановление исходного набора

Третий запланированный кейс (text-field filter) семантически совпадает
с advanced-column когда колонка строкового типа — оставлен на регресс P1.
2026-05-02 20:11:58 +03:00
Nick Shirokov 11e961c816 test(07-tabs): smoke переключение страниц формы Основное/Дополнительно
Покрывает clickElement по имени страницы как механизм переключения
вкладок формы. Используем форму элемента Номенклатура: page1
показывает шапку (Артикул, ВидНоменклатуры, ...), page2 — Дополнительно
(ЕдиницаИзмерения, Комментарий). Verify: набор state.fields различен
после переключения и совпадает после возврата.
2026-05-02 20:07:46 +03:00
Nick Shirokov 05ca810461 test(06-document): сверка с Комментарий=docId, защита от грязной базы
Раньше verify-list брал первый попавшийся проведённый документ Север —
если в базе уже лежал проведённый Север из прошлого прогона, тест
проходил даже если текущий не сохранился. Теперь среди кандидатов
открываем каждый и сверяем Комментарий с уникальным docId текущего
прогона; ассерт срабатывает только при совпадении.
2026-05-02 20:04:38 +03:00
Nick Shirokov a0407b74dc test(06-document): проверка закрытия по смене номера формы вместо костыля
Раньше использовалось отсутствие поля Контрагент после Провести и закрыть
как косвенный признак закрытия — это работало, но было привязано к
конкретному реквизиту накладной. Заменил на сравнение state.form до и
после: номер активной формы меняется (11 → 5), это прямой и общий
признак, что мы переключились с формы документа на другую.
2026-05-02 19:58:56 +03:00
Nick Shirokov 3aad254399 test(06-document): smoke workflow проведения накладной
Создание, заполнение шапки и табличной части, Провести и закрыть,
проверка появления документа в списке с Проведён=Да.

Проверка закрытия формы документа: в синтетике web-test форма списка и
форма документа делят один слот (formCount=1 в обоих состояниях),
поэтому используем признак отсутствия поля Контрагент в текущем
state.fields после Провести и закрыть — если поле есть, мы остались
на форме документа.
2026-05-02 19:54:34 +03:00
Nick Shirokov 07753921be test(05-table): smoke add/edit/delete для табличной части накладной
Покрывает работу с табличной частью Товары документа Приходная накладная:
- fillTableRow с add:true добавляет строки последовательно
- fillTableRow с row:N редактирует существующую строку (Tab-навигация)
- deleteTableRow удаляет строку по индексу

Закрытие формы без сохранения (save:false) — соответствует новой
семантике после фикса form-compile (SavedData).
2026-05-02 19:49:19 +03:00
Nick Shirokov ba0c71fa45 test(smoke): починить 01-navigation и 04-selectvalue после фикса form-compile
01-navigation: первое открытое окно 1С имеет form=0 (number), и
assert.ok(state.form, ...) валился на falsy при первом запуске сессии.
Сменил на state.form != null.

04-selectvalue: явный save:false при закрытии модифицированной формы
накладной — после фикса SavedData=true главного реквизита платформа
требует решения по confirmation dialog.
2026-05-02 19:45:15 +03:00
Nick Shirokov 33c9fdade0 test(03-fillfields): boolean → CheckBoxField, явный save:false при закрытии
После фикса form-compile (kind=check для Boolean + SavedData=true для
главного реквизита) Активен передаётся как настоящий boolean (toggle),
getFormState возвращает value:true/false. Закрытие модифицированных форм
теперь требует явного save:false — иначе платформа показывает
confirmation dialog «Записать?».
2026-05-02 19:40:26 +03:00
Nick Shirokov 1c1fe7b2d9 test(02-crud): убрать устаревший комментарий про T11/SavedData
После фикса form-compile (a59be4b SavedData=true для главного реквизита)
canonical confirm-save-yes flow работает без ручного патча Form.xml —
предупреждение в шаге неактуально.
2026-05-02 19:26:56 +03:00
Nick Shirokov 0bd2587e74 test(build-webtest-config): Активен как check вместо input для Boolean
После фикса form-compile (Дефект 2: kind=check → CheckBoxField) булевый
реквизит Активен в форме элемента и форме списка Номенклатуры теперь
описывается как check — рендерится настоящим чекбоксом.
2026-05-02 19:24:50 +03:00
Nick Shirokov 6f17b1c2f6 Merge branch 'dev' into feature/web-test-runner 2026-05-02 19:08:28 +03:00
Nick Shirokov 36ad686316 feat(web-test): smoke-тест 04-selectvalue (dropdown быстрый выбор)
Один P0 кейс из coverage matrix:
- dropdown: selectValue('Контрагент', 'ООО Север') → method='dropdown'
  на форме новой ПриходнойНакладной (CatalogRef + малый список)

API возвращает form state с .selected = {field, search, method}.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-02 15:57:37 +03:00
Nick Shirokov 66e37fb8cc feat(web-test): smoke-тест 03-fillfields (text, dropdown, date, reference)
2 шага, 5 типов полей зелёные на синтетике webtest:
- text (paste): Артикул на форме Номенклатура
- dropdown (Да/Нет): Активен — Boolean рендерится как Да/Нет селектор
- dropdown (EnumRef): ВидНоменклатуры
- date (paste): ДатаПоступления
- reference (dropdown CatalogRef): Контрагент в новой ПриходнаяНакладная

NB: 1C рендерит Boolean-атрибут не как чекбокс, а как dropdown «Да/Нет»
(actions: ["select"]) — fillFields правильно определяет это.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-02 15:55:14 +03:00
Nick Shirokov 99c77e1dde fix(web-test): 02-crud использует canonical closeForm({save:true})
Гипотеза о баге fillField paste была ошибочной — реальная причина в form-compile
который не эмитит <SavedData>true</SavedData> для MainAttribute главной формы.
Платформа без SavedData не трекает modified-state, confirmation dialog не
появляется.

Платформенная верификация на патченной Form.xml: closeForm({save:true})
после fillField корректно ловит confirmation, жмёт «Да», изменения
сохраняются. См. T11 в upload/web-test-runner-tasks.md.

ВНИМАНИЕ: тест зависит от ручного патча Form.xml. После прогона
build-webtest-db.mjs тест упадёт до фикса form-compile (T11).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-02 15:44:39 +03:00
Nick Shirokov 8d6612027f feat(web-test): smoke-тест 02-crud (open-item, close-clean, read, save)
4 шага зелёные на синтетике webtest:
- read: список Контрагентов отдаёт колонки/строки/total
- open-item: dblclick открывает форму элемента
- close-clean: Escape без изменений закрывает форму без диалога
- save-via-button: fillField + «Записать и закрыть» → значение сохраняется

confirm-save-yes (P0 из coverage matrix) отложен — fillField через paste не
выставляет 1C "modified" флаг, confirmation dialog не появляется. Зафиксировано
в upload/web-test-runner-tasks.md как T11.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-02 15:34:37 +03:00
Nick Shirokov c3b67a18cb feat(tests): build-webtest-db скрипт для постоянной webtest базы
Заменяет одноразовый platform-webtest-config.test.mjs на скрипт сборки в
постоянные пути из .v8-project.json (tests/skills/.cache/webtest-config
+ C:\edt\IB\webtest). Переиспользует steps из build-webtest-config.test.mjs.

Generic platform-config.test.mjs уже покрывает regression «платформа принимает
сборку» — отдельный синтетический тест дублировал.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-02 15:23:42 +03:00
Nick Shirokov 4d1b66638c Merge branch 'dev' into feature/web-test-runner 2026-05-02 14:54:50 +03:00
Nick Shirokov 363a9f34f2 Merge dev: cf-info раскладка, cf-edit set-panels с русскими синонимами 2026-05-01 17:06:51 +03:00
Nick Shirokov 4f8ce7b747 chore(web-test): убрать инлайн ClientApplicationInterface.xml
Файл теперь генерируется самим cf-init с ERP-дефолтом (см. предыдущий
коммит на dev), отдельный writeFile в build-webtest-config больше не нужен.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-01 16:48:13 +03:00
Nick Shirokov fc48d68ed1 Merge dev: cf-init создаёт Ext/ClientApplicationInterface.xml 2026-05-01 16:46:30 +03:00
Nick Shirokov 3e34ec0bdd fix(web-test): заголовки и стиль вкладок на форме Номенклатуры
Page элементы в DSL получали name (через ключ 'page'), но не получали
title, поэтому вкладки рендерились пустыми квадратиками. Также Pages
без явного pagesRepresentation отображались в режиме None (без табов).

- Добавил title к каждой Page (Основное, Дополнительно)
- pagesRepresentation: 'TabsOnTop' на Pages

После: getFormState().tabs возвращает [{name:'Основное'},{name:'Дополнительно'}]
вместо пустого массива.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-01 15:33:19 +03:00
Nick Shirokov fff2e83960 fix(web-test): починить runtime синтетики для тонкого клиента
Два бага, найденные при попытке запустить синтетическую ИБ через
web-publish + web-test:

1. ОбщиеФункции без ServerCall=true — ManagedApplicationModule (клиент)
   не мог звать процедуры серверного модуля напрямую. ПриНачалеРаботыСистемы
   падал с ошибкой компиляции в runtime, страница не догружалась. Добавил
   serverCall: true в DSL meta-compile.

2. Без Ext/ClientApplicationInterface.xml панель разделов рендерилась
   icon-only (без подписей), web-test navigateSection не находил секции.
   Добавил writeFile-шаг с раскладкой панелей как в acc/erp:
   - top: панель разделов (8e10648b...) + панель информации (cbab57f2...)
   - left: панель функций текущего раздела (b553047f...)

Проверено end-to-end: после пересборки runner-ом + web-publish + start
работают navigateSection, openCommand, readTable. Фикстуры (4 контрагента,
25 номенклатуры в группах, 3 документа) автоматически заполняются при
первом старте через ManagedApplicationModule → ОбщиеФункции.ЗаполнитьФикстурыЕслиНужно.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-01 15:12:43 +03:00
Nick Shirokov 1ff209849f feat(web-test): первоначальное заполнение фикстур (M1 Step 5)
Покрытие matrix #9 — данные для smoke-тестов:
- Константа ДанныеЗаполнены (Boolean) — флаг идемпотентности
- ОбщиеФункции.ЗаполнитьФикстурыЕслиНужно() — транзакционно создаёт:
  * 4 контрагента (ООО Север/Юг/Восток, АО Запад)
  * 25 номенклатуры в группах Товары (15) и Услуги (10)
  * 3 приходных накладных по 3 строки
- Ext/ManagedApplicationModule.bsl с ПриНачалеРаботыСистемы — вызывает
  заполнение при первом старте тонкого клиента

Платформенная верификация компилирует BSL (43 шага, 23.7s). Реальное
выполнение заполнения произойдёт при первом подключении web-test
runner-а к синтетической базе.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-01 14:36:13 +03:00
Nick Shirokov 1a8415283e chore(web-test): убрать избыточный cf-edit (объекты регистрируются автоматически)
meta-compile/subsystem-compile/role-compile сами добавляют записи в
Configuration.xml. cf-edit в каждом прогоне рапортовал Added: 0 — был
no-op + дублировал список объектов, который надо было синхронизировать
руками при каждом изменении.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-01 14:32:48 +03:00
Nick Shirokov db1e78a534 feat(web-test): подчинённый каталог КонтактныеЛица (M1 Step 4)
Покрытие matrix #8 — getFormState.navigation (12-formstate/subordinate-nav):
- Catalog.КонтактныеЛица с Owner=Catalog.Контрагенты
- Реквизиты: Должность, Телефон
- ФормаЭлемента (с владельцем) + ФормаСписка
- Регистрация в Configuration + Subsystem.Склад + Role

Платформенная верификация: 41 шаг, 23.8s.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-01 14:30:15 +03:00
Nick Shirokov a828f1847f feat(web-test): обработка ТестовыеОшибки + bsl-модули (M1 Step 3)
Покрытие matrix #6 — errors balloon/messages/modal (10-validation,
fetchErrorStack Path 2):
- ОбщиеФункции.ПоказатьСообщение() → Сообщить("Тестовое сообщение")
- ОбщиеФункции.ВызватьТестовоеИсключение() → ВызватьИсключение
- DataProcessor.ТестовыеОшибки + ФормаОбработки с двумя кнопками,
  каждая делает клиент→сервер вызов соответствующей процедуры
- Регистрация в Configuration + Subsystem.Администрирование

Runner расширен step-типом writeFile — записывает произвольный текст
(обычно Module.bsl) в workDir. Нет навыка для модификации bsl-кода
модулей, и плодить отдельный навык под одну задачу избыточно.

Платформенная верификация: 36 шагов, 21.2s.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-01 14:27:38 +03:00
Nick Shirokov 3e8159b591 feat(web-test): формы списков с правильными колонками (M1 Step 2)
Расширение синтетики (пункты 3, 4, 5 из M1):
- Контрагенты.КодКПП: новый String реквизит, НЕ выводимый в форму списка
  (для теста filterList #5 — FieldSelector DLB по скрытой колонке)
- Catalog.Контрагенты.ФормаСписка: Code, Description, ИНН, Телефон, Адрес
- Catalog.Номенклатура.ФормаСписка: Code, Description, Артикул,
  ВидНоменклатуры, ДатаПоступления, Цена, Активен (date-колонка для
  filterList #6)
- Document.ПриходнаяНакладная.ФормаСписка: Date, Number, Контрагент, Posted
  (reference-колонка для filterList #7)

Платформенная верификация: 31 шаг, 21.4s.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-01 14:23:38 +03:00
Nick Shirokov 57bb964c1e feat(web-test): ссылочные типы и Boolean в синтетике (M1 Step 1)
Расширение build-webtest-config под coverage matrix (пункты 1, 2, 7
из upload/web-test-runner-tasks.md M1):
- Перечисление КатегорииЦен (для будущего radio-button теста)
- Номенклатура.ВидНоменклатуры → EnumRef.ВидыНоменклатуры
- Номенклатура.КатегорияЦены → EnumRef.КатегорииЦен
- ПриходнаяНакладная.Контрагент: String → CatalogRef.Контрагенты
- ПриходнаяНакладная.Товары.Номенклатура: String → CatalogRef.Номенклатура
- ПриходнаяНакладная.Товары.Согласовано: новый Boolean (для checkbox
  в grid, fillTableRow ветка #6)
- Формы Номенклатура и Документ обновлены под новые поля
- Subsystem.Склад: добавлены Enum.* в content
- Configuration.xml регистрирует Enum.КатегорииЦен

Платформенная верификация (platform-webtest-config.test.mjs) зелёная,
25 шагов 16.7s.

Гэп: form-compile не умеет рендерить RadioButtonField — представление
КатегорияЦены остаётся обычным input. Будет отдельной задачей перед
тестами P1 fillFields/radio.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-01 14:21:22 +03:00
Nick Shirokov 41c4b6b1f7 fix(skills/tests): cleanupWorkspace терпимо переживает EBUSY от 1cv8
После платформенных тестов (db-create/db-load-xml/db-update) Windows
держит файловые хэндлы 1cv8 ещё несколько сотен миллисекунд. rmSync без
ретраев падал EBUSY на Roles/.../Rights.xml, и uncaught-ошибка в finally
рушила весь node-процесс — теряли результат теста.

Теперь rmSync с maxRetries: 10, retryDelay: 200 (≈2с буфер) и try/catch
вокруг — в худшем случае warning + лишняя tmp-папка вместо краша.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-01 14:21:08 +03:00
Nick Shirokov ffb0ee740d fix(web-test): восстановить синтетическую конфу + платформенная верификация
build-webtest-config упал после ужесточения form-compile (запрет runtime-типа
FormDataStructure для главного реквизита). Перевёл типы на конкретные
CatalogObject.X / DocumentObject.X без cfg:-префикса. Добавил
platform-webtest-config.test.mjs — переиспользует шаги сборки и в хвосте
делает db-create + db-load-xml + db-update. Зелёный, 24 шага.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-01 12:12:45 +03:00
Nick Shirokov f5e487096f Merge branch 'dev' into feature/web-test-runner 2026-05-01 11:58:28 +03:00
Nick Shirokov 6d5c1a0b19 Merge branch 'dev' into feature/web-test-runner 2026-04-05 18:18:25 +03:00
Nick Shirokov b322c02fdb fix(web-test): discoverTests для одиночного файла + первый smoke-тест
- Fix: discoverTests падал с ENOTDIR при передаче .test.mjs файла
- Добавлен 01-navigation.test.mjs — навигация по разделам, открытие
  списков через navigateLink, переключение между подсистемами

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-05 15:22:40 +03:00
Nick Shirokov 61ef7ac891 fix(web-test): фикс синтетической конфигурации для загрузки в платформу
- Подсистемы: singular формы в Content (Catalog вместо Catalogs)
- КурсыВалют: Independent вместо RecorderSubordinate
- Убран AccumulationRegister (требует регистратор, не нужен для UI)
- Отчёт: запрос из ТЧ документа вместо регистра

Формы загружаются без Form.xml (автогенерация платформой) —
баг form-compile (XDTO exception) требует отдельного исследования.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-05 15:09:43 +03:00
Nick Shirokov ba19b4111d feat(web-test): синтетическая конфигурация для регресс-тестов
22 шага: cf-init → meta-compile (10 объектов) → form-compile (3 формы,
вкл. 2 вкладки для Номенклатуры) → skd-compile → subsystem-compile
(Склад + Администрирование) → role-compile (полные права) → cf-validate.

Расширения: иерархический справочник, разнотипные реквизиты (Number,
Boolean, Date, String unlimited), FillChecking, вторая подсистема.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-05 14:57:52 +03:00
Nick Shirokov ded11437c5 docs(web-test): обновить статус дорожной карты — #1-5 done
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-05 14:55:00 +03:00
Nick Shirokov 5eda7f8eb3 feat(web-test): test runner — buildContext, cmdTest, assertions, step
- Извлечён buildContext() из executeScript (переиспользуется)
- Новая команда `test [url] <dir> [--tags/--bail/--retry/--timeout/--report]`
- Обнаружение *.test.mjs, импорт ES-модулей, фильтрация по тегам/grep/only
- Хуки: prepare/cleanup (без браузера) + beforeAll/afterAll/beforeEach/afterEach
- Встроенный сброс состояния (dismissPendingErrors + closeForm) после каждого теста
- step(name, fn) обёртка с вложенностью и таймингами
- Assertions: ok/equal/deepEqual/includes/match/throws + 1C-специфичные
- Консольный вывод с деревом шагов, JSON-отчёт
- Поддержка webtest.config.mjs

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-05 14:53:33 +03:00
Nick Shirokov f39a0d9c5e docs(web-test): BrowserContext вместо sequential reconnect для мульти-контекста
Один процесс браузера, несколько изолированных BrowserContext'ов.
Мгновенное переключение между пользователями, состояние каждой
сессии сохраняется. Не требует полного рефакторинга createContext().

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-05 14:47:26 +03:00
Nick Shirokov 2347859bdd docs(web-test): спецификация test runner для регрессионного тестирования
Единый механизм для внутреннего регресса browser.mjs API и
пользовательского регресса 1С-приложений. Паттерны Playwright Test.

Содержание: CLI, формат тестов, контексты, хуки, assertions, step(),
отчёты (JSON/Allure/JUnit), синтетическая конфигурация, дорожная карта.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-05 14:37:47 +03:00
89 changed files with 8202 additions and 270 deletions
@@ -1,4 +1,4 @@
# form-compile v1.20 — Compile 1C managed form from JSON or object metadata
# form-compile v1.21 — Compile 1C managed form from JSON or object metadata
# Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
param(
[string]$JsonPath,
@@ -1912,6 +1912,7 @@ function Emit-Element {
# input-specific
"multiLine"=1;"passwordMode"=1;"choiceButton"=1;"clearButton"=1
"spinButton"=1;"dropListButton"=1;"markIncomplete"=1;"skipOnInput"=1;"inputHint"=1
"textEdit"=1
# label/hyperlink
"hyperlink"=1
# group-specific
@@ -2137,6 +2138,7 @@ function Emit-Input {
if ($el.spinButton -eq $true) { X "$inner<SpinButton>true</SpinButton>" }
if ($el.dropListButton -eq $true) { X "$inner<DropListButton>true</DropListButton>" }
if ($el.markIncomplete -eq $true) { X "$inner<AutoMarkIncomplete>true</AutoMarkIncomplete>" }
if ($el.textEdit -eq $false) { X "$inner<TextEdit>false</TextEdit>" }
if ($el.skipOnInput -eq $true) { X "$inner<SkipOnInput>true</SkipOnInput>" }
$hasAmw = $el.PSObject.Properties.Name -contains 'autoMaxWidth'
if ($hasAmw) {
@@ -1,5 +1,5 @@
#!/usr/bin/env python3
# form-compile v1.20 — Compile 1C managed form from JSON or object metadata
# form-compile v1.21 — Compile 1C managed form from JSON or object metadata
# Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
import argparse
import copy
@@ -1350,6 +1350,7 @@ KNOWN_KEYS = {
"maxWidth", "maxHeight",
"multiLine", "passwordMode", "choiceButton", "clearButton",
"spinButton", "dropListButton", "markIncomplete", "skipOnInput", "inputHint",
"textEdit",
"hyperlink",
"showTitle", "united", "collapsed",
"children", "columns",
@@ -1940,6 +1941,8 @@ def emit_input(lines, el, name, eid, indent):
lines.append(f'{inner}<DropListButton>true</DropListButton>')
if el.get('markIncomplete') is True:
lines.append(f'{inner}<AutoMarkIncomplete>true</AutoMarkIncomplete>')
if el.get('textEdit') is False:
lines.append(f'{inner}<TextEdit>false</TextEdit>')
if el.get('skipOnInput') is True:
lines.append(f'{inner}<SkipOnInput>true</SkipOnInput>')
if 'autoMaxWidth' in el:
@@ -1,4 +1,4 @@
# form-validate v1.4 — Validate 1C managed form
# form-validate v1.6 — Validate 1C managed form
# Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
param(
[Parameter(Mandatory)]
@@ -366,13 +366,51 @@ if (-not $stopped) {
$dataPath = $dpNode.InnerText.Trim()
if (-not $dataPath) { continue }
# Opaque platform-internal DataPath shapes — not validatable from Form.xml alone:
# - bare numeric (e.g. "10", "1000003") — internal index
# - "N/M:<uuid>" — metadata reference by UUID
if ($dataPath -match '^\d+$' -or $dataPath -match '^\d+/\d+:[0-9a-fA-F-]+$') {
continue
}
$pathChecked++
# Extract root segment of path, strip array indices like [0]
$cleanPath = $dataPath -replace '\[\d+\]', ''
# Strip leading '~' (current row of DynamicList: ~Список.Поле)
if ($cleanPath.StartsWith('~')) { $cleanPath = $cleanPath.Substring(1) }
$segments = $cleanPath -split '\.'
$rootAttr = $segments[0]
# Resolve Items.<TableName>.CurrentData.<Field>... — table element, not attribute
if ($rootAttr -eq 'Items') {
if ($segments.Count -lt 3 -or $segments[2] -ne 'CurrentData') {
Report-Warn "[$tag] '$elName': DataPath='$dataPath' — unknown Items.* shape, expected Items.<Table>.CurrentData.*"
continue
}
$tableName = $segments[1]
$tableEl = $null
foreach ($candidate in $allElements) {
if ($candidate.Tag -eq 'Table' -and $candidate.Name -eq $tableName) {
$tableEl = $candidate
break
}
}
if (-not $tableEl) {
Report-Error "[$tag] '$elName': DataPath='$dataPath' — table element '$tableName' not found"
$pathErrors++
continue
}
$tableDpNode = $tableEl.Node.SelectSingleNode("f:DataPath", $nsMgr)
if (-not $tableDpNode -or -not $tableDpNode.InnerText.Trim()) {
# Table without DataPath — can't resolve further, accept silently
continue
}
$tableDp = $tableDpNode.InnerText.Trim() -replace '\[\d+\]', ''
if ($tableDp.StartsWith('~')) { $tableDp = $tableDp.Substring(1) }
$rootAttr = ($tableDp -split '\.')[0]
}
if (-not $attrMap.ContainsKey($rootAttr)) {
Report-Error "[$tag] '$elName': DataPath='$dataPath' — attribute '$rootAttr' not found"
$pathErrors++
@@ -1,5 +1,5 @@
#!/usr/bin/env python3
# form-validate v1.4 — Validate 1C managed form
# form-validate v1.6 — Validate 1C managed form
# Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
import argparse
@@ -376,12 +376,44 @@ def main():
if not data_path:
continue
# Opaque platform-internal DataPath shapes — not validatable from Form.xml alone:
# - bare numeric (e.g. "10", "1000003") — internal index
# - "N/M:<uuid>" — metadata reference by UUID
if re.match(r'^\d+$', data_path) or re.match(r'^\d+/\d+:[0-9a-fA-F-]+$', data_path):
continue
path_checked += 1
clean_path = re.sub(r'\[\d+\]', '', data_path)
# Strip leading '~' (current row of DynamicList: ~\u0421\u043f\u0438\u0441\u043e\u043a.\u041f\u043e\u043b\u0435)
if clean_path.startswith('~'):
clean_path = clean_path[1:]
segments = clean_path.split(".")
root_attr = segments[0]
# Resolve Items.<TableName>.CurrentData.<Field>... \u2014 table element, not attribute
if root_attr == 'Items':
if len(segments) < 3 or segments[2] != 'CurrentData':
report_warn(f"[{tag}] '{el_name}': DataPath='{data_path}' \u2014 unknown Items.* shape, expected Items.<Table>.CurrentData.*")
continue
table_name = segments[1]
table_el = None
for candidate in all_elements:
if candidate["Tag"] == 'Table' and candidate["Name"] == table_name:
table_el = candidate
break
if table_el is None:
report_error(f"[{tag}] '{el_name}': DataPath='{data_path}' \u2014 table element '{table_name}' not found")
path_errors += 1
continue
table_dp_node = table_el["Node"].find(f"{{{F_NS}}}DataPath")
if table_dp_node is None or not (table_dp_node.text or "").strip():
continue
table_dp = re.sub(r'\[\d+\]', '', (table_dp_node.text or "").strip())
if table_dp.startswith('~'):
table_dp = table_dp[1:]
root_attr = table_dp.split(".")[0]
if root_attr not in attr_map:
report_error(f"[{tag}] '{el_name}': DataPath='{data_path}' \u2014 attribute '{root_attr}' not found")
path_errors += 1
@@ -1,4 +1,4 @@
# meta-compile v1.11 — Compile 1C metadata object from JSON
# meta-compile v1.12 — Compile 1C metadata object from JSON
# Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
param(
[Parameter(Mandatory)]
@@ -502,6 +502,7 @@ function Parse-AttributeShorthand {
fillChecking = if ($val.fillChecking) { "$($val.fillChecking)" } else { "" }
indexing = if ($val.indexing) { "$($val.indexing)" } else { "" }
multiLine = if ($val.multiLine -eq $true) { $true } else { $false }
choiceHistoryOnInput = if ($val.choiceHistoryOnInput) { "$($val.choiceHistoryOnInput)" } else { "" }
}
}
@@ -822,7 +823,8 @@ function Emit-Attribute {
X "$indent`t`t<CreateOnInput>Auto</CreateOnInput>"
X "$indent`t`t<ChoiceForm/>"
X "$indent`t`t<LinkByType/>"
X "$indent`t`t<ChoiceHistoryOnInput>Auto</ChoiceHistoryOnInput>"
$chi = if ($parsed.choiceHistoryOnInput) { $parsed.choiceHistoryOnInput } else { "Auto" }
X "$indent`t`t<ChoiceHistoryOnInput>$chi</ChoiceHistoryOnInput>"
# Use — only for catalog top-level attributes
if ($context -eq "catalog") {
@@ -1,5 +1,5 @@
#!/usr/bin/env python3
# meta-compile v1.11 — Compile 1C metadata object from JSON
# meta-compile v1.12 — Compile 1C metadata object from JSON
# Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
import argparse
@@ -465,6 +465,7 @@ def parse_attribute_shorthand(val):
'fillChecking': str(val['fillChecking']) if val.get('fillChecking') else '',
'indexing': str(val['indexing']) if val.get('indexing') else '',
'multiLine': True if val.get('multiLine') is True else False,
'choiceHistoryOnInput': str(val['choiceHistoryOnInput']) if val.get('choiceHistoryOnInput') else '',
}
def parse_enum_value_shorthand(val):
@@ -774,7 +775,8 @@ def emit_attribute(indent, parsed, context):
X(f'{indent}\t\t<CreateOnInput>Auto</CreateOnInput>')
X(f'{indent}\t\t<ChoiceForm/>')
X(f'{indent}\t\t<LinkByType/>')
X(f'{indent}\t\t<ChoiceHistoryOnInput>Auto</ChoiceHistoryOnInput>')
chi = parsed.get('choiceHistoryOnInput') or 'Auto'
X(f'{indent}\t\t<ChoiceHistoryOnInput>{chi}</ChoiceHistoryOnInput>')
if context == 'catalog':
X(f'{indent}\t\t<Use>ForItem</Use>')
if context not in ('processor', 'processor-tabular'):
@@ -1,4 +1,4 @@
# skd-compile v1.21 — Compile 1C DCS from JSON
# skd-compile v1.22 — Compile 1C DCS from JSON
# Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
param(
[string]$DefinitionFile,
@@ -1130,6 +1130,8 @@ function Emit-ParamValue {
X "$indent<value xsi:type=`"xs:decimal`">$(Esc-Xml $valStr)</value>"
} elseif ($type -match '^string') {
X "$indent<value xsi:type=`"xs:string`">$(Esc-Xml $valStr)</value>"
} elseif ($type -match '^(CatalogRef|DocumentRef|EnumRef|ChartOfAccountsRef|ChartOfCharacteristicTypesRef|ChartOfCalculationTypesRef|BusinessProcessRef|TaskRef|ExchangePlanRef)\.') {
X "$indent<value xsi:type=`"dcscor:DesignTimeValue`">$(Esc-Xml $valStr)</value>"
} else {
# Guess from value
if ($valStr -match '^\d{4}-\d{2}-\d{2}T') {
@@ -1,5 +1,5 @@
#!/usr/bin/env python3
# skd-compile v1.21 — Compile 1C DCS from JSON
# skd-compile v1.22 — Compile 1C DCS from JSON
# Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
import argparse
import json
@@ -811,6 +811,8 @@ def emit_param_value(lines, type_str, val, indent):
lines.append(f'{indent}<value xsi:type="xs:decimal">{esc_xml(val_str)}</value>')
elif type_str and re.match(r'^string', type_str):
lines.append(f'{indent}<value xsi:type="xs:string">{esc_xml(val_str)}</value>')
elif type_str and re.match(r'^(CatalogRef|DocumentRef|EnumRef|ChartOfAccountsRef|ChartOfCharacteristicTypesRef|ChartOfCalculationTypesRef|BusinessProcessRef|TaskRef|ExchangePlanRef)\.', type_str):
lines.append(f'{indent}<value xsi:type="dcscor:DesignTimeValue">{esc_xml(val_str)}</value>')
else:
# Guess from value
if re.match(r'^\d{4}-\d{2}-\d{2}T', val_str):
+68 -10
View File
@@ -54,9 +54,12 @@ Shorthand: `"Имя [Заголовок]: тип @роль #ограничени
### add-total — добавить итог
Shorthand: `"<dataPath>: <выражение>"`. Если выражение — известная аггрегатная функция без скобок (`Сумма`, `Количество`, `Минимум`, `Максимум`, `Среднее`), оно автоматически оборачивается в `Func(dataPath)`. Если функция со скобками или произвольное выражение — используется как есть.
```
"Цена: Среднее"
"Стоимость: Сумма(Кол * Цена)"
"Цена: Среднее" # → Среднее(Цена)
"Стоимость: Сумма(Кол * Цена)" # → как есть
"Проверка: Проверка" # identity: выражение = Проверка
```
### add-calculated-field — добавить вычисляемое поле
@@ -80,24 +83,46 @@ Shorthand: `"Имя [Заголовок]: тип = Выражение #noFilter
"Организация: CatalogRef.Организации"
```
Shorthand: `"Имя [Заголовок]: тип = значение @флаги"`. `[Заголовок]` опциональный — добавляет `<title>`.
Shorthand: `"Имя [Заголовок]: тип = значение [availableValue=список] [@флаги]"`. `[Заголовок]` опциональный — добавляет `<title>`.
`@autoDates` генерирует пару скрытых параметров `ДатаНачала`/`ДатаОкончания` для StandardPeriod-параметра — для БСП-отчётов, чтобы получить пару полей «Начало/Конец» в панели быстрых настроек.
Флаги:
- `@autoDates` — генерирует пару скрытых параметров `ДатаНачала`/`ДатаОкончания` для StandardPeriod-параметра.
- `@hidden` — скрывает параметр от пользовательских настроек (для параметров-констант, используемых в запросе).
- `@always` — параметр всегда подставляется в запрос. Часто вместе с `@hidden`, но используется и отдельно (для видимых обязательных параметров типа отчётного периода).
```
"ПС: CatalogRef.Контрагенты = Справочник.Контрагенты.ПустаяСсылка @hidden"
"Период: StandardPeriod = LastMonth @always"
"ПСчет: ChartOfAccountsRef.Хозрасчетный = ПланСчетов.Хозрасчетный.X @hidden @always"
"Округление: EnumRef.Округления = Окр1 availableValue=Перечисление.Округления.Окр1: руб., Перечисление.Округления.Окр1000: тыс."
```
`availableValue=` задаёт начальный список допустимых значений. Формат списка: `v1[: p1], v2[: p2], ...` — элементы через `,`, представление после `:`. Если в значении или представлении встречается `,` или `:` — оборачивай в одинарные кавычки `'...'`:
```
"Округление: ... = Окр1 availableValue=Окр1_00: 'руб., коп.', Окр1: руб."
```
### modify-parameter — изменить существующий параметр
Находит параметр по имени, добавляет/обновляет свойства.
Shorthand: `"ИмяПараметра [Заголовок] [ключ=значение]... [@флаги]"`. Находит параметр по имени, обновляет указанные свойства.
```
"ПорядокОкругления use=Always"
"ПорядокОкругления [Округление сумм] denyIncompleteValues=true"
"ПериодОтчета [Отчетный период]" # только title
"ПорядокОкругления availableValue=Перечисление.Округления.Окр1 presentation=руб."
"ПорядокОкругления availableValue=Перечисление.Округления.Окр1: руб., Перечисление.Округления.Окр1000: тыс."
"СчетПС value=ПланСчетов.Хозрасчетный.КассаПредприятия"
"Контрагент @hidden @always"
```
`[Заголовок]` опциональный — устанавливает или заменяет `<title>`. Можно вызывать без других kv-пар, чтобы только обновить title.
`availableValue=` добавляет один элемент списка допустимых значений (можно несколько через `;;`). Тип значения определяется автоматически (DesignTimeValue для ссылок).
`availableValue=` **заменяет весь список** допустимых значений (старые удаляются). Формат и кавычки — те же, что в `add-parameter`.
`value=` заменяет значение параметра (тип значения подбирается автоматически по объявленному типу параметра).
Флаги `@hidden` / `@always` — те же, что и в `add-parameter`. Идемпотентны.
### rename-parameter — переименовать параметр
@@ -231,13 +256,17 @@ Value — имена ресурсов (как в полях/вычисляемы
### patch-query — точечная замена в тексте запроса
Shorthand: `"старое => новое"`. Заменяет все вхождения подстроки. Поддерживает пакетный режим и `-DataSet`.
Shorthand: `"старое => новое [@once]"`. По умолчанию заменяет все вхождения подстроки. Поддерживает пакетный режим и `-DataSet`.
```
"СубконтоДт1) В => СубконтоКт1) В"
"ЛЕВОЕ СОЕДИНЕНИЕ => ВНУТРЕННЕЕ СОЕДИНЕНИЕ"
"КАК ВТ_СтароеИмя => КАК ВТ_НовоеИмя @once"
```
`@once` — упасть с ошибкой, если в запросе не **ровно одно** вхождение. Защищает от случайных замен в комментариях и однотипных идентификаторах.
Многострочные подстроки поддерживаются — переводы строк в `старое`/`новое` сравниваются буквально (включая отступы).
### set-outputParameter — установить параметр вывода
```
@@ -249,16 +278,27 @@ Shorthand: `"старое => новое"`. Заменяет все вхожде
### set-structure — установить структуру варианта
Shorthand: `"Поле1 > Поле2 > details"`. `details`/`детали` — детальные записи. Заменяет всю структуру. Не поддерживает пакетный режим.
Shorthand: `"Поле1 > Поле2 > details"`. `>` — вложенный уровень группировки, `,` — несколько полей в одном уровне, `details` — детальные записи. **Заменяет всю структуру полностью** (включая Selection/order/filter/conditionalAppearance каждой группы). Для точечной модификации полей группировки с сохранением настроек — используй `modify-structure`. Не поддерживает пакетный режим.
```
"Организация > Номенклатура > details"
"Валюта, НаименованиеБанка, ИНН"
"details"
"СчетМеждународногоУчета @name=ДанныеОтчета"
```
`@name=Имя` — присваивает имя группировке (`<dcsset:name>`). Используется для привязки шаблонов через `groupName`.
### modify-structure — изменить поля группировки существующей группы
Тот же shorthand что и `set-structure`. Находит группу по `@name=`, заменяет только `<groupItems>` (поля группировки). Selection/order/filter/conditionalAppearance/outputParameters группы сохраняются. Без `@name=` — ошибка.
```
"Валюта @name=ДанныеОтчета"
"Валюта, НаименованиеБанка @name=ДанныеОтчета"
"details @name=ДанныеОтчета"
```
### modify-field — изменить существующее поле
Тот же shorthand что и `add-field`. Находит по dataPath, объединяет свойства (непустые переопределяют), сохраняет позицию.
@@ -267,6 +307,23 @@ Shorthand: `"Поле1 > Поле2 > details"`. `details`/`детали` — д
"Цена [Цена USD]: decimal(10,4) @dimension"
```
### set-field-role — установить роль поля
Shorthand: `"<dataPath> [@флаги] [kv=значение]"`. **Полностью заменяет** роль поля. Если в значении только dataPath без флагов/kv — удаляет роль.
```
"Сумма" # снять роль полностью
"СуммаОстаток @balance" # простая балансовая роль
"СуммаНач @balance balanceGroupName=Сумма balanceType=OpeningBalance" # с уточнением
"Контрагент @dimension parentDimension=Группа"
"Период @period" # роль периода
```
Флаги: `@balance`, `@dimension`, `@account`, `@period`, `@required`, `@autoOrder`, `@ignoreNullValues`.
KV: `balanceGroupName`, `balanceType` (OpeningBalance/ClosingBalance), `parentDimension`, `accountTypeExpression`, `orderType` (Asc/Desc), `expression`, `periodNumber`, `periodType`.
Поддерживает пакетный режим (`;;`).
### modify-filter — изменить существующий фильтр
Тот же shorthand что и `add-filter`. Находит по полю, обновляет оператор/значение/флаги. См. правило для `<use>` ниже.
@@ -294,6 +351,7 @@ Shorthand: `"Поле1 > Поле2 > details"`. `details`/`детали` — д
| `clear-selection` | `*` | Очищает все элементы selection |
| `clear-order` | `*` | Очищает все элементы order |
| `clear-filter` | `*` | Очищает все элементы filter |
| `clear-conditionalAppearance` | `*` | Очищает все правила условного оформления |
## Верификация
+533 -58
View File
@@ -1,4 +1,4 @@
# skd-edit v1.11 — Atomic 1C DCS editor
# skd-edit v1.18 — Atomic 1C DCS editor
# Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
param(
[Parameter(Mandatory)]
@@ -11,9 +11,9 @@ param(
"add-dataParameter","add-order","add-selection","add-dataSetLink",
"add-dataSet","add-variant","add-conditionalAppearance","add-drilldown",
"set-query","patch-query","set-outputParameter","set-structure",
"modify-field","modify-filter","modify-dataParameter","modify-parameter",
"modify-field","modify-filter","modify-dataParameter","modify-parameter","modify-structure","set-field-role",
"rename-parameter","reorder-parameters",
"clear-selection","clear-order","clear-filter",
"clear-selection","clear-order","clear-filter","clear-conditionalAppearance",
"remove-field","remove-total","remove-calculated-field","remove-parameter","remove-filter")]
[string]$Operation,
@@ -238,14 +238,25 @@ function Read-FieldProperties($fieldEl) {
function Parse-TotalShorthand {
param([string]$s)
# "DataPath: Func" or "DataPath: Func(expr)" or "DataPath: ИмяРесурса" (identity)
$parts = $s -split ':', 2
$dataPath = $parts[0].Trim()
$funcPart = $parts[1].Trim()
# Known DCS aggregate functions (ru + en)
$aggFuncs = @('Сумма','Количество','Минимум','Максимум','Среднее',
'Sum','Count','Min','Max','Avg',
'Minimum','Maximum','Average')
if ($funcPart -match '^\w+\(') {
# Already has expression form: Func(expr)
return @{ dataPath = $dataPath; expression = $funcPart }
} else {
} elseif ($funcPart -in $aggFuncs) {
# Short: Func → Func(DataPath)
return @{ dataPath = $dataPath; expression = "$funcPart($dataPath)" }
} else {
# Identity or custom expression — use as-is
return @{ dataPath = $dataPath; expression = $funcPart }
}
}
@@ -297,13 +308,29 @@ function Parse-CalcShorthand {
function Parse-ParamShorthand {
param([string]$s)
$result = @{ name = ""; type = ""; value = $null; autoDates = $false; title = $null }
$result = @{ name = ""; type = ""; value = $null; autoDates = $false; title = $null; hidden = $false; always = $false; availableValues = @() }
# Extract availableValue=... (must be before main parse — captures to end of string)
if ($s -match '\s*availableValue=(.+)$') {
$result.availableValues = Parse-AvailableValueList $Matches[1].Trim()
$s = ($s -replace '\s*availableValue=.+$', '').Trim()
}
if ($s -match '@autoDates') {
$result.autoDates = $true
$s = $s -replace '\s*@autoDates', ''
}
if ($s -match '@hidden\b') {
$result.hidden = $true
$s = $s -replace '\s*@hidden\b', ''
}
if ($s -match '@always\b') {
$result.always = $true
$s = $s -replace '\s*@always\b', ''
}
# Extract optional [Title] (mirrors Parse-FieldShorthand)
if ($s -match '\[([^\]]*)\]') {
$result.title = $Matches[1].Trim()
@@ -581,15 +608,17 @@ function Parse-StructureShorthand {
$seg = $segments[$i].Trim()
$group = @{ type = "group" }
if ($seg -match '@name=(.+)') {
$group["name"] = $Matches[1].Trim()
$seg = ($seg -replace '\s*@name=.+', '').Trim()
if ($seg -match '@name=(?:"([^"]+)"|''([^'']+)''|(\S+))') {
$rawName = if ($Matches[1]) { $Matches[1] } elseif ($Matches[2]) { $Matches[2] } else { $Matches[3] }
$group["name"] = $rawName.Trim()
$seg = ($seg -replace '\s*@name=(?:"[^"]+"|''[^'']+''|\S+)', '').Trim()
}
if ($seg -match '^(?i)(details|детали)$') {
$group["groupBy"] = @()
} else {
$group["groupBy"] = @($seg)
$fields = @($seg -split '\s*,\s*' | ForEach-Object { $_.Trim() } | Where-Object { $_ })
$group["groupBy"] = $fields
}
if ($null -ne $innermost) {
@@ -614,6 +643,75 @@ function Parse-OutputParamShorthand {
return @{ key = $s.Trim(); value = "" }
}
function Parse-AvailableValueList {
# Returns array of @{ value=...; presentation=... } from comma-separated list.
# Items can use 'single' or "double" quotes (stripped). Quoted spans preserve commas/colons.
param([string]$s)
$result = @()
if (-not $s) { return ,$result }
# Tokenize by ',' respecting quoted spans
$items = @()
$buf = New-Object System.Text.StringBuilder
$inQuote = $null
for ($i = 0; $i -lt $s.Length; $i++) {
$ch = $s[$i]
if ($inQuote) {
[void]$buf.Append($ch)
if ($ch -eq $inQuote) { $inQuote = $null }
} elseif ($ch -eq "'" -or $ch -eq '"') {
$inQuote = $ch
[void]$buf.Append($ch)
} elseif ($ch -eq ',') {
$items += $buf.ToString()
[void]$buf.Clear()
} else {
[void]$buf.Append($ch)
}
}
if ($buf.Length -gt 0) { $items += $buf.ToString() }
# For each item: split into value[:presentation], strip quotes
$stripQuotes = {
param($t)
$t = $t.Trim()
if ($t.Length -ge 2 -and (($t[0] -eq "'" -and $t[-1] -eq "'") -or ($t[0] -eq '"' -and $t[-1] -eq '"'))) {
return $t.Substring(1, $t.Length - 2)
}
return $t
}
foreach ($raw in $items) {
$item = $raw.Trim()
if (-not $item) { continue }
# Find first ':' outside quotes
$colonIdx = -1
$q = $null
for ($j = 0; $j -lt $item.Length; $j++) {
$c = $item[$j]
if ($q) {
if ($c -eq $q) { $q = $null }
} elseif ($c -eq "'" -or $c -eq '"') {
$q = $c
} elseif ($c -eq ':') {
$colonIdx = $j; break
}
}
if ($colonIdx -ge 0) {
$valPart = $item.Substring(0, $colonIdx)
$presPart = $item.Substring($colonIdx + 1)
$result += @{ value = (& $stripQuotes $valPart); presentation = (& $stripQuotes $presPart) }
} else {
$result += @{ value = (& $stripQuotes $item); presentation = "" }
}
}
return ,$result
}
# --- 4. Build-* functions (XML fragment generators) ---
function Build-ValueTypeXml {
@@ -804,6 +902,68 @@ function Build-CalcFieldFragment {
return $lines -join "`r`n"
}
function Build-ParamValueXml {
# Returns array of XML lines for a <value xsi:type=...>...</value> element (or StandardPeriod block).
# Selects xsi:type by declared type, then falls back to value pattern.
param([string]$type, [string]$value, [string]$indent, [string]$tagName = "value", [string]$tagNs = "")
$i = $indent
$valStr = "$value"
$open = if ($tagNs) { "$tagNs`:$tagName" } else { $tagName }
$lines = @()
if ($type -eq "StandardPeriod") {
$lines += "$i<$open xsi:type=`"v8:StandardPeriod`">"
$lines += "$i`t<v8:variant xsi:type=`"v8:StandardPeriodVariant`">$(Esc-Xml $valStr)</v8:variant>"
$lines += "$i`t<v8:startDate>0001-01-01T00:00:00</v8:startDate>"
$lines += "$i`t<v8:endDate>0001-01-01T00:00:00</v8:endDate>"
$lines += "$i</$open>"
return $lines
}
$xsi = $null
if ($type -match '^date') { $xsi = "xs:dateTime" }
elseif ($type -eq "boolean") { $xsi = "xs:boolean" }
elseif ($type -match '^decimal') { $xsi = "xs:decimal" }
elseif ($type -match '^string') { $xsi = "xs:string" }
elseif ($type -match '^(CatalogRef|DocumentRef|EnumRef|ChartOfAccountsRef|ChartOfCharacteristicTypesRef|ChartOfCalculationTypesRef|BusinessProcessRef|TaskRef|ExchangePlanRef)\.') {
$xsi = "dcscor:DesignTimeValue"
}
else {
# Type unknown or empty — guess from value
if ($valStr -match '^\d{4}-\d{2}-\d{2}T') { $xsi = "xs:dateTime" }
elseif ($valStr -eq "true" -or $valStr -eq "false") { $xsi = "xs:boolean" }
elseif ($valStr -match '^(Перечисление|Справочник|ПланСчетов|Документ|ПланВидовХарактеристик|ПланВидовРасчета|БизнесПроцесс|Задача|РегистрСведений|ПланОбмена)\.' -or
$valStr -match '^(Catalog|Document|Enum|ChartOfAccounts|ChartOfCharacteristicTypes|ChartOfCalculationTypes|BusinessProcess|Task|InformationRegister|ExchangePlan)\.') {
$xsi = "dcscor:DesignTimeValue"
}
else { $xsi = "xs:string" }
}
$lines += "$i<$open xsi:type=`"$xsi`">$(Esc-Xml $valStr)</$open>"
return $lines
}
function Build-AvailableValueFragment {
# Returns XML lines (array) for a single <availableValue> block.
param($item, [string]$declaredType, [string]$indent)
$lines = @()
$lines += "$indent<availableValue>"
$valueLines = Build-ParamValueXml -type $declaredType -value $item.value -indent "$indent`t"
foreach ($vl in $valueLines) { $lines += $vl }
if ($item.presentation) {
$lines += "$indent`t<presentation xsi:type=`"v8:LocalStringType`">"
$lines += "$indent`t`t<v8:item>"
$lines += "$indent`t`t`t<v8:lang>ru</v8:lang>"
$lines += "$indent`t`t`t<v8:content>$(Esc-Xml $item.presentation)</v8:content>"
$lines += "$indent`t`t</v8:item>"
$lines += "$indent`t</presentation>"
}
$lines += "$indent</availableValue>"
return $lines
}
function Build-ParamFragment {
param($parsed, [string]$indent)
@@ -825,24 +985,26 @@ function Build-ParamFragment {
}
if ($null -ne $parsed.value) {
$valStr = "$($parsed.value)"
if ($parsed.type -eq "StandardPeriod") {
$lines += "$i`t<value xsi:type=`"v8:StandardPeriod`">"
$lines += "$i`t`t<v8:variant xsi:type=`"v8:StandardPeriodVariant`">$(Esc-Xml $valStr)</v8:variant>"
$lines += "$i`t`t<v8:startDate>0001-01-01T00:00:00</v8:startDate>"
$lines += "$i`t`t<v8:endDate>0001-01-01T00:00:00</v8:endDate>"
$lines += "$i`t</value>"
} elseif ($parsed.type -match '^date') {
$lines += "$i`t<value xsi:type=`"xs:dateTime`">$(Esc-Xml $valStr)</value>"
} elseif ($parsed.type -eq "boolean") {
$lines += "$i`t<value xsi:type=`"xs:boolean`">$(Esc-Xml $valStr)</value>"
} elseif ($parsed.type -match '^decimal') {
$lines += "$i`t<value xsi:type=`"xs:decimal`">$(Esc-Xml $valStr)</value>"
} else {
$lines += "$i`t<value xsi:type=`"xs:string`">$(Esc-Xml $valStr)</value>"
$valueLines = Build-ParamValueXml -type $parsed.type -value $parsed.value -indent "$i`t"
foreach ($vl in $valueLines) { $lines += $vl }
}
if ($parsed.hidden) {
$lines += "$i`t<useRestriction>true</useRestriction>"
$lines += "$i`t<availableAsField>false</availableAsField>"
}
if ($parsed.availableValues -and $parsed.availableValues.Count -gt 0) {
foreach ($av in $parsed.availableValues) {
$avLines = Build-AvailableValueFragment -item $av -declaredType $parsed.type -indent "$i`t"
foreach ($l in $avLines) { $lines += $l }
}
}
if ($parsed.always) {
$lines += "$i`t<use>Always</use>"
}
$lines += "$i</parameter>"
$fragments += ($lines -join "`r`n")
@@ -1565,10 +1727,10 @@ $corNs = "http://v8.1c.ru/8.1/data-composition-system/core"
# --- 7. Batch value splitting ---
if ($Operation -eq "set-query" -or $Operation -eq "set-structure" -or $Operation -eq "add-dataSet") {
if ($Operation -eq "set-query" -or $Operation -eq "set-structure" -or $Operation -eq "modify-structure" -or $Operation -eq "add-dataSet") {
$values = @($Value)
} elseif ($Operation -eq "patch-query") {
$values = @($Value -split ';;' | Where-Object { $_.Trim() })
$values = @($Value -split ';;' | ForEach-Object { $_.Trim() } | Where-Object { $_ })
} elseif ($Operation -eq "add-drilldown") {
if ($Value.Contains(';;')) {
$values = @($Value -split ';;' | ForEach-Object { $_.Trim() } | Where-Object { $_ })
@@ -1766,6 +1928,12 @@ switch ($Operation) {
$paramName = $parts[0].Trim()
$rest = if ($parts.Count -gt 1) { $parts[1].Trim() } else { "" }
# Extract @hidden / @always flags
$flagHidden = $false
$flagAlways = $false
if ($rest -match '@hidden\b') { $flagHidden = $true; $rest = ($rest -replace '\s*@hidden\b', '').Trim() }
if ($rest -match '@always\b') { $flagAlways = $true; $rest = ($rest -replace '\s*@always\b', '').Trim() }
# Find parameter element
$paramEl = Find-ElementByChildValue $xmlDoc.DocumentElement "parameter" "name" $paramName $schNs
if (-not $paramEl) {
@@ -1810,15 +1978,63 @@ switch ($Operation) {
$avPart = $rest.Substring($avIdx)
}
# Process simple key=value pairs (use, denyIncompleteValues, etc.)
# Process simple key=value pairs (use, denyIncompleteValues, value, etc.)
if ($simpleRest) {
$kvPairs = [regex]::Matches($simpleRest, '(\w+)=(\S+)')
foreach ($kv in $kvPairs) {
$key = $kv.Groups[1].Value
$value = $kv.Groups[2].Value
$existing = $paramEl.SelectSingleNode($key)
if ($existing) {
# Namespace-aware lookup (children live in $schNs)
$existing = $null
foreach ($ch in $paramEl.ChildNodes) {
if ($ch.NodeType -eq 'Element' -and $ch.LocalName -eq $key -and $ch.NamespaceURI -eq $schNs) {
$existing = $ch; break
}
}
if ($key -eq "value") {
# Special-case: rebuild <value> with correct xsi:type from <valueType>
$declaredType = ""
$vtEl = $null
foreach ($ch in $paramEl.ChildNodes) {
if ($ch.NodeType -eq 'Element' -and $ch.LocalName -eq 'valueType' -and $ch.NamespaceURI -eq $schNs) { $vtEl = $ch; break }
}
if ($vtEl) {
foreach ($tnode in $vtEl.ChildNodes) {
if ($tnode.NodeType -eq 'Element' -and $tnode.LocalName -eq 'Type') {
$declaredType = $tnode.InnerText.Trim() -replace '^d\d+p\d+:', ''
break
}
}
}
$valueLines = Build-ParamValueXml -type $declaredType -value $value -indent $childIndent
$fragXml = $valueLines -join "`r`n"
$wasExisting = ($null -ne $existing)
if ($existing) {
# Capture position by next-element sibling, then remove existing
$refNode = $existing.NextSibling
while ($refNode -and ($refNode.NodeType -eq 'Whitespace' -or $refNode.NodeType -eq 'SignificantWhitespace')) {
$refNode = $refNode.NextSibling
}
Remove-NodeWithWhitespace $existing
} else {
# Insert before useRestriction/availableValue/denyIncompleteValues/use
$refNode = $null
foreach ($child in $paramEl.ChildNodes) {
if ($child.NodeType -eq 'Element' -and $child.LocalName -in @('useRestriction','availableValue','denyIncompleteValues','use')) {
$refNode = $child; break
}
}
}
$nodes = Import-Fragment $xmlDoc $fragXml
foreach ($node in $nodes) {
Insert-BeforeElement $paramEl $node $refNode $childIndent
}
$verb = if ($wasExisting) { "updated" } else { "added" }
Write-Host "[OK] Parameter `"$paramName`": value $verb to $value"
} elseif ($existing) {
$existing.InnerText = $value
Write-Host "[OK] Parameter `"$paramName`": $key updated to $value"
} else {
@@ -1841,46 +2057,102 @@ switch ($Operation) {
}
}
# Process availableValue
# Process availableValue — replace whole list with new items
if ($avPart) {
$avRest = $avPart -replace '^availableValue=', ''
# Parse: "Перечисление...X presentation=текст с пробелами"
$avParts = $avRest -split '\s+presentation=', 2
$avValue = $avParts[0].Trim()
$avPresentation = if ($avParts.Count -gt 1) { $avParts[1].Trim() } else { "" }
$avRest = ($avPart -replace '^availableValue=', '').Trim()
$avItems = Parse-AvailableValueList $avRest
# Detect value type
$avType = "xs:string"
if ($avValue -match '^(Перечисление|Справочник|ПланСчетов|Документ|ПланВидовХарактеристик|ПланВидовРасчета)\.') {
$avType = "dcscor:DesignTimeValue"
# Detect value type: prefer declared <valueType> of the parameter, else guess from value
$declaredType = ""
$vtEl = $null
foreach ($ch in $paramEl.ChildNodes) {
if ($ch.NodeType -eq 'Element' -and $ch.LocalName -eq 'valueType' -and $ch.NamespaceURI -eq $schNs) { $vtEl = $ch; break }
}
if ($vtEl) {
foreach ($tnode in $vtEl.ChildNodes) {
if ($tnode.NodeType -eq 'Element' -and $tnode.LocalName -eq 'Type') {
$declaredType = $tnode.InnerText.Trim() -replace '^d\d+p\d+:', ''
break
}
}
}
$avLines = @()
$avLines += "$childIndent<availableValue>"
$avLines += "$childIndent`t<value xsi:type=`"$avType`">$(Esc-Xml $avValue)</value>"
if ($avPresentation) {
$avLines += "$childIndent`t<presentation xsi:type=`"v8:LocalStringType`">"
$avLines += "$childIndent`t`t<v8:item>"
$avLines += "$childIndent`t`t`t<v8:lang>ru</v8:lang>"
$avLines += "$childIndent`t`t`t<v8:content>$(Esc-Xml $avPresentation)</v8:content>"
$avLines += "$childIndent`t`t</v8:item>"
$avLines += "$childIndent`t</presentation>"
# Remove all existing <availableValue> elements
$toRemove = @()
foreach ($ch in $paramEl.ChildNodes) {
if ($ch.NodeType -eq 'Element' -and $ch.LocalName -eq 'availableValue' -and $ch.NamespaceURI -eq $schNs) {
$toRemove += $ch
}
}
$avLines += "$childIndent</availableValue>"
$fragXml = $avLines -join "`r`n"
foreach ($el in $toRemove) { Remove-NodeWithWhitespace $el }
# Insert before first of (denyIncompleteValues, use) in document order
# Insert each new <availableValue> before (denyIncompleteValues, use)
$refNode = $null
foreach ($child in $paramEl.ChildNodes) {
if ($child.NodeType -eq 'Element' -and ($child.LocalName -eq 'denyIncompleteValues' -or $child.LocalName -eq 'use')) {
$refNode = $child; break
}
}
$nodes = Import-Fragment $xmlDoc $fragXml
foreach ($node in $nodes) {
Insert-BeforeElement $paramEl $node $refNode $childIndent
foreach ($av in $avItems) {
$avLines = Build-AvailableValueFragment -item $av -declaredType $declaredType -indent $childIndent
$fragXml = $avLines -join "`r`n"
$nodes = Import-Fragment $xmlDoc $fragXml
foreach ($node in $nodes) {
Insert-BeforeElement $paramEl $node $refNode $childIndent
}
}
Write-Host "[OK] Parameter `"$paramName`": availableValue added"
Write-Host "[OK] Parameter `"$paramName`": availableValue set to $($avItems.Count) item(s)"
}
# Process @hidden / @always flags (idempotent)
if ($flagHidden) {
# useRestriction → true (insert after <value>, before <expression>/<availableAsField>/...)
$urEl = $null
foreach ($ch in $paramEl.ChildNodes) {
if ($ch.NodeType -eq 'Element' -and $ch.LocalName -eq 'useRestriction' -and $ch.NamespaceURI -eq $schNs) { $urEl = $ch; break }
}
if ($urEl) {
if ($urEl.InnerText.Trim() -ne 'true') { $urEl.InnerText = 'true' }
} else {
$refNode = $null
foreach ($child in $paramEl.ChildNodes) {
if ($child.NodeType -eq 'Element' -and $child.LocalName -in @('expression','availableAsField','availableValue','denyIncompleteValues','use')) { $refNode = $child; break }
}
$nodes = Import-Fragment $xmlDoc "$childIndent<useRestriction>true</useRestriction>"
foreach ($node in $nodes) { Insert-BeforeElement $paramEl $node $refNode $childIndent }
}
# availableAsField → false (insert after <expression>, before <availableValue>/<denyIncompleteValues>/<use>)
$afEl = $null
foreach ($ch in $paramEl.ChildNodes) {
if ($ch.NodeType -eq 'Element' -and $ch.LocalName -eq 'availableAsField' -and $ch.NamespaceURI -eq $schNs) { $afEl = $ch; break }
}
if ($afEl) {
if ($afEl.InnerText.Trim() -ne 'false') { $afEl.InnerText = 'false' }
} else {
$refNode = $null
foreach ($child in $paramEl.ChildNodes) {
if ($child.NodeType -eq 'Element' -and $child.LocalName -in @('availableValue','denyIncompleteValues','use')) { $refNode = $child; break }
}
$nodes = Import-Fragment $xmlDoc "$childIndent<availableAsField>false</availableAsField>"
foreach ($node in $nodes) { Insert-BeforeElement $paramEl $node $refNode $childIndent }
}
Write-Host "[OK] Parameter `"$paramName`": @hidden applied"
}
if ($flagAlways) {
$useEl = $null
foreach ($ch in $paramEl.ChildNodes) {
if ($ch.NodeType -eq 'Element' -and $ch.LocalName -eq 'use' -and $ch.NamespaceURI -eq $schNs) { $useEl = $ch; break }
}
if ($useEl) {
if ($useEl.InnerText.Trim() -ne 'Always') { $useEl.InnerText = 'Always' }
} else {
$nodes = Import-Fragment $xmlDoc "$childIndent<use>Always</use>"
foreach ($node in $nodes) { Insert-BeforeElement $paramEl $node $null $childIndent }
}
Write-Host "[OK] Parameter `"$paramName`": @always applied"
}
}
}
@@ -2211,6 +2483,12 @@ switch ($Operation) {
}
foreach ($val in $values) {
$once = $false
if ($val -match '@once\b') {
$once = $true
$val = ($val -replace '\s*@once\b', '').Trim()
}
$sepIdx = $val.IndexOf(" => ")
if ($sepIdx -lt 0) {
Write-Error "patch-query value must contain ' => ' separator: old => new"
@@ -2219,12 +2497,20 @@ switch ($Operation) {
$oldStr = $val.Substring(0, $sepIdx)
$newStr = $val.Substring($sepIdx + 4)
$queryText = $queryEl.InnerText
if (-not $queryText.Contains($oldStr)) {
$count = ([regex]::Matches($queryText, [regex]::Escape($oldStr))).Count
if ($count -eq 0) {
Write-Error "Substring not found in query of dataset '$dsName': $oldStr"
exit 1
}
if ($once -and $count -ne 1) {
Write-Error "@once: expected 1 occurrence of '$oldStr' in dataset '$dsName', found $count"
exit 1
}
$queryEl.InnerText = $queryText.Replace($oldStr, $newStr)
Write-Host "[OK] Query patched in dataset `"$dsName`": replaced '$oldStr'"
$suffix = if ($once) { " (1 occurrence)" } else { " ($count occurrence(s))" }
Write-Host "[OK] Query patched in dataset `"$dsName`": replaced '$oldStr'$suffix"
}
}
@@ -2289,6 +2575,102 @@ switch ($Operation) {
Write-Host "[OK] Structure set in variant `"$varName`": $Value"
}
"modify-structure" {
$settings = Resolve-VariantSettings
$varName = Get-VariantName
$structItems = Parse-StructureShorthand $Value
# Flatten parsed tree into (name, groupBy) targets
$targets = @()
$stack = New-Object System.Collections.Stack
foreach ($it in $structItems) { $stack.Push($it) }
while ($stack.Count -gt 0) {
$it = $stack.Pop()
if ($it["name"]) {
$targets += @{ name = $it["name"]; groupBy = $it["groupBy"] }
}
if ($it["children"]) {
foreach ($ch in $it["children"]) { $stack.Push($ch) }
}
}
if ($targets.Count -eq 0) {
Write-Error "modify-structure requires @name= for at least one group: $Value"
exit 1
}
$nsMgr = New-Object System.Xml.XmlNamespaceManager($xmlDoc.NameTable)
$nsMgr.AddNamespace("dcsset", $setNs)
$nsMgr.AddNamespace("xsi", "http://www.w3.org/2001/XMLSchema-instance")
foreach ($t in $targets) {
$groupEl = $settings.SelectSingleNode(".//dcsset:item[@xsi:type='dcsset:StructureItemGroup'][dcsset:name='$($t.name)']", $nsMgr)
if (-not $groupEl) {
Write-Host "[WARN] Group with @name=`"$($t.name)`" not found — skipped"
continue
}
$giEl = $null
foreach ($ch in $groupEl.ChildNodes) {
if ($ch.NodeType -eq 'Element' -and $ch.LocalName -eq 'groupItems' -and $ch.NamespaceURI -eq $setNs) {
$giEl = $ch; break
}
}
$groupIndent = Get-ChildIndent $groupEl
if (-not $giEl) {
# Create <groupItems> after <name>, before <order>/<selection>/...
$nameEl = $null
$refAfterName = $null
foreach ($ch in $groupEl.ChildNodes) {
if ($ch.NodeType -eq 'Element' -and $ch.LocalName -eq 'name' -and $ch.NamespaceURI -eq $setNs) {
$nameEl = $ch
} elseif ($ch.NodeType -eq 'Element' -and $nameEl -and -not $refAfterName) {
$refAfterName = $ch; break
}
}
$giFrag = "$groupIndent<dcsset:groupItems></dcsset:groupItems>"
$nodes = Import-Fragment $xmlDoc $giFrag
foreach ($node in $nodes) {
Insert-BeforeElement $groupEl $node $refAfterName $groupIndent
}
# Re-find
foreach ($ch in $groupEl.ChildNodes) {
if ($ch.NodeType -eq 'Element' -and $ch.LocalName -eq 'groupItems' -and $ch.NamespaceURI -eq $setNs) {
$giEl = $ch; break
}
}
}
$toRemove = @()
foreach ($ch in $giEl.ChildNodes) {
if ($ch.NodeType -eq 'Element') { $toRemove += $ch }
}
foreach ($el in $toRemove) { Remove-NodeWithWhitespace $el }
$itemIndent = "$groupIndent`t"
foreach ($field in $t.groupBy) {
$lines = @()
$lines += "$itemIndent<dcsset:item xsi:type=`"dcsset:GroupItemField`">"
$lines += "$itemIndent`t<dcsset:field>$(Esc-Xml $field)</dcsset:field>"
$lines += "$itemIndent`t<dcsset:groupType>Items</dcsset:groupType>"
$lines += "$itemIndent`t<dcsset:periodAdditionType>None</dcsset:periodAdditionType>"
$lines += "$itemIndent`t<dcsset:periodAdditionBegin xsi:type=`"xs:dateTime`">0001-01-01T00:00:00</dcsset:periodAdditionBegin>"
$lines += "$itemIndent`t<dcsset:periodAdditionEnd xsi:type=`"xs:dateTime`">0001-01-01T00:00:00</dcsset:periodAdditionEnd>"
$lines += "$itemIndent</dcsset:item>"
$fragXml = $lines -join "`r`n"
$nodes = Import-Fragment $xmlDoc $fragXml
foreach ($node in $nodes) {
Insert-BeforeElement $giEl $node $null $itemIndent
}
}
$desc = if ($t.groupBy.Count -eq 0) { "details" } else { $t.groupBy -join ', ' }
Write-Host "[OK] Group `"$($t.name)`" groupItems updated: $desc"
}
}
"add-dataSetLink" {
foreach ($val in $values) {
$parsed = Parse-DataSetLinkShorthand $val
@@ -2476,6 +2858,18 @@ switch ($Operation) {
}
}
"clear-conditionalAppearance" {
$settings = Resolve-VariantSettings
$varName = Get-VariantName
$caEl = Find-FirstElement $settings @("conditionalAppearance") $setNs
if ($caEl) {
Clear-ContainerChildren $caEl
Write-Host "[OK] ConditionalAppearance cleared in variant `"$varName`""
} else {
Write-Host "[INFO] No conditionalAppearance section in variant `"$varName`""
}
}
"modify-filter" {
$settings = Resolve-VariantSettings
$varName = Get-VariantName
@@ -2675,6 +3069,87 @@ switch ($Operation) {
}
}
"set-field-role" {
$dsNode = Resolve-DataSet
$dsName = Get-DataSetName $dsNode
foreach ($val in $values) {
# Parse shorthand: "dataPath [@flag ...] [kv=value ...]"
$s = $val.Trim()
# Extract @flags
$flags = @()
$flagMatches = [regex]::Matches($s, '@(\w+)')
foreach ($m in $flagMatches) { $flags += $m.Groups[1].Value }
$s = [regex]::Replace($s, '\s*@\w+', '').Trim()
# Extract kv=value (value is non-whitespace)
$kv = [ordered]@{}
$kvMatches = [regex]::Matches($s, '(\w+)=(\S+)')
foreach ($m in $kvMatches) { $kv[$m.Groups[1].Value] = $m.Groups[2].Value }
$s = [regex]::Replace($s, '\s*\w+=\S+', '').Trim()
$dataPath = $s
if (-not $dataPath) {
Write-Host "[WARN] set-field-role: empty dataPath in `"$val`""
continue
}
$fieldEl = Find-ElementByChildValue $dsNode "field" "dataPath" $dataPath $schNs
if (-not $fieldEl) {
Write-Host "[WARN] Field `"$dataPath`" not found in dataset `"$dsName`""
continue
}
$fieldIndent = Get-ChildIndent $fieldEl
# Remove existing <role>
$oldRole = $null
foreach ($ch in $fieldEl.ChildNodes) {
if ($ch.NodeType -eq 'Element' -and $ch.LocalName -eq 'role' -and $ch.NamespaceURI -eq $schNs) { $oldRole = $ch; break }
}
if ($oldRole) { Remove-NodeWithWhitespace $oldRole }
# Empty spec — remove only
if ($flags.Count -eq 0 -and $kv.Count -eq 0) {
Write-Host "[OK] Field `"$dataPath`" role cleared"
continue
}
# Build new <role>
$lines = @()
$lines += "$fieldIndent<role>"
foreach ($flag in $flags) {
if ($flag -eq 'period') {
$lines += "$fieldIndent`t<dcscom:periodNumber>1</dcscom:periodNumber>"
$lines += "$fieldIndent`t<dcscom:periodType>Main</dcscom:periodType>"
} else {
$lines += "$fieldIndent`t<dcscom:$flag>true</dcscom:$flag>"
}
}
foreach ($k in $kv.Keys) {
$lines += "$fieldIndent`t<dcscom:$k>$(Esc-Xml $kv[$k])</dcscom:$k>"
}
$lines += "$fieldIndent</role>"
$fragXml = $lines -join "`r`n"
# Insert before <valueType>, else before <inputParameters>, else at end
$refNode = $null
foreach ($ch in $fieldEl.ChildNodes) {
if ($ch.NodeType -eq 'Element' -and $ch.LocalName -in @('valueType','inputParameters') -and $ch.NamespaceURI -eq $schNs) { $refNode = $ch; break }
}
$nodes = Import-Fragment $xmlDoc $fragXml
foreach ($node in $nodes) {
Insert-BeforeElement $fieldEl $node $refNode $fieldIndent
}
$desc = @()
if ($flags.Count -gt 0) { $desc += ($flags | ForEach-Object { "@$_" }) -join ' ' }
if ($kv.Count -gt 0) { $desc += ($kv.Keys | ForEach-Object { "$_=$($kv[$_])" }) -join ' ' }
Write-Host "[OK] Field `"$dataPath`" role set: $($desc -join ' ')"
}
}
"remove-field" {
$dsNode = Resolve-DataSet
$dsName = Get-DataSetName $dsNode
+422 -54
View File
@@ -1,4 +1,4 @@
# skd-edit v1.11 — Atomic 1C DCS editor (Python port)
# skd-edit v1.18 — Atomic 1C DCS editor (Python port)
# Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
import argparse
import os
@@ -18,9 +18,9 @@ VALID_OPS = [
"add-dataParameter", "add-order", "add-selection", "add-dataSetLink",
"add-dataSet", "add-variant", "add-conditionalAppearance", "add-drilldown",
"set-query", "patch-query", "set-outputParameter", "set-structure",
"modify-field", "modify-filter", "modify-dataParameter", "modify-parameter",
"modify-field", "modify-filter", "modify-dataParameter", "modify-parameter", "modify-structure", "set-field-role",
"rename-parameter", "reorder-parameters",
"clear-selection", "clear-order", "clear-filter",
"clear-selection", "clear-order", "clear-filter", "clear-conditionalAppearance",
"remove-field", "remove-total", "remove-calculated-field", "remove-parameter", "remove-filter",
]
@@ -250,13 +250,21 @@ def read_field_properties(field_el):
def parse_total_shorthand(s):
# "DataPath: Func" or "DataPath: Func(expr)" or "DataPath: ИмяРесурса" (identity)
parts = s.split(":", 1)
data_path = parts[0].strip()
func_part = parts[1].strip()
agg_funcs = {'Сумма', 'Количество', 'Минимум', 'Максимум', 'Среднее',
'Sum', 'Count', 'Min', 'Max', 'Avg',
'Minimum', 'Maximum', 'Average'}
if re.match(r'^\w+\(', func_part):
return {"dataPath": data_path, "expression": func_part}
else:
elif func_part in agg_funcs:
return {"dataPath": data_path, "expression": f"{func_part}({data_path})"}
else:
return {"dataPath": data_path, "expression": func_part}
def parse_calc_shorthand(s):
@@ -299,12 +307,26 @@ def parse_calc_shorthand(s):
def parse_param_shorthand(s):
result = {"name": "", "type": "", "value": None, "autoDates": False, "title": None}
result = {"name": "", "type": "", "value": None, "autoDates": False, "title": None, "hidden": False, "always": False, "availableValues": []}
# Extract availableValue=... (must be before main parse — captures to end of string)
m_av = re.search(r'\s*availableValue=(.+)$', s)
if m_av:
result["availableValues"] = parse_available_value_list(m_av.group(1).strip())
s = re.sub(r'\s*availableValue=.+$', '', s).strip()
if re.search(r'@autoDates', s):
result["autoDates"] = True
s = re.sub(r'\s*@autoDates', '', s)
if re.search(r'@hidden\b', s):
result["hidden"] = True
s = re.sub(r'\s*@hidden\b', '', s)
if re.search(r'@always\b', s):
result["always"] = True
s = re.sub(r'\s*@always\b', '', s)
# Extract optional [Title] (mirrors parse_field_shorthand)
m = re.search(r'\[([^\]]*)\]', s)
if m:
@@ -545,15 +567,17 @@ def parse_structure_shorthand(s):
seg = segments[i].strip()
group = {"type": "group"}
name_m = re.search(r'\s*@name=(.+)', seg)
name_m = re.search(r'@name=(?:"([^"]+)"|\'([^\']+)\'|(\S+))', seg)
if name_m:
group["name"] = name_m.group(1).strip()
seg = re.sub(r'\s*@name=.+', '', seg).strip()
raw_name = name_m.group(1) or name_m.group(2) or name_m.group(3)
group["name"] = raw_name.strip()
seg = re.sub(r'\s*@name=(?:"[^"]+"|\'[^\']+\'|\S+)', '', seg).strip()
if re.match(r'^(details|\u0434\u0435\u0442\u0430\u043b\u0438)$', seg, re.IGNORECASE):
group["groupBy"] = []
else:
group["groupBy"] = [seg]
fields = [f.strip() for f in re.split(r'\s*,\s*', seg) if f.strip()]
group["groupBy"] = fields
if innermost is not None:
group["children"] = [innermost]
@@ -571,6 +595,80 @@ def parse_output_param_shorthand(s):
return {"key": s.strip(), "value": ""}
def parse_available_value_list(s):
"""Returns list of {value, presentation} from comma-separated list.
Items can use single/double quotes (stripped). Quoted spans preserve commas/colons."""
if not s:
return []
# Tokenize by ',' respecting quoted spans
items = []
buf = []
in_quote = None
for ch in s:
if in_quote:
buf.append(ch)
if ch == in_quote:
in_quote = None
elif ch in ("'", '"'):
in_quote = ch
buf.append(ch)
elif ch == ',':
items.append("".join(buf))
buf = []
else:
buf.append(ch)
if buf:
items.append("".join(buf))
def strip_quotes(t):
t = t.strip()
if len(t) >= 2 and ((t[0] == "'" and t[-1] == "'") or (t[0] == '"' and t[-1] == '"')):
return t[1:-1]
return t
result = []
for raw in items:
item = raw.strip()
if not item:
continue
# Find first ':' outside quotes
colon_idx = -1
q = None
for j, c in enumerate(item):
if q:
if c == q:
q = None
elif c in ("'", '"'):
q = c
elif c == ':':
colon_idx = j
break
if colon_idx >= 0:
val_part = item[:colon_idx]
pres_part = item[colon_idx + 1:]
result.append({"value": strip_quotes(val_part), "presentation": strip_quotes(pres_part)})
else:
result.append({"value": strip_quotes(item), "presentation": ""})
return result
def build_available_value_fragment(item, declared_type, indent):
"""Return XML lines for a single <availableValue> block."""
lines = [f"{indent}<availableValue>"]
for vl in build_param_value_xml(declared_type, item["value"], f"{indent}\t"):
lines.append(vl)
if item.get("presentation"):
lines.append(f'{indent}\t<presentation xsi:type="v8:LocalStringType">')
lines.append(f"{indent}\t\t<v8:item>")
lines.append(f"{indent}\t\t\t<v8:lang>ru</v8:lang>")
lines.append(f"{indent}\t\t\t<v8:content>{esc_xml(item['presentation'])}</v8:content>")
lines.append(f"{indent}\t\t</v8:item>")
lines.append(f"{indent}\t</presentation>")
lines.append(f"{indent}</availableValue>")
return lines
# ── 4. Build-* functions (XML fragment generators) ──────────
def build_value_type_xml(type_str, indent):
@@ -724,6 +822,47 @@ def build_calc_field_fragment(parsed, indent):
return "\r\n".join(lines)
def build_param_value_xml(type_str, value, indent, tag_name="value", tag_ns=""):
"""Return list of XML lines for <value xsi:type=...>...</value>."""
val_str = "" if value is None else str(value)
open_tag = f"{tag_ns}:{tag_name}" if tag_ns else tag_name
lines = []
if type_str == "StandardPeriod":
lines.append(f'{indent}<{open_tag} xsi:type="v8:StandardPeriod">')
lines.append(f'{indent}\t<v8:variant xsi:type="v8:StandardPeriodVariant">{esc_xml(val_str)}</v8:variant>')
lines.append(f"{indent}\t<v8:startDate>0001-01-01T00:00:00</v8:startDate>")
lines.append(f"{indent}\t<v8:endDate>0001-01-01T00:00:00</v8:endDate>")
lines.append(f"{indent}</{open_tag}>")
return lines
t = type_str or ""
xsi = None
if t.startswith("date"):
xsi = "xs:dateTime"
elif t == "boolean":
xsi = "xs:boolean"
elif t.startswith("decimal"):
xsi = "xs:decimal"
elif t.startswith("string"):
xsi = "xs:string"
elif re.match(r'^(CatalogRef|DocumentRef|EnumRef|ChartOfAccountsRef|ChartOfCharacteristicTypesRef|ChartOfCalculationTypesRef|BusinessProcessRef|TaskRef|ExchangePlanRef)\.', t):
xsi = "dcscor:DesignTimeValue"
else:
if re.match(r'^\d{4}-\d{2}-\d{2}T', val_str):
xsi = "xs:dateTime"
elif val_str in ("true", "false"):
xsi = "xs:boolean"
elif re.match(r'^(Перечисление|Справочник|ПланСчетов|Документ|ПланВидовХарактеристик|ПланВидовРасчета|БизнесПроцесс|Задача|РегистрСведений|ПланОбмена)\.', val_str) or \
re.match(r'^(Catalog|Document|Enum|ChartOfAccounts|ChartOfCharacteristicTypes|ChartOfCalculationTypes|BusinessProcess|Task|InformationRegister|ExchangePlan)\.', val_str):
xsi = "dcscor:DesignTimeValue"
else:
xsi = "xs:string"
lines.append(f'{indent}<{open_tag} xsi:type="{xsi}">{esc_xml(val_str)}</{open_tag}>')
return lines
def build_param_fragment(parsed, indent):
i = indent
fragments = []
@@ -739,21 +878,19 @@ def build_param_fragment(parsed, indent):
lines.append(f"{i}\t</valueType>")
if parsed["value"] is not None:
val_str = str(parsed["value"])
if parsed.get("type") == "StandardPeriod":
lines.append(f'{i}\t<value xsi:type="v8:StandardPeriod">')
lines.append(f'{i}\t\t<v8:variant xsi:type="v8:StandardPeriodVariant">{esc_xml(val_str)}</v8:variant>')
lines.append(f"{i}\t\t<v8:startDate>0001-01-01T00:00:00</v8:startDate>")
lines.append(f"{i}\t\t<v8:endDate>0001-01-01T00:00:00</v8:endDate>")
lines.append(f"{i}\t</value>")
elif parsed.get("type", "").startswith("date"):
lines.append(f'{i}\t<value xsi:type="xs:dateTime">{esc_xml(val_str)}</value>')
elif parsed.get("type") == "boolean":
lines.append(f'{i}\t<value xsi:type="xs:boolean">{esc_xml(val_str)}</value>')
elif parsed.get("type", "").startswith("decimal"):
lines.append(f'{i}\t<value xsi:type="xs:decimal">{esc_xml(val_str)}</value>')
else:
lines.append(f'{i}\t<value xsi:type="xs:string">{esc_xml(val_str)}</value>')
for vl in build_param_value_xml(parsed.get("type", ""), parsed["value"], f"{i}\t"):
lines.append(vl)
if parsed.get("hidden"):
lines.append(f"{i}\t<useRestriction>true</useRestriction>")
lines.append(f"{i}\t<availableAsField>false</availableAsField>")
for av in parsed.get("availableValues", []) or []:
for l in build_available_value_fragment(av, parsed.get("type", ""), f"{i}\t"):
lines.append(l)
if parsed.get("always"):
lines.append(f"{i}\t<use>Always</use>")
lines.append(f"{i}</parameter>")
fragments.append("\r\n".join(lines))
@@ -1361,10 +1498,10 @@ xml_doc = tree.getroot()
# ── 7. Batch value splitting ────────────────────────────────
if operation in ("set-query", "set-structure", "add-dataSet"):
if operation in ("set-query", "set-structure", "modify-structure", "add-dataSet"):
values = [value_arg]
elif operation == "patch-query":
values = [v for v in value_arg.split(";;") if v.strip()]
values = [v.strip() for v in value_arg.split(";;") if v.strip()]
elif operation == "add-drilldown":
if ";;" in value_arg:
values = [v.strip() for v in value_arg.split(";;") if v.strip()]
@@ -1537,6 +1674,15 @@ elif operation == "modify-parameter":
param_name = parts[0].strip()
rest = parts[1].strip() if len(parts) > 1 else ""
flag_hidden = False
flag_always = False
if re.search(r'@hidden\b', rest):
flag_hidden = True
rest = re.sub(r'\s*@hidden\b', '', rest).strip()
if re.search(r'@always\b', rest):
flag_always = True
rest = re.sub(r'\s*@always\b', '', rest).strip()
param_el = find_element_by_child_value(xml_doc, "parameter", "name", param_name, SCH_NS)
if param_el is None:
print(f'[WARN] Parameter "{param_name}" not found -- skipped')
@@ -1568,8 +1714,33 @@ elif operation == "modify-parameter":
if simple_rest:
for m in re.finditer(r'(\w+)=(\S+)', simple_rest):
key, value = m.group(1), m.group(2)
existing = next((ch for ch in param_el if isinstance(ch.tag, str) and local_name(ch) == key), None)
if existing is not None:
existing = next((ch for ch in param_el if isinstance(ch.tag, str) and local_name(ch) == key and etree.QName(ch.tag).namespace == SCH_NS), None)
if key == "value":
# Rebuild <value> with correct xsi:type from <valueType>
declared_type = ""
vt_el = next((ch for ch in param_el if isinstance(ch.tag, str) and local_name(ch) == "valueType" and etree.QName(ch.tag).namespace == SCH_NS), None)
if vt_el is not None:
for tnode in vt_el:
if isinstance(tnode.tag, str) and local_name(tnode) == "Type":
declared_type = re.sub(r'^d\d+p\d+:', '', (tnode.text or "").strip())
break
value_lines = build_param_value_xml(declared_type, value, child_indent)
frag_xml = "\r\n".join(value_lines)
was_existing = existing is not None
if existing is not None:
# Find next-element sibling as ref before removing
idx = list(param_el).index(existing)
ref_node = param_el[idx + 1] if idx + 1 < len(param_el) else None
remove_node_with_whitespace(existing)
else:
ref_node = next((ch for ch in param_el if isinstance(ch.tag, str) and local_name(ch) in ("useRestriction", "availableValue", "denyIncompleteValues", "use")), None)
nodes = import_fragment(xml_doc, frag_xml)
for node in nodes:
insert_before_element(param_el, node, ref_node, child_indent)
verb = "updated" if was_existing else "added"
print(f'[OK] Parameter "{param_name}": value {verb} to {value}')
elif existing is not None:
existing.text = value
print(f'[OK] Parameter "{param_name}": {key} updated to {value}')
else:
@@ -1585,38 +1756,71 @@ elif operation == "modify-parameter":
# Process availableValue
if av_part:
av_rest = av_part[len("availableValue="):]
# Parse: "Перечисление...X presentation=текст с пробелами"
av_parts = re.split(r'\s+presentation=', av_rest, 1)
av_value = av_parts[0].strip()
av_presentation = av_parts[1].strip() if len(av_parts) > 1 else ""
av_rest = av_part[len("availableValue="):].strip()
av_items = parse_available_value_list(av_rest)
av_type = "xs:string"
if re.match(r'^(Перечисление|Справочник|ПланСчетов|Документ|ПланВидовХарактеристик|ПланВидовРасчета)\.', av_value):
av_type = "dcscor:DesignTimeValue"
# Prefer declared <valueType> of the parameter; fall back to value pattern
declared_type = ""
vt_el = None
for ch in param_el:
if isinstance(ch.tag, str) and local_name(ch) == "valueType" and etree.QName(ch.tag).namespace == SCH_NS:
vt_el = ch
break
if vt_el is not None:
for tnode in vt_el:
if isinstance(tnode.tag, str) and local_name(tnode) == "Type":
declared_type = re.sub(r'^d\d+p\d+:', '', (tnode.text or "").strip())
break
av_lines = [f"{child_indent}<availableValue>"]
av_lines.append(f'{child_indent}\t<value xsi:type="{av_type}">{esc_xml(av_value)}</value>')
if av_presentation:
av_lines.append(f'{child_indent}\t<presentation xsi:type="v8:LocalStringType">')
av_lines.append(f"{child_indent}\t\t<v8:item>")
av_lines.append(f"{child_indent}\t\t\t<v8:lang>ru</v8:lang>")
av_lines.append(f"{child_indent}\t\t\t<v8:content>{esc_xml(av_presentation)}</v8:content>")
av_lines.append(f"{child_indent}\t\t</v8:item>")
av_lines.append(f"{child_indent}\t</presentation>")
av_lines.append(f"{child_indent}</availableValue>")
frag_xml = "\r\n".join(av_lines)
# Remove all existing <availableValue>
to_remove = [ch for ch in param_el if isinstance(ch.tag, str) and local_name(ch) == "availableValue" and etree.QName(ch.tag).namespace == SCH_NS]
for el in to_remove:
remove_node_with_whitespace(el)
# Insert before first of (denyIncompleteValues, use) in document order
# Insert each new <availableValue> before (denyIncompleteValues, use)
ref_node = None
for child in param_el:
if isinstance(child.tag, str) and local_name(child) in ("denyIncompleteValues", "use"):
ref_node = child
break
nodes = import_fragment(xml_doc, frag_xml)
for node in nodes:
insert_before_element(param_el, node, ref_node, child_indent)
print(f'[OK] Parameter "{param_name}": availableValue added')
for av in av_items:
av_lines = build_available_value_fragment(av, declared_type, child_indent)
frag_xml = "\r\n".join(av_lines)
nodes = import_fragment(xml_doc, frag_xml)
for node in nodes:
insert_before_element(param_el, node, ref_node, child_indent)
print(f'[OK] Parameter "{param_name}": availableValue set to {len(av_items)} item(s)')
if flag_hidden:
ur_el = next((ch for ch in param_el if isinstance(ch.tag, str) and local_name(ch) == "useRestriction" and etree.QName(ch.tag).namespace == SCH_NS), None)
if ur_el is not None:
if (ur_el.text or "").strip() != "true":
ur_el.text = "true"
else:
ref_node = next((ch for ch in param_el if isinstance(ch.tag, str) and local_name(ch) in ("expression", "availableAsField", "availableValue", "denyIncompleteValues", "use")), None)
for node in import_fragment(xml_doc, f"{child_indent}<useRestriction>true</useRestriction>"):
insert_before_element(param_el, node, ref_node, child_indent)
af_el = next((ch for ch in param_el if isinstance(ch.tag, str) and local_name(ch) == "availableAsField" and etree.QName(ch.tag).namespace == SCH_NS), None)
if af_el is not None:
if (af_el.text or "").strip() != "false":
af_el.text = "false"
else:
ref_node = next((ch for ch in param_el if isinstance(ch.tag, str) and local_name(ch) in ("availableValue", "denyIncompleteValues", "use")), None)
for node in import_fragment(xml_doc, f"{child_indent}<availableAsField>false</availableAsField>"):
insert_before_element(param_el, node, ref_node, child_indent)
print(f'[OK] Parameter "{param_name}": @hidden applied')
if flag_always:
use_el = next((ch for ch in param_el if isinstance(ch.tag, str) and local_name(ch) == "use" and etree.QName(ch.tag).namespace == SCH_NS), None)
if use_el is not None:
if (use_el.text or "").strip() != "Always":
use_el.text = "Always"
else:
for node in import_fragment(xml_doc, f"{child_indent}<use>Always</use>"):
insert_before_element(param_el, node, None, child_indent)
print(f'[OK] Parameter "{param_name}": @always applied')
elif operation == "rename-parameter":
root = xml_doc
@@ -1868,6 +2072,11 @@ elif operation == "patch-query":
print(f"No <query> element found in dataset '{ds_name}'", file=sys.stderr)
sys.exit(1)
for val in values:
once = False
if re.search(r'@once\b', val):
once = True
val = re.sub(r'\s*@once\b', '', val).strip()
sep_idx = val.find(" => ")
if sep_idx < 0:
print("patch-query value must contain ' => ' separator: old => new", file=sys.stderr)
@@ -1875,11 +2084,18 @@ elif operation == "patch-query":
old_str = val[:sep_idx]
new_str = val[sep_idx + 4:]
query_text = query_el.text or ""
if old_str not in query_text:
count = query_text.count(old_str)
if count == 0:
print(f"Substring not found in query of dataset '{ds_name}': {old_str}", file=sys.stderr)
sys.exit(1)
if once and count != 1:
print(f"@once: expected 1 occurrence of '{old_str}' in dataset '{ds_name}', found {count}", file=sys.stderr)
sys.exit(1)
query_el.text = query_text.replace(old_str, new_str)
print(f'[OK] Query patched in dataset "{ds_name}": replaced \'{old_str}\'')
suffix = " (1 occurrence)" if once else f" ({count} occurrence(s))"
print(f'[OK] Query patched in dataset "{ds_name}": replaced \'{old_str}\'{suffix}')
elif operation == "set-outputParameter":
settings = resolve_variant_settings()
@@ -1922,6 +2138,85 @@ elif operation == "set-structure":
print(f'[OK] Structure set in variant "{var_name}": {value_arg}')
elif operation == "modify-structure":
settings = resolve_variant_settings()
var_name = get_variant_name()
struct_items = parse_structure_shorthand(value_arg)
# Flatten parsed tree into (name, groupBy) targets
targets = []
stack = list(struct_items)
while stack:
it = stack.pop()
if it.get("name"):
targets.append({"name": it["name"], "groupBy": it.get("groupBy", [])})
for ch in it.get("children", []) or []:
stack.append(ch)
if not targets:
print(f"modify-structure requires @name= for at least one group: {value_arg}", file=sys.stderr)
sys.exit(1)
ns = {"dcsset": SET_NS, "xsi": XSI_NS}
for t in targets:
xpath = f".//dcsset:item[@xsi:type='dcsset:StructureItemGroup'][dcsset:name='{t['name']}']"
group_el = settings.find(xpath, ns)
if group_el is None:
print(f'[WARN] Group with @name="{t["name"]}" not found — skipped')
continue
gi_el = None
for ch in group_el:
if isinstance(ch.tag, str) and local_name(ch) == "groupItems" and etree.QName(ch.tag).namespace == SET_NS:
gi_el = ch
break
group_indent = get_child_indent(group_el)
if gi_el is None:
# Insert <groupItems> after <name>, before first non-name sibling
ref_after_name = None
saw_name = False
for ch in group_el:
if not isinstance(ch.tag, str):
continue
if local_name(ch) == "name" and etree.QName(ch.tag).namespace == SET_NS:
saw_name = True
elif saw_name:
ref_after_name = ch
break
gi_frag = f"{group_indent}<dcsset:groupItems></dcsset:groupItems>"
for node in import_fragment(xml_doc, gi_frag):
insert_before_element(group_el, node, ref_after_name, group_indent)
for ch in group_el:
if isinstance(ch.tag, str) and local_name(ch) == "groupItems" and etree.QName(ch.tag).namespace == SET_NS:
gi_el = ch
break
to_remove = [ch for ch in gi_el if isinstance(ch.tag, str)]
for el in to_remove:
remove_node_with_whitespace(el)
item_indent = group_indent + "\t"
for field in t["groupBy"]:
lines = [
f'{item_indent}<dcsset:item xsi:type="dcsset:GroupItemField">',
f'{item_indent}\t<dcsset:field>{esc_xml(field)}</dcsset:field>',
f'{item_indent}\t<dcsset:groupType>Items</dcsset:groupType>',
f'{item_indent}\t<dcsset:periodAdditionType>None</dcsset:periodAdditionType>',
f'{item_indent}\t<dcsset:periodAdditionBegin xsi:type="xs:dateTime">0001-01-01T00:00:00</dcsset:periodAdditionBegin>',
f'{item_indent}\t<dcsset:periodAdditionEnd xsi:type="xs:dateTime">0001-01-01T00:00:00</dcsset:periodAdditionEnd>',
f'{item_indent}</dcsset:item>',
]
frag_xml = "\r\n".join(lines)
for node in import_fragment(xml_doc, frag_xml):
insert_before_element(gi_el, node, None, item_indent)
desc = "details" if not t["groupBy"] else ", ".join(t["groupBy"])
print(f'[OK] Group "{t["name"]}" groupItems updated: {desc}')
elif operation == "add-dataSetLink":
for val in values:
parsed = parse_data_set_link_shorthand(val)
@@ -2085,6 +2380,16 @@ elif operation == "clear-filter":
else:
print(f'[INFO] No filter section in variant "{var_name}"')
elif operation == "clear-conditionalAppearance":
settings = resolve_variant_settings()
var_name = get_variant_name()
ca_el = find_first_element(settings, ["conditionalAppearance"], SET_NS)
if ca_el is not None:
clear_container_children(ca_el)
print(f'[OK] ConditionalAppearance cleared in variant "{var_name}"')
else:
print(f'[INFO] No conditionalAppearance section in variant "{var_name}"')
elif operation == "modify-filter":
settings = resolve_variant_settings()
var_name = get_variant_name()
@@ -2237,6 +2542,69 @@ elif operation == "modify-field":
print(f'[OK] Field "{field_name}" modified in dataset "{ds_name}"')
elif operation == "set-field-role":
ds_node = resolve_data_set()
ds_name = get_data_set_name(ds_node)
for val in values:
s = val.strip()
flags = []
for m in re.finditer(r'@(\w+)', s):
flags.append(m.group(1))
s = re.sub(r'\s*@\w+', '', s).strip()
kv = []
for m in re.finditer(r'(\w+)=(\S+)', s):
kv.append((m.group(1), m.group(2)))
s = re.sub(r'\s*\w+=\S+', '', s).strip()
data_path = s
if not data_path:
print(f'[WARN] set-field-role: empty dataPath in "{val}"')
continue
field_el = find_element_by_child_value(ds_node, "field", "dataPath", data_path, SCH_NS)
if field_el is None:
print(f'[WARN] Field "{data_path}" not found in dataset "{ds_name}"')
continue
field_indent = get_child_indent(field_el)
# Remove existing <role>
old_role = next((ch for ch in field_el if isinstance(ch.tag, str) and local_name(ch) == "role" and etree.QName(ch.tag).namespace == SCH_NS), None)
if old_role is not None:
remove_node_with_whitespace(old_role)
# Empty spec — remove only
if not flags and not kv:
print(f'[OK] Field "{data_path}" role cleared')
continue
# Build new <role>
lines = [f"{field_indent}<role>"]
for flag in flags:
if flag == "period":
lines.append(f"{field_indent}\t<dcscom:periodNumber>1</dcscom:periodNumber>")
lines.append(f"{field_indent}\t<dcscom:periodType>Main</dcscom:periodType>")
else:
lines.append(f"{field_indent}\t<dcscom:{flag}>true</dcscom:{flag}>")
for k, v in kv:
lines.append(f"{field_indent}\t<dcscom:{k}>{esc_xml(v)}</dcscom:{k}>")
lines.append(f"{field_indent}</role>")
frag_xml = "\r\n".join(lines)
ref_node = next((ch for ch in field_el if isinstance(ch.tag, str) and local_name(ch) in ("valueType", "inputParameters") and etree.QName(ch.tag).namespace == SCH_NS), None)
for node in import_fragment(xml_doc, frag_xml):
insert_before_element(field_el, node, ref_node, field_indent)
parts = []
if flags:
parts.append(" ".join(f"@{f}" for f in flags))
if kv:
parts.append(" ".join(f"{k}={v}" for k, v in kv))
print(f'[OK] Field "{data_path}" role set: {" ".join(parts)}')
elif operation == "remove-field":
ds_node = resolve_data_set()
ds_name = get_data_set_name(ds_node)
+8 -2
View File
@@ -1,4 +1,4 @@
# skd-info v1.3 — Analyze 1C DCS structure
# skd-info v1.4 — Analyze 1C DCS structure
# Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
param(
[Parameter(Mandatory=$true)]
@@ -824,8 +824,14 @@ function Show-Fields {
$roleParts = @()
if ($role) {
foreach ($child in $role.ChildNodes) {
if ($child.NodeType -eq "Element" -and $child.InnerText -eq "true") {
if ($child.NodeType -ne "Element") { continue }
$txt = $child.InnerText.Trim()
if ($txt -eq "true") {
$roleParts += $child.LocalName
} elseif ($txt -eq "false") {
# skip default-false flags
} else {
$roleParts += "$($child.LocalName)=$txt"
}
}
}
+9 -2
View File
@@ -1,5 +1,5 @@
#!/usr/bin/env python3
# skd-info v1.3 — Analyze 1C DCS structure
# skd-info v1.4 — Analyze 1C DCS structure
# Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
import argparse
@@ -777,8 +777,15 @@ def main():
role_parts = []
if role is not None:
for child in role:
if isinstance(child.tag, str) and (child.text or "").strip() == "true":
if not isinstance(child.tag, str):
continue
txt = (child.text or "").strip()
if txt == "true":
role_parts.append(localname(child))
elif txt == "false":
pass
else:
role_parts.append(f"{localname(child)}={txt}")
info["role"] = ", ".join(role_parts)
# UseRestriction
+4
View File
@@ -529,3 +529,7 @@ On error (auto-screenshot taken):
- **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.
+433
View File
@@ -0,0 +1,433 @@
# 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) |
| **Walk through a scenario live before committing it as a test** | `exec` first, then `test` |
| 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. Don't skip either.
### 1. Static recon — metadata
Never invent identifiers. For every metadata object the user mentions (or that you decide to cover), run the matching info skill first:
| Object type | Skill |
|-------------|-------|
| Catalog/document/register attributes, tabular sections | `/meta-info` |
| Form layout — fields, buttons, tabs, tables | `/form-info` |
| DCS report — fields, parameters, filters | `/skd-info` |
| Spreadsheet template areas/parameters | `/mxl-info` |
| Role rights / restrictions | `/role-info` |
| Subsystem composition / command interface | `/subsystem-info` |
This gives the real Russian field labels, command names, column headers, table-section names. Without it, fuzzy matching will silently land on the wrong element, or fail with no useful diagnostic.
If the user names objects you cannot find: stop and ask. Do not guess.
### 2. Live recon — interactive walkthrough
For any non-trivial scenario, walk the path live in `exec` mode before writing it down. Metadata tells you what exists; the live walkthrough tells you what actually happens — which button posts the document, which dialog 1C raises, how the form looks after `clickElement('Создать')`, what fields are required, where `wait()` is genuinely needed.
```bash
# Start a session (background).
node $RUN start http://localhost:9191/myapp/ru_RU
# Step the scenario interactively. After each step, inspect.
cat <<'EOF' | node $RUN exec -
await navigateSection('Склад');
const cmds = await getCommands();
console.log(cmds);
EOF
cat <<'EOF' | node $RUN exec -
await openCommand('Приходная накладная');
await clickElement('Создать');
const s = await getFormState();
console.log(JSON.stringify(s.fields.map(f => ({ name: f.name, label: f.label, required: f.required })), null, 2));
console.log('buttons:', s.buttons.map(b => b.name));
console.log('tables:', s.tables.map(t => ({ name: t.name, label: t.label, columns: t.columns })));
EOF
# Try the actions you plan to encode. If a step fails, fix and re-try
# before transcribing it.
cat <<'EOF' | node $RUN exec -
await fillFields({ 'Контрагент': 'ООО Север' });
await fillTableRow({ 'Номенклатура': 'Товар 01', 'Количество': '5' },
{ table: 'Товары', add: true });
await clickElement('Провести и закрыть');
console.log(JSON.stringify(await getFormState()));
EOF
# When done, stop the session (or leave it for the next test you write).
node $RUN stop
```
What to record from the walkthrough into the test:
- Exact button names (`'Провести и закрыть'`, not `'Сохранить'`).
- Field labels as 1C renders them (with possible non-breaking spaces — `fillFields` normalises, but be exact).
- Table section names from `getFormState().tables[].name`/`label` for multi-grid forms.
- Required `wait()` durations — only where a real async event happens (report generation, server-side calculation). Default actions await internally.
- The shape of `getFormState()` after each action — gives you the right `assert.equal(...)` paths.
After this, transcribe the working sequence into `*.test.mjs`, wrap each chunk in `step('...', async () => { ... })`, add assertions for the invariants you saw. Run the file once with `node $RUN test path/to/file.test.mjs` to confirm.
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: anything with confirmation dialogs, posting/cancellation flows, reports with custom filters, multi-grid forms, or user-customised forms you've never seen.
## 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/
web-test/ # engine self-tests (reserved if our repo layout)
<app-name>/ # application regression — one per solution
_hooks.mjs
webtest.config.mjs
01-login/
02-counterparties/
...
<another-app>/ # second solution, fully isolated
_hooks.mjs
...
```
`<app-name>` is the project/extension slug (`acc-payroll`, `erp-customisation`, etc.). Pick something stable and pass it on the CLI:
```bash
node $RUN test tests/<app-name>/
```
Inside the application subfolder, organize by **feature**, not by metadata kind. Numeric prefixes on both folder and file enforce run order (discovery is alphabetic by full path).
```
tests/<app-name>/
_hooks.mjs # stand prep + cross-cutting hooks (optional)
webtest.config.mjs # url, contexts, defaults (optional)
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
03-unpost.test.mjs
04-balance-report/
01-generate.test.mjs
02-warehouse-filter.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('Тест '));
});
}
```
The runner injects every `browser.mjs` export into `ctx` plus `assert`, `step`, `log`, `testInfo`, `testResult` (afterEach only). For multi-context tests, each context name is its own scoped namespace (`ctx.clerk.clickElement(...)` etc.) — `step`/`assert` stay top-level.
**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'`, `'verify'`) and not a transliteration. Same applies to `export const name` and `displayName` in `webtest.config.mjs`.
## webtest.config.mjs
```js
export default {
// Single-context: just url.
url: 'http://localhost:9191/myapp/ru_RU',
// OR multi-context: named contexts. Each test picks via `context`/`contexts` exports.
// 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',
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. Recommend latin context IDs + Russian `displayName` for video badges.
## _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 }) {
// Restore DB, publish to Apache, build EPF, etc.
// hookArgs = everything after `--` on the CLI. Parse yourself.
if (hookArgs.includes('--rebuild-stand')) { /* full rebuild */ }
// Use idempotent hash-locks to skip work on warm starts.
}
export async function cleanup({ log, config }) {
// Tear down or leave the stand running. Choose per project.
}
// 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.testResult is 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.
**Where to put data setup:**
- DB restore, publication, EPF build → `prepare()`. Make it idempotent (hash-locks on inputs — config sources, EPF spec, DB dump) so warm starts skip everything but a liveness probe.
- Test-specific seed data (the document this test will edit, the counterparty it expects) → per-test `setup`.
- Shared session-wide warmup → `beforeAll`.
## Ready-to-paste patterns
### Catalog full cycle
```js
await step('Создать контрагента', async () => {
await navigateSection('Продажи');
await openCommand('Контрагенты');
await clickElement('Создать');
await fillFields({ 'Наименование': 'ТД Тест', 'ИНН': '7707083893' });
await clickElement('Записать и закрыть');
});
await step('Проверить наличие в списке', async () => {
const t = await readTable({ maxRows: 50 });
assert.tableHasRow(t, { 'Наименование': 'ТД Тест' });
});
await step('Удалить контрагента и подтвердить удаление', async () => {
await clickElement('ТД Тест');
const page = await getPage();
await page.keyboard.press('Delete');
await clickElement('Да');
});
```
### Document create + post
```js
const marker = 'Тест-' + Date.now();
await openCommand('Приходная накладная');
await clickElement('Создать');
await fillFields({ 'Контрагент': 'ООО Север', 'Комментарий': marker });
await fillTableRow(
{ 'Номенклатура': 'Товар 01', 'Количество': '5', 'Цена': '100' },
{ table: 'Товары', add: true }
);
await clickElement('Провести и закрыть');
// Verify: re-open list, filter or scan, assert by `marker`.
```
Use a unique marker (`Date.now()` or random suffix) so re-runs don't collide. Identify your own row by it, not by position or natural keys that may already exist in the DB.
### 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.find(f => f.name === 'Статус')?.value, 'Утверждён');
});
await step('Освободить сессию кладовщика', async () => {
await manager.closeContext('clerk'); // free a 1C license for the next test
});
}
```
License caveat: stock 1C allows ~2 web sessions concurrently. Close contexts you no longer need before the next multi-user test starts.
### 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.find(f => f.name === 'Контрагент')?.required,
'Должна быть ошибка валидации или поле помечено обязательным');
}
```
Write it red first, hand it to the user, fix the underlying issue, re-run green.
## 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>/ -- --rebuild-stand # everything after `--` goes to hooks
```
Default report is JSON when `--report=…` is given. Allure needs `--format=allure` + a directory. JUnit similarly with `--format=junit`.
### Allure static config — `_allure/` directory
The runner copies `<testDir>/_allure/` into the report directory before generating Allure output. Standard Allure convention applies — three files are typically used:
- **`categories.json`** — failure classification. Always emit this when setting up a suite, with 1C-specific patterns: license pool exhaustion (`Не обнаружено свободной лицензии`), 1C application errors (`ВызватьИсключение|Произошла ошибка|…`), navigation/element lookup misses, runner timeouts, assertion failures.
- **`environment.properties`** — `key=value` lines for the Environment widget. Useful when the suite runs across builds/branches (URL, 1C platform version, git branch, configuration version). Often emitted dynamically by `prepare()` rather than committed as a static file.
- **`executor.json`** — CI metadata (Jenkins URL, GitHub run ID, etc.). Only relevant when the suite runs on a CI server; for local runs, skip it.
Discovery skips the underscored directory, so it never collides with tests.
## 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 — find what state you can wait on with `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.
- **Raw DOM via `getPage().$$(...)`.** Use `getFormState`, `readTable`, `readSpreadsheet`. Raw selectors break across 1C platform versions.
- **`clickElement('×')` or `clickElement('Закрыть')`** to dismiss a form. Use `closeForm({ save: true|false })` — handles confirmation correctly.
- **Position-based row identification** (`rows[0]`) when the DB has shared seed data. Filter by unique marker or label instead.
- **Skipping recon** because "I know what this catalog looks like." You don't — the project's customisation almost certainly differs from a stock config.
- **`tags: ['smoke']` on a 90-second test.** Smoke means fast.
- **Hand-writing reset code** in `afterEach`. The runner already closes forms and dismisses errors.
- **Cross-test state assumptions.** Each test must start from desktop and seed its own data. Order-of-execution coupling is a regression-suite trap.
## After a run — failure triage
1. Scan the JSON or Allure summary for `failed`.
2. For each failure, read `error.message` + `error.step` + screenshot (saved next to the report).
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 (`node $RUN test tests/03-goods-receipt/`) 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)
+305 -66
View File
@@ -1,4 +1,4 @@
// web-test browser v1.9 — Playwright browser management for 1C web client
// web-test browser v1.12 — Playwright browser management for 1C web client
// Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
/**
* Playwright browser management for 1C web client.
@@ -37,6 +37,16 @@ let lastCaptions = []; // captions from the last completed recording (for addNar
let lastRecordingDuration = null; // wall-clock duration of the last recording (seconds)
let highlightMode = false;
// 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.
const contexts = new Map();
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).
let activeMode = null;
const LOAD_TIMEOUT = 60000;
const INIT_TIMEOUT = 60000;
const ACTION_WAIT = 2000; // fallback minimum wait
@@ -158,31 +168,51 @@ export async function connect(url, { extensionPath } = {}) {
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() {
// Auto-stop recording if active (prevents orphaned ffmpeg)
// 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();
activeContextName = null;
activeMode = null;
}
// Single-session path (connect): auto-stop recording if active
if (recorder) {
try { await stopRecording(); } catch {}
}
if (browser) {
// Graceful logout — release the 1C license
if (page && !page.isClosed() && seanceId && sessionPrefix) {
try {
const logoutUrl = `${sessionPrefix}/e1cib/logout?seanceId=${seanceId}`;
await page.evaluate(async (url) => {
await fetch(url, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: '{"root":{}}'
});
}, logoutUrl);
await page.waitForTimeout(1000);
} catch {}
}
// Graceful logout — release the 1C license (single-session connect path)
await _logoutSlot({ page, sessionPrefix, seanceId }, 1000);
await browser.close().catch(() => {});
browser = null;
page = null;
@@ -228,6 +258,203 @@ 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.`);
page = slot.page;
sessionPrefix = slot.sessionPrefix;
seanceId = slot.seanceId;
highlightMode = slot.highlightMode || false;
activeContextName = 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) {
sessionPrefix = m[1];
seanceId = 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
persistentUserDataDir = pathJoin(tmpdir(), 'pw-1c-test-' + Date.now());
mkdirSync(persistentUserDataDir, { recursive: true });
browser = 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
browser = await chromium.launch({ headless: false, args: launchArgs });
}
activeMode = 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);
}
/**
* Close startup modals and guide tabs.
* Strategy: Escape click default buttons close extra tabs repeat.
@@ -4861,10 +5088,7 @@ export async function startRecording(outputPath, opts = {}) {
const resolvedPath = resolveProjectPath(outputPath);
mkdirSync(dirname(resolvedPath), { recursive: true });
// Create CDP session for screencast
const cdp = await page.context().newCDPSession(page);
// Spawn ffmpeg process
// Spawn ffmpeg process — single output file across context switches
const ffmpeg = spawn(ffmpegPath, [
'-y', // overwrite output
'-f', 'image2pipe', // input: piped images
@@ -4880,71 +5104,86 @@ export async function startRecording(outputPath, opts = {}) {
resolvedPath
], { stdio: ['pipe', 'ignore', 'pipe'] });
let ffmpegError = '';
ffmpeg.stderr.on('data', d => { ffmpegError += d.toString(); });
ffmpeg.on('error', err => { ffmpegError += err.message; });
ffmpeg.on('error', err => { if (recorder) recorder.ffmpegError += err.message; });
// Listen for screencast frames and pipe to ffmpeg
// CDP sends frames only on screen changes, so we duplicate frames
// to fill gaps and maintain real-time playback speed
const frameDuration = 1000 / fps;
let lastFrameTime = null;
let lastFrameBuf = null;
const speechRate = opts.speechRate || 70; // ms per character for smart TTS wait
cdp.on('Page.screencastFrame', async ({ data, sessionId }) => {
// 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 (lastFrameTime && lastFrameBuf) {
// Fill the gap with duplicates of the previous frame
const gap = now - lastFrameTime;
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(lastFrameBuf);
ffmpeg.stdin.write(recorder.lastFrameBuf);
framesWritten++;
}
}
ffmpeg.stdin.write(buf);
framesWritten++;
// Track actual video timeline position (accounts for frame duplication)
if (recorder) recorder.videoTimeMs += framesWritten * frameDuration;
recorder.videoTimeMs += framesWritten * frameDuration;
}
lastFrameTime = now;
lastFrameBuf = buf;
recorder.lastFrameTime = now;
recorder.lastFrameBuf = buf;
try { await cdp.send('Page.screencastFrameAck', { sessionId }); } catch {}
});
// Start the screencast
await cdp.send('Page.startScreencast', {
format: 'jpeg',
quality,
everyNthFrame: 1
});
// Expose a frame-writing helper on the recorder object.
// During static periods (e.g. smart TTS pauses), CDP may not send screencast
// frames. Call _flushFrames() to fill the gap with duplicates of the last frame,
// keeping video timeline in sync with wall-clock time.
const _flushFrames = () => {
if (!lastFrameBuf || !lastFrameTime || ffmpeg.stdin.destroyed) return;
const now = Date.now();
const gap = now - lastFrameTime;
const dupes = Math.round(gap / frameDuration);
for (let i = 0; i < dupes; i++) {
ffmpeg.stdin.write(lastFrameBuf);
if (recorder) recorder.videoTimeMs += frameDuration;
}
if (dupes > 0) lastFrameTime = now;
};
const speechRate = opts.speechRate || 70; // ms per character for smart TTS wait
recorder = { cdp, ffmpeg, startTime: Date.now(), outputPath: resolvedPath, ffmpegError: '', captions: [], videoTimeMs: 0, _flushFrames, speechRate };
// Redirect stderr accumulation to the recorder object
ffmpeg.stderr.removeAllListeners('data');
// 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;
};
recorder = {
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);
}
/**
File diff suppressed because it is too large Load Diff
+2
View File
@@ -74,6 +74,7 @@ python tools/cc-1c-skills/scripts/switch.py
| Веб-публикация (Web) | 4 навыка `/web-*` | Публикация баз через Apache, статус, остановка, удаление публикаций | [Подробнее](docs/web-guide.md) |
| Тестирование (Web) | `/web-test` | Взаимодействие с веб-клиентом 1С — навигация, формы, таблицы, отчёты, тестирование | [Подробнее](docs/web-test-guide.md) |
| Запись видео (Web) | `/web-test` | Запись видеоинструкций с субтитрами, подсветкой и TTS-озвучкой | [Подробнее](docs/web-test-recording-guide.md) |
| Регресс прикладного решения (Web) | `/web-test` | Автоматический регресс конфигурации: тесты, проверки, отчёты, прогон после правок | [Подробнее](docs/web-test-regression-guide.md) |
| Утилиты | `/img-grid` | Наложение сетки на изображение для определения пропорций колонок | — |
## Требования
@@ -255,6 +256,7 @@ docs/
├── web-guide.md # Гайд: веб-публикация через Apache
├── web-test-guide.md # Гайд: тестирование через веб-клиент
├── web-test-recording-guide.md # Гайд: запись видеоинструкций
├── web-test-regression-guide.md # Гайд: регресс прикладного решения
├── 1c-epf-spec.md # Спецификация XML-формата (EPF)
├── 1c-erf-spec.md # Спецификация XML-формата (ERF)
├── 1c-form-spec.md # Спецификация управляемых форм
+391
View File
@@ -0,0 +1,391 @@
# Регрессионное тестирование прикладного решения
Навык `/web-test` умеет не только разово выполнить сценарий в браузере, но и сопровождать прикладное решение полноценным набором автотестов: каждый тест — отдельный файл, с шагами, проверками, тегами, отчётом и видеозаписью падений. После каждой правки конфигурации модель прогоняет весь набор и показывает, что ожидаемо ведёт себя как раньше, а что сломалось.
```
правка конфигурации → загрузка → обновление → публикация → прогон тестов → отчёт
```
Это про прикладное решение в целом, не про разовую проверку одной формы. Для разовых сценариев («открой накладную, проверь сумму») по-прежнему удобнее интерактивный режим из [web-test-guide.md](web-test-guide.md).
## Предусловия
- База опубликована через Apache (`/web-publish`).
- Установлен Node.js 18+, зависимости подняты: `cd .claude/skills/web-test/scripts && npm install`.
- ffmpeg — нужен только если хотите видеозапись прогона как доказательство падения. Без него падения фиксируются скриншотами. Установка описана в [web-test-recording-guide.md](web-test-recording-guide.md).
## Как это устроено
Набор тестов живёт в каталоге `tests/` вашего проекта. Каждое прикладное решение — отдельная подпапка. Внутри подпапки:
- `_hooks.mjs` — подготовка стенда (восстановление базы, публикация) и общая очистка после прогона. Необязателен.
- `webtest.config.mjs` — адрес базы и набор пользователей (например, кладовщик и менеджер для процессов согласования). Необязателен — если в проекте один пользователь и один URL, можно обойтись без него.
- Сами тесты — файлы `*.test.mjs`, сгруппированные по функциональным папкам.
```
tests/
моя-конфигурация/
_hooks.mjs
webtest.config.mjs
01-вход/
01-открытие-базы.test.mjs
02-контрагенты/
01-создание.test.mjs
02-правка-телефона.test.mjs
03-поступление-товаров/
01-оформление.test.mjs
02-проведение.test.mjs
04-отчёт-остатки/
01-формирование.test.mjs
05-согласование/
01-полный-цикл.test.mjs
```
Порядок выполнения — по алфавиту, поэтому удобно префиксовать папки и файлы номерами. Это даёт предсказуемый сценарий: сначала вход, потом справочники, потом документы, потом отчёты, в конце — процессы с несколькими пользователями.
## Быстрый старт
Самый короткий путь от нуля до зелёного теста — попросить модель пройти ваш сценарий руками и зафиксировать его как тест:
```
> Покрой регрессом справочник Контрагенты в моей конфигурации.
> Нужны проверки: создание, правка телефона, удаление.
```
Что сделает модель:
1. Соберёт информацию о справочнике через `/meta-info` и `/form-info` — посмотрит реквизиты и форму элемента, чтобы знать правильные имена полей.
2. Подключится к опубликованной базе в интерактивном режиме и **руками пройдёт** каждый сценарий — создание, правка, удаление. Это нужно, чтобы зафиксировать настоящие имена кнопок, увидеть, какие диалоги показывает 1С, понять, требуется ли подтверждение сохранения.
3. Зафиксирует пройденный сценарий как файл `tests/<ваша-конфигурация>/02-контрагенты/01-создание.test.mjs`.
4. Запустит его и покажет результат.
При следующих прогонах ничего этого делать не нужно — модель просто запустит готовый набор.
## Сценарии работы с моделью
### Покрытие регрессом доработанного объекта
```
> Я добавил в справочник Номенклатура реквизит "Цена" и "Активен".
> Покрой это регрессом — создание, редактирование, фильтрация по активности
```
Модель:
- посмотрит структуру справочника и формы (через `/meta-info`, `/form-info`);
- интерактивно проверит, как ведут себя новые поля в браузере;
- сгенерирует 2-3 тестовых файла под папкой `02-номенклатура/`;
- прогонит — покажет, что зелёное, что красное.
### Тест процесса с несколькими пользователями
```
> Сделай тест для процесса согласования приходных накладных.
> Кладовщик создаёт накладную, менеджер утверждает,
> кладовщик видит обновлённый статус
```
Модель настроит в `webtest.config.mjs` двух пользователей (с разными URL базы — например, `app-clerk` и `app-manager`), напишет тест, который оркестрирует переключение между ними, и положит его в `05-согласование/`.
```js
export const contexts = ['кладовщик', 'менеджер'];
export default async function({ кладовщик, менеджер, step, assert }) {
await step('Кладовщик создаёт накладную', async () => {
await кладовщик.navigateSection('Склад');
await кладовщик.openCommand('Приходные накладные');
await кладовщик.clickElement('Создать');
// ...
});
await step('Менеджер утверждает', async () => {
await менеджер.navigateSection('Согласование');
// ...
});
// ...
}
```
Учтите ограничение по лицензиям 1С: каждый одновременно открытый пользователь — это занятая клиентская лицензия. Если в наборе много многопользовательских тестов, а на стенде лицензий впритык, прогоны начнут спотыкаться на «свободных лицензий не осталось». Модель освобождает сессии между тестами автоматически (закрывает контексты после процессного теста), но если стенд ограничен — закладывайте это в планирование набора: один-два многопользовательских сценария вместо десяти.
### Воспроизведение ошибки тестом
```
> При проведении накладной без заполненного контрагента у нас не появляется
> ошибка валидации, документ просто проводится с пустым контрагентом — это баг.
> Зафиксируй это падающим тестом
```
Модель воспроизведёт сценарий, напишет тест с проверкой «должна быть ошибка», получит красный — потом, когда вы поправите конфигурацию и попросите перепрогнать, тест станет зелёным. Это документирует ожидаемое поведение в виде кода.
### Прогон регресса после изменений
```
> Я обновил расширение, накатил в базу. Прогони регресс
```
Модель запустит весь набор, дождётся завершения и расскажет:
- сколько тестов прошло, сколько упало, сколько пропущено;
- по каждому упавшему — что именно сломалось (название шага, сообщение об ошибке, ссылка на скриншот);
- классифицирует падения: это ошибка в самом тесте (нужно поправить тест), ошибка в приложении (баг, который вы внесли изменением), или нестабильность стенда (Apache не ответил вовремя, лицензия не освободилась).
```
> Прогони только тесты по контрагентам с подробным отчётом
```
Запустит подмножество — фильтр по тегу или папке, с записью JSON-отчёта.
### Подготовка автономного стенда
Если вы хотите, чтобы регресс можно было запустить «с нуля» — даже на чистой машине без подготовленной базы, — модель настроит автоматическую подготовку стенда:
```
> Сделай, чтобы перед прогоном тестов база восстанавливалась из эталона,
> а после прогона публикация снималась
```
Это пишется один раз в файле `_hooks.mjs`: при запуске тестов запускается подготовка (через навыки `/db-create`, `/db-load-xml`, `/web-publish`), а после — очистка. Внутри предусмотрено кэширование: если ничего не менялось со прошлого прогона, повторная подготовка занимает доли секунды.
## Пример организации покрытия
Допустим, у нас условное прикладное решение «Учёт поступлений товаров» — справочники контрагентов и номенклатуры, документ приходной накладной, отчёт остатков, процесс согласования с двумя пользователями. Логично организовать набор так:
```
tests/учёт-поступлений/
_hooks.mjs # подготовка: восстановление базы + публикация
webtest.config.mjs # URL базы, контексты кладовщика и менеджера
01-вход/
01-открытие-базы.test.mjs # базовая работоспособность: вход проходит, разделы видны
02-навигация-по-разделам.test.mjs # обход всех разделов конфигурации
02-контрагенты/
01-создание.test.mjs # создание, проверка появления в списке
02-редактирование.test.mjs # правка реквизита, проверка сохранения
03-удаление.test.mjs # удаление с подтверждением
03-номенклатура/
01-создание.test.mjs
02-фильтр-по-активности.test.mjs # быстрая фильтрация списка
04-поступление-товаров/
01-оформление.test.mjs # заполнение шапки и табличной части
02-проведение.test.mjs # проведение документа, проверка движений
03-отмена-проведения.test.mjs
04-валидация-обязательных.test.mjs # негативный тест: пустой контрагент → ошибка
05-отчёт-остатки/
01-формирование.test.mjs
02-отбор-по-складу.test.mjs
03-расшифровка.test.mjs # переход из ячейки отчёта в исходный документ
06-согласование/
01-полный-цикл.test.mjs # многопользовательский тест
```
Принципы:
- **Папки — по бизнес-функции**, не по типу метаданных. Лучше `04-поступление-товаров/` (что делает пользователь), чем `документы/` (что лежит в дереве конфигурации).
- **Цифровые префиксы** — на папке и на файле. Гарантируют, что сначала отработают базовые проверки (вход, справочники), потом сложные (документы, отчёты, процессы). При падении базы остальное и так не пройдёт — нет смысла занимать стенд получасом.
- **Один файл — одна логически связанная история.** Не «всё про контрагентов в одном файле», а «отдельно создание, отдельно правка, отдельно удаление». Когда падает — сразу видно, какой именно сценарий сломан.
- **Негативные тесты тоже есть.** «Документ без контрагента не проводится» — такой же важный регресс, как и позитивный сценарий, особенно после правок в обработчиках проверки заполнения.
- **Процессные тесты — в конце.** Они самые хрупкие (зависят от двух сессий, лицензий, синхронизации) и самые длинные. Если упадут — у вас уже есть данные от предыдущих тестов.
## Анатомия одного теста
Пользователь, как правило, тест не пишет — генерирует модель. Но прочитать и поправить полезно уметь. Стандартный файл выглядит так:
```js
export const name = 'Создание контрагента';
export const tags = ['контрагенты', 'базовая-проверка'];
export const timeout = 60000;
export default async function({
navigateSection, openCommand, clickElement, fillFields,
readTable, closeForm, assert, step
}) {
await step('Открыть список контрагентов', async () => {
await navigateSection('Продажи');
await openCommand('Контрагенты');
});
await step('Создать нового контрагента', async () => {
await clickElement('Создать');
await fillFields({ 'Наименование': 'ТД Тест', 'ИНН': '7707083893' });
await clickElement('Записать и закрыть');
});
await step('Убедиться, что элемент появился в списке', async () => {
const t = await readTable();
assert.tableHasRow(t, r => r['Наименование'] === 'ТД Тест');
});
}
```
Что здесь есть:
- **`name`** — человекочитаемое имя теста. Появится в отчёте.
- **`tags`** — теги для фильтрации. Можно прогонять не весь набор, а только нужные: `--tags=контрагенты`.
- **`timeout`** — сколько максимум тест может идти. По умолчанию 30 секунд, для длинных сценариев увеличиваем.
- **Тело теста** — функция, которая получает API браузера (см. [SKILL.md](../.claude/skills/web-test/SKILL.md)) плюс `assert` и `step`.
- **`step('имя', async () => {...})`** — обёртка шага. Имена шагов попадают в отчёт, при падении видно, какой именно шаг сломался.
- **`assert.*`** — проверки. `assert.tableHasRow`, `assert.equal`, `assert.ok` и т.д. Если проверка не выполнилась — тест считается упавшим.
Имена шагов и теста — по-русски, описательные. Они показываются и в консоли, и в отчётах.
## Запуск и отчёты
### Простой прогон
```
> Прогони регресс
```
Модель запустит весь набор, дождётся, покажет сводку:
```
✓ Открытие базы (2.1s)
✓ Создание контрагента (8.4s)
✗ Проведение приходной накладной (12.7s)
└ Заполнить табличную часть (5.2s)
Не найден столбец "Цена" в табличной части "Товары"
скриншот: tests/учёт-поступлений/error-shot.png
23 пройдено, 1 упал, 0 пропущено (3 мин 42 с)
```
### Подробный отчёт
```
> Прогони регресс и сохрани подробный отчёт
```
Модель добавит флаг записи отчёта (JSON или Allure) — потом по нему можно листать историю прогонов, видеть длительности шагов, открывать прикреплённые скриншоты.
Allure — стандартный визуальный отчёт с категориями падений, графиками, таймлайном. Чтобы посмотреть отчёт после прогона:
```bash
# Allure CLI устанавливается отдельно (npm install -g allure-commandline)
allure serve allure-results
```
### Категории падений в Allure
Без дополнительной настройки Allure складывает все упавшие тесты в один общий список «Defects». Если в прогоне упало 15 тестов, не сразу понятно, что из этого — пятнадцать разных проблем или одна и та же ошибка (например, нехватка лицензии на стенде), которая зацепила пятнадцать тестов подряд.
Чтобы Allure группировал падения по причинам, рядом с тестами кладётся каталог `_allure/` с файлом `categories.json`. Подчёркивание в имени каталога — чтобы он не воспринимался как папка с тестами; раннер копирует его содержимое в отчёт.
```
tests/моя-конфигурация/
_allure/
categories.json # классификация падений
environment.properties # необязательно: URL, версия 1С, ветка git
executor.json # необязательно: метаданные сборки CI
_hooks.mjs
01-вход/
...
```
`categories.json` — это список регулярных выражений, по которым ошибка теста относится к той или иной группе:
```json
[
{ "name": "Нехватка лицензий 1С",
"matchedStatuses": ["failed", "broken"],
"messageRegex": ".*Не обнаружено свободной лицензии.*" },
{ "name": "Ошибка приложения 1С",
"matchedStatuses": ["failed"],
"messageRegex": ".*(ВызватьИсключение|В поле введены некорректные данные|Произошла ошибка).*" },
{ "name": "Элемент не найден",
"matchedStatuses": ["failed"],
"messageRegex": ".*(clickElement|fillFields|selectValue).*not found.*" },
{ "name": "Превышен лимит времени теста",
"matchedStatuses": ["failed", "broken"],
"messageRegex": "Timeout \\(\\d+ms\\)" },
{ "name": "Несовпадение ожидания и факта",
"matchedStatuses": ["failed"],
"messageRegex": "(Expected|AssertionError).*" }
]
```
Когда вы попросите модель в первый раз настроить регресс, она положит шаблонный `categories.json` со стандартными классами. По мере того как вы будете находить новые типичные причины падений (например, специфичные для вашего расширения тексты ошибок), категории дополняются.
В виджете «Categories» итогового отчёта вы увидите примерно так:
```
Нехватка лицензий 1С — 12 падений
Ошибка приложения 1С — 2 падения
Несовпадение ожидания и факта — 1 падение
```
— и сразу понятно, что 12 падений — это один стенд-баг, а двумя «ошибками приложения» нужно разобраться по существу.
Помимо `categories.json` в каталог `_allure/` можно положить ещё два стандартных файла:
- **`environment.properties`** — список `ключ=значение` (URL базы, версия платформы 1С, имя ветки git, номер сборки). Покажется в отчёте в виджете «Environment». Полезно, когда регресс гоняется на нескольких стендах или после каждого билда — видно, на чём именно был получен результат. Этот файл удобно генерировать прямо в подготовке стенда (`_hooks.mjs`), а не держать статичной копией.
- **`executor.json`** — метаданные системы сборки: ссылка на Jenkins-задачу, идентификатор запуска GitHub Actions и т.д. Нужен только если регресс запускается на сервере сборки. При локальном прогоне ничего класть не надо.
### Прогон части набора
```
> Прогони только тесты по поступлениям товаров
> Прогони только базовые проверки
> Прогони только упавший вчера тест с проведением накладной
```
Модель выберет нужное подмножество — по папке, по тегу или по имени теста.
### Принудительная пересборка стенда
Если хотите, чтобы перед прогоном база восстановилась с нуля:
```
> Прогони регресс с полной пересборкой стенда
```
Это передаст в подготовку флаг типа `--rebuild-stand``_hooks.mjs` пересоздаст базу из эталона. Полезно после крупных правок или если подозреваете, что предыдущие прогоны загрязнили данные.
## Что делать, когда тест упал
Модель проанализирует падение и отнесёт его к одной из трёх категорий:
1. **Ошибка в самом тесте.** Например, переименовали реквизит — тест ищет старое имя поля. Решение: модель обновит тест.
2. **Ошибка в приложении.** Это и есть то, ради чего регресс существует: что-то поменялось в конфигурации, и сценарий, который раньше работал, теперь не отрабатывает. Модель опишет, что именно произошло, со скриншотом и трассировкой стека 1С, если ошибка была серверной.
3. **Нестабильность стенда.** Apache не ответил, не освободилась лицензия, база отвалилась. Это лечится не правкой теста, а починкой подготовки стенда в `_hooks.mjs` или, реже, повторным прогоном с одним повтором.
Просите модель не «исправь упавший тест», а «разберись с падением» — иначе она может молча подкрутить ожидание под текущее поведение, замаскировав настоящий баг.
## Полезные подробности
### Тестовые данные
В прикладном решении обычно нужны какие-то стартовые данные: пара контрагентов, номенклатура, заведённые организации. Их кладём не в каждый тест, а один раз в подготовку стенда (`_hooks.mjs`) — после восстановления базы загружаются эталонные данные, на которых работают все тесты.
Если конкретному тесту нужны свои данные (например, документ, который мы будем редактировать), он создаёт их сам в начале и убирает в конце.
### Имена документов и уникальность
Тесты прогоняются многократно. Если тест создаёт документ «Накладная-Тест», следующий прогон может натолкнуться на старую запись. Решение — добавлять к имени метку времени:
```js
const метка = 'Тест-' + Date.now();
await fillFields({ 'Комментарий': метка });
// ...
const t = await readTable();
assert.tableHasRow(t, r => r['Комментарий'] === метка);
```
Модель это делает автоматически, но если правите тест руками — держите в голове.
### Видео при падении
Можно включить запись видео всех тестов — тогда при падении прикладывается не только скриншот, но и MP4 со всей сессией:
```
> Прогони регресс с записью видео
```
Размер прогона при этом растёт (на 2-3 минутах теста выходит 5-10 МБ), но при отладке сложного падения видео экономит кучу времени.
### Многоязычные конфигурации
Если у вас есть конфигурация с командами и реквизитами на нескольких языках, тесты пишутся под один язык (как правило, тот, в котором ведётся работа в проде). При смене языка интерфейса в браузере тесты не пройдут — модель видит другие подписи кнопок.
## Где смотреть дальше
- API браузера, которое вызывают тесты — [SKILL.md](../.claude/skills/web-test/SKILL.md).
- Подробная инструкция для модели по написанию тестов (на английском, технический документ) — [.claude/skills/web-test/regress.md](../.claude/skills/web-test/regress.md).
- Интерактивный режим без тестов — [web-test-guide.md](web-test-guide.md).
- Запись видеоинструкций — [web-test-recording-guide.md](web-test-recording-guide.md).
+251
View File
@@ -0,0 +1,251 @@
#!/usr/bin/env node
// build-webtest-db v0.2 — Собирает синтетическую web-test конфигурацию в постоянные пути
// и накатывает её в зарегистрированную базу `webtest` (см. .v8-project.json).
//
// Двойной режим:
// - CLI: node tests/skills/build-webtest-db.mjs [--runtime ...] [--skip-platform]
// - Module: import { runSteps, execSkill, getProjectInfo, ... } from './build-webtest-db.mjs'
//
// CLI:
// node tests/skills/build-webtest-db.mjs # пересобрать с нуля
// node tests/skills/build-webtest-db.mjs --runtime python
// node tests/skills/build-webtest-db.mjs --skip-platform # только XML, без db-create/load/update
//
// После завершения база готова к /web-publish + web-test сессии.
import { execFile } from 'child_process';
import { existsSync, mkdirSync, rmSync, readFileSync, writeFileSync } from 'fs';
import { join, resolve, dirname } from 'path';
import { fileURLToPath } from 'url';
const __filename = fileURLToPath(import.meta.url);
const ROOT = dirname(__filename);
const REPO_ROOT = resolve(ROOT, '../..');
const SKILLS = resolve(REPO_ROOT, '.claude/skills');
// ── Public API ────────────────────────────────────────────────────────────────
/**
* Reads .v8-project.json and locates webtest registration.
* @returns {{ v8path: string, v8exe: string, webtestDb: object, configSrc: string, dbPath: string }}
*/
export function getProjectInfo() {
const projectFile = join(REPO_ROOT, '.v8-project.json');
if (!existsSync(projectFile)) throw new Error('.v8-project.json not found');
const proj = JSON.parse(readFileSync(projectFile, 'utf8'));
const webtestDb = proj.databases?.find(d => d.id === 'webtest');
if (!webtestDb) throw new Error('Database "webtest" not registered in .v8-project.json');
const v8path = proj.v8path;
const v8exe = join(v8path, '1cv8.exe');
const dbPath = webtestDb.path;
const configSrc = resolve(REPO_ROOT, webtestDb.configSrc);
return { v8path, v8exe, webtestDb, configSrc, dbPath };
}
/**
* Resolves a skill script path to an absolute file (chooses .ps1 or .py based on runtime).
*/
export function resolveScript(scriptRelPath, runtime = 'powershell') {
const ext = runtime === 'python' ? '.py' : '.ps1';
const full = join(SKILLS, scriptRelPath + ext);
if (!existsSync(full)) throw new Error(`Script not found: ${full}`);
return full;
}
/**
* Executes a single skill script with provided arguments.
* @returns {Promise<string>} stdout
*/
export function execSkill(scriptPath, args, runtime = 'powershell') {
return new Promise((res, rej) => {
const cmd = runtime === 'python'
? [process.env.PYTHON || 'python', [scriptPath, ...args]]
: ['powershell.exe', ['-NoProfile', '-NonInteractive', '-ExecutionPolicy', 'Bypass', '-File', scriptPath, ...args]];
execFile(cmd[0], cmd[1], { encoding: 'utf8', timeout: 120_000, cwd: REPO_ROOT }, (err, stdout, stderr) => {
if (err) {
rej(new Error(stderr?.trim() || stdout?.trim() || err.message));
} else {
res(stdout);
}
});
});
}
/**
* Replaces {workDir}/{v8path}/{dbPath} placeholders in a string value.
*/
export function replacePlaceholders(s, paths) {
return String(s)
.replace('{workDir}', paths.workDir ?? '')
.replace('{v8path}', paths.v8path ?? '')
.replace('{dbPath}', paths.dbPath ?? '');
}
/**
* Executes an array of build steps.
*
* Each step: { name, script?, args?, input?, writeFile?, content? }
* - writeFile: write content to a file (relative to workDir or absolute), skip script call
* - script: relative path under .claude/skills (without extension)
* - args: { '-Flag': value | true }, value may contain {workDir}/{v8path}/{dbPath}/{inputFile}
* - input: JSON object written to __input.json (referenced by {inputFile} in args)
*
* @param {Array} steps
* @param {{ workDir: string, v8path: string, dbPath: string }} paths
* @param {string} runtime 'powershell' | 'python'
* @param {(line: string) => void} log
* @returns {Promise<{ ok: boolean, elapsed: number, failedAt?: number }>}
*/
export async function runSteps(steps, paths, runtime, log = console.log) {
const t0 = Date.now();
for (let i = 0; i < steps.length; i++) {
const step = steps[i];
const stepT0 = Date.now();
if (step.writeFile) {
try {
const target = replacePlaceholders(step.writeFile, paths);
const abs = target.includes(':') || target.startsWith('/') ? target : join(paths.workDir, target);
mkdirSync(dirname(abs), { recursive: true });
writeFileSync(abs, step.content ?? '', 'utf8');
const ms = Date.now() - stepT0;
log(` [${i + 1}/${steps.length}] OK ${step.name} (${(ms / 1000).toFixed(1)}s)`);
} catch (e) {
log(` [${i + 1}/${steps.length}] FAIL ${step.name}: ${e.message}`);
return { ok: false, elapsed: (Date.now() - t0) / 1000, failedAt: i };
}
continue;
}
let inputFile = null;
if (step.input) {
inputFile = join(paths.workDir, '__input.json');
writeFileSync(inputFile, JSON.stringify(step.input, null, 2), 'utf8');
}
const script = resolveScript(step.script, runtime);
const args = [];
for (const [flag, value] of Object.entries(step.args || {})) {
args.push(flag);
if (value === true) continue;
let v = String(value).replace('{inputFile}', inputFile || '');
v = replacePlaceholders(v, paths);
args.push(v);
}
try {
await execSkill(script, args, runtime);
if (inputFile && existsSync(inputFile)) rmSync(inputFile);
const ms = Date.now() - stepT0;
log(` [${i + 1}/${steps.length}] OK ${step.name} (${(ms / 1000).toFixed(1)}s)`);
} catch (e) {
if (inputFile && existsSync(inputFile)) rmSync(inputFile);
log(` [${i + 1}/${steps.length}] FAIL ${step.name}`);
log(` ${e.message.split('\n').join('\n ').substring(0, 1500)}`);
return { ok: false, elapsed: (Date.now() - t0) / 1000, failedAt: i };
}
}
return { ok: true, elapsed: (Date.now() - t0) / 1000 };
}
/**
* Returns the standard platform load steps (db-create + db-load-xml + db-update).
*/
export function platformLoadSteps() {
return [
{
name: 'db-create: создание файловой ИБ',
script: 'db-create/scripts/db-create',
args: { '-V8Path': '{v8path}', '-InfoBasePath': '{dbPath}' },
},
{
name: 'db-load-xml: загрузка конфигурации',
script: 'db-load-xml/scripts/db-load-xml',
args: { '-V8Path': '{v8path}', '-InfoBasePath': '{dbPath}', '-ConfigDir': '{workDir}' },
},
{
name: 'db-update: обновление БД',
script: 'db-update/scripts/db-update',
args: { '-V8Path': '{v8path}', '-InfoBasePath': '{dbPath}' },
},
];
}
/**
* Imports the build-webtest-config.test.mjs steps array.
*/
export async function loadBuildSteps() {
const buildModule = await import(`file://${join(ROOT, 'integration/build-webtest-config.test.mjs').replace(/\\/g, '/')}`);
return buildModule.steps;
}
// ── CLI ────────────────────────────────────────────────────────────────────────
async function runCli() {
const argv = process.argv.slice(2);
const opts = { runtime: 'powershell', skipPlatform: false };
for (let i = 0; i < argv.length; i++) {
const a = argv[i];
if (a === '--runtime' && argv[i + 1]) { opts.runtime = argv[++i]; continue; }
if (a === '--skip-platform') { opts.skipPlatform = true; continue; }
if (a === '-h' || a === '--help') {
console.log('Usage: build-webtest-db.mjs [--runtime powershell|python] [--skip-platform]');
process.exit(0);
}
}
const { v8path, v8exe, configSrc, dbPath } = getProjectInfo();
if (!opts.skipPlatform && !existsSync(v8exe)) {
console.error(`1cv8.exe not found at ${v8exe}`);
process.exit(1);
}
console.log(`[build-webtest-db] configSrc: ${configSrc}`);
console.log(`[build-webtest-db] dbPath: ${dbPath}`);
console.log(`[build-webtest-db] runtime: ${opts.runtime}`);
console.log('');
if (existsSync(configSrc)) {
console.log(`Removing existing configSrc...`);
rmSync(configSrc, { recursive: true, force: true, maxRetries: 5, retryDelay: 200 });
}
mkdirSync(configSrc, { recursive: true });
if (!opts.skipPlatform && existsSync(dbPath)) {
console.log(`Removing existing IB...`);
rmSync(dbPath, { recursive: true, force: true, maxRetries: 5, retryDelay: 200 });
}
const buildSteps = await loadBuildSteps();
const platformSteps = opts.skipPlatform ? [] : platformLoadSteps();
const allSteps = [...buildSteps, ...platformSteps];
const paths = { workDir: configSrc, v8path, dbPath };
const result = await runSteps(allSteps, paths, opts.runtime, console.log);
console.log('');
if (!result.ok) {
console.error(`Build FAILED after ${result.elapsed.toFixed(1)}s`);
process.exit(1);
}
console.log(`Build OK (${result.elapsed.toFixed(1)}s)`);
console.log('');
console.log(` configSrc: ${configSrc}`);
if (!opts.skipPlatform) {
console.log(` IB: ${dbPath}`);
console.log('');
console.log(` Next: /web-publish webtest → open in browser`);
}
}
// CLI guard: run only when invoked directly, not when imported.
const invokedDirectly = process.argv[1]
? fileURLToPath(import.meta.url) === resolve(process.argv[1])
: false;
if (invokedDirectly) {
runCli().catch(e => {
console.error(e.message);
process.exit(1);
});
}
@@ -0,0 +1,252 @@
<?xml version="1.0" encoding="utf-8"?>
<MetaDataObject xmlns="http://v8.1c.ru/8.3/MDClasses" xmlns:app="http://v8.1c.ru/8.2/managed-application/core" xmlns:cfg="http://v8.1c.ru/8.1/data/enterprise/current-config" xmlns:cmi="http://v8.1c.ru/8.2/managed-application/cmi" xmlns:ent="http://v8.1c.ru/8.1/data/enterprise" xmlns:lf="http://v8.1c.ru/8.2/managed-application/logform" xmlns:style="http://v8.1c.ru/8.1/data/ui/style" xmlns:sys="http://v8.1c.ru/8.1/data/ui/fonts/system" xmlns:v8="http://v8.1c.ru/8.1/data/core" xmlns:v8ui="http://v8.1c.ru/8.1/data/ui" xmlns:web="http://v8.1c.ru/8.1/data/ui/colors/web" xmlns:win="http://v8.1c.ru/8.1/data/ui/colors/windows" xmlns:xen="http://v8.1c.ru/8.3/xcf/enums" xmlns:xpr="http://v8.1c.ru/8.3/xcf/predef" xmlns:xr="http://v8.1c.ru/8.3/xcf/readable" xmlns:xs="http://www.w3.org/2001/XMLSchema" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" version="2.17">
<Configuration uuid="UUID-001">
<InternalInfo>
<xr:ContainedObject>
<xr:ClassId>UUID-002</xr:ClassId>
<xr:ObjectId>UUID-003</xr:ObjectId>
</xr:ContainedObject>
<xr:ContainedObject>
<xr:ClassId>UUID-004</xr:ClassId>
<xr:ObjectId>UUID-005</xr:ObjectId>
</xr:ContainedObject>
<xr:ContainedObject>
<xr:ClassId>UUID-006</xr:ClassId>
<xr:ObjectId>UUID-007</xr:ObjectId>
</xr:ContainedObject>
<xr:ContainedObject>
<xr:ClassId>UUID-008</xr:ClassId>
<xr:ObjectId>UUID-009</xr:ObjectId>
</xr:ContainedObject>
<xr:ContainedObject>
<xr:ClassId>UUID-010</xr:ClassId>
<xr:ObjectId>UUID-011</xr:ObjectId>
</xr:ContainedObject>
<xr:ContainedObject>
<xr:ClassId>UUID-012</xr:ClassId>
<xr:ObjectId>UUID-013</xr:ObjectId>
</xr:ContainedObject>
<xr:ContainedObject>
<xr:ClassId>UUID-014</xr:ClassId>
<xr:ObjectId>UUID-015</xr:ObjectId>
</xr:ContainedObject>
</InternalInfo>
<Properties>
<Name>TestConfig</Name>
<Synonym>
<v8:item>
<v8:lang>ru</v8:lang>
<v8:content>TestConfig</v8:content>
</v8:item>
</Synonym>
<Comment />
<NamePrefix />
<ConfigurationExtensionCompatibilityMode>Version8_3_24</ConfigurationExtensionCompatibilityMode>
<DefaultRunMode>ManagedApplication</DefaultRunMode>
<UsePurposes>
<v8:Value xsi:type="app:ApplicationUsePurpose">PlatformApplication</v8:Value>
</UsePurposes>
<ScriptVariant>Russian</ScriptVariant>
<DefaultRoles />
<Vendor></Vendor>
<Version></Version>
<UpdateCatalogAddress />
<IncludeHelpInContents>false</IncludeHelpInContents>
<UseManagedFormInOrdinaryApplication>false</UseManagedFormInOrdinaryApplication>
<UseOrdinaryFormInManagedApplication>false</UseOrdinaryFormInManagedApplication>
<AdditionalFullTextSearchDictionaries />
<CommonSettingsStorage />
<ReportsUserSettingsStorage />
<ReportsVariantsStorage />
<FormDataSettingsStorage />
<DynamicListsUserSettingsStorage />
<URLExternalDataStorage />
<Content />
<DefaultReportForm />
<DefaultReportVariantForm />
<DefaultReportSettingsForm />
<DefaultReportAppearanceTemplate />
<DefaultDynamicListSettingsForm />
<DefaultSearchForm />
<DefaultDataHistoryChangeHistoryForm />
<DefaultDataHistoryVersionDataForm />
<DefaultDataHistoryVersionDifferencesForm />
<DefaultCollaborationSystemUsersChoiceForm />
<RequiredMobileApplicationPermissions />
<UsedMobileApplicationFunctionalities>
<app:functionality>
<app:functionality>Biometrics</app:functionality>
<app:use>true</app:use>
</app:functionality>
<app:functionality>
<app:functionality>Location</app:functionality>
<app:use>false</app:use>
</app:functionality>
<app:functionality>
<app:functionality>BackgroundLocation</app:functionality>
<app:use>false</app:use>
</app:functionality>
<app:functionality>
<app:functionality>BluetoothPrinters</app:functionality>
<app:use>false</app:use>
</app:functionality>
<app:functionality>
<app:functionality>WiFiPrinters</app:functionality>
<app:use>false</app:use>
</app:functionality>
<app:functionality>
<app:functionality>Contacts</app:functionality>
<app:use>false</app:use>
</app:functionality>
<app:functionality>
<app:functionality>Calendars</app:functionality>
<app:use>false</app:use>
</app:functionality>
<app:functionality>
<app:functionality>PushNotifications</app:functionality>
<app:use>false</app:use>
</app:functionality>
<app:functionality>
<app:functionality>LocalNotifications</app:functionality>
<app:use>false</app:use>
</app:functionality>
<app:functionality>
<app:functionality>InAppPurchases</app:functionality>
<app:use>false</app:use>
</app:functionality>
<app:functionality>
<app:functionality>PersonalComputerFileExchange</app:functionality>
<app:use>false</app:use>
</app:functionality>
<app:functionality>
<app:functionality>Ads</app:functionality>
<app:use>false</app:use>
</app:functionality>
<app:functionality>
<app:functionality>NumberDialing</app:functionality>
<app:use>false</app:use>
</app:functionality>
<app:functionality>
<app:functionality>CallProcessing</app:functionality>
<app:use>false</app:use>
</app:functionality>
<app:functionality>
<app:functionality>CallLog</app:functionality>
<app:use>false</app:use>
</app:functionality>
<app:functionality>
<app:functionality>AutoSendSMS</app:functionality>
<app:use>false</app:use>
</app:functionality>
<app:functionality>
<app:functionality>ReceiveSMS</app:functionality>
<app:use>false</app:use>
</app:functionality>
<app:functionality>
<app:functionality>SMSLog</app:functionality>
<app:use>false</app:use>
</app:functionality>
<app:functionality>
<app:functionality>Camera</app:functionality>
<app:use>false</app:use>
</app:functionality>
<app:functionality>
<app:functionality>Microphone</app:functionality>
<app:use>false</app:use>
</app:functionality>
<app:functionality>
<app:functionality>MusicLibrary</app:functionality>
<app:use>false</app:use>
</app:functionality>
<app:functionality>
<app:functionality>PictureAndVideoLibraries</app:functionality>
<app:use>false</app:use>
</app:functionality>
<app:functionality>
<app:functionality>AudioPlaybackAndVibration</app:functionality>
<app:use>false</app:use>
</app:functionality>
<app:functionality>
<app:functionality>BackgroundAudioPlaybackAndVibration</app:functionality>
<app:use>false</app:use>
</app:functionality>
<app:functionality>
<app:functionality>InstallPackages</app:functionality>
<app:use>false</app:use>
</app:functionality>
<app:functionality>
<app:functionality>OSBackup</app:functionality>
<app:use>true</app:use>
</app:functionality>
<app:functionality>
<app:functionality>ApplicationUsageStatistics</app:functionality>
<app:use>false</app:use>
</app:functionality>
<app:functionality>
<app:functionality>BarcodeScanning</app:functionality>
<app:use>false</app:use>
</app:functionality>
<app:functionality>
<app:functionality>BackgroundAudioRecording</app:functionality>
<app:use>false</app:use>
</app:functionality>
<app:functionality>
<app:functionality>AllFilesAccess</app:functionality>
<app:use>false</app:use>
</app:functionality>
<app:functionality>
<app:functionality>Videoconferences</app:functionality>
<app:use>false</app:use>
</app:functionality>
<app:functionality>
<app:functionality>NFC</app:functionality>
<app:use>false</app:use>
</app:functionality>
<app:functionality>
<app:functionality>DocumentScanning</app:functionality>
<app:use>false</app:use>
</app:functionality>
<app:functionality>
<app:functionality>SpeechToText</app:functionality>
<app:use>false</app:use>
</app:functionality>
<app:functionality>
<app:functionality>Geofences</app:functionality>
<app:use>false</app:use>
</app:functionality>
<app:functionality>
<app:functionality>IncomingShareRequests</app:functionality>
<app:use>false</app:use>
</app:functionality>
<app:functionality>
<app:functionality>AllIncomingShareRequestsTypesProcessing</app:functionality>
<app:use>false</app:use>
</app:functionality>
</UsedMobileApplicationFunctionalities>
<StandaloneConfigurationRestrictionRoles />
<MobileApplicationURLs />
<AllowedIncomingShareRequestTypes />
<MainClientApplicationWindowMode>Normal</MainClientApplicationWindowMode>
<DefaultInterface />
<DefaultStyle />
<DefaultLanguage>Language.Русский</DefaultLanguage>
<BriefInformation />
<DetailedInformation />
<Copyright />
<VendorInformationAddress />
<ConfigurationInformationAddress />
<DataLockControlMode>Managed</DataLockControlMode>
<ObjectAutonumerationMode>NotAutoFree</ObjectAutonumerationMode>
<ModalityUseMode>DontUse</ModalityUseMode>
<SynchronousPlatformExtensionAndAddInCallUseMode>DontUse</SynchronousPlatformExtensionAndAddInCallUseMode>
<InterfaceCompatibilityMode>TaxiEnableVersion8_2</InterfaceCompatibilityMode>
<DatabaseTablespacesUseMode>DontUse</DatabaseTablespacesUseMode>
<CompatibilityMode>Version8_3_24</CompatibilityMode>
<DefaultConstantsForm />
</Properties>
<ChildObjects>
<Language>Русский</Language>
<DataProcessor>ЗапретРучногоВвода</DataProcessor>
</ChildObjects>
</Configuration>
</MetaDataObject>
@@ -0,0 +1,34 @@
<?xml version="1.0" encoding="utf-8"?>
<MetaDataObject xmlns="http://v8.1c.ru/8.3/MDClasses" xmlns:app="http://v8.1c.ru/8.2/managed-application/core" xmlns:cfg="http://v8.1c.ru/8.1/data/enterprise/current-config" xmlns:cmi="http://v8.1c.ru/8.2/managed-application/cmi" xmlns:ent="http://v8.1c.ru/8.1/data/enterprise" xmlns:lf="http://v8.1c.ru/8.2/managed-application/logform" xmlns:style="http://v8.1c.ru/8.1/data/ui/style" xmlns:sys="http://v8.1c.ru/8.1/data/ui/fonts/system" xmlns:v8="http://v8.1c.ru/8.1/data/core" xmlns:v8ui="http://v8.1c.ru/8.1/data/ui" xmlns:web="http://v8.1c.ru/8.1/data/ui/colors/web" xmlns:win="http://v8.1c.ru/8.1/data/ui/colors/windows" xmlns:xen="http://v8.1c.ru/8.3/xcf/enums" xmlns:xpr="http://v8.1c.ru/8.3/xcf/predef" xmlns:xr="http://v8.1c.ru/8.3/xcf/readable" xmlns:xs="http://www.w3.org/2001/XMLSchema" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" version="2.17">
<DataProcessor uuid="UUID-001">
<InternalInfo>
<xr:GeneratedType name="DataProcessorObject.ЗапретРучногоВвода" category="Object">
<xr:TypeId>UUID-002</xr:TypeId>
<xr:ValueId>UUID-003</xr:ValueId>
</xr:GeneratedType>
<xr:GeneratedType name="DataProcessorManager.ЗапретРучногоВвода" category="Manager">
<xr:TypeId>UUID-004</xr:TypeId>
<xr:ValueId>UUID-005</xr:ValueId>
</xr:GeneratedType>
</InternalInfo>
<Properties>
<Name>ЗапретРучногоВвода</Name>
<Synonym>
<v8:item>
<v8:lang>ru</v8:lang>
<v8:content>Запрет ручного ввода</v8:content>
</v8:item>
</Synonym>
<Comment />
<UseStandardCommands>false</UseStandardCommands>
<DefaultForm>DataProcessor.ЗапретРучногоВвода.Form.Форма</DefaultForm>
<AuxiliaryForm />
<IncludeHelpInContents>false</IncludeHelpInContents>
<ExtendedPresentation />
<Explanation />
</Properties>
<ChildObjects>
<Form>Форма</Form>
</ChildObjects>
</DataProcessor>
</MetaDataObject>
@@ -0,0 +1,22 @@
<?xml version="1.0" encoding="UTF-8"?>
<MetaDataObject xmlns="http://v8.1c.ru/8.3/MDClasses" xmlns:app="http://v8.1c.ru/8.2/managed-application/core" xmlns:cfg="http://v8.1c.ru/8.1/data/enterprise/current-config" xmlns:cmi="http://v8.1c.ru/8.2/managed-application/cmi" xmlns:ent="http://v8.1c.ru/8.1/data/enterprise" xmlns:lf="http://v8.1c.ru/8.2/managed-application/logform" xmlns:style="http://v8.1c.ru/8.1/data/ui/style" xmlns:sys="http://v8.1c.ru/8.1/data/ui/fonts/system" xmlns:v8="http://v8.1c.ru/8.1/data/core" xmlns:v8ui="http://v8.1c.ru/8.1/data/ui" xmlns:web="http://v8.1c.ru/8.1/data/ui/colors/web" xmlns:win="http://v8.1c.ru/8.1/data/ui/colors/windows" xmlns:xen="http://v8.1c.ru/8.3/xcf/enums" xmlns:xpr="http://v8.1c.ru/8.3/xcf/predef" xmlns:xr="http://v8.1c.ru/8.3/xcf/readable" xmlns:xs="http://www.w3.org/2001/XMLSchema" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" version="2.17">
<Form uuid="UUID-001">
<Properties>
<Name>Форма</Name>
<Synonym>
<v8:item>
<v8:lang>ru</v8:lang>
<v8:content>Форма</v8:content>
</v8:item>
</Synonym>
<Comment/>
<FormType>Managed</FormType>
<IncludeHelpInContents>false</IncludeHelpInContents>
<UsePurposes>
<v8:Value xsi:type="app:ApplicationUsePurpose">PlatformApplication</v8:Value>
<v8:Value xsi:type="app:ApplicationUsePurpose">MobilePlatformApplication</v8:Value>
</UsePurposes>
<ExtendedPresentation/>
</Properties>
</Form>
</MetaDataObject>
@@ -0,0 +1,74 @@
<?xml version="1.0" encoding="UTF-8"?>
<Form xmlns="http://v8.1c.ru/8.3/xcf/logform" xmlns:app="http://v8.1c.ru/8.2/managed-application/core" xmlns:cfg="http://v8.1c.ru/8.1/data/enterprise/current-config" xmlns:dcscor="http://v8.1c.ru/8.1/data-composition-system/core" xmlns:dcssch="http://v8.1c.ru/8.1/data-composition-system/schema" xmlns:dcsset="http://v8.1c.ru/8.1/data-composition-system/settings" xmlns:ent="http://v8.1c.ru/8.1/data/enterprise" xmlns:lf="http://v8.1c.ru/8.2/managed-application/logform" xmlns:style="http://v8.1c.ru/8.1/data/ui/style" xmlns:sys="http://v8.1c.ru/8.1/data/ui/fonts/system" xmlns:v8="http://v8.1c.ru/8.1/data/core" xmlns:v8ui="http://v8.1c.ru/8.1/data/ui" xmlns:web="http://v8.1c.ru/8.1/data/ui/colors/web" xmlns:win="http://v8.1c.ru/8.1/data/ui/colors/windows" xmlns:xr="http://v8.1c.ru/8.3/xcf/readable" xmlns:xs="http://www.w3.org/2001/XMLSchema" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" version="2.17">
<Title>
<v8:item>
<v8:lang>ru</v8:lang>
<v8:content>Запрет ручного ввода</v8:content>
</v8:item>
</Title>
<AutoTitle>false</AutoTitle>
<AutoCommandBar name="ФормаКоманднаяПанель" id="-1"/>
<ChildItems>
<InputField name="ОбычноеПоле" id="1">
<DataPath>ОбычноеПоле</DataPath>
<Title>
<v8:item>
<v8:lang>ru</v8:lang>
<v8:content>Обычное поле</v8:content>
</v8:item>
</Title>
<ContextMenu name="ОбычноеПолеКонтекстноеМеню" id="2"/>
<ExtendedTooltip name="ОбычноеПолеРасширеннаяПодсказка" id="3"/>
</InputField>
<InputField name="ПолеБезРучногоВвода" id="4">
<DataPath>ПолеБезРучногоВвода</DataPath>
<Title>
<v8:item>
<v8:lang>ru</v8:lang>
<v8:content>Только через выбор</v8:content>
</v8:item>
</Title>
<TextEdit>false</TextEdit>
<ContextMenu name="ПолеБезРучногоВводаКонтекстноеМеню" id="5"/>
<ExtendedTooltip name="ПолеБезРучногоВводаРасширеннаяПодсказка" id="6"/>
</InputField>
</ChildItems>
<Attributes>
<Attribute name="Объект" id="7">
<Type>
<v8:Type>cfg:DataProcessorObject.ЗапретРучногоВвода</v8:Type>
</Type>
<MainAttribute>true</MainAttribute>
</Attribute>
<Attribute name="ОбычноеПоле" id="8">
<Title>
<v8:item>
<v8:lang>ru</v8:lang>
<v8:content>Обычное поле</v8:content>
</v8:item>
</Title>
<Type>
<v8:Type>xs:string</v8:Type>
<v8:StringQualifiers>
<v8:Length>100</v8:Length>
<v8:AllowedLength>Variable</v8:AllowedLength>
</v8:StringQualifiers>
</Type>
</Attribute>
<Attribute name="ПолеБезРучногоВвода" id="9">
<Title>
<v8:item>
<v8:lang>ru</v8:lang>
<v8:content>Поле без ручного ввода</v8:content>
</v8:item>
</Title>
<Type>
<v8:Type>xs:string</v8:Type>
<v8:StringQualifiers>
<v8:Length>100</v8:Length>
<v8:AllowedLength>Variable</v8:AllowedLength>
</v8:StringQualifiers>
</Type>
</Attribute>
</Attributes>
</Form>
@@ -0,0 +1,19 @@
#Область ОбработчикиСобытийФормы
#КонецОбласти
#Область ОбработчикиСобытийЭлементовФормы
#КонецОбласти
#Область ОбработчикиКомандФормы
#КонецОбласти
#Область ОбработчикиОповещений
#КонецОбласти
#Область СлужебныеПроцедурыИФункции
#КонецОбласти
@@ -0,0 +1,18 @@
<?xml version="1.0" encoding="UTF-8"?>
<ClientApplicationInterface xmlns="http://v8.1c.ru/8.2/managed-application/core" xmlns:xs="http://www.w3.org/2001/XMLSchema" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:type="InterfaceLayouter">
<top>
<panel id="UUID-001">
<uuid>UUID-002</uuid>
</panel>
</top>
<left>
<panel id="UUID-003">
<uuid>UUID-004</uuid>
</panel>
</left>
<panelDef id="UUID-004"/>
<panelDef id="UUID-005"/>
<panelDef id="UUID-006"/>
<panelDef id="UUID-002"/>
<panelDef id="UUID-007"/>
</ClientApplicationInterface>
@@ -0,0 +1,16 @@
<?xml version="1.0" encoding="UTF-8"?>
<MetaDataObject xmlns="http://v8.1c.ru/8.3/MDClasses" xmlns:app="http://v8.1c.ru/8.2/managed-application/core" xmlns:cfg="http://v8.1c.ru/8.1/data/enterprise/current-config" xmlns:cmi="http://v8.1c.ru/8.2/managed-application/cmi" xmlns:ent="http://v8.1c.ru/8.1/data/enterprise" xmlns:lf="http://v8.1c.ru/8.2/managed-application/logform" xmlns:style="http://v8.1c.ru/8.1/data/ui/style" xmlns:sys="http://v8.1c.ru/8.1/data/ui/fonts/system" xmlns:v8="http://v8.1c.ru/8.1/data/core" xmlns:v8ui="http://v8.1c.ru/8.1/data/ui" xmlns:web="http://v8.1c.ru/8.1/data/ui/colors/web" xmlns:win="http://v8.1c.ru/8.1/data/ui/colors/windows" xmlns:xen="http://v8.1c.ru/8.3/xcf/enums" xmlns:xpr="http://v8.1c.ru/8.3/xcf/predef" xmlns:xr="http://v8.1c.ru/8.3/xcf/readable" xmlns:xs="http://www.w3.org/2001/XMLSchema" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" version="2.17">
<Language uuid="UUID-001">
<Properties>
<Name>Русский</Name>
<Synonym>
<v8:item>
<v8:lang>ru</v8:lang>
<v8:content>Русский</v8:content>
</v8:item>
</Synonym>
<Comment/>
<LanguageCode>ru</LanguageCode>
</Properties>
</Language>
</MetaDataObject>
@@ -0,0 +1,28 @@
{
"name": "Поле ввода с textEdit:false (запрет ручного ввода)",
"preRun": [
{
"script": "meta-compile/scripts/meta-compile",
"input": { "type": "DataProcessor", "name": "ЗапретРучногоВвода" },
"args": { "-JsonPath": "{inputFile}", "-OutputDir": "{workDir}" }
},
{
"script": "form-add/scripts/form-add",
"args": { "-ObjectPath": "{workDir}/DataProcessors/ЗапретРучногоВвода.xml", "-FormName": "Форма" }
}
],
"params": { "outputPath": "DataProcessors/ЗапретРучногоВвода/Forms/Форма/Ext/Form.xml" },
"validatePath": "DataProcessors/ЗапретРучногоВвода/Forms/Форма/Ext/Form.xml",
"input": {
"title": "Запрет ручного ввода",
"elements": [
{ "input": "ОбычноеПоле", "path": "ОбычноеПоле", "title": "Обычное поле" },
{ "input": "ПолеБезРучногоВвода", "path": "ПолеБезРучногоВвода", "textEdit": false, "title": "Только через выбор" }
],
"attributes": [
{ "name": "Объект", "type": "DataProcessorObject.ЗапретРучногоВвода", "main": true },
{ "name": "ОбычноеПоле", "type": "string(100)" },
{ "name": "ПолеБезРучногоВвода", "type": "string(100)" }
]
}
}
@@ -0,0 +1,5 @@
{
"name": "DataPath с Items.<Table>.CurrentData и ~Атрибут не вызывают ложных ошибок",
"setup": "fixture:datapath-currentdata",
"params": { "formPath": "DataProcessors/Spec/Forms/Форма" }
}
@@ -0,0 +1,5 @@
{
"name": "Числовые и UUID DataPath не вызывают ложных ошибок",
"setup": "fixture:datapath-opaque-refs",
"params": { "formPath": "DataProcessors/Opaque/Forms/Форма" }
}
@@ -0,0 +1,45 @@
<?xml version="1.0" encoding="UTF-8"?>
<Form xmlns="http://v8.1c.ru/8.3/xcf/logform" xmlns:v8="http://v8.1c.ru/8.1/data/core" xmlns:xs="http://www.w3.org/2001/XMLSchema" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" version="2.17">
<Title>
<v8:item>
<v8:lang>ru</v8:lang>
<v8:content>Тест</v8:content>
</v8:item>
</Title>
<AutoCommandBar name="ФормаКоманднаяПанель" id="-1"/>
<ChildItems>
<Table name="Список" id="1">
<DataPath>Список</DataPath>
<ContextMenu name="СписокКонтекстноеМеню" id="2"/>
<AutoCommandBar name="СписокКоманднаяПанель" id="3"/>
<SearchStringAddition name="СписокСтрокаПоиска" id="4"/>
<ViewStatusAddition name="СписокСостояниеПросмотра" id="5"/>
<SearchControlAddition name="СписокУправлениеПоиском" id="6"/>
<ChildItems>
<InputField name="Ссылка" id="7">
<DataPath>Список.Ссылка</DataPath>
<ContextMenu name="СсылкаКонтекстноеМеню" id="8"/>
<ExtendedTooltip name="СсылкаРасширеннаяПодсказка" id="9"/>
</InputField>
</ChildItems>
</Table>
<InputField name="ТекущаяСсылка" id="10">
<DataPath>Items.Список.CurrentData.Ссылка</DataPath>
<ContextMenu name="ТекущаяСсылкаКонтекстноеМеню" id="11"/>
<ExtendedTooltip name="ТекущаяСсылкаРасширеннаяПодсказка" id="12"/>
</InputField>
<InputField name="ВыбраннаяСсылка" id="13">
<DataPath>~Список.Ссылка</DataPath>
<ContextMenu name="ВыбраннаяСсылкаКонтекстноеМеню" id="14"/>
<ExtendedTooltip name="ВыбраннаяСсылкаРасширеннаяПодсказка" id="15"/>
</InputField>
</ChildItems>
<Attributes>
<Attribute name="Список" id="16">
<Type>
<v8:Type>cfg:DynamicList</v8:Type>
</Type>
<MainAttribute>true</MainAttribute>
</Attribute>
</Attributes>
</Form>
@@ -0,0 +1,35 @@
<?xml version="1.0" encoding="UTF-8"?>
<Form xmlns="http://v8.1c.ru/8.3/xcf/logform" xmlns:v8="http://v8.1c.ru/8.1/data/core" xmlns:xs="http://www.w3.org/2001/XMLSchema" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" version="2.17">
<Title>
<v8:item>
<v8:lang>ru</v8:lang>
<v8:content>Тест</v8:content>
</v8:item>
</Title>
<AutoCommandBar name="ФормаКоманднаяПанель" id="-1"/>
<ChildItems>
<InputField name="ПривязкаПоUUID" id="1">
<DataPath>1/0:a917a122-f663-4c45-8de0-fd5104007de3</DataPath>
<ContextMenu name="ПривязкаПоUUIDКонтекстноеМеню" id="2"/>
<ExtendedTooltip name="ПривязкаПоUUIDРасширеннаяПодсказка" id="3"/>
</InputField>
<LabelField name="ЧисловаяПривязка" id="4">
<DataPath>10</DataPath>
<ContextMenu name="ЧисловаяПривязкаКонтекстноеМеню" id="5"/>
<ExtendedTooltip name="ЧисловаяПривязкаРасширеннаяПодсказка" id="6"/>
</LabelField>
<InputField name="Нормальное" id="7">
<DataPath>Объект.Наименование</DataPath>
<ContextMenu name="НормальноеКонтекстноеМеню" id="8"/>
<ExtendedTooltip name="НормальноеРасширеннаяПодсказка" id="9"/>
</InputField>
</ChildItems>
<Attributes>
<Attribute name="Объект" id="10">
<Type>
<v8:Type>cfg:DataProcessorObject.Opaque</v8:Type>
</Type>
<MainAttribute>true</MainAttribute>
</Attribute>
</Attributes>
</Form>
@@ -0,0 +1,21 @@
{
"name": "add-parameter @hidden @always: useRestriction+availableAsField+use=Always",
"preRun": [
{
"script": "skd-compile/scripts/skd-compile",
"input": {
"dataSets": [{
"name": "Основной",
"query": "ВЫБРАТЬ Т.Поле ИЗ Регистр КАК Т",
"fields": ["Поле: string"]
}]
},
"args": { "-DefinitionFile": "{inputFile}", "-OutputPath": "{workDir}/Template.xml" }
}
],
"params": {
"templatePath": "Template.xml",
"operation": "add-parameter",
"value": "Контрагент: CatalogRef.Контрагенты = Справочник.Контрагенты.ПустаяСсылка @hidden @always"
}
}
@@ -0,0 +1,21 @@
{
"name": "add-parameter: ссылочный тип → xsi:type=dcscor:DesignTimeValue",
"preRun": [
{
"script": "skd-compile/scripts/skd-compile",
"input": {
"dataSets": [{
"name": "Основной",
"query": "ВЫБРАТЬ Т.Счет ИЗ Регистр КАК Т",
"fields": ["Счет: string"]
}]
},
"args": { "-DefinitionFile": "{inputFile}", "-OutputPath": "{workDir}/Template.xml" }
}
],
"params": {
"templatePath": "Template.xml",
"operation": "add-parameter",
"value": "Контрагент: CatalogRef.Контрагенты = Справочник.Контрагенты.ПустаяСсылка"
}
}
@@ -0,0 +1,21 @@
{
"name": "add-parameter с initial availableValue list (с запятой в кавычках)",
"preRun": [
{
"script": "skd-compile/scripts/skd-compile",
"input": {
"dataSets": [{
"name": "Основной",
"query": "ВЫБРАТЬ Т.Поле ИЗ Регистр КАК Т",
"fields": ["Поле: string"]
}]
},
"args": { "-DefinitionFile": "{inputFile}", "-OutputPath": "{workDir}/Template.xml" }
}
],
"params": {
"templatePath": "Template.xml",
"operation": "add-parameter",
"value": "Округление: EnumRef.Округления = Окр1_00 availableValue=Перечисление.Округления.Окр1_00: 'руб., коп.', Перечисление.Округления.Окр1: руб., Перечисление.Округления.Окр1000: тыс. руб."
}
}
@@ -0,0 +1,21 @@
{
"name": "add-total: 'X: X' — identity expression (без обёртки в Func)",
"preRun": [
{
"script": "skd-compile/scripts/skd-compile",
"input": {
"dataSets": [{
"name": "Основной",
"query": "ВЫБРАТЬ Т.Проверка ИЗ Регистр КАК Т",
"fields": ["Проверка: decimal(15,2)"]
}]
},
"args": { "-DefinitionFile": "{inputFile}", "-OutputPath": "{workDir}/Template.xml" }
}
],
"params": {
"templatePath": "Template.xml",
"operation": "add-total",
"value": "Проверка: Проверка"
}
}
@@ -0,0 +1,25 @@
{
"name": "clear-conditionalAppearance: очистить все правила оформления варианта",
"preRun": [
{
"script": "skd-compile/scripts/skd-compile",
"input": {
"dataSets": [{
"name": "Основной",
"query": "ВЫБРАТЬ Т.Сумма ИЗ Регистр КАК Т",
"fields": ["Сумма: decimal(15,2)"]
}]
},
"args": { "-DefinitionFile": "{inputFile}", "-OutputPath": "{workDir}/Template.xml" }
},
{
"script": "skd-edit/scripts/skd-edit",
"args": { "-TemplatePath": "{workDir}/Template.xml", "-Operation": "add-conditionalAppearance", "-Value": "ЦветТекста = web:Red when Сумма < 0 ;; ЦветФона = web:LightGreen when Сумма > 1000" }
}
],
"params": {
"templatePath": "Template.xml",
"operation": "clear-conditionalAppearance",
"value": "*"
}
}
@@ -0,0 +1,25 @@
{
"name": "modify-parameter availableValue: replace whole list (старый список удалён, новый добавлен)",
"preRun": [
{
"script": "skd-compile/scripts/skd-compile",
"input": {
"dataSets": [{
"name": "Основной",
"query": "ВЫБРАТЬ Т.Поле ИЗ Регистр КАК Т",
"fields": ["Поле: string"]
}]
},
"args": { "-DefinitionFile": "{inputFile}", "-OutputPath": "{workDir}/Template.xml" }
},
{
"script": "skd-edit/scripts/skd-edit",
"args": { "-TemplatePath": "{workDir}/Template.xml", "-Operation": "add-parameter", "-Value": "Округление: EnumRef.Округления = Окр1_00 availableValue=Перечисление.Округления.Окр1_00: ст1, Перечисление.Округления.Окр1: ст2, Перечисление.Округления.Окр1000: ст3" }
}
],
"params": {
"templatePath": "Template.xml",
"operation": "modify-parameter",
"value": "Округление availableValue=Перечисление.Округления.Окр1: руб., Перечисление.Округления.Окр1000: тыс., Перечисление.Округления.Окр1000000: млн."
}
}
@@ -17,6 +17,6 @@
"params": {
"templatePath": "Template.xml",
"operation": "modify-parameter",
"value": "ПорядокОкругления denyIncompleteValues=true use=Always availableValue=Перечисление.Округления.Окр1_00 presentation=руб. коп ;; ПорядокОкругления availableValue=Перечисление.Округления.Окр1 presentation=руб. ;; ПорядокОкругления availableValue=Перечисление.Округления.Окр1000 presentation=тыс. руб"
"value": "ПорядокОкругления denyIncompleteValues=true use=Always availableValue=Перечисление.Округления.Окр1_00: руб. коп, Перечисление.Округления.Окр1: руб., Перечисление.Округления.Окр1000: тыс. руб"
}
}
@@ -0,0 +1,29 @@
{
"name": "modify-parameter @hidden @always: идемпотентность",
"preRun": [
{
"script": "skd-compile/scripts/skd-compile",
"input": {
"dataSets": [{
"name": "Основной",
"query": "ВЫБРАТЬ Т.Поле ИЗ Регистр КАК Т",
"fields": ["Поле: string"]
}]
},
"args": { "-DefinitionFile": "{inputFile}", "-OutputPath": "{workDir}/Template.xml" }
},
{
"script": "skd-edit/scripts/skd-edit",
"args": { "-TemplatePath": "{workDir}/Template.xml", "-Operation": "add-parameter", "-Value": "Контрагент: CatalogRef.Контрагенты = Справочник.Контрагенты.ПустаяСсылка" }
},
{
"script": "skd-edit/scripts/skd-edit",
"args": { "-TemplatePath": "{workDir}/Template.xml", "-Operation": "modify-parameter", "-Value": "Контрагент @hidden @always" }
}
],
"params": {
"templatePath": "Template.xml",
"operation": "modify-parameter",
"value": "Контрагент @hidden @always"
}
}
@@ -0,0 +1,25 @@
{
"name": "modify-parameter value=... обновляет существующий <value> (не дублирует)",
"preRun": [
{
"script": "skd-compile/scripts/skd-compile",
"input": {
"dataSets": [{
"name": "Основной",
"query": "ВЫБРАТЬ Т.Счет ИЗ Регистр КАК Т",
"fields": ["Счет: string"]
}]
},
"args": { "-DefinitionFile": "{inputFile}", "-OutputPath": "{workDir}/Template.xml" }
},
{
"script": "skd-edit/scripts/skd-edit",
"args": { "-TemplatePath": "{workDir}/Template.xml", "-Operation": "add-parameter", "-Value": "Контрагент: CatalogRef.Контрагенты = Справочник.Контрагенты.ПустаяСсылка" }
}
],
"params": {
"templatePath": "Template.xml",
"operation": "modify-parameter",
"value": "Контрагент value=Справочник.Контрагенты.НашаОрганизация"
}
}
@@ -17,6 +17,6 @@
"params": {
"templatePath": "Template.xml",
"operation": "modify-parameter",
"value": "ПорядокОкругления use=Always ;; ПорядокОкругления denyIncompleteValues=true ;; ПорядокОкругления availableValue=Перечисление.Округления.Окр1_00 presentation=руб. коп ;; ПорядокОкругления availableValue=Перечисление.Округления.Окр1 presentation=руб."
"value": "ПорядокОкругления use=Always ;; ПорядокОкругления denyIncompleteValues=true ;; ПорядокОкругления availableValue=Перечисление.Округления.Окр1_00: руб. коп, Перечисление.Округления.Окр1: руб."
}
}
@@ -0,0 +1,29 @@
{
"name": "modify-structure меняет groupItems, сохраняет Selection",
"preRun": [
{
"script": "skd-compile/scripts/skd-compile",
"input": {
"dataSets": [{
"name": "Основной",
"query": "ВЫБРАТЬ Т.Счет, Т.Сумма, Т.Валюта ИЗ Регистр КАК Т",
"fields": ["Счет: string", "Сумма: decimal(15,2)", "Валюта: string"]
}]
},
"args": { "-DefinitionFile": "{inputFile}", "-OutputPath": "{workDir}/Template.xml" }
},
{
"script": "skd-edit/scripts/skd-edit",
"args": { "-TemplatePath": "{workDir}/Template.xml", "-Operation": "set-structure", "-Value": "Счет @name=ДанныеОтчета" }
},
{
"script": "skd-edit/scripts/skd-edit",
"args": { "-TemplatePath": "{workDir}/Template.xml", "-Operation": "add-selection", "-Value": "Счет @group=ДанныеОтчета ;; Сумма @group=ДанныеОтчета" }
}
],
"params": {
"templatePath": "Template.xml",
"operation": "modify-structure",
"value": "Валюта @name=ДанныеОтчета"
}
}
@@ -0,0 +1,21 @@
{
"name": "patch-query ;;-batch: сегменты триммятся по краям",
"preRun": [
{
"script": "skd-compile/scripts/skd-compile",
"input": {
"dataSets": [{
"name": "Основной",
"query": "ВЫБРАТЬ Т.СтароеИмя1, Т.СтароеИмя2 ИЗ Регистр КАК Т",
"fields": ["СтароеИмя1: string", "СтароеИмя2: string"]
}]
},
"args": { "-DefinitionFile": "{inputFile}", "-OutputPath": "{workDir}/Template.xml" }
}
],
"params": {
"templatePath": "Template.xml",
"operation": "patch-query",
"value": "СтароеИмя1 => НовоеИмя1 ;; СтароеИмя2 => НовоеИмя2"
}
}
@@ -0,0 +1,22 @@
{
"name": "patch-query @once: множественные вхождения — ошибка",
"preRun": [
{
"script": "skd-compile/scripts/skd-compile",
"input": {
"dataSets": [{
"name": "Основной",
"query": "ВЫБРАТЬ Т.Поле1, Т.Поле2 ИЗ Регистр КАК Т ГДЕ Т.Поле1 = Т.Поле2",
"fields": ["Поле1: string", "Поле2: string"]
}]
},
"args": { "-DefinitionFile": "{inputFile}", "-OutputPath": "{workDir}/Template.xml" }
}
],
"params": {
"templatePath": "Template.xml",
"operation": "patch-query",
"value": "Поле1 => Новое @once"
},
"expectError": "expected 1 occurrence"
}
@@ -0,0 +1,21 @@
{
"name": "patch-query @once: 1 вхождение — успешная замена",
"preRun": [
{
"script": "skd-compile/scripts/skd-compile",
"input": {
"dataSets": [{
"name": "Основной",
"query": "ВЫБРАТЬ Т.УникальноеИмя ИЗ Регистр КАК Т",
"fields": ["УникальноеИмя: string"]
}]
},
"args": { "-DefinitionFile": "{inputFile}", "-OutputPath": "{workDir}/Template.xml" }
}
],
"params": {
"templatePath": "Template.xml",
"operation": "patch-query",
"value": "УникальноеИмя => НовоеИмя @once"
}
}
@@ -0,0 +1,21 @@
{
"name": "set-field-role: установить балансовую роль с уточнением",
"preRun": [
{
"script": "skd-compile/scripts/skd-compile",
"input": {
"dataSets": [{
"name": "Основной",
"query": "ВЫБРАТЬ Т.Сумма ИЗ Регистр КАК Т",
"fields": ["Сумма: decimal(15,2)"]
}]
},
"args": { "-DefinitionFile": "{inputFile}", "-OutputPath": "{workDir}/Template.xml" }
}
],
"params": {
"templatePath": "Template.xml",
"operation": "set-field-role",
"value": "Сумма @balance balanceGroupName=Сумма balanceType=OpeningBalance"
}
}
@@ -0,0 +1,21 @@
{
"name": "set-field-role: пустой spec — снимает роль",
"preRun": [
{
"script": "skd-compile/scripts/skd-compile",
"input": {
"dataSets": [{
"name": "Основной",
"query": "ВЫБРАТЬ Т.Сумма ИЗ Регистр КАК Т",
"fields": ["Сумма: decimal(15,2) @balance"]
}]
},
"args": { "-DefinitionFile": "{inputFile}", "-OutputPath": "{workDir}/Template.xml" }
}
],
"params": {
"templatePath": "Template.xml",
"operation": "set-field-role",
"value": "Сумма"
}
}
@@ -0,0 +1,21 @@
{
"name": "set-structure: запятая в shorthand → несколько GroupItemField",
"preRun": [
{
"script": "skd-compile/scripts/skd-compile",
"input": {
"dataSets": [{
"name": "Основной",
"query": "ВЫБРАТЬ Т.Валюта, Т.Банк, Т.ИНН ИЗ Регистр КАК Т",
"fields": ["Валюта: string", "Банк: string", "ИНН: string"]
}]
},
"args": { "-DefinitionFile": "{inputFile}", "-OutputPath": "{workDir}/Template.xml" }
}
],
"params": {
"templatePath": "Template.xml",
"operation": "set-structure",
"value": "Валюта, Банк, ИНН @name=ДанныеОтчета"
}
}
@@ -0,0 +1,54 @@
<?xml version="1.0" encoding="UTF-8"?>
<DataCompositionSchema xmlns="http://v8.1c.ru/8.1/data-composition-system/schema" xmlns:dcscom="http://v8.1c.ru/8.1/data-composition-system/common" xmlns:dcscor="http://v8.1c.ru/8.1/data-composition-system/core" xmlns:dcsset="http://v8.1c.ru/8.1/data-composition-system/settings" xmlns:v8="http://v8.1c.ru/8.1/data/core" xmlns:v8ui="http://v8.1c.ru/8.1/data/ui" xmlns:xs="http://www.w3.org/2001/XMLSchema" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance">
<dataSource>
<name>ИсточникДанных1</name>
<dataSourceType>Local</dataSourceType>
</dataSource>
<dataSet xsi:type="DataSetQuery">
<name>Основной</name>
<field xsi:type="DataSetFieldField">
<dataPath>Поле</dataPath>
<field>Поле</field>
<valueType>
<v8:Type>xs:string</v8:Type>
<v8:StringQualifiers>
<v8:Length>0</v8:Length>
<v8:AllowedLength>Variable</v8:AllowedLength>
</v8:StringQualifiers>
</valueType>
</field>
<dataSource>ИсточникДанных1</dataSource>
<query>ВЫБРАТЬ Т.Поле ИЗ Регистр КАК Т</query>
</dataSet>
<parameter>
<name>Контрагент</name>
<valueType>
<v8:Type xmlns:d5p1="http://v8.1c.ru/8.1/data/enterprise/current-config">d5p1:CatalogRef.Контрагенты</v8:Type>
</valueType>
<value xsi:type="dcscor:DesignTimeValue">Справочник.Контрагенты.ПустаяСсылка</value>
<useRestriction>true</useRestriction>
<availableAsField>false</availableAsField>
<use>Always</use>
</parameter>
<settingsVariant>
<dcsset:name>Основной</dcsset:name>
<dcsset:presentation xsi:type="v8:LocalStringType">
<v8:item>
<v8:lang>ru</v8:lang>
<v8:content>Основной</v8:content>
</v8:item>
</dcsset:presentation>
<dcsset:settings xmlns:style="http://v8.1c.ru/8.1/data/ui/style" xmlns:sys="http://v8.1c.ru/8.1/data/ui/fonts/system" xmlns:web="http://v8.1c.ru/8.1/data/ui/colors/web" xmlns:win="http://v8.1c.ru/8.1/data/ui/colors/windows">
<dcsset:selection>
</dcsset:selection>
<dcsset:item xsi:type="dcsset:StructureItemGroup">
<dcsset:order>
<dcsset:item xsi:type="dcsset:OrderItemAuto" />
</dcsset:order>
<dcsset:selection>
<dcsset:item xsi:type="dcsset:SelectedItemAuto" />
</dcsset:selection>
</dcsset:item>
</dcsset:settings>
</settingsVariant>
</DataCompositionSchema>
@@ -0,0 +1,51 @@
<?xml version="1.0" encoding="UTF-8"?>
<DataCompositionSchema xmlns="http://v8.1c.ru/8.1/data-composition-system/schema" xmlns:dcscom="http://v8.1c.ru/8.1/data-composition-system/common" xmlns:dcscor="http://v8.1c.ru/8.1/data-composition-system/core" xmlns:dcsset="http://v8.1c.ru/8.1/data-composition-system/settings" xmlns:v8="http://v8.1c.ru/8.1/data/core" xmlns:v8ui="http://v8.1c.ru/8.1/data/ui" xmlns:xs="http://www.w3.org/2001/XMLSchema" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance">
<dataSource>
<name>ИсточникДанных1</name>
<dataSourceType>Local</dataSourceType>
</dataSource>
<dataSet xsi:type="DataSetQuery">
<name>Основной</name>
<field xsi:type="DataSetFieldField">
<dataPath>Счет</dataPath>
<field>Счет</field>
<valueType>
<v8:Type>xs:string</v8:Type>
<v8:StringQualifiers>
<v8:Length>0</v8:Length>
<v8:AllowedLength>Variable</v8:AllowedLength>
</v8:StringQualifiers>
</valueType>
</field>
<dataSource>ИсточникДанных1</dataSource>
<query>ВЫБРАТЬ Т.Счет ИЗ Регистр КАК Т</query>
</dataSet>
<parameter>
<name>Контрагент</name>
<valueType>
<v8:Type xmlns:d5p1="http://v8.1c.ru/8.1/data/enterprise/current-config">d5p1:CatalogRef.Контрагенты</v8:Type>
</valueType>
<value xsi:type="dcscor:DesignTimeValue">Справочник.Контрагенты.ПустаяСсылка</value>
</parameter>
<settingsVariant>
<dcsset:name>Основной</dcsset:name>
<dcsset:presentation xsi:type="v8:LocalStringType">
<v8:item>
<v8:lang>ru</v8:lang>
<v8:content>Основной</v8:content>
</v8:item>
</dcsset:presentation>
<dcsset:settings xmlns:style="http://v8.1c.ru/8.1/data/ui/style" xmlns:sys="http://v8.1c.ru/8.1/data/ui/fonts/system" xmlns:web="http://v8.1c.ru/8.1/data/ui/colors/web" xmlns:win="http://v8.1c.ru/8.1/data/ui/colors/windows">
<dcsset:selection>
</dcsset:selection>
<dcsset:item xsi:type="dcsset:StructureItemGroup">
<dcsset:order>
<dcsset:item xsi:type="dcsset:OrderItemAuto" />
</dcsset:order>
<dcsset:selection>
<dcsset:item xsi:type="dcsset:SelectedItemAuto" />
</dcsset:selection>
</dcsset:item>
</dcsset:settings>
</settingsVariant>
</DataCompositionSchema>
@@ -0,0 +1,78 @@
<?xml version="1.0" encoding="UTF-8"?>
<DataCompositionSchema xmlns="http://v8.1c.ru/8.1/data-composition-system/schema" xmlns:dcscom="http://v8.1c.ru/8.1/data-composition-system/common" xmlns:dcscor="http://v8.1c.ru/8.1/data-composition-system/core" xmlns:dcsset="http://v8.1c.ru/8.1/data-composition-system/settings" xmlns:v8="http://v8.1c.ru/8.1/data/core" xmlns:v8ui="http://v8.1c.ru/8.1/data/ui" xmlns:xs="http://www.w3.org/2001/XMLSchema" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance">
<dataSource>
<name>ИсточникДанных1</name>
<dataSourceType>Local</dataSourceType>
</dataSource>
<dataSet xsi:type="DataSetQuery">
<name>Основной</name>
<field xsi:type="DataSetFieldField">
<dataPath>Поле</dataPath>
<field>Поле</field>
<valueType>
<v8:Type>xs:string</v8:Type>
<v8:StringQualifiers>
<v8:Length>0</v8:Length>
<v8:AllowedLength>Variable</v8:AllowedLength>
</v8:StringQualifiers>
</valueType>
</field>
<dataSource>ИсточникДанных1</dataSource>
<query>ВЫБРАТЬ Т.Поле ИЗ Регистр КАК Т</query>
</dataSet>
<parameter>
<name>Округление</name>
<valueType>
<v8:Type xmlns:d5p1="http://v8.1c.ru/8.1/data/enterprise/current-config">d5p1:EnumRef.Округления</v8:Type>
</valueType>
<value xsi:type="dcscor:DesignTimeValue">Окр1_00</value>
<availableValue>
<value xsi:type="dcscor:DesignTimeValue">Перечисление.Округления.Окр1_00</value>
<presentation xsi:type="v8:LocalStringType">
<v8:item>
<v8:lang>ru</v8:lang>
<v8:content>руб., коп.</v8:content>
</v8:item>
</presentation>
</availableValue>
<availableValue>
<value xsi:type="dcscor:DesignTimeValue">Перечисление.Округления.Окр1</value>
<presentation xsi:type="v8:LocalStringType">
<v8:item>
<v8:lang>ru</v8:lang>
<v8:content>руб.</v8:content>
</v8:item>
</presentation>
</availableValue>
<availableValue>
<value xsi:type="dcscor:DesignTimeValue">Перечисление.Округления.Окр1000</value>
<presentation xsi:type="v8:LocalStringType">
<v8:item>
<v8:lang>ru</v8:lang>
<v8:content>тыс. руб.</v8:content>
</v8:item>
</presentation>
</availableValue>
</parameter>
<settingsVariant>
<dcsset:name>Основной</dcsset:name>
<dcsset:presentation xsi:type="v8:LocalStringType">
<v8:item>
<v8:lang>ru</v8:lang>
<v8:content>Основной</v8:content>
</v8:item>
</dcsset:presentation>
<dcsset:settings xmlns:style="http://v8.1c.ru/8.1/data/ui/style" xmlns:sys="http://v8.1c.ru/8.1/data/ui/fonts/system" xmlns:web="http://v8.1c.ru/8.1/data/ui/colors/web" xmlns:win="http://v8.1c.ru/8.1/data/ui/colors/windows">
<dcsset:selection>
</dcsset:selection>
<dcsset:item xsi:type="dcsset:StructureItemGroup">
<dcsset:order>
<dcsset:item xsi:type="dcsset:OrderItemAuto" />
</dcsset:order>
<dcsset:selection>
<dcsset:item xsi:type="dcsset:SelectedItemAuto" />
</dcsset:selection>
</dcsset:item>
</dcsset:settings>
</settingsVariant>
</DataCompositionSchema>
@@ -0,0 +1,49 @@
<?xml version="1.0" encoding="UTF-8"?>
<DataCompositionSchema xmlns="http://v8.1c.ru/8.1/data-composition-system/schema" xmlns:dcscom="http://v8.1c.ru/8.1/data-composition-system/common" xmlns:dcscor="http://v8.1c.ru/8.1/data-composition-system/core" xmlns:dcsset="http://v8.1c.ru/8.1/data-composition-system/settings" xmlns:v8="http://v8.1c.ru/8.1/data/core" xmlns:v8ui="http://v8.1c.ru/8.1/data/ui" xmlns:xs="http://www.w3.org/2001/XMLSchema" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance">
<dataSource>
<name>ИсточникДанных1</name>
<dataSourceType>Local</dataSourceType>
</dataSource>
<dataSet xsi:type="DataSetQuery">
<name>Основной</name>
<field xsi:type="DataSetFieldField">
<dataPath>Проверка</dataPath>
<field>Проверка</field>
<valueType>
<v8:Type>xs:decimal</v8:Type>
<v8:NumberQualifiers>
<v8:Digits>15</v8:Digits>
<v8:FractionDigits>2</v8:FractionDigits>
<v8:AllowedSign>Any</v8:AllowedSign>
</v8:NumberQualifiers>
</valueType>
</field>
<dataSource>ИсточникДанных1</dataSource>
<query>ВЫБРАТЬ Т.Проверка ИЗ Регистр КАК Т</query>
</dataSet>
<totalField>
<dataPath>Проверка</dataPath>
<expression>Проверка</expression>
</totalField>
<settingsVariant>
<dcsset:name>Основной</dcsset:name>
<dcsset:presentation xsi:type="v8:LocalStringType">
<v8:item>
<v8:lang>ru</v8:lang>
<v8:content>Основной</v8:content>
</v8:item>
</dcsset:presentation>
<dcsset:settings xmlns:style="http://v8.1c.ru/8.1/data/ui/style" xmlns:sys="http://v8.1c.ru/8.1/data/ui/fonts/system" xmlns:web="http://v8.1c.ru/8.1/data/ui/colors/web" xmlns:win="http://v8.1c.ru/8.1/data/ui/colors/windows">
<dcsset:selection>
</dcsset:selection>
<dcsset:item xsi:type="dcsset:StructureItemGroup">
<dcsset:order>
<dcsset:item xsi:type="dcsset:OrderItemAuto" />
</dcsset:order>
<dcsset:selection>
<dcsset:item xsi:type="dcsset:SelectedItemAuto" />
</dcsset:selection>
</dcsset:item>
</dcsset:settings>
</settingsVariant>
</DataCompositionSchema>
@@ -0,0 +1,47 @@
<?xml version="1.0" encoding="UTF-8"?>
<DataCompositionSchema xmlns="http://v8.1c.ru/8.1/data-composition-system/schema" xmlns:dcscom="http://v8.1c.ru/8.1/data-composition-system/common" xmlns:dcscor="http://v8.1c.ru/8.1/data-composition-system/core" xmlns:dcsset="http://v8.1c.ru/8.1/data-composition-system/settings" xmlns:v8="http://v8.1c.ru/8.1/data/core" xmlns:v8ui="http://v8.1c.ru/8.1/data/ui" xmlns:xs="http://www.w3.org/2001/XMLSchema" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance">
<dataSource>
<name>ИсточникДанных1</name>
<dataSourceType>Local</dataSourceType>
</dataSource>
<dataSet xsi:type="DataSetQuery">
<name>Основной</name>
<field xsi:type="DataSetFieldField">
<dataPath>Сумма</dataPath>
<field>Сумма</field>
<valueType>
<v8:Type>xs:decimal</v8:Type>
<v8:NumberQualifiers>
<v8:Digits>15</v8:Digits>
<v8:FractionDigits>2</v8:FractionDigits>
<v8:AllowedSign>Any</v8:AllowedSign>
</v8:NumberQualifiers>
</valueType>
</field>
<dataSource>ИсточникДанных1</dataSource>
<query>ВЫБРАТЬ Т.Сумма ИЗ Регистр КАК Т</query>
</dataSet>
<settingsVariant>
<dcsset:name>Основной</dcsset:name>
<dcsset:presentation xsi:type="v8:LocalStringType">
<v8:item>
<v8:lang>ru</v8:lang>
<v8:content>Основной</v8:content>
</v8:item>
</dcsset:presentation>
<dcsset:settings xmlns:style="http://v8.1c.ru/8.1/data/ui/style" xmlns:sys="http://v8.1c.ru/8.1/data/ui/fonts/system" xmlns:web="http://v8.1c.ru/8.1/data/ui/colors/web" xmlns:win="http://v8.1c.ru/8.1/data/ui/colors/windows">
<dcsset:selection>
</dcsset:selection>
<dcsset:conditionalAppearance>
</dcsset:conditionalAppearance>
<dcsset:item xsi:type="dcsset:StructureItemGroup">
<dcsset:order>
<dcsset:item xsi:type="dcsset:OrderItemAuto" />
</dcsset:order>
<dcsset:selection>
<dcsset:item xsi:type="dcsset:SelectedItemAuto" />
</dcsset:selection>
</dcsset:item>
</dcsset:settings>
</settingsVariant>
</DataCompositionSchema>
@@ -0,0 +1,78 @@
<?xml version="1.0" encoding="UTF-8"?>
<DataCompositionSchema xmlns="http://v8.1c.ru/8.1/data-composition-system/schema" xmlns:dcscom="http://v8.1c.ru/8.1/data-composition-system/common" xmlns:dcscor="http://v8.1c.ru/8.1/data-composition-system/core" xmlns:dcsset="http://v8.1c.ru/8.1/data-composition-system/settings" xmlns:v8="http://v8.1c.ru/8.1/data/core" xmlns:v8ui="http://v8.1c.ru/8.1/data/ui" xmlns:xs="http://www.w3.org/2001/XMLSchema" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance">
<dataSource>
<name>ИсточникДанных1</name>
<dataSourceType>Local</dataSourceType>
</dataSource>
<dataSet xsi:type="DataSetQuery">
<name>Основной</name>
<field xsi:type="DataSetFieldField">
<dataPath>Поле</dataPath>
<field>Поле</field>
<valueType>
<v8:Type>xs:string</v8:Type>
<v8:StringQualifiers>
<v8:Length>0</v8:Length>
<v8:AllowedLength>Variable</v8:AllowedLength>
</v8:StringQualifiers>
</valueType>
</field>
<dataSource>ИсточникДанных1</dataSource>
<query>ВЫБРАТЬ Т.Поле ИЗ Регистр КАК Т</query>
</dataSet>
<parameter>
<name>Округление</name>
<valueType>
<v8:Type xmlns:d5p1="http://v8.1c.ru/8.1/data/enterprise/current-config">d5p1:EnumRef.Округления</v8:Type>
</valueType>
<value xsi:type="dcscor:DesignTimeValue">Окр1_00</value>
<availableValue>
<value xsi:type="dcscor:DesignTimeValue">Перечисление.Округления.Окр1</value>
<presentation xsi:type="v8:LocalStringType">
<v8:item>
<v8:lang>ru</v8:lang>
<v8:content>руб.</v8:content>
</v8:item>
</presentation>
</availableValue>
<availableValue>
<value xsi:type="dcscor:DesignTimeValue">Перечисление.Округления.Окр1000</value>
<presentation xsi:type="v8:LocalStringType">
<v8:item>
<v8:lang>ru</v8:lang>
<v8:content>тыс.</v8:content>
</v8:item>
</presentation>
</availableValue>
<availableValue>
<value xsi:type="dcscor:DesignTimeValue">Перечисление.Округления.Окр1000000</value>
<presentation xsi:type="v8:LocalStringType">
<v8:item>
<v8:lang>ru</v8:lang>
<v8:content>млн.</v8:content>
</v8:item>
</presentation>
</availableValue>
</parameter>
<settingsVariant>
<dcsset:name>Основной</dcsset:name>
<dcsset:presentation xsi:type="v8:LocalStringType">
<v8:item>
<v8:lang>ru</v8:lang>
<v8:content>Основной</v8:content>
</v8:item>
</dcsset:presentation>
<dcsset:settings xmlns:style="http://v8.1c.ru/8.1/data/ui/style" xmlns:sys="http://v8.1c.ru/8.1/data/ui/fonts/system" xmlns:web="http://v8.1c.ru/8.1/data/ui/colors/web" xmlns:win="http://v8.1c.ru/8.1/data/ui/colors/windows">
<dcsset:selection>
</dcsset:selection>
<dcsset:item xsi:type="dcsset:StructureItemGroup">
<dcsset:order>
<dcsset:item xsi:type="dcsset:OrderItemAuto" />
</dcsset:order>
<dcsset:selection>
<dcsset:item xsi:type="dcsset:SelectedItemAuto" />
</dcsset:selection>
</dcsset:item>
</dcsset:settings>
</settingsVariant>
</DataCompositionSchema>
@@ -0,0 +1,54 @@
<?xml version="1.0" encoding="UTF-8"?>
<DataCompositionSchema xmlns="http://v8.1c.ru/8.1/data-composition-system/schema" xmlns:dcscom="http://v8.1c.ru/8.1/data-composition-system/common" xmlns:dcscor="http://v8.1c.ru/8.1/data-composition-system/core" xmlns:dcsset="http://v8.1c.ru/8.1/data-composition-system/settings" xmlns:v8="http://v8.1c.ru/8.1/data/core" xmlns:v8ui="http://v8.1c.ru/8.1/data/ui" xmlns:xs="http://www.w3.org/2001/XMLSchema" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance">
<dataSource>
<name>ИсточникДанных1</name>
<dataSourceType>Local</dataSourceType>
</dataSource>
<dataSet xsi:type="DataSetQuery">
<name>Основной</name>
<field xsi:type="DataSetFieldField">
<dataPath>Поле</dataPath>
<field>Поле</field>
<valueType>
<v8:Type>xs:string</v8:Type>
<v8:StringQualifiers>
<v8:Length>0</v8:Length>
<v8:AllowedLength>Variable</v8:AllowedLength>
</v8:StringQualifiers>
</valueType>
</field>
<dataSource>ИсточникДанных1</dataSource>
<query>ВЫБРАТЬ Т.Поле ИЗ Регистр КАК Т</query>
</dataSet>
<parameter>
<name>Контрагент</name>
<valueType>
<v8:Type xmlns:d5p1="http://v8.1c.ru/8.1/data/enterprise/current-config">d5p1:CatalogRef.Контрагенты</v8:Type>
</valueType>
<value xsi:type="dcscor:DesignTimeValue">Справочник.Контрагенты.ПустаяСсылка</value>
<useRestriction>true</useRestriction>
<availableAsField>false</availableAsField>
<use>Always</use>
</parameter>
<settingsVariant>
<dcsset:name>Основной</dcsset:name>
<dcsset:presentation xsi:type="v8:LocalStringType">
<v8:item>
<v8:lang>ru</v8:lang>
<v8:content>Основной</v8:content>
</v8:item>
</dcsset:presentation>
<dcsset:settings xmlns:style="http://v8.1c.ru/8.1/data/ui/style" xmlns:sys="http://v8.1c.ru/8.1/data/ui/fonts/system" xmlns:web="http://v8.1c.ru/8.1/data/ui/colors/web" xmlns:win="http://v8.1c.ru/8.1/data/ui/colors/windows">
<dcsset:selection>
</dcsset:selection>
<dcsset:item xsi:type="dcsset:StructureItemGroup">
<dcsset:order>
<dcsset:item xsi:type="dcsset:OrderItemAuto" />
</dcsset:order>
<dcsset:selection>
<dcsset:item xsi:type="dcsset:SelectedItemAuto" />
</dcsset:selection>
</dcsset:item>
</dcsset:settings>
</settingsVariant>
</DataCompositionSchema>
@@ -0,0 +1,51 @@
<?xml version="1.0" encoding="UTF-8"?>
<DataCompositionSchema xmlns="http://v8.1c.ru/8.1/data-composition-system/schema" xmlns:dcscom="http://v8.1c.ru/8.1/data-composition-system/common" xmlns:dcscor="http://v8.1c.ru/8.1/data-composition-system/core" xmlns:dcsset="http://v8.1c.ru/8.1/data-composition-system/settings" xmlns:v8="http://v8.1c.ru/8.1/data/core" xmlns:v8ui="http://v8.1c.ru/8.1/data/ui" xmlns:xs="http://www.w3.org/2001/XMLSchema" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance">
<dataSource>
<name>ИсточникДанных1</name>
<dataSourceType>Local</dataSourceType>
</dataSource>
<dataSet xsi:type="DataSetQuery">
<name>Основной</name>
<field xsi:type="DataSetFieldField">
<dataPath>Счет</dataPath>
<field>Счет</field>
<valueType>
<v8:Type>xs:string</v8:Type>
<v8:StringQualifiers>
<v8:Length>0</v8:Length>
<v8:AllowedLength>Variable</v8:AllowedLength>
</v8:StringQualifiers>
</valueType>
</field>
<dataSource>ИсточникДанных1</dataSource>
<query>ВЫБРАТЬ Т.Счет ИЗ Регистр КАК Т</query>
</dataSet>
<parameter>
<name>Контрагент</name>
<valueType>
<v8:Type xmlns:d5p1="http://v8.1c.ru/8.1/data/enterprise/current-config">d5p1:CatalogRef.Контрагенты</v8:Type>
</valueType>
<value xsi:type="dcscor:DesignTimeValue">Справочник.Контрагенты.НашаОрганизация</value>
</parameter>
<settingsVariant>
<dcsset:name>Основной</dcsset:name>
<dcsset:presentation xsi:type="v8:LocalStringType">
<v8:item>
<v8:lang>ru</v8:lang>
<v8:content>Основной</v8:content>
</v8:item>
</dcsset:presentation>
<dcsset:settings xmlns:style="http://v8.1c.ru/8.1/data/ui/style" xmlns:sys="http://v8.1c.ru/8.1/data/ui/fonts/system" xmlns:web="http://v8.1c.ru/8.1/data/ui/colors/web" xmlns:win="http://v8.1c.ru/8.1/data/ui/colors/windows">
<dcsset:selection>
</dcsset:selection>
<dcsset:item xsi:type="dcsset:StructureItemGroup">
<dcsset:order>
<dcsset:item xsi:type="dcsset:OrderItemAuto" />
</dcsset:order>
<dcsset:selection>
<dcsset:item xsi:type="dcsset:SelectedItemAuto" />
</dcsset:selection>
</dcsset:item>
</dcsset:settings>
</settingsVariant>
</DataCompositionSchema>
@@ -0,0 +1,83 @@
<?xml version="1.0" encoding="UTF-8"?>
<DataCompositionSchema xmlns="http://v8.1c.ru/8.1/data-composition-system/schema" xmlns:dcscom="http://v8.1c.ru/8.1/data-composition-system/common" xmlns:dcscor="http://v8.1c.ru/8.1/data-composition-system/core" xmlns:dcsset="http://v8.1c.ru/8.1/data-composition-system/settings" xmlns:v8="http://v8.1c.ru/8.1/data/core" xmlns:v8ui="http://v8.1c.ru/8.1/data/ui" xmlns:xs="http://www.w3.org/2001/XMLSchema" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance">
<dataSource>
<name>ИсточникДанных1</name>
<dataSourceType>Local</dataSourceType>
</dataSource>
<dataSet xsi:type="DataSetQuery">
<name>Основной</name>
<field xsi:type="DataSetFieldField">
<dataPath>Счет</dataPath>
<field>Счет</field>
<valueType>
<v8:Type>xs:string</v8:Type>
<v8:StringQualifiers>
<v8:Length>0</v8:Length>
<v8:AllowedLength>Variable</v8:AllowedLength>
</v8:StringQualifiers>
</valueType>
</field>
<field xsi:type="DataSetFieldField">
<dataPath>Сумма</dataPath>
<field>Сумма</field>
<valueType>
<v8:Type>xs:decimal</v8:Type>
<v8:NumberQualifiers>
<v8:Digits>15</v8:Digits>
<v8:FractionDigits>2</v8:FractionDigits>
<v8:AllowedSign>Any</v8:AllowedSign>
</v8:NumberQualifiers>
</valueType>
</field>
<field xsi:type="DataSetFieldField">
<dataPath>Валюта</dataPath>
<field>Валюта</field>
<valueType>
<v8:Type>xs:string</v8:Type>
<v8:StringQualifiers>
<v8:Length>0</v8:Length>
<v8:AllowedLength>Variable</v8:AllowedLength>
</v8:StringQualifiers>
</valueType>
</field>
<dataSource>ИсточникДанных1</dataSource>
<query>ВЫБРАТЬ Т.Счет, Т.Сумма, Т.Валюта ИЗ Регистр КАК Т</query>
</dataSet>
<settingsVariant>
<dcsset:name>Основной</dcsset:name>
<dcsset:presentation xsi:type="v8:LocalStringType">
<v8:item>
<v8:lang>ru</v8:lang>
<v8:content>Основной</v8:content>
</v8:item>
</dcsset:presentation>
<dcsset:settings xmlns:style="http://v8.1c.ru/8.1/data/ui/style" xmlns:sys="http://v8.1c.ru/8.1/data/ui/fonts/system" xmlns:web="http://v8.1c.ru/8.1/data/ui/colors/web" xmlns:win="http://v8.1c.ru/8.1/data/ui/colors/windows">
<dcsset:item xsi:type="dcsset:StructureItemGroup">
<dcsset:name>ДанныеОтчета</dcsset:name>
<dcsset:groupItems>
<dcsset:item xsi:type="dcsset:GroupItemField">
<dcsset:field>Валюта</dcsset:field>
<dcsset:groupType>Items</dcsset:groupType>
<dcsset:periodAdditionType>None</dcsset:periodAdditionType>
<dcsset:periodAdditionBegin xsi:type="xs:dateTime">0001-01-01T00:00:00</dcsset:periodAdditionBegin>
<dcsset:periodAdditionEnd xsi:type="xs:dateTime">0001-01-01T00:00:00</dcsset:periodAdditionEnd>
</dcsset:item>
</dcsset:groupItems>
<dcsset:order>
<dcsset:item xsi:type="dcsset:OrderItemAuto" />
</dcsset:order>
<dcsset:selection>
<dcsset:item xsi:type="dcsset:SelectedItemAuto" />
<dcsset:item xsi:type="dcsset:SelectedItemField">
<dcsset:field>Счет</dcsset:field>
</dcsset:item>
<dcsset:item xsi:type="dcsset:SelectedItemField">
<dcsset:field>Сумма</dcsset:field>
</dcsset:item>
</dcsset:selection>
</dcsset:item>
<dcsset:selection>
</dcsset:selection>
</dcsset:settings>
</settingsVariant>
</DataCompositionSchema>
@@ -0,0 +1,55 @@
<?xml version="1.0" encoding="UTF-8"?>
<DataCompositionSchema xmlns="http://v8.1c.ru/8.1/data-composition-system/schema" xmlns:dcscom="http://v8.1c.ru/8.1/data-composition-system/common" xmlns:dcscor="http://v8.1c.ru/8.1/data-composition-system/core" xmlns:dcsset="http://v8.1c.ru/8.1/data-composition-system/settings" xmlns:v8="http://v8.1c.ru/8.1/data/core" xmlns:v8ui="http://v8.1c.ru/8.1/data/ui" xmlns:xs="http://www.w3.org/2001/XMLSchema" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance">
<dataSource>
<name>ИсточникДанных1</name>
<dataSourceType>Local</dataSourceType>
</dataSource>
<dataSet xsi:type="DataSetQuery">
<name>Основной</name>
<field xsi:type="DataSetFieldField">
<dataPath>СтароеИмя1</dataPath>
<field>СтароеИмя1</field>
<valueType>
<v8:Type>xs:string</v8:Type>
<v8:StringQualifiers>
<v8:Length>0</v8:Length>
<v8:AllowedLength>Variable</v8:AllowedLength>
</v8:StringQualifiers>
</valueType>
</field>
<field xsi:type="DataSetFieldField">
<dataPath>СтароеИмя2</dataPath>
<field>СтароеИмя2</field>
<valueType>
<v8:Type>xs:string</v8:Type>
<v8:StringQualifiers>
<v8:Length>0</v8:Length>
<v8:AllowedLength>Variable</v8:AllowedLength>
</v8:StringQualifiers>
</valueType>
</field>
<dataSource>ИсточникДанных1</dataSource>
<query>ВЫБРАТЬ Т.НовоеИмя1, Т.НовоеИмя2 ИЗ Регистр КАК Т</query>
</dataSet>
<settingsVariant>
<dcsset:name>Основной</dcsset:name>
<dcsset:presentation xsi:type="v8:LocalStringType">
<v8:item>
<v8:lang>ru</v8:lang>
<v8:content>Основной</v8:content>
</v8:item>
</dcsset:presentation>
<dcsset:settings xmlns:style="http://v8.1c.ru/8.1/data/ui/style" xmlns:sys="http://v8.1c.ru/8.1/data/ui/fonts/system" xmlns:web="http://v8.1c.ru/8.1/data/ui/colors/web" xmlns:win="http://v8.1c.ru/8.1/data/ui/colors/windows">
<dcsset:selection>
</dcsset:selection>
<dcsset:item xsi:type="dcsset:StructureItemGroup">
<dcsset:order>
<dcsset:item xsi:type="dcsset:OrderItemAuto" />
</dcsset:order>
<dcsset:selection>
<dcsset:item xsi:type="dcsset:SelectedItemAuto" />
</dcsset:selection>
</dcsset:item>
</dcsset:settings>
</settingsVariant>
</DataCompositionSchema>
@@ -0,0 +1,44 @@
<?xml version="1.0" encoding="UTF-8"?>
<DataCompositionSchema xmlns="http://v8.1c.ru/8.1/data-composition-system/schema" xmlns:dcscom="http://v8.1c.ru/8.1/data-composition-system/common" xmlns:dcscor="http://v8.1c.ru/8.1/data-composition-system/core" xmlns:dcsset="http://v8.1c.ru/8.1/data-composition-system/settings" xmlns:v8="http://v8.1c.ru/8.1/data/core" xmlns:v8ui="http://v8.1c.ru/8.1/data/ui" xmlns:xs="http://www.w3.org/2001/XMLSchema" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance">
<dataSource>
<name>ИсточникДанных1</name>
<dataSourceType>Local</dataSourceType>
</dataSource>
<dataSet xsi:type="DataSetQuery">
<name>Основной</name>
<field xsi:type="DataSetFieldField">
<dataPath>УникальноеИмя</dataPath>
<field>УникальноеИмя</field>
<valueType>
<v8:Type>xs:string</v8:Type>
<v8:StringQualifiers>
<v8:Length>0</v8:Length>
<v8:AllowedLength>Variable</v8:AllowedLength>
</v8:StringQualifiers>
</valueType>
</field>
<dataSource>ИсточникДанных1</dataSource>
<query>ВЫБРАТЬ Т.НовоеИмя ИЗ Регистр КАК Т</query>
</dataSet>
<settingsVariant>
<dcsset:name>Основной</dcsset:name>
<dcsset:presentation xsi:type="v8:LocalStringType">
<v8:item>
<v8:lang>ru</v8:lang>
<v8:content>Основной</v8:content>
</v8:item>
</dcsset:presentation>
<dcsset:settings xmlns:style="http://v8.1c.ru/8.1/data/ui/style" xmlns:sys="http://v8.1c.ru/8.1/data/ui/fonts/system" xmlns:web="http://v8.1c.ru/8.1/data/ui/colors/web" xmlns:win="http://v8.1c.ru/8.1/data/ui/colors/windows">
<dcsset:selection>
</dcsset:selection>
<dcsset:item xsi:type="dcsset:StructureItemGroup">
<dcsset:order>
<dcsset:item xsi:type="dcsset:OrderItemAuto" />
</dcsset:order>
<dcsset:selection>
<dcsset:item xsi:type="dcsset:SelectedItemAuto" />
</dcsset:selection>
</dcsset:item>
</dcsset:settings>
</settingsVariant>
</DataCompositionSchema>
@@ -0,0 +1,50 @@
<?xml version="1.0" encoding="UTF-8"?>
<DataCompositionSchema xmlns="http://v8.1c.ru/8.1/data-composition-system/schema" xmlns:dcscom="http://v8.1c.ru/8.1/data-composition-system/common" xmlns:dcscor="http://v8.1c.ru/8.1/data-composition-system/core" xmlns:dcsset="http://v8.1c.ru/8.1/data-composition-system/settings" xmlns:v8="http://v8.1c.ru/8.1/data/core" xmlns:v8ui="http://v8.1c.ru/8.1/data/ui" xmlns:xs="http://www.w3.org/2001/XMLSchema" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance">
<dataSource>
<name>ИсточникДанных1</name>
<dataSourceType>Local</dataSourceType>
</dataSource>
<dataSet xsi:type="DataSetQuery">
<name>Основной</name>
<field xsi:type="DataSetFieldField">
<dataPath>Сумма</dataPath>
<field>Сумма</field>
<role>
<dcscom:balance>true</dcscom:balance>
<dcscom:balanceGroupName>Сумма</dcscom:balanceGroupName>
<dcscom:balanceType>OpeningBalance</dcscom:balanceType>
</role>
<valueType>
<v8:Type>xs:decimal</v8:Type>
<v8:NumberQualifiers>
<v8:Digits>15</v8:Digits>
<v8:FractionDigits>2</v8:FractionDigits>
<v8:AllowedSign>Any</v8:AllowedSign>
</v8:NumberQualifiers>
</valueType>
</field>
<dataSource>ИсточникДанных1</dataSource>
<query>ВЫБРАТЬ Т.Сумма ИЗ Регистр КАК Т</query>
</dataSet>
<settingsVariant>
<dcsset:name>Основной</dcsset:name>
<dcsset:presentation xsi:type="v8:LocalStringType">
<v8:item>
<v8:lang>ru</v8:lang>
<v8:content>Основной</v8:content>
</v8:item>
</dcsset:presentation>
<dcsset:settings xmlns:style="http://v8.1c.ru/8.1/data/ui/style" xmlns:sys="http://v8.1c.ru/8.1/data/ui/fonts/system" xmlns:web="http://v8.1c.ru/8.1/data/ui/colors/web" xmlns:win="http://v8.1c.ru/8.1/data/ui/colors/windows">
<dcsset:selection>
</dcsset:selection>
<dcsset:item xsi:type="dcsset:StructureItemGroup">
<dcsset:order>
<dcsset:item xsi:type="dcsset:OrderItemAuto" />
</dcsset:order>
<dcsset:selection>
<dcsset:item xsi:type="dcsset:SelectedItemAuto" />
</dcsset:selection>
</dcsset:item>
</dcsset:settings>
</settingsVariant>
</DataCompositionSchema>
@@ -0,0 +1,45 @@
<?xml version="1.0" encoding="UTF-8"?>
<DataCompositionSchema xmlns="http://v8.1c.ru/8.1/data-composition-system/schema" xmlns:dcscom="http://v8.1c.ru/8.1/data-composition-system/common" xmlns:dcscor="http://v8.1c.ru/8.1/data-composition-system/core" xmlns:dcsset="http://v8.1c.ru/8.1/data-composition-system/settings" xmlns:v8="http://v8.1c.ru/8.1/data/core" xmlns:v8ui="http://v8.1c.ru/8.1/data/ui" xmlns:xs="http://www.w3.org/2001/XMLSchema" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance">
<dataSource>
<name>ИсточникДанных1</name>
<dataSourceType>Local</dataSourceType>
</dataSource>
<dataSet xsi:type="DataSetQuery">
<name>Основной</name>
<field xsi:type="DataSetFieldField">
<dataPath>Сумма</dataPath>
<field>Сумма</field>
<valueType>
<v8:Type>xs:decimal</v8:Type>
<v8:NumberQualifiers>
<v8:Digits>15</v8:Digits>
<v8:FractionDigits>2</v8:FractionDigits>
<v8:AllowedSign>Any</v8:AllowedSign>
</v8:NumberQualifiers>
</valueType>
</field>
<dataSource>ИсточникДанных1</dataSource>
<query>ВЫБРАТЬ Т.Сумма ИЗ Регистр КАК Т</query>
</dataSet>
<settingsVariant>
<dcsset:name>Основной</dcsset:name>
<dcsset:presentation xsi:type="v8:LocalStringType">
<v8:item>
<v8:lang>ru</v8:lang>
<v8:content>Основной</v8:content>
</v8:item>
</dcsset:presentation>
<dcsset:settings xmlns:style="http://v8.1c.ru/8.1/data/ui/style" xmlns:sys="http://v8.1c.ru/8.1/data/ui/fonts/system" xmlns:web="http://v8.1c.ru/8.1/data/ui/colors/web" xmlns:win="http://v8.1c.ru/8.1/data/ui/colors/windows">
<dcsset:selection>
</dcsset:selection>
<dcsset:item xsi:type="dcsset:StructureItemGroup">
<dcsset:order>
<dcsset:item xsi:type="dcsset:OrderItemAuto" />
</dcsset:order>
<dcsset:selection>
<dcsset:item xsi:type="dcsset:SelectedItemAuto" />
</dcsset:selection>
</dcsset:item>
</dcsset:settings>
</settingsVariant>
</DataCompositionSchema>
@@ -0,0 +1,90 @@
<?xml version="1.0" encoding="UTF-8"?>
<DataCompositionSchema xmlns="http://v8.1c.ru/8.1/data-composition-system/schema" xmlns:dcscom="http://v8.1c.ru/8.1/data-composition-system/common" xmlns:dcscor="http://v8.1c.ru/8.1/data-composition-system/core" xmlns:dcsset="http://v8.1c.ru/8.1/data-composition-system/settings" xmlns:v8="http://v8.1c.ru/8.1/data/core" xmlns:v8ui="http://v8.1c.ru/8.1/data/ui" xmlns:xs="http://www.w3.org/2001/XMLSchema" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance">
<dataSource>
<name>ИсточникДанных1</name>
<dataSourceType>Local</dataSourceType>
</dataSource>
<dataSet xsi:type="DataSetQuery">
<name>Основной</name>
<field xsi:type="DataSetFieldField">
<dataPath>Валюта</dataPath>
<field>Валюта</field>
<valueType>
<v8:Type>xs:string</v8:Type>
<v8:StringQualifiers>
<v8:Length>0</v8:Length>
<v8:AllowedLength>Variable</v8:AllowedLength>
</v8:StringQualifiers>
</valueType>
</field>
<field xsi:type="DataSetFieldField">
<dataPath>Банк</dataPath>
<field>Банк</field>
<valueType>
<v8:Type>xs:string</v8:Type>
<v8:StringQualifiers>
<v8:Length>0</v8:Length>
<v8:AllowedLength>Variable</v8:AllowedLength>
</v8:StringQualifiers>
</valueType>
</field>
<field xsi:type="DataSetFieldField">
<dataPath>ИНН</dataPath>
<field>ИНН</field>
<valueType>
<v8:Type>xs:string</v8:Type>
<v8:StringQualifiers>
<v8:Length>0</v8:Length>
<v8:AllowedLength>Variable</v8:AllowedLength>
</v8:StringQualifiers>
</valueType>
</field>
<dataSource>ИсточникДанных1</dataSource>
<query>ВЫБРАТЬ Т.Валюта, Т.Банк, Т.ИНН ИЗ Регистр КАК Т</query>
</dataSet>
<settingsVariant>
<dcsset:name>Основной</dcsset:name>
<dcsset:presentation xsi:type="v8:LocalStringType">
<v8:item>
<v8:lang>ru</v8:lang>
<v8:content>Основной</v8:content>
</v8:item>
</dcsset:presentation>
<dcsset:settings xmlns:style="http://v8.1c.ru/8.1/data/ui/style" xmlns:sys="http://v8.1c.ru/8.1/data/ui/fonts/system" xmlns:web="http://v8.1c.ru/8.1/data/ui/colors/web" xmlns:win="http://v8.1c.ru/8.1/data/ui/colors/windows">
<dcsset:item xsi:type="dcsset:StructureItemGroup">
<dcsset:name>ДанныеОтчета</dcsset:name>
<dcsset:groupItems>
<dcsset:item xsi:type="dcsset:GroupItemField">
<dcsset:field>Валюта</dcsset:field>
<dcsset:groupType>Items</dcsset:groupType>
<dcsset:periodAdditionType>None</dcsset:periodAdditionType>
<dcsset:periodAdditionBegin xsi:type="xs:dateTime">0001-01-01T00:00:00</dcsset:periodAdditionBegin>
<dcsset:periodAdditionEnd xsi:type="xs:dateTime">0001-01-01T00:00:00</dcsset:periodAdditionEnd>
</dcsset:item>
<dcsset:item xsi:type="dcsset:GroupItemField">
<dcsset:field>Банк</dcsset:field>
<dcsset:groupType>Items</dcsset:groupType>
<dcsset:periodAdditionType>None</dcsset:periodAdditionType>
<dcsset:periodAdditionBegin xsi:type="xs:dateTime">0001-01-01T00:00:00</dcsset:periodAdditionBegin>
<dcsset:periodAdditionEnd xsi:type="xs:dateTime">0001-01-01T00:00:00</dcsset:periodAdditionEnd>
</dcsset:item>
<dcsset:item xsi:type="dcsset:GroupItemField">
<dcsset:field>ИНН</dcsset:field>
<dcsset:groupType>Items</dcsset:groupType>
<dcsset:periodAdditionType>None</dcsset:periodAdditionType>
<dcsset:periodAdditionBegin xsi:type="xs:dateTime">0001-01-01T00:00:00</dcsset:periodAdditionBegin>
<dcsset:periodAdditionEnd xsi:type="xs:dateTime">0001-01-01T00:00:00</dcsset:periodAdditionEnd>
</dcsset:item>
</dcsset:groupItems>
<dcsset:order>
<dcsset:item xsi:type="dcsset:OrderItemAuto" />
</dcsset:order>
<dcsset:selection>
<dcsset:item xsi:type="dcsset:SelectedItemAuto" />
</dcsset:selection>
</dcsset:item>
<dcsset:selection>
</dcsset:selection>
</dcsset:settings>
</settingsVariant>
</DataCompositionSchema>
@@ -0,0 +1,887 @@
// build-webtest-config.test.mjs — Integration test: build synthetic configuration for web-test regression
// Extends base-config with: diverse field types, hierarchical catalog, two-tab form,
// second subsystem, full-rights role.
// Steps: cf-init → meta-compile → form-add + form-compile → skd-compile
// → subsystem-compile → role-compile → cf-validate
export const name = 'Сборка конфигурации для web-test';
export const setup = 'none';
export const cache = 'webtest-config';
export const steps = [
// ── 1. Init empty configuration ──
{
name: 'cf-init: пустая конфигурация',
script: 'cf-init/scripts/cf-init',
args: { '-Name': 'ТестоваяВебКонфигурация', '-OutputDir': '{workDir}' },
validate: { script: 'cf-validate/scripts/cf-validate', flag: '-ConfigPath' },
},
// ── 2. Metadata objects ──
// Справочник Контрагенты — простой, для CRUD и ссылочных полей
{
name: 'meta-compile: Справочник Контрагенты',
script: 'meta-compile/scripts/meta-compile',
input: {
type: 'Catalog', name: 'Контрагенты',
codeLength: 9, descriptionLength: 100,
attributes: [
{ name: 'ИНН', type: 'String', length: 12 },
{ name: 'Телефон', type: 'String', length: 20 },
{ name: 'Адрес', type: 'String', length: 200 },
{ name: 'КодКПП', type: 'String', length: 9 },
],
},
args: { '-JsonPath': '{inputFile}', '-OutputDir': '{workDir}' },
validate: { script: 'meta-validate/scripts/meta-validate', flag: '-ObjectPath', path: 'Catalogs/Контрагенты' },
},
// Справочник Организации — маленький список с быстрым выбором (selectValue dropdown)
{
name: 'meta-compile: Справочник Организации',
script: 'meta-compile/scripts/meta-compile',
input: {
type: 'Catalog', name: 'Организации',
codeLength: 9, descriptionLength: 100,
quickChoice: true,
attributes: [
{ name: 'ИНН', type: 'String', length: 12 },
{ name: 'КПП', type: 'String', length: 9 },
],
},
args: { '-JsonPath': '{inputFile}', '-OutputDir': '{workDir}' },
validate: { script: 'meta-validate/scripts/meta-validate', flag: '-ObjectPath', path: 'Catalogs/Организации' },
},
// Подчинённый каталог КонтактныеЛица — для теста getFormState.navigation (subordinate-nav)
{
name: 'meta-compile: Справочник КонтактныеЛица (подчинённый Контрагентам)',
script: 'meta-compile/scripts/meta-compile',
input: {
type: 'Catalog', name: 'КонтактныеЛица',
codeLength: 9, descriptionLength: 100,
owners: ['Catalog.Контрагенты'],
attributes: [
{ name: 'Должность', type: 'String', length: 100 },
{ name: 'Телефон', type: 'String', length: 20 },
],
},
args: { '-JsonPath': '{inputFile}', '-OutputDir': '{workDir}' },
validate: { script: 'meta-validate/scripts/meta-validate', flag: '-ObjectPath', path: 'Catalogs/КонтактныеЛица' },
},
// Справочник Номенклатура — иерархический, все типы полей
{
name: 'meta-compile: Справочник Номенклатура',
script: 'meta-compile/scripts/meta-compile',
input: {
type: 'Catalog', name: 'Номенклатура',
codeLength: 11, descriptionLength: 150,
hierarchical: true,
attributes: [
{ name: 'Артикул', type: 'String', length: 25 },
{ name: 'Цена', type: 'Number', length: 15, precision: 2 },
{ name: 'Активен', type: 'Boolean' },
{ name: 'ДатаПоступления', type: 'Date' },
{ name: 'Комментарий', type: 'String' },
{ name: 'ЕдиницаИзмерения', type: 'String', length: 10 },
{ name: 'ВидНоменклатуры', type: 'EnumRef.ВидыНоменклатуры' },
{ name: 'КатегорияЦены', type: 'EnumRef.КатегорииЦен' },
{ name: 'СпособУчёта', type: 'EnumRef.СпособыУчёта' },
],
fillChecking: { 'Description': 'ShowError' },
},
args: { '-JsonPath': '{inputFile}', '-OutputDir': '{workDir}' },
validate: { script: 'meta-validate/scripts/meta-validate', flag: '-ObjectPath', path: 'Catalogs/Номенклатура' },
},
// Перечисление ВидыНоменклатуры
{
name: 'meta-compile: Перечисление ВидыНоменклатуры',
script: 'meta-compile/scripts/meta-compile',
input: {
type: 'Enum', name: 'ВидыНоменклатуры',
values: ['Товар', 'Услуга', 'Работа'],
},
args: { '-JsonPath': '{inputFile}', '-OutputDir': '{workDir}' },
validate: { script: 'meta-validate/scripts/meta-validate', flag: '-ObjectPath', path: 'Enums/ВидыНоменклатуры' },
},
// Перечисление КатегорииЦен — для будущего radio-button теста (fillFields branch #3)
{
name: 'meta-compile: Перечисление КатегорииЦен',
script: 'meta-compile/scripts/meta-compile',
input: {
type: 'Enum', name: 'КатегорииЦен',
values: ['Розничная', 'Оптовая', 'Закупочная'],
},
args: { '-JsonPath': '{inputFile}', '-OutputDir': '{workDir}' },
validate: { script: 'meta-validate/scripts/meta-validate', flag: '-ObjectPath', path: 'Enums/КатегорииЦен' },
},
// Перечисление СпособыУчёта — для radio с видом Tumbler (fillFields branch #3)
{
name: 'meta-compile: Перечисление СпособыУчёта',
script: 'meta-compile/scripts/meta-compile',
input: {
type: 'Enum', name: 'СпособыУчёта',
values: ['ПоСреднему', 'ФИФО'],
},
args: { '-JsonPath': '{inputFile}', '-OutputDir': '{workDir}' },
validate: { script: 'meta-validate/scripts/meta-validate', flag: '-ObjectPath', path: 'Enums/СпособыУчёта' },
},
// Документ ПриходнаяНакладная — шапка + ТЧ
{
name: 'meta-compile: Документ ПриходнаяНакладная',
script: 'meta-compile/scripts/meta-compile',
input: {
type: 'Document', name: 'ПриходнаяНакладная',
attributes: [
{ name: 'Организация', type: 'CatalogRef.Организации' },
// choiceHistoryOnInput=DontUse: предотвращает выбор через историю в smoke-тестах
// (04-selectvalue/direct-form проверяет open-form path; история обходит его).
{ name: 'Контрагент', type: 'CatalogRef.Контрагенты', choiceHistoryOnInput: 'DontUse' },
{ name: 'Склад', type: 'String', length: 50 },
// Источник — составной тип (для 03-fillfields/composite).
// Платформа покажет селектор типа в UI перед выбором значения.
{ name: 'Источник', type: 'CatalogRef.Контрагенты + CatalogRef.Номенклатура + CatalogRef.Организации' },
// Поставщик — обычная ссылка, но на форме элемент с textEdit:false
// (для 03-fillfields/direct-edit-form). Ручной ввод запрещён,
// выбор только через pick-кнопку → форма выбора.
{ name: 'Поставщик', type: 'CatalogRef.Контрагенты' },
// Менеджер — ссылка с дефолтным choiceHistoryOnInput=Auto (история включена,
// для 04-selectvalue/show-all-form). После первого выбора платформа
// запоминает значение и при повторном вводе показывает dropdown
// с историей + кнопку «Показать все» → форма выбора.
{ name: 'Менеджер', type: 'CatalogRef.Контрагенты' },
{ name: 'Комментарий', type: 'String', length: 200 },
],
tabularSections: [{
name: 'Товары',
attributes: [
{ name: 'Номенклатура', type: 'CatalogRef.Номенклатура' },
{ name: 'Количество', type: 'Number', length: 15, precision: 3 },
{ name: 'Цена', type: 'Number', length: 15, precision: 2 },
{ name: 'Сумма', type: 'Number', length: 15, precision: 2 },
{ name: 'Согласовано', type: 'Boolean' },
// Источник — составной тип в ТЧ (для edit-dblclick через выбор типа)
{ name: 'Источник', type: 'CatalogRef.Контрагенты + CatalogRef.Номенклатура + CatalogRef.Организации' },
],
}],
},
args: { '-JsonPath': '{inputFile}', '-OutputDir': '{workDir}' },
validate: { script: 'meta-validate/scripts/meta-validate', flag: '-ObjectPath', path: 'Documents/ПриходнаяНакладная' },
},
// Регистр сведений КурсыВалют (Independent — без регистратора)
{
name: 'meta-compile: Регистр сведений КурсыВалют',
script: 'meta-compile/scripts/meta-compile',
input: {
type: 'InformationRegister', name: 'КурсыВалют',
writeMode: 'Independent',
dimensions: [
{ name: 'Валюта', type: 'String', length: 10 },
],
resources: [
{ name: 'Курс', type: 'Number', length: 10, precision: 4 },
{ name: 'Кратность', type: 'Number', length: 10 },
],
},
args: { '-JsonPath': '{inputFile}', '-OutputDir': '{workDir}' },
validate: { script: 'meta-validate/scripts/meta-validate', flag: '-ObjectPath', path: 'InformationRegisters/КурсыВалют' },
},
// Константа ОсновнаяВалюта
{
name: 'meta-compile: Константа ОсновнаяВалюта',
script: 'meta-compile/scripts/meta-compile',
input: {
type: 'Constant', name: 'ОсновнаяВалюта',
valueType: 'String', length: 10,
},
args: { '-JsonPath': '{inputFile}', '-OutputDir': '{workDir}' },
validate: { script: 'meta-validate/scripts/meta-validate', flag: '-ObjectPath', path: 'Constants/ОсновнаяВалюта' },
},
// Константа ДанныеЗаполнены — флаг первоначального заполнения фикстур
{
name: 'meta-compile: Константа ДанныеЗаполнены',
script: 'meta-compile/scripts/meta-compile',
input: {
type: 'Constant', name: 'ДанныеЗаполнены',
valueType: 'Boolean',
},
args: { '-JsonPath': '{inputFile}', '-OutputDir': '{workDir}' },
validate: { script: 'meta-validate/scripts/meta-validate', flag: '-ObjectPath', path: 'Constants/ДанныеЗаполнены' },
},
// Общий модуль ОбщиеФункции
{
name: 'meta-compile: Общий модуль ОбщиеФункции',
script: 'meta-compile/scripts/meta-compile',
input: {
type: 'CommonModule', name: 'ОбщиеФункции',
server: true, serverCall: true, clientManagedApplication: false,
},
args: { '-JsonPath': '{inputFile}', '-OutputDir': '{workDir}' },
validate: { script: 'meta-validate/scripts/meta-validate', flag: '-ObjectPath', path: 'CommonModules/ОбщиеФункции' },
},
{
name: 'writeFile: ОбщиеФункции Module.bsl',
writeFile: 'CommonModules/ОбщиеФункции/Ext/Module.bsl',
content: `Процедура ПоказатьСообщение() Экспорт
\tСообщить("Тестовое сообщение");
КонецПроцедуры
Процедура ВызватьТестовоеИсключение() Экспорт
\tВызватьИсключение "Тестовое исключение";
КонецПроцедуры
Процедура ЗаполнитьФикстурыЕслиНужно() Экспорт
\tЕсли Константы.ДанныеЗаполнены.Получить() Тогда
\t\tВозврат;
\tКонецЕсли;
\tНачатьТранзакцию();
\tПопытка
\t\tЗаполнитьОрганизации();
\t\tЗаполнитьКонтрагентов();
\t\tЗаполнитьНоменклатуру();
\t\tЗаполнитьДокументы();
\t\tКонстанты.ДанныеЗаполнены.Установить(Истина);
\t\tЗафиксироватьТранзакцию();
\tИсключение
\t\tОтменитьТранзакцию();
\t\tВызватьИсключение;
\tКонецПопытки;
КонецПроцедуры
Процедура ЗаполнитьОрганизации()
\tСписок = Новый Массив;
\tСписок.Добавить(Новый Структура("Имя,ИНН,КПП", "Альфа", "7800000001", "780000001"));
\tСписок.Добавить(Новый Структура("Имя,ИНН,КПП", "Бета", "7800000002", "780000002"));
\tДля Каждого Запись Из Список Цикл
\t\tЭлемент = Справочники.Организации.СоздатьЭлемент();
\t\tЭлемент.Наименование = Запись.Имя;
\t\tЭлемент.ИНН = Запись.ИНН;
\t\tЭлемент.КПП = Запись.КПП;
\t\tЭлемент.Записать();
\tКонецЦикла;
КонецПроцедуры
Процедура ЗаполнитьКонтрагентов()
\tСписок = Новый Массив;
\tСписок.Добавить(Новый Структура("Имя,ИНН", "ООО Север", "7700000001"));
\tСписок.Добавить(Новый Структура("Имя,ИНН", "ООО Юг", "7700000002"));
\tСписок.Добавить(Новый Структура("Имя,ИНН", "ООО Восток", "7700000003"));
\tСписок.Добавить(Новый Структура("Имя,ИНН", "АО Запад", "7700000004"));
\tДля Каждого Запись Из Список Цикл
\t\tЭлемент = Справочники.Контрагенты.СоздатьЭлемент();
\t\tЭлемент.Наименование = Запись.Имя;
\t\tЭлемент.ИНН = Запись.ИНН;
\t\tЭлемент.Записать();
\tКонецЦикла;
КонецПроцедуры
Процедура ЗаполнитьНоменклатуру()
\tГруппаТовары = СоздатьГруппуНоменклатуры("Товары");
\tГруппаУслуги = СоздатьГруппуНоменклатуры("Услуги");
\tДля Сч = 1 По 15 Цикл
\t\tЭлемент = Справочники.Номенклатура.СоздатьЭлемент();
\t\tЭлемент.Родитель = ГруппаТовары;
\t\tЭлемент.Наименование = "Товар " + Формат(Сч, "ЧЦ=2; ЧВН=");
\t\tЭлемент.Артикул = "T" + Формат(Сч, "ЧЦ=4; ЧВН=");
\t\tЭлемент.Цена = 100 * Сч;
\t\tЭлемент.Активен = Истина;
\t\tЭлемент.ВидНоменклатуры = Перечисления.ВидыНоменклатуры.Товар;
\t\tЭлемент.Записать();
\tКонецЦикла;
\tДля Сч = 1 По 10 Цикл
\t\tЭлемент = Справочники.Номенклатура.СоздатьЭлемент();
\t\tЭлемент.Родитель = ГруппаУслуги;
\t\tЭлемент.Наименование = "Услуга " + Формат(Сч, "ЧЦ=2; ЧВН=");
\t\tЭлемент.Артикул = "U" + Формат(Сч, "ЧЦ=4; ЧВН=");
\t\tЭлемент.Цена = 500 * Сч;
\t\tЭлемент.Активен = Истина;
\t\tЭлемент.ВидНоменклатуры = Перечисления.ВидыНоменклатуры.Услуга;
\t\tЭлемент.Записать();
\tКонецЦикла;
КонецПроцедуры
Функция СоздатьГруппуНоменклатуры(Имя)
\tГруппа = Справочники.Номенклатура.СоздатьГруппу();
\tГруппа.Наименование = Имя;
\tГруппа.Записать();
\tВозврат Группа.Ссылка;
КонецФункции
Процедура ЗаполнитьДокументы()
\tЗапросК = Новый Запрос("ВЫБРАТЬ ПЕРВЫЕ 5 Контрагенты.Ссылка КАК Контрагент ИЗ Справочник.Контрагенты КАК Контрагенты");
\tКонтрагенты = ЗапросК.Выполнить().Выгрузить().ВыгрузитьКолонку("Контрагент");
\tЗапросН = Новый Запрос("ВЫБРАТЬ ПЕРВЫЕ 10 Номенклатура.Ссылка КАК Номенклатура ИЗ Справочник.Номенклатура КАК Номенклатура ГДЕ НЕ Номенклатура.ЭтоГруппа");
\tНоменклатура = ЗапросН.Выполнить().Выгрузить().ВыгрузитьКолонку("Номенклатура");
\tЕсли Контрагенты.Количество() = 0 Или Номенклатура.Количество() = 0 Тогда
\t\tВозврат;
\tКонецЕсли;
\tЗапросО = Новый Запрос("ВЫБРАТЬ ПЕРВЫЕ 1 Организации.Ссылка КАК Организация ИЗ Справочник.Организации КАК Организации");
\tВыборкаО = ЗапросО.Выполнить().Выбрать();
\tОрганизация = Неопределено;
\tЕсли ВыборкаО.Следующий() Тогда
\t\tОрганизация = ВыборкаО.Организация;
\tКонецЕсли;
\tДля Сч = 1 По 3 Цикл
\t\tДок = Документы.ПриходнаяНакладная.СоздатьДокумент();
\t\tДок.Дата = ТекущаяДата();
\t\tДок.Организация = Организация;
\t\tДок.Контрагент = Контрагенты[(Сч - 1) % Контрагенты.Количество()];
\t\tДок.Склад = "Основной";
\t\tДля Поз = 1 По 3 Цикл
\t\t\tСтрока = Док.Товары.Добавить();
\t\t\tСтрока.Номенклатура = Номенклатура[(Сч * Поз) % Номенклатура.Количество()];
\t\t\tСтрока.Количество = Поз * 10;
\t\t\tСтрока.Цена = Поз * 100;
\t\t\tСтрока.Сумма = Строка.Количество * Строка.Цена;
\t\tКонецЦикла;
\t\tДок.Записать(РежимЗаписиДокумента.Запись);
\tКонецЦикла;
КонецПроцедуры
`,
},
// ManagedApplicationModule — вызывает заполнение фикстур при первом запуске
{
name: 'writeFile: ManagedApplicationModule.bsl',
writeFile: 'Ext/ManagedApplicationModule.bsl',
content: `&НаКлиенте
Процедура ПриНачалеРаботыСистемы()
\tОбщиеФункции.ЗаполнитьФикстурыЕслиНужно();
КонецПроцедуры
`,
},
// Раскладка панелей (Ext/ClientApplicationInterface.xml) теперь создаётся
// самим cf-init с ERP-дефолтом — отдельная запись больше не нужна.
// Обработка ТестовыеОшибки — для тестов errors balloon/messages/modal (10-validation)
{
name: 'meta-compile: Обработка ТестовыеОшибки',
script: 'meta-compile/scripts/meta-compile',
input: {
type: 'DataProcessor', name: 'ТестовыеОшибки',
},
args: { '-JsonPath': '{inputFile}', '-OutputDir': '{workDir}' },
validate: { script: 'meta-validate/scripts/meta-validate', flag: '-ObjectPath', path: 'DataProcessors/ТестовыеОшибки' },
},
// Обработка ДеревоНоменклатуры — реквизит формы ДеревоЗначений с данными
// справочника Номенклатура для тестов tree-grid (05-table/direct-edit-form,
// 08-hierarchy/tree-edit).
{
name: 'meta-compile: Обработка ДеревоНоменклатуры',
script: 'meta-compile/scripts/meta-compile',
input: {
type: 'DataProcessor', name: 'ДеревоНоменклатуры',
},
args: { '-JsonPath': '{inputFile}', '-OutputDir': '{workDir}' },
validate: { script: 'meta-validate/scripts/meta-validate', flag: '-ObjectPath', path: 'DataProcessors/ДеревоНоменклатуры' },
},
// Отчёт ОстаткиТоваров
{
name: 'meta-compile: Отчёт ОстаткиТоваров',
script: 'meta-compile/scripts/meta-compile',
input: {
type: 'Report', name: 'ОстаткиТоваров',
},
args: { '-JsonPath': '{inputFile}', '-OutputDir': '{workDir}' },
validate: { script: 'meta-validate/scripts/meta-validate', flag: '-ObjectPath', path: 'Reports/ОстаткиТоваров' },
},
// ── 3. Forms ──
// Форма элемента Контрагенты — простая
{
name: 'form-add: Форма элемента Контрагенты',
script: 'form-add/scripts/form-add',
args: { '-ObjectPath': '{workDir}/Catalogs/Контрагенты.xml', '-FormName': 'ФормаЭлемента' },
},
{
name: 'form-compile: Форма элемента Контрагенты',
script: 'form-compile/scripts/form-compile',
input: {
title: 'Контрагент',
attributes: [
{ name: 'Объект', type: 'CatalogObject.Контрагенты', main: true },
],
elements: [
{ input: 'Наименование', path: 'Объект.Description', title: 'Наименование' },
{ input: 'ИНН', path: 'Объект.ИНН', title: 'ИНН' },
{ input: 'Телефон', path: 'Объект.Телефон', title: 'Телефон' },
{ input: 'Адрес', path: 'Объект.Адрес', title: 'Адрес' },
],
},
args: { '-JsonPath': '{inputFile}', '-OutputPath': '{workDir}/Catalogs/Контрагенты/Forms/ФормаЭлемента/Ext/Form.xml' },
validate: { script: 'form-validate/scripts/form-validate', flag: '-FormPath', path: 'Catalogs/Контрагенты/Forms/ФормаЭлемента/Ext/Form.xml' },
},
// Форма элемента КонтактныеЛица + список — для подчинённого каталога
{
name: 'form-add: Форма элемента КонтактныеЛица',
script: 'form-add/scripts/form-add',
args: { '-ObjectPath': '{workDir}/Catalogs/КонтактныеЛица.xml', '-FormName': 'ФормаЭлемента' },
},
{
name: 'form-compile: Форма элемента КонтактныеЛица',
script: 'form-compile/scripts/form-compile',
input: {
title: 'Контактное лицо',
attributes: [
{ name: 'Объект', type: 'CatalogObject.КонтактныеЛица', main: true },
],
elements: [
{ input: 'Владелец', path: 'Объект.Owner', title: 'Контрагент' },
{ input: 'Наименование', path: 'Объект.Description', title: 'ФИО' },
{ input: 'Должность', path: 'Объект.Должность', title: 'Должность' },
{ input: 'Телефон', path: 'Объект.Телефон', title: 'Телефон' },
],
},
args: { '-JsonPath': '{inputFile}', '-OutputPath': '{workDir}/Catalogs/КонтактныеЛица/Forms/ФормаЭлемента/Ext/Form.xml' },
validate: { script: 'form-validate/scripts/form-validate', flag: '-FormPath', path: 'Catalogs/КонтактныеЛица/Forms/ФормаЭлемента/Ext/Form.xml' },
},
{
name: 'form-add: Форма списка КонтактныеЛица',
script: 'form-add/scripts/form-add',
args: { '-ObjectPath': '{workDir}/Catalogs/КонтактныеЛица.xml', '-FormName': 'ФормаСписка', '-Purpose': 'List' },
},
{
name: 'form-compile: Форма списка КонтактныеЛица',
script: 'form-compile/scripts/form-compile',
input: {
title: 'Контактные лица',
attributes: [
{ name: 'Список', type: 'DynamicList', main: true,
settings: { mainTable: 'Catalog.КонтактныеЛица', dynamicDataRead: true } },
],
elements: [
{ table: 'Список', path: 'Список', columns: [
{ input: 'Description', path: 'Список.Description', title: 'ФИО' },
{ input: 'Должность', path: 'Список.Должность', title: 'Должность' },
{ input: 'Телефон', path: 'Список.Телефон', title: 'Телефон' },
]},
],
},
args: { '-JsonPath': '{inputFile}', '-OutputPath': '{workDir}/Catalogs/КонтактныеЛица/Forms/ФормаСписка/Ext/Form.xml' },
validate: { script: 'form-validate/scripts/form-validate', flag: '-FormPath', path: 'Catalogs/КонтактныеЛица/Forms/ФормаСписка/Ext/Form.xml' },
},
// Форма списка Контрагенты — для filterList тестов. КодКПП НЕ выводим
// в форму — это покрывает FieldSelector DLB ветку (filterList #5)
{
name: 'form-add: Форма списка Контрагенты',
script: 'form-add/scripts/form-add',
args: { '-ObjectPath': '{workDir}/Catalogs/Контрагенты.xml', '-FormName': 'ФормаСписка', '-Purpose': 'List' },
},
{
name: 'form-compile: Форма списка Контрагенты',
script: 'form-compile/scripts/form-compile',
input: {
title: 'Контрагенты',
attributes: [
{ name: 'Список', type: 'DynamicList', main: true,
settings: { mainTable: 'Catalog.Контрагенты', dynamicDataRead: true } },
],
elements: [
{ table: 'Список', path: 'Список', columns: [
{ input: 'Code', path: 'Список.Code', title: 'Код' },
{ input: 'Description', path: 'Список.Description', title: 'Наименование' },
{ input: 'ИНН', path: 'Список.ИНН', title: 'ИНН' },
{ input: 'Телефон', path: 'Список.Телефон', title: 'Телефон' },
{ input: 'Адрес', path: 'Список.Адрес', title: 'Адрес' },
]},
],
},
args: { '-JsonPath': '{inputFile}', '-OutputPath': '{workDir}/Catalogs/Контрагенты/Forms/ФормаСписка/Ext/Form.xml' },
validate: { script: 'form-validate/scripts/form-validate', flag: '-FormPath', path: 'Catalogs/Контрагенты/Forms/ФормаСписка/Ext/Form.xml' },
},
// Форма элемента Номенклатура — 2 вкладки, все типы полей
{
name: 'form-add: Форма элемента Номенклатура',
script: 'form-add/scripts/form-add',
args: { '-ObjectPath': '{workDir}/Catalogs/Номенклатура.xml', '-FormName': 'ФормаЭлемента' },
},
{
name: 'form-compile: Форма элемента Номенклатура (2 вкладки)',
script: 'form-compile/scripts/form-compile',
input: {
title: 'Номенклатура',
attributes: [
{ name: 'Объект', type: 'CatalogObject.Номенклатура', main: true },
],
elements: [
{ pages: 'Страницы', pagesRepresentation: 'TabsOnTop', children: [
{ page: 'Основное', title: 'Основное', children: [
{ input: 'Наименование', path: 'Объект.Description', title: 'Наименование' },
{ input: 'Артикул', path: 'Объект.Артикул', title: 'Артикул' },
{ input: 'ВидНоменклатуры', path: 'Объект.ВидНоменклатуры', title: 'Вид номенклатуры' },
{ input: 'Цена', path: 'Объект.Цена', title: 'Цена' },
{ radio: 'КатегорияЦены', path: 'Объект.КатегорияЦены',
title: 'Категория цены',
radioButtonType: 'RadioButtons',
titleLocation: 'Top',
choiceList: [
{ value: 'Enum.КатегорииЦен.EnumValue.Розничная', presentation: 'Розничная' },
{ value: 'Enum.КатегорииЦен.EnumValue.Оптовая', presentation: 'Оптовая' },
{ value: 'Enum.КатегорииЦен.EnumValue.Закупочная', presentation: 'Закупочная' },
],
},
{ radio: 'СпособУчёта', path: 'Объект.СпособУчёта',
title: 'Способ учёта',
radioButtonType: 'Tumbler',
titleLocation: 'Top',
choiceList: [
{ value: 'Enum.СпособыУчёта.EnumValue.ПоСреднему', presentation: 'По среднему' },
{ value: 'Enum.СпособыУчёта.EnumValue.ФИФО', presentation: 'ФИФО' },
],
},
{ check: 'Активен', path: 'Объект.Активен', title: 'Активен' },
{ input: 'ДатаПоступления', path: 'Объект.ДатаПоступления', title: 'Дата поступления' },
]},
{ page: 'Дополнительно', title: 'Дополнительно', children: [
{ input: 'ЕдиницаИзмерения', path: 'Объект.ЕдиницаИзмерения', title: 'Единица измерения' },
{ input: 'Комментарий', path: 'Объект.Комментарий', title: 'Комментарий' },
]},
]},
],
},
args: { '-JsonPath': '{inputFile}', '-OutputPath': '{workDir}/Catalogs/Номенклатура/Forms/ФормаЭлемента/Ext/Form.xml' },
validate: { script: 'form-validate/scripts/form-validate', flag: '-FormPath', path: 'Catalogs/Номенклатура/Forms/ФормаЭлемента/Ext/Form.xml' },
},
// Форма списка Номенклатура — с колонкой ДатаПоступления для filterList #6 (date pattern)
{
name: 'form-add: Форма списка Номенклатура',
script: 'form-add/scripts/form-add',
args: { '-ObjectPath': '{workDir}/Catalogs/Номенклатура.xml', '-FormName': 'ФормаСписка', '-Purpose': 'List' },
},
{
name: 'form-compile: Форма списка Номенклатура',
script: 'form-compile/scripts/form-compile',
input: {
title: 'Номенклатура',
attributes: [
{ name: 'Список', type: 'DynamicList', main: true,
settings: { mainTable: 'Catalog.Номенклатура', dynamicDataRead: true } },
],
elements: [
{ table: 'Список', path: 'Список', columns: [
{ input: 'Code', path: 'Список.Code', title: 'Код' },
{ input: 'Description', path: 'Список.Description', title: 'Наименование' },
{ input: 'Артикул', path: 'Список.Артикул', title: 'Артикул' },
{ input: 'ВидНоменклатуры', path: 'Список.ВидНоменклатуры', title: 'Вид номенклатуры' },
{ input: 'ДатаПоступления', path: 'Список.ДатаПоступления', title: 'Дата поступления' },
{ input: 'Цена', path: 'Список.Цена', title: 'Цена' },
{ check: 'Активен', path: 'Список.Активен', title: 'Активен' },
]},
],
},
args: { '-JsonPath': '{inputFile}', '-OutputPath': '{workDir}/Catalogs/Номенклатура/Forms/ФормаСписка/Ext/Form.xml' },
validate: { script: 'form-validate/scripts/form-validate', flag: '-FormPath', path: 'Catalogs/Номенклатура/Forms/ФормаСписка/Ext/Form.xml' },
},
// Форма документа ПриходнаяНакладная
{
name: 'form-add: Форма документа ПриходнаяНакладная',
script: 'form-add/scripts/form-add',
args: { '-ObjectPath': '{workDir}/Documents/ПриходнаяНакладная.xml', '-FormName': 'ФормаДокумента' },
},
{
name: 'form-compile: Форма документа ПриходнаяНакладная',
script: 'form-compile/scripts/form-compile',
input: {
title: 'Приходная накладная',
attributes: [
{ name: 'Объект', type: 'DocumentObject.ПриходнаяНакладная', main: true },
],
elements: [
{ input: 'Организация', path: 'Объект.Организация', title: 'Организация' },
{ input: 'Контрагент', path: 'Объект.Контрагент', title: 'Контрагент' },
{ input: 'Склад', path: 'Объект.Склад', title: 'Склад' },
{ input: 'Источник', path: 'Объект.Источник', title: 'Источник' },
// textEdit:false — ручной ввод запрещён, только pick → форма выбора
{ input: 'Поставщик', path: 'Объект.Поставщик', title: 'Поставщик', textEdit: false },
{ input: 'Менеджер', path: 'Объект.Менеджер', title: 'Менеджер' },
{ input: 'Комментарий', path: 'Объект.Комментарий', title: 'Комментарий' },
{ table: 'Товары', path: 'Объект.Товары', title: 'Товары', changeRowSet: true, columns: [
{ input: 'Номенклатура', path: 'Объект.Товары.Номенклатура', title: 'Номенклатура' },
{ input: 'Количество', path: 'Объект.Товары.Количество', title: 'Количество' },
{ input: 'Цена', path: 'Объект.Товары.Цена', title: 'Цена' },
{ input: 'Сумма', path: 'Объект.Товары.Сумма', title: 'Сумма' },
{ check: 'Согласовано', path: 'Объект.Товары.Согласовано', title: 'Согласовано' },
// Имя элемента отличается от Источник (в шапке) — иначе ContextMenu
// companion-имена дублируются в одной форме. form-compile использует
// имя элемента, не путь, для генерации companion-имён.
{ input: 'ИсточникТЧ', path: 'Объект.Товары.Источник', title: 'Источник' },
]},
],
},
args: { '-JsonPath': '{inputFile}', '-OutputPath': '{workDir}/Documents/ПриходнаяНакладная/Forms/ФормаДокумента/Ext/Form.xml' },
validate: { script: 'form-validate/scripts/form-validate', flag: '-FormPath', path: 'Documents/ПриходнаяНакладная/Forms/ФормаДокумента/Ext/Form.xml' },
},
// Форма списка ПриходнаяНакладная — с колонкой Контрагент для filterList #7 (reference pattern)
{
name: 'form-add: Форма списка ПриходнаяНакладная',
script: 'form-add/scripts/form-add',
args: { '-ObjectPath': '{workDir}/Documents/ПриходнаяНакладная.xml', '-FormName': 'ФормаСписка', '-Purpose': 'List' },
},
{
name: 'form-compile: Форма списка ПриходнаяНакладная',
script: 'form-compile/scripts/form-compile',
input: {
title: 'Приходные накладные',
attributes: [
{ name: 'Список', type: 'DynamicList', main: true,
settings: { mainTable: 'Document.ПриходнаяНакладная', dynamicDataRead: true } },
],
elements: [
{ table: 'Список', path: 'Список', columns: [
{ input: 'Date', path: 'Список.Date', title: 'Дата' },
{ input: 'Number', path: 'Список.Number', title: 'Номер' },
{ input: 'Контрагент', path: 'Список.Контрагент', title: 'Контрагент' },
{ input: 'Posted', path: 'Список.Posted', title: 'Проведён' },
]},
],
},
args: { '-JsonPath': '{inputFile}', '-OutputPath': '{workDir}/Documents/ПриходнаяНакладная/Forms/ФормаСписка/Ext/Form.xml' },
validate: { script: 'form-validate/scripts/form-validate', flag: '-FormPath', path: 'Documents/ПриходнаяНакладная/Forms/ФормаСписка/Ext/Form.xml' },
},
// Форма обработки ТестовыеОшибки — кнопки вызова процедур ОбщиеФункции
{
name: 'form-add: Форма обработки ТестовыеОшибки',
script: 'form-add/scripts/form-add',
args: { '-ObjectPath': '{workDir}/DataProcessors/ТестовыеОшибки.xml', '-FormName': 'ФормаОбработки' },
},
{
name: 'form-compile: Форма обработки ТестовыеОшибки',
script: 'form-compile/scripts/form-compile',
input: {
title: 'Тестовые ошибки',
attributes: [
{ name: 'Объект', type: 'DataProcessorObject.ТестовыеОшибки', main: true },
],
elements: [
{ button: 'ПоказатьСообщение', command: 'ПоказатьСообщение', title: 'Показать сообщение' },
{ button: 'ВызватьИсключение', command: 'ВызватьИсключениеКоманда', title: 'Вызвать исключение' },
],
commands: [
{ name: 'ПоказатьСообщение', action: 'ПоказатьСообщение' },
{ name: 'ВызватьИсключениеКоманда', action: 'ВызватьИсключениеКоманда' },
],
},
args: { '-JsonPath': '{inputFile}', '-OutputPath': '{workDir}/DataProcessors/ТестовыеОшибки/Forms/ФормаОбработки/Ext/Form.xml' },
validate: { script: 'form-validate/scripts/form-validate', flag: '-FormPath', path: 'DataProcessors/ТестовыеОшибки/Forms/ФормаОбработки/Ext/Form.xml' },
},
{
name: 'writeFile: ТестовыеОшибки form Module.bsl',
writeFile: 'DataProcessors/ТестовыеОшибки/Forms/ФормаОбработки/Ext/Form/Module.bsl',
content: `&НаКлиенте
Процедура ПоказатьСообщение(Команда)
\tПоказатьСообщениеНаСервере();
КонецПроцедуры
&НаСервере
Процедура ПоказатьСообщениеНаСервере()
\tОбщиеФункции.ПоказатьСообщение();
КонецПроцедуры
&НаКлиенте
Процедура ВызватьИсключениеКоманда(Команда)
\tВызватьИсключениеНаСервере();
КонецПроцедуры
&НаСервере
Процедура ВызватьИсключениеНаСервере()
\tОбщиеФункции.ВызватьТестовоеИсключение();
КонецПроцедуры
`,
},
// Форма обработки ДеревоНоменклатуры — tree-grid с двумя колонками
{
name: 'form-add: Форма обработки ДеревоНоменклатуры',
script: 'form-add/scripts/form-add',
args: { '-ObjectPath': '{workDir}/DataProcessors/ДеревоНоменклатуры.xml', '-FormName': 'ФормаОбработки' },
},
{
name: 'form-compile: Форма обработки ДеревоНоменклатуры',
script: 'form-compile/scripts/form-compile',
input: {
title: 'Дерево номенклатуры',
events: { OnCreateAtServer: 'ПриСозданииНаСервере' },
attributes: [
{ name: 'Объект', type: 'DataProcessorObject.ДеревоНоменклатуры', main: true },
{ name: 'Дерево', type: 'ValueTree', columns: [
{ name: 'Номенклатура', type: 'CatalogRef.Номенклатура', title: 'Номенклатура' },
{ name: 'Цена', type: 'Number(15,2)', title: 'Цена' },
]},
],
elements: [
{ table: 'Дерево', path: 'Дерево', initialTreeView: 'ExpandTopLevel', changeRowSet: true, columns: [
{ input: 'Номенклатура', path: 'Дерево.Номенклатура', readOnly: true, title: 'Номенклатура' },
{ input: 'Цена', path: 'Дерево.Цена', title: 'Цена' },
]},
],
},
args: { '-JsonPath': '{inputFile}', '-OutputPath': '{workDir}/DataProcessors/ДеревоНоменклатуры/Forms/ФормаОбработки/Ext/Form.xml' },
validate: { script: 'form-validate/scripts/form-validate', flag: '-FormPath', path: 'DataProcessors/ДеревоНоменклатуры/Forms/ФормаОбработки/Ext/Form.xml' },
},
{
name: 'writeFile: ДеревоНоменклатуры form Module.bsl',
writeFile: 'DataProcessors/ДеревоНоменклатуры/Forms/ФормаОбработки/Ext/Form/Module.bsl',
content: `&НаСервере
Процедура ПриСозданииНаСервере(Отказ, СтандартнаяОбработка)
\tЗаполнитьУровень(Дерево.ПолучитьЭлементы(), Справочники.Номенклатура.ПустаяСсылка());
КонецПроцедуры
&НаСервере
Процедура ЗаполнитьУровень(КоллекцияЭлементов, Родитель)
\tЗапрос = Новый Запрос;
\tЗапрос.Текст =
\t\t"ВЫБРАТЬ
\t\t|\tСсылка, ЭтоГруппа, Цена, Наименование
\t\t|ИЗ
\t\t|\tСправочник.Номенклатура
\t\t|ГДЕ
\t\t|\tРодитель = &Родитель
\t\t|УПОРЯДОЧИТЬ ПО
\t\t|\tЭтоГруппа УБЫВ, Наименование";
\tЗапрос.УстановитьПараметр("Родитель", Родитель);
\tВыборка = Запрос.Выполнить().Выбрать();
\tПока Выборка.Следующий() Цикл
\t\tНовыйУзел = КоллекцияЭлементов.Добавить();
\t\tНовыйУзел.Номенклатура = Выборка.Ссылка;
\t\tНовыйУзел.Цена = Выборка.Цена;
\t\tЕсли Выборка.ЭтоГруппа Тогда
\t\t\tЗаполнитьУровень(НовыйУзел.ПолучитьЭлементы(), Выборка.Ссылка);
\t\tКонецЕсли;
\tКонецЦикла;
КонецПроцедуры
`,
},
// ── 4. DCS for report ──
// Сначала добавляем макет ОсновнаяСхемаКомпоновкиДанных к отчёту (регистрируется
// в Reports/ОстаткиТоваров.xml + автоматически выставляется MainDataCompositionSchema),
// затем skd-compile наполняет его содержимым.
{
name: 'template-add: ОсновнаяСхемаКомпоновкиДанных к отчёту ОстаткиТоваров',
script: 'template-add/scripts/add-template',
args: {
'-ObjectName': 'ОстаткиТоваров',
'-TemplateName': 'ОсновнаяСхемаКомпоновкиДанных',
'-TemplateType': 'DataCompositionSchema',
'-SrcDir': '{workDir}/Reports',
},
},
{
name: 'skd-compile: Схема отчёта ОстаткиТоваров',
script: 'skd-compile/scripts/skd-compile',
input: {
dataSets: [{
name: 'НаборДанных',
query: 'ВЫБРАТЬ\n\tТовары.Ссылка КАК Документ,\n\tТовары.Номенклатура КАК Номенклатура,\n\tТовары.Количество КАК Количество,\n\tТовары.Цена КАК Цена,\n\tТовары.Сумма КАК Сумма\nИЗ\n\tДокумент.ПриходнаяНакладная.Товары КАК Товары',
fields: [
{ field: 'Документ', title: 'Документ', type: 'DocumentRef.ПриходнаяНакладная' },
{ field: 'Номенклатура', title: 'Номенклатура', type: 'CatalogRef.Номенклатура' },
{ field: 'Количество', title: 'Количество', type: 'decimal(15,3)' },
{ field: 'Цена', title: 'Цена', type: 'decimal(15,2)' },
{ field: 'Сумма', title: 'Сумма', type: 'decimal(15,2)' },
],
}],
totalFields: ['Количество: Сумма', 'Сумма: Сумма'],
settingsVariants: [{
name: 'Основной',
title: 'Остатки товаров',
settings: {
selection: ['Номенклатура', 'Количество', 'Сумма', 'Auto'],
filter: ['Номенклатура = _ @off @user @quickAccess'],
structure: 'Номенклатура > details',
},
}],
},
args: { '-DefinitionFile': '{inputFile}', '-OutputPath': '{workDir}/Reports/ОстаткиТоваров/Templates/ОсновнаяСхемаКомпоновкиДанных/Ext/Template.xml' },
validate: { script: 'skd-validate/scripts/skd-validate', flag: '-TemplatePath', path: 'Reports/ОстаткиТоваров/Templates/ОсновнаяСхемаКомпоновкиДанных/Ext/Template.xml' },
},
// ── 5. Subsystems ──
{
name: 'subsystem-compile: Подсистема Склад',
script: 'subsystem-compile/scripts/subsystem-compile',
input: {
name: 'Склад',
synonym: 'Склад',
content: [
'Catalog.Организации',
'Catalog.Контрагенты',
'Catalog.КонтактныеЛица',
'Catalog.Номенклатура',
'Enum.ВидыНоменклатуры',
'Enum.КатегорииЦен',
'Enum.СпособыУчёта',
'Document.ПриходнаяНакладная',
'Report.ОстаткиТоваров',
],
},
args: { '-DefinitionFile': '{inputFile}', '-OutputDir': '{workDir}' },
validate: { script: 'subsystem-validate/scripts/subsystem-validate', flag: '-SubsystemPath', path: 'Subsystems/Склад' },
},
{
name: 'subsystem-compile: Подсистема Администрирование',
script: 'subsystem-compile/scripts/subsystem-compile',
input: {
name: 'Администрирование',
synonym: 'Администрирование',
content: [
'InformationRegister.КурсыВалют',
'Constant.ОсновнаяВалюта',
'DataProcessor.ТестовыеОшибки',
'DataProcessor.ДеревоНоменклатуры',
],
},
args: { '-DefinitionFile': '{inputFile}', '-OutputDir': '{workDir}' },
validate: { script: 'subsystem-validate/scripts/subsystem-validate', flag: '-SubsystemPath', path: 'Subsystems/Администрирование' },
},
// ── 6. Role with full rights ──
{
name: 'role-compile: Роль Администратор',
script: 'role-compile/scripts/role-compile',
input: {
name: 'Администратор',
objects: [
'Catalog.Организации: Read View Add Update Delete',
'Catalog.Контрагенты: Read View Add Update Delete',
'Catalog.КонтактныеЛица: Read View Add Update Delete',
'Catalog.Номенклатура: Read View Add Update Delete',
'Document.ПриходнаяНакладная: Read View Add Update Delete Posting UnPosting',
'InformationRegister.КурсыВалют: Read View Add Update Delete',
'Report.ОстаткиТоваров: Use View',
'DataProcessor.ДеревоНоменклатуры: Use View',
],
},
args: { '-JsonPath': '{inputFile}', '-OutputDir': '{workDir}' },
validate: { script: 'role-validate/scripts/role-validate', flag: '-RightsPath', path: 'Roles/Администратор' },
},
// ── 7. Final validation ──
// (meta-compile, subsystem-compile, role-compile уже регистрируют объекты в Configuration.xml)
{
name: 'cf-validate: Финальная валидация конфигурации',
script: 'cf-validate/scripts/cf-validate',
args: { '-ConfigPath': '{workDir}' },
},
];
+24 -2
View File
@@ -217,8 +217,14 @@ function createWorkspace(fixturePath, readOnly) {
}
function cleanupWorkspace(ws) {
if (!ws.readOnly) {
rmSync(ws.path, { recursive: true, force: true });
if (ws.readOnly) return;
// On Windows, file handles from db-update (1cv8) may linger briefly after the
// process exits — rmSync then throws EBUSY. Retry a few times, then swallow:
// a leaked tmp dir is preferable to crashing the entire runner.
try {
rmSync(ws.path, { recursive: true, force: true, maxRetries: 10, retryDelay: 200 });
} catch (e) {
console.warn(`Warning: failed to clean workspace ${ws.path}: ${e.message}`);
}
}
@@ -944,6 +950,22 @@ async function runIntegrationTest(test, opts) {
const step = test.steps[i];
const stepT0 = performance.now();
// writeFile step: записать содержимое (обычно .bsl модуля) в workDir
if (step.writeFile) {
try {
const target = replacePlaceholders(step.writeFile);
const abs = target.includes(':') || target.startsWith('/') ? target : join(workDir, target);
mkdirSync(dirname(abs), { recursive: true });
writeFileSync(abs, step.content ?? '', 'utf8');
const stepElapsed = ((performance.now() - stepT0) / 1000).toFixed(1);
stepResults.push({ name: step.name, passed: true, elapsed: `${stepElapsed}s` });
} catch (e) {
stepResults.push({ name: step.name, passed: false, error: `writeFile failed: ${e.message}` });
break;
}
continue;
}
// Write input if provided
let inputFile = null;
if (step.input) {
+65
View File
@@ -0,0 +1,65 @@
// 00-hooks.test.mjs — индикатор покрытия testlevel-хуков (M7.4).
//
// Тест запускается ПЕРВЫМ (алфавитно), импортирует shared `_state` из
// `_hooks.mjs` и проверяет:
// - `beforeAll` отработал ровно один раз ДО любого теста.
// - `beforeEach` уже отработал для самого 00-hooks (счётчик === 1).
// - `testInfo` доступен внутри тела (через ctx).
// - `afterEach` для 00-hooks ещё не вызывался — `afterEach < beforeEach`.
// - Последнее событие — `beforeEach:00-hooks.test.mjs`.
//
// `afterAll` проверить из теста невозможно (он зовётся после всех тестов).
// Покрывается косвенно: финальный run должен показать `afterAll = 1` в
// summary log (см. ctx.log в этом тесте).
import { _state } from './_hooks.mjs';
export const name = 'Хуки testlevel — индикатор порядка вызовов';
export const tags = ['hooks', 'smoke'];
export const timeout = 10000;
export default async function ({ step, assert, log, testInfo }) {
await step('beforeAll отработал ровно один раз', () => {
assert.equal(_state.beforeAll, 1, `beforeAll=${_state.beforeAll}, ожидался 1`);
assert.equal(_state.afterAll, 0, `afterAll=${_state.afterAll}, ожидался 0 (вызывается после всех тестов)`);
});
await step('beforeEach отработал для этого теста', () => {
assert.ok(_state.beforeEach >= 1, `beforeEach=${_state.beforeEach}, ожидался >= 1`);
const last = _state.events[_state.events.length - 1];
assert.ok(typeof last === 'string' && last.startsWith('beforeEach:'),
`последнее событие должно быть beforeEach:..., но это "${last}"`);
assert.ok(last.includes('00-hooks'),
`последнее beforeEach должно ссылаться на 00-hooks, а не "${last}"`);
});
await step('testInfo доступен в теле теста', () => {
assert.equal(testInfo.file, '00-hooks.test.mjs', `testInfo.file=${testInfo.file}`);
assert.ok(Array.isArray(testInfo.tags), 'testInfo.tags должен быть массивом');
assert.includes(testInfo.tags, 'hooks', 'testInfo.tags должен содержать "hooks"');
assert.equal(testInfo.attempt, 1, `attempt=${testInfo.attempt}`);
assert.equal(typeof testInfo.primaryContext, 'string', 'primaryContext должен быть строкой');
});
await step('afterOpenContext отработал хотя бы для default', () => {
// Default контекст создаётся до beforeAll → afterOpenContext должен был
// отработать как минимум один раз. beforeCloseContext в теле первого
// теста ещё не вызывался (контексты живы).
assert.ok(_state.afterOpenContext >= 1,
`afterOpenContext=${_state.afterOpenContext}, ожидался >= 1 (default-контекст создан)`);
assert.equal(_state.beforeCloseContext, 0,
`beforeCloseContext=${_state.beforeCloseContext}, ожидался 0 (контексты ещё живы)`);
});
await step('afterEach для этого теста ещё не вызывался', () => {
// В теле теста afterEach НЕ должен быть вызван ни разу для текущего теста.
// Если 00-hooks запущен первым (что и ожидается), afterEach === 0.
// Tolerance: проверяем относительное неравенство, чтобы тест не сломался
// если кто-то добавит ещё один тест с алфавитно меньшим именем.
assert.ok(_state.afterEach < _state.beforeEach,
`afterEach (${_state.afterEach}) должен быть строго меньше beforeEach (${_state.beforeEach}) в теле теста`);
});
log(`hooks indicator: beforeAll=${_state.beforeAll}, beforeEach=${_state.beforeEach}, afterEach=${_state.afterEach}, events.length=${_state.events.length}`);
}
+96
View File
@@ -0,0 +1,96 @@
export const name = 'Навигация по разделам';
export const tags = ['nav', 'smoke'];
export const timeout = 60000;
export default async function({ navigateSection, getPageState, openCommand, navigateLink, switchTab, closeForm, assert, step, log }) {
await step('Чтение начального состояния', async () => {
const state = await getPageState();
const names = (state.sections || []).map(s => s.name);
log('Sections: ' + names.join(', '));
assert.ok(names.length >= 2, 'Минимум 2 раздела');
assert.includes(names, 'Склад', 'Раздел Склад должен быть');
assert.includes(names, 'Администрирование', 'Раздел Администрирование должен быть');
});
await step('Переход в раздел Склад', async () => {
const result = await navigateSection('Склад');
log('Commands: ' + (result.commands || []).map(c => c.name).join(', '));
assert.ok(result.commands?.length > 0, 'Должны быть команды в разделе Склад');
});
await step('Открыть справочник Контрагенты', async () => {
const state = await openCommand('Контрагенты');
assert.ok(state.form != null, 'Форма списка Контрагентов должна открыться');
log('Opened: ' + state.title);
await closeForm();
});
await step('Переход в раздел Администрирование', async () => {
const result = await navigateSection('Администрирование');
log('Commands: ' + (result.commands || []).map(c => c.name).join(', '));
assert.ok(result.commands?.length > 0, 'Должны быть команды в разделе Администрирование');
});
await step('Открыть Номенклатуру из раздела Склад', async () => {
await navigateSection('Склад');
const state = await openCommand('Номенклатура');
assert.ok(state.form, 'Форма списка Номенклатуры должна открыться');
log('Opened: ' + state.title);
await closeForm();
});
await step('section-error: navigateSection с несуществующим именем кидает ошибку', async () => {
let err = null;
try {
await navigateSection('НетТакогоРаздела_xyz');
} catch (e) {
err = e;
}
log(`section-error: ${err?.message}`);
assert.ok(err, 'Должна быть ошибка для несуществующего раздела');
});
await step('command-error: openCommand с несуществующим именем кидает ошибку', async () => {
await navigateSection('Склад');
let err = null;
try {
await openCommand('НетТакойКоманды_xyz');
} catch (e) {
err = e;
}
log(`command-error: ${err?.message}`);
assert.ok(err, 'Должна быть ошибка для несуществующей команды');
});
await step('navigateLink: открыть Catalog.Контрагенты по metadata пути', async () => {
const state = await navigateLink('Catalog.Контрагенты');
log(`link-type form=${state.form} formCount=${state.formCount}`);
assert.ok(state.form != null, 'navigateLink должен открыть форму');
await closeForm();
});
await step('navigateLink: e1cib URL', async () => {
// e1cib path-form: Catalog.Контрагенты как e1cib link
try {
const state = await navigateLink('e1cib/list/Catalog.Контрагенты');
log(`link-e1cib form=${state.form}`);
assert.ok(state.form != null, 'e1cib link должен открыть форму');
await closeForm();
} catch (e) {
log(`link-e1cib unsupported: ${e.message}`);
// некоторые версии не поддерживают полный e1cib через Shift+F11
}
});
await step('switchTab: ошибка при несуществующем имени', async () => {
let err = null;
try {
await switchTab('НетТакогоТаба_xyz');
} catch (e) {
err = e;
}
log(`switchTab-error: ${err?.message}`);
assert.ok(err, 'switchTab должен кидать ошибку для несуществующего таба');
});
}
+112
View File
@@ -0,0 +1,112 @@
export const name = 'CRUD: открытие, чтение, закрытие с подтверждением';
export const tags = ['crud', 'smoke'];
export const timeout = 60000;
export default async function({ navigateSection, openCommand, clickElement, closeForm, readTable, fillField, getFormState, getPage, assert, step, log }) {
await step('read: список Контрагентов отдаёт колонки/строки/total', async () => {
await navigateSection('Склад');
await openCommand('Контрагенты');
const t = await readTable();
log(`columns=${t.columns?.length} rows=${t.rows?.length} total=${t.total}`);
assert.ok(t.total >= 4, `Должно быть >= 4 контрагента (got ${t.total})`);
assert.ok(t.rows?.length >= 4, 'rows должен содержать заполненные строки');
const names = t.rows.map(r => r['Наименование']);
assert.includes(names, 'ООО Север', 'ООО Север должен быть в списке');
await closeForm();
});
await step('open-item: dblclick открывает форму элемента', async () => {
await navigateSection('Склад');
await openCommand('Контрагенты');
await clickElement('ООО Север', { dblclick: true });
const state = await getFormState();
const nameField = state.fields?.find(f => f.name === 'Наименование' || f.label === 'Наименование');
log(`Opened form=${state.form} Наименование='${nameField?.value}'`);
assert.ok(state.form, 'Форма элемента должна открыться (state.form задан)');
assert.equal(nameField?.value, 'ООО Север', 'В открытой форме должен быть указан выбранный контрагент');
await closeForm();
});
await step('close-clean: закрытие без изменений не показывает confirmation', async () => {
await navigateSection('Склад');
await openCommand('Контрагенты');
await clickElement('ООО Юг', { dblclick: true });
const before = await getFormState();
const after = await closeForm();
assert.ok(after.closed, 'Форма должна закрыться без диалога');
assert.ok(!after.confirmation, 'Confirmation dialog не должен появиться');
log(`closed=${after.closed} form-was=${before.form}`);
});
await step('confirm-save-yes: fillField + closeForm({save:true}) → значение сохранилось', async () => {
await navigateSection('Склад');
await openCommand('Контрагенты');
await clickElement('ООО Восток', { dblclick: true });
const newPhone = '+7 (999) 111-22-33';
await fillField('Телефон', newPhone);
await closeForm({ save: true });
// Verify persisted
await navigateSection('Склад');
await openCommand('Контрагенты');
await clickElement('ООО Восток', { dblclick: true });
const state = await getFormState();
const phoneField = state.fields?.find(f => f.name === 'Телефон' || f.label === 'Телефон');
log(`Re-opened phone='${phoneField?.value}'`);
assert.equal(phoneField?.value, newPhone, 'Телефон должен сохраниться');
await closeForm();
});
await step('confirm-save-no: closeForm({save:false}) → изменения откатываются', async () => {
await navigateSection('Склад');
await openCommand('Контрагенты');
await clickElement('ООО Восток', { dblclick: true });
const before = await getFormState();
const origPhone = before.fields?.find(f => f.name === 'Телефон')?.value;
log(`origPhone='${origPhone}'`);
await fillField('Телефон', '+7 (000) 000-00-00');
const closed = await closeForm({ save: false });
assert.ok(closed.closed, 'Форма должна закрыться через "Нет"');
await navigateSection('Склад');
await openCommand('Контрагенты');
await clickElement('ООО Восток', { dblclick: true });
const state = await getFormState();
const phone = state.fields?.find(f => f.name === 'Телефон')?.value;
log(`Re-opened phone after save:false='${phone}'`);
assert.equal(phone, origPhone, 'Телефон не должен измениться (save:false откатил)');
await closeForm();
});
await step('confirm-pending: closeForm() без решения → confirmation в state', async () => {
await navigateSection('Склад');
await openCommand('Контрагенты');
await clickElement('ООО Север', { dblclick: true });
await fillField('Телефон', '+7 (123) 456-78-90');
const pending = await closeForm();
log(`pending: closed=${pending.closed} confirmation=${JSON.stringify(pending.confirmation)}`);
assert.ok(!pending.closed, 'Форма НЕ должна закрыться без решения');
assert.ok(pending.confirmation, 'state.confirmation должен присутствовать');
// Закрыть через явный отказ от сохранения
await closeForm({ save: false });
});
await step('more-menu / submenu-read: clickElement("Ещё") возвращает submenu[] с типовыми пунктами', async () => {
await navigateSection('Склад');
await openCommand('Контрагенты');
const r = await clickElement('Ещё');
const items = r.submenu || [];
log(`submenu items: ${items.length} sample=${items.slice(0, 5).join(', ')}`);
assert.equal(r.clicked?.kind, 'submenu', 'clicked.kind=submenu');
assert.ok(Array.isArray(r.submenu), 'clickElement("Ещё") должен вернуть submenu[]');
assert.ok(items.length >= 5, `submenu должен содержать типовые пункты (got ${items.length})`);
assert.includes(items, 'Создать', 'пункт «Создать»');
assert.includes(items, 'Изменить', 'пункт «Изменить»');
assert.includes(items, 'Расширенный поиск', 'пункт «Расширенный поиск»');
// Закрыть submenu
const page = await getPage();
await page.keyboard.press('Escape');
await closeForm();
});
}
+178
View File
@@ -0,0 +1,178 @@
export const name = 'fillFields: text, checkbox, date, dropdown, reference';
export const tags = ['fillfields', 'smoke'];
export const timeout = 120000;
const findField = (state, name) => state.fields?.find(f => f.name === name || f.label === name);
export default async function({ navigateSection, openCommand, clickElement, fillFields, fillTableRow, selectValue, filterList, closeForm, getFormState, assert, step, log }) {
await step('text+checkbox+date+dropdown: fillFields на Номенклатура', async () => {
await navigateSection('Склад');
await openCommand('Номенклатура');
await clickElement('Товары', { dblclick: true }); // войти в папку
await clickElement('Товар 01', { dblclick: true });
const result = await fillFields({
'Артикул': 'TEST-001',
'Активен': false, // Boolean → CheckBoxField, toggle
'ДатаПоступления': '15.05.2026', // date
'ВидНоменклатуры': 'Услуга', // EnumRef dropdown
});
log('methods: ' + result.filled.map(f => `${f.field}=${f.method}`).join(', '));
for (const f of result.filled) {
assert.ok(f.ok, `fillField "${f.field}" должен вернуть ok=true`);
}
const state = await getFormState();
assert.equal(findField(state, 'Артикул')?.value, 'TEST-001', 'Артикул text');
assert.equal(findField(state, 'Активен')?.value, false, 'Активен checkbox=false');
assert.equal(findField(state, 'ДатаПоступления')?.value, '15.05.2026', 'ДатаПоступления');
assert.equal(findField(state, 'ВидНоменклатуры')?.value, 'Услуга', 'ВидНоменклатуры dropdown');
await closeForm({ save: false });
});
await step('reference-dropdown: Организация → CatalogRef.Организации (quickChoice=true)', async () => {
await navigateSection('Склад');
await openCommand('Приходная накладная');
await clickElement('Создать');
const fillRes = await fillFields({
'Организация': 'Альфа',
});
log('reference method: ' + fillRes.filled[0]?.method);
assert.ok(fillRes.filled[0]?.ok, 'Организация fillField должна сработать');
const state = await getFormState();
const org = findField(state, 'Организация');
log(`Организация value='${org?.value}'`);
assert.includes(org?.value || '', 'Альфа', 'Организация должна показать выбранное значение');
await closeForm({ save: false });
});
await step('clear: fillFields пустым значением очищает текстовое поле', async () => {
await navigateSection('Склад');
await openCommand('Контрагенты');
await clickElement('ООО Север', { dblclick: true });
const before = await getFormState();
const phoneBefore = findField(before, 'Телефон')?.value;
log(`phone before clear='${phoneBefore}'`);
const r = await fillFields({ 'Телефон': '' });
log('clear method: ' + r.filled[0]?.method);
assert.ok(r.filled[0]?.ok, 'clear должен вернуть ok=true');
assert.equal(r.filled[0]?.method, 'clear', 'method должен быть clear (Shift+F4)');
const state = await getFormState();
assert.equal(findField(state, 'Телефон')?.value, '', 'Телефон должен быть пустым');
await closeForm({ save: false });
});
await step('reference-non-quickchoice: fillFields на Контрагент (quickChoice=false)', async () => {
// Поле имеет DLB+CB → fillFields идёт через fillReferenceField (method=dropdown/typeahead).
// Чистый method='form' путь требует поля без DLB (hasPick && !hasSelect) — в синтетике
// такого поля нет, поэтому проверяем сам факт корректного заполнения через DLB.
await navigateSection('Склад');
await openCommand('Приходная накладная');
await clickElement('Создать');
const r = await fillFields({ 'Контрагент': 'ООО Север' });
log('reference method: ' + r.filled[0]?.method);
assert.ok(r.filled[0]?.ok, 'fillFields на Контрагент должен сработать');
assert.ok(['dropdown', 'typeahead', 'form'].includes(r.filled[0]?.method),
`method=${r.filled[0]?.method} должен быть один из dropdown|typeahead|form`);
const state = await getFormState();
const v = findField(state, 'Контрагент')?.value || '';
log(`Контрагент value='${v}'`);
assert.includes(v, 'Север', 'Контрагент должен содержать "Север"');
await closeForm({ save: false });
});
await step('radio: КатегорияЦены (RadioButtons) через fillFields, СпособУчёта (Tumbler) через clickElement', async () => {
// Tumbler-представление не парсится fillFields как radio-поле (см.
// upload/web-test-bugs.md пункт 5). Но варианты тумблера видны в
// state.buttons и кликаются через clickElement — покрываем через него.
await navigateSection('Склад');
await openCommand('Номенклатура');
await filterList('Товар 02');
await clickElement('Товар 02', { dblclick: true });
// RadioButtons — fillFields с method=radio
const result = await fillFields({ 'Категория цены': 'Оптовая' });
log('RadioButtons method: ' + result.filled[0]?.method + ', value: ' + result.filled[0]?.value);
assert.ok(result.filled[0]?.ok, 'КатегорияЦены fillField должна сработать');
assert.equal(result.filled[0]?.method, 'radio', 'КатегорияЦены должна использовать method=radio');
assert.includes(result.filled[0]?.value || '', 'Оптовая', 'КатегорияЦены = Оптовая');
// Tumbler — варианты «По среднему» / «ФИФО» доступны как buttons
const before = await getFormState();
const tumblerButtons = (before.buttons || [])
.map(b => b.name || b)
.filter(n => n === 'По среднему' || n === 'ФИФО');
log('Tumbler buttons: ' + tumblerButtons.join(', '));
assert.equal(tumblerButtons.length, 2, 'Tumbler должен показывать оба варианта в buttons[]');
await clickElement('ФИФО');
log('Tumbler clicked: ФИФО');
await closeForm({ save: false });
});
await step('composite: selectValue с {type} в шапке и ТЧ накладной', async () => {
// ПриходнаяНакладная.Источник — составной тип:
// CatalogRef.Контрагенты + CatalogRef.Номенклатура + CatalogRef.Организации
// fillFields без type→ошибка с подсказкой «specify the type»;
// selectValue('Источник', value, {type:'Контрагенты'}) выбирает тип в диалоге.
await navigateSection('Склад');
await openCommand('Приходная накладная');
await clickElement('Создать');
// Шапка: выбор Контрагента в составном поле
const headRes = await selectValue('Источник', 'ООО Север', { type: 'Контрагенты' });
log('header: type=' + headRes.selected?.type + ' method=' + headRes.selected?.method);
assert.equal(headRes.selected?.method, 'form', 'composite header → method=form');
assert.equal(headRes.selected?.type, 'Контрагенты', 'type=Контрагенты выбран');
const state1 = await getFormState();
const headField = state1.fields?.find(f => f.name === 'Источник');
assert.equal(headField?.value, 'ООО Север', 'значение в шапке установилось');
// ТЧ: добавить строку, выбрать тип Организация (квик-чойс — без формы выбора)
await clickElement('Добавить');
const rowRes = await fillTableRow(
{ Источник: { value: 'Альфа', type: 'Организации' } },
{ row: 0 },
);
log('row: ' + JSON.stringify(rowRes.filled?.[0]));
assert.equal(rowRes.filled?.[0]?.ok, true, 'composite row → ok');
assert.equal(rowRes.filled?.[0]?.type, 'Организации', 'выбран тип Организации в ТЧ');
await closeForm({ save: false });
});
await step('direct-edit-form: textEdit:false → fillFields method=form', async () => {
// ПриходнаяНакладная.Поставщик — обычный CatalogRef.Контрагенты, но
// элемент формы с textEdit:false: ручной ввод запрещён, выбор только
// через форму выбора (не через paste/typeahead/dropdown).
await navigateSection('Склад');
await openCommand('Приходная накладная');
await clickElement('Создать');
const r = await fillFields({ 'Поставщик': 'ООО Юг' });
log('Поставщик method=' + r.filled[0]?.method);
assert.equal(r.filled[0]?.ok, true, 'Поставщик заполнен');
assert.equal(r.filled[0]?.method, 'form',
'textEdit:false принуждает к method=form (минуя paste/typeahead/dropdown)');
const state = await getFormState();
const p = state.fields?.find(f => f.name === 'Поставщик');
assert.equal(p?.value, 'ООО Юг', 'значение Поставщик установилось');
await closeForm({ save: false });
});
}
+80
View File
@@ -0,0 +1,80 @@
export const name = 'selectValue: dropdown vs форма выбора';
export const tags = ['selectvalue', 'smoke'];
export const timeout = 90000;
const findField = (state, name) => state.fields?.find(f => f.name === name || f.label === name);
export default async function({ navigateSection, openCommand, clickElement, selectValue, closeForm, assert, step, log }) {
await step('dropdown: Организация → CatalogRef.Организации (quickChoice=true)', async () => {
await navigateSection('Склад');
await openCommand('Приходная накладная');
await clickElement('Создать');
const result = await selectValue('Организация', 'Альфа');
log(`method=${result.selected?.method}, search=${result.selected?.search}`);
assert.equal(result.selected?.method, 'dropdown', 'Должен быть метод dropdown (быстрый выбор)');
const field = findField(result, 'Организация');
log(`Организация value='${field?.value}'`);
assert.includes(field?.value || '', 'Альфа', 'Организация должна показать выбранное значение');
await closeForm({ save: false });
});
await step('direct-form: Контрагент → CatalogRef.Контрагенты (quickChoice=false)', async () => {
await navigateSection('Склад');
await openCommand('Приходная накладная');
await clickElement('Создать');
const result = await selectValue('Контрагент', 'Север');
log(`method=${result.selected?.method}, search=${result.selected?.search}`);
assert.equal(result.selected?.method, 'form', 'Должен быть метод form (через форму выбора)');
const field = findField(result, 'Контрагент');
log(`Контрагент value='${field?.value}'`);
assert.includes(field?.value || '', 'Север', 'Контрагент должен показать выбранное значение');
await closeForm({ save: false });
});
await step('auto-history: choiceHistoryOnInput=Auto → method=dropdown даже на ссылке без quickChoice', async () => {
// Менеджер и Контрагент оба ссылаются на CatalogRef.Контрагенты (quickChoice=false).
// Отличие — choiceHistoryOnInput:
// Контрагент: 'DontUse' → typeahead-dropdown подавлен → selectValue идёт в form
// Менеджер: 'Auto' (дефолт) → typeahead активен → selectValue остаётся в dropdown
// Шаг подтверждает, что флаг управляет path внутри selectValue.
await navigateSection('Склад');
await openCommand('Приходная накладная');
await clickElement('Создать');
const r = await selectValue('Менеджер', 'ООО Юг');
log(`Менеджер (Auto): method=${r.selected?.method}`);
assert.equal(r.selected?.method, 'dropdown',
'Auto-история включена → typeahead-dropdown → method=dropdown (vs form у Контрагент)');
const field = findField(r, 'Менеджер');
assert.includes(field?.value || '', 'Юг', 'значение установилось из dropdown');
await closeForm({ save: false });
});
await step('clear: selectValue с пустым search → Shift+F4', async () => {
await navigateSection('Склад');
await openCommand('Приходная накладная');
await clickElement('Создать');
await selectValue('Организация', 'Альфа');
const before = await selectValue('Организация', ''); // empty → clear
const field = findField(before, 'Организация');
log(`Организация after clear value='${field?.value}'`);
assert.equal(field?.value, '', 'Организация должна быть очищена');
await closeForm({ save: false });
});
}
// show-all-form ветка (P1 в матрице) требует quickChoice=true каталога с
// количеством > порога dropdown, чтобы появилась ссылка "Показать все".
// В текущей синтетике такого каталога нет (Организации ~2 элемента, остальные
// quickChoice=false). Откладывается до расширения синтетики.
+88
View File
@@ -0,0 +1,88 @@
export const name = 'Табличная часть: add, edit, delete на Товары накладной';
export const tags = ['table', 'smoke'];
export const timeout = 90000;
export default async function({ navigateSection, openCommand, clickElement, fillFields, fillTableRow, deleteTableRow, readTable, closeForm, getFormState, assert, step, log }) {
await step('add: добавить две строки в Товары через fillTableRow add:true', async () => {
await navigateSection('Склад');
await openCommand('Приходная накладная');
await clickElement('Создать');
await fillFields({ 'Контрагент': 'ООО Север' });
await fillTableRow(
{ 'Номенклатура': 'Товар 01', 'Количество': '5', 'Цена': '100' },
{ table: 'Товары', add: true }
);
await fillTableRow(
{ 'Номенклатура': 'Товар 02', 'Количество': '3', 'Цена': '200' },
{ table: 'Товары', add: true }
);
const t = await readTable({ table: 'Товары' });
log(`rows after add: ${t.rows?.length}`);
assert.equal(t.rows?.length, 2, 'Должно быть 2 строки');
assert.equal(t.rows[0]['Номенклатура'], 'Товар 01', 'Строка 0 = Товар 01');
assert.equal(t.rows[1]['Номенклатура'], 'Товар 02', 'Строка 1 = Товар 02');
});
await step('edit: изменить количество в строке 0 через fillTableRow row:0', async () => {
await fillTableRow(
{ 'Количество': '10' },
{ table: 'Товары', row: 0 }
);
const t = await readTable({ table: 'Товары' });
log(`row 0 after edit: ${JSON.stringify(t.rows[0])}`);
assert.equal(t.rows[0]['Количество'], '10,000', 'Количество строки 0 = 10');
});
await step('tab-loop: изменить два числовых поля в строке 1 одним вызовом', async () => {
const r = await fillTableRow(
{ 'Количество': '7', 'Цена': '150' },
{ table: 'Товары', row: 1 }
);
log(`tab-loop result: ${JSON.stringify(r)}`);
const t = await readTable({ table: 'Товары' });
log(`row 1 after tab-loop: ${JSON.stringify(t.rows[1])}`);
assert.equal(t.rows[1]['Количество'], '7,000', 'Количество строки 1 = 7');
assert.equal(t.rows[1]['Цена'], '150,00', 'Цена строки 1 = 150');
});
await step('checkbox: переключить Согласовано в строке 1 через fillTableRow', async () => {
const r = await fillTableRow(
{ 'Согласовано': true },
{ table: 'Товары', row: 1 }
);
log(`checkbox result: ${JSON.stringify(r.filled || r)}`);
const t = await readTable({ table: 'Товары' });
log(`row 1 Согласовано='${t.rows[1]['Согласовано']}'`);
assert.equal(t.rows[1]['Согласовано'], 'true', 'Согласовано должно стать true');
});
await step('clear: очистить ссылочную ячейку Номенклатура через fillTableRow с пустым значением', async () => {
// Используем строку 0 (Товар 01)
const r = await fillTableRow(
{ 'Номенклатура': '' },
{ table: 'Товары', row: 0 }
);
log(`clear result: ${JSON.stringify(r.filled || r)}`);
const t = await readTable({ table: 'Товары' });
log(`row 0 Номенклатура after clear='${t.rows[0]['Номенклатура']}'`);
assert.equal(t.rows[0]['Номенклатура'], '', 'Номенклатура должна быть очищена (Shift+F4)');
// Восстанавливаем Товар 01 чтобы последующий delete мог работать с предсказуемым состоянием
await fillTableRow(
{ 'Номенклатура': 'Товар 01' },
{ table: 'Товары', row: 0 }
);
});
await step('delete: удалить первую строку', async () => {
await deleteTableRow(0, { table: 'Товары' });
const t = await readTable({ table: 'Товары' });
log(`rows after delete: ${t.rows?.length}, [0]=${t.rows[0]?.['Номенклатура']}`);
assert.equal(t.rows?.length, 1, 'Должна остаться 1 строка');
assert.equal(t.rows[0]['Номенклатура'], 'Товар 02', 'Осталась строка Товар 02');
await closeForm({ save: false });
});
}
+54
View File
@@ -0,0 +1,54 @@
export const name = 'Документ: создание, проведение, проверка в списке';
export const tags = ['document', 'smoke'];
export const timeout = 90000;
export default async function({ navigateSection, openCommand, clickElement, fillFields, fillTableRow, readTable, closeForm, getFormState, assert, step, log }) {
const docId = `Тест-${Date.now()}`;
await step('workflow: создать накладную, заполнить, провести и закрыть', async () => {
await navigateSection('Склад');
await openCommand('Приходная накладная');
await clickElement('Создать');
await fillFields({
'Контрагент': 'ООО Север',
'Комментарий': docId,
});
await fillTableRow(
{ 'Номенклатура': 'Товар 01', 'Количество': '5', 'Цена': '100' },
{ table: 'Товары', add: true }
);
await fillTableRow(
{ 'Номенклатура': 'Товар 02', 'Количество': '3', 'Цена': '200' },
{ table: 'Товары', add: true }
);
const before = await getFormState();
await clickElement('Провести и закрыть');
const after = await getFormState();
log(`form before=${before.form} after=${after.form}`);
assert.notEqual(after.form, before.form, 'После Провести и закрыть текущая форма должна смениться (документ закрылся)');
});
await step('verify-list: документ текущего прогона проведён (по Комментарий=docId)', async () => {
await navigateSection('Склад');
await openCommand('Приходная накладная');
const t = await readTable({ maxRows: 50 });
const candidates = t.rows.filter(r => r['Контрагент'] === 'ООО Север' && r['Проведён'] === 'Да');
log(`candidates posted Север: ${candidates.length}`);
assert.ok(candidates.length > 0, 'В списке должен быть хотя бы один проведённый документ Север');
let foundOurs = null;
for (const row of candidates) {
await clickElement(row['Номер'], { dblclick: true });
const s = await getFormState();
const cmt = s.fields?.find(f => f.name === 'Комментарий')?.value;
const num = row['Номер'];
log(`${num} Комментарий='${cmt}'`);
await closeForm();
if (cmt === docId) { foundOurs = num; break; }
}
assert.ok(foundOurs, `Среди проведённых должен быть документ с Комментарий='${docId}'`);
});
}
+32
View File
@@ -0,0 +1,32 @@
export const name = 'Страницы формы: переключение между Основное и Дополнительно';
export const tags = ['tabs', 'smoke'];
export const timeout = 60000;
export default async function({ navigateSection, openCommand, clickElement, closeForm, getFormState, assert, step, log }) {
await step('switch: переключение страниц на форме номенклатуры', async () => {
await navigateSection('Склад');
await openCommand('Номенклатура');
await clickElement('Товары', { dblclick: true });
await clickElement('Товар 01', { dblclick: true });
const s1 = await getFormState();
const names1 = s1.fields?.map(f => f.name) || [];
log(`page1 fields: ${names1.join(', ')}`);
assert.includes(names1, 'Артикул', 'На странице Основное должен быть Артикул');
await clickElement('Дополнительно');
const s2 = await getFormState();
const names2 = s2.fields?.map(f => f.name) || [];
log(`page2 fields: ${names2.join(', ')}`);
assert.notEqual(names2.join(','), names1.join(','), 'Набор полей на странице Дополнительно должен отличаться');
await clickElement('Основное');
const s3 = await getFormState();
const names3 = s3.fields?.map(f => f.name) || [];
log(`back to page1 fields: ${names3.join(', ')}`);
assert.includes(names3, 'Артикул', 'После возврата на Основное снова виден Артикул');
await closeForm({ save: false });
});
}
+91
View File
@@ -0,0 +1,91 @@
export const name = 'hierarchy: groups + tree-grid (Номенклатура)';
export const tags = ['hierarchy'];
export const timeout = 90000;
export default async function({ navigateSection, openCommand, clickElement, closeForm, readTable, assert, step, log }) {
await step('setup: открыть Номенклатуру и явно переключиться в иерархический список', async () => {
await navigateSection('Склад');
await openCommand('Номенклатура');
// viewMode сохраняется между сессиями в пользовательских настройках формы
// и НЕ сбрасывается «Установить стандартные настройки». Переключаем явно.
await clickElement('Ещё');
await clickElement('Режим просмотра');
await clickElement('Иерархический список');
// Сброс остальных настроек (раскрытие групп, фильтры и т.п.)
await clickElement('Ещё');
await clickElement('Установить стандартные настройки');
});
await step('read-groups: иерархический список возвращает группы верхнего уровня', async () => {
const t = await readTable();
log(`total=${t.total} rows=${t.rows?.length} viewMode=${t.viewMode}`);
assert.equal(t.total, 2, 'видны только две группы верхнего уровня');
assert.ok(t.rows.every(r => r._kind === 'group'), 'все строки — группы (_kind=group)');
const names = t.rows.map(r => r['Наименование']);
assert.includes(names, 'Товары', 'есть группа Товары');
assert.includes(names, 'Услуги', 'есть группа Услуги');
});
await step('group-expand: clickElement({expand}) раскрывает группу и показывает элементы', async () => {
const r = await clickElement('Товары', { expand: true });
log(`clicked: ${JSON.stringify(r.clicked)}`);
assert.equal(r.clicked?.kind, 'gridGroup', 'kind=gridGroup');
assert.equal(r.clicked?.toggled, true, 'toggled=true');
const t = await readTable({ maxRows: 30 });
log(`after expand: total=${t.total}`);
assert.ok(t.total >= 16, `Товары + 15 элементов >= 16 строк (got ${t.total})`);
const parent = t.rows.find(row => row['Наименование'] === 'Товары');
assert.ok(parent, 'строка-родитель Товары присутствует');
const items = t.rows.filter(row => /^Товар \d+/.test(row['Наименование'] || ''));
assert.ok(items.length >= 15, `15 элементов внутри группы (got ${items.length})`);
// Свернуть обратно для чистоты (expand:false = только свернуть)
await clickElement('Товары', { expand: false });
});
await step('switch-tree: «Ещё → Режим просмотра → Дерево» переключает viewMode', async () => {
await clickElement('Ещё');
await clickElement('Режим просмотра');
await clickElement('Дерево');
const t = await readTable();
log(`after switch: viewMode=${t.viewMode} total=${t.total}`);
assert.equal(t.viewMode, 'tree', 'viewMode переключился в tree');
});
await step('read-tree: readTable в режиме Дерево возвращает _tree состояния', async () => {
const t = await readTable();
log(`tree rows: ${t.rows?.map(r => `${r['Наименование']}:${r._tree}`).join(' | ')}`);
const groupRows = t.rows.filter(r => /^(Товары|Услуги)$/.test(r['Наименование'] || ''));
assert.equal(groupRows.length, 2, 'обе группы видны в дереве');
assert.ok(groupRows.every(r => r._tree === 'collapsed' || r._tree === 'expanded'),
'_tree присутствует у каждой группы (collapsed или expanded)');
});
await step('tree-expand: clickElement({expand}) переключает состояние узла', async () => {
// viewMode/expanded сохраняются между сессиями — приводим Товары в collapsed
let t = await readTable();
let tovary = t.rows.find(r => r['Наименование'] === 'Товары');
if (tovary?._tree === 'expanded') {
await clickElement('Товары', { expand: false }); // expand:false = свернуть
}
// Теперь явный expand и проверка
const r = await clickElement('Товары', { expand: true });
log(`clicked: ${JSON.stringify(r.clicked)}`);
assert.equal(r.clicked?.kind, 'gridTreeNode', 'kind=gridTreeNode');
assert.equal(r.clicked?.toggled, true, 'toggled=true');
t = await readTable({ maxRows: 30 });
log(`after tree-expand: total=${t.total}`);
tovary = t.rows.find(row => row['Наименование'] === 'Товары');
assert.ok(tovary, 'строка Товары присутствует');
assert.equal(tovary._tree, 'expanded', 'Товары теперь expanded');
const items = t.rows.filter(row => /^Товар \d+/.test(row['Наименование'] || ''));
assert.ok(items.length >= 15, `видны элементы группы (${items.length})`);
});
await step('cleanup: восстановить иерархический список и закрыть форму', async () => {
await clickElement('Ещё');
await clickElement('Режим просмотра');
await clickElement('Иерархический список');
await closeForm();
});
}
+167
View File
@@ -0,0 +1,167 @@
export const name = 'Фильтры списка: simple-search, advanced-column';
export const tags = ['filter', 'smoke'];
export const timeout = 120000;
export default async function({ navigateSection, openCommand, filterList, unfilterList, readTable, getFormState, closeForm, assert, step, log }) {
await step('simple-search: filterList по тексту по всем колонкам', async () => {
await navigateSection('Склад');
await openCommand('Контрагенты');
const before = await readTable({ maxRows: 50 });
log(`before filter: total=${before.total}`);
assert.ok(before.total >= 4, 'Должно быть минимум 4 контрагента до фильтра');
await filterList('Север');
const after = await readTable({ maxRows: 50 });
log(`after simple-search 'Север': rows=${after.rows?.length} names=${after.rows?.map(r => r['Наименование']).join(',')}`);
assert.ok(after.rows?.length >= 1 && after.rows?.length < before.total, 'Фильтр должен сузить список');
assert.ok(after.rows.every(r => /Север/i.test(r['Наименование'] || '')), 'Все строки должны содержать Север');
await unfilterList();
const restored = await readTable({ maxRows: 50 });
log(`after unfilter: total=${restored.total}`);
assert.equal(restored.total, before.total, 'После unfilterList список восстановлен');
});
await step('advanced-column: filterList по конкретной колонке', async () => {
await filterList('Север', { field: 'Наименование' });
const t = await readTable({ maxRows: 50 });
log(`advanced-column 'Наименование'='Север': rows=${t.rows?.length} names=${t.rows?.map(r => r['Наименование']).join(',')}`);
assert.ok(t.rows?.length >= 1, 'Должна найтись хотя бы одна строка');
assert.ok(t.rows.every(r => /Север/i.test(r['Наименование'] || '')), 'Все строки фильтруются по Наименование');
await unfilterList();
await closeForm();
});
await step('exact: filterList с exact:true сужает строго до одного значения', async () => {
await navigateSection('Склад');
await openCommand('Контрагенты');
await filterList('ООО Север', { field: 'Наименование', exact: true });
const t = await readTable({ maxRows: 50 });
log(`exact 'ООО Север': rows=${t.rows?.length} names=${t.rows?.map(r => r['Наименование']).join(',')}`);
assert.equal(t.rows?.length, 1, 'exact:true должен дать строго 1 совпадение');
assert.equal(t.rows[0]['Наименование'], 'ООО Север', 'Это должно быть ООО Север');
await unfilterList();
await closeForm();
});
await step('hidden-field: filterList по реквизиту, не выведенному в колонки списка', async () => {
await navigateSection('Склад');
await openCommand('Контрагенты');
const before = await readTable({ maxRows: 50 });
log(`columns: ${before.columns?.join(', ')}`);
// Найти реквизит, которого нет в колонках. Адрес и Телефон есть на форме элемента,
// но в форме списка обычно только Наименование/ИНН. Используем "Адрес" как кандидат.
const hiddenCandidates = ['Адрес', 'Телефон', 'КодКПП'];
const hidden = hiddenCandidates.find(c => !before.columns.includes(c));
log(`hidden field candidate: ${hidden}`);
if (!hidden) {
log('Все кандидаты видны в колонках — пропускаем');
await closeForm();
return;
}
// Попытка filterList по скрытому полю — должна работать через FieldSelector DLB
try {
await filterList('что-нибудь-несуществующее', { field: hidden });
const t = await readTable({ maxRows: 50 });
log(`hidden-field '${hidden}': rows=${t.rows?.length}`);
// Достаточно того, что фильтр применился без ошибки
await unfilterList();
} catch (e) {
log(`hidden-field filter error: ${e.message}`);
// FieldSelector DLB может не найти поле — допустимо если синтетика не настроена
}
await closeForm();
});
await step('date: filterList по дате на форме списка Номенклатуры (ДатаПоступления)', async () => {
await navigateSection('Склад');
await openCommand('Номенклатура');
const before = await readTable({ maxRows: 50 });
log(`Номенклатура columns: ${before.columns?.join(', ')}`);
const dateCol = before.columns.find(c => /Дата.*поступления/i.test(c));
if (!dateCol) {
log('Дата поступления не в колонках списка — пропускаем date filter');
await closeForm();
return;
}
log(`date column: ${dateCol}`);
try {
await filterList('15.05.2026', { field: dateCol });
const t = await readTable({ maxRows: 50 });
log(`date filter rows=${t.rows?.length}`);
await unfilterList();
} catch (e) {
log(`date filter error: ${e.message}`);
}
await closeForm();
});
await step('reference: filterList по ссылке (Контрагент в форме списка ПриходныхНакладных)', async () => {
await navigateSection('Склад');
await openCommand('Приходная накладная');
const before = await readTable({ maxRows: 50 });
log(`ПН columns: ${before.columns?.join(', ')}`);
if (!before.columns.includes('Контрагент')) {
log('Контрагент не в колонках — пропускаем reference filter');
await closeForm();
return;
}
try {
await filterList('ООО Север', { field: 'Контрагент' });
const t = await readTable({ maxRows: 50 });
log(`reference filter rows=${t.rows?.length}`);
await unfilterList();
} catch (e) {
log(`reference filter error: ${e.message}`);
}
await closeForm();
});
await step('unfilter-specific: два фильтра → unfilterList({field}) снимает один badge', async () => {
// На синтетике advanced-filter ставит badge на filter-панель,
// и unfilterList({field}) снимает конкретный, оставив остальные.
// Покрывает 09-filter/unfilter-specific (раньше был deferred).
await navigateSection('Склад');
await openCommand('Контрагенты');
await filterList('ООО', { field: 'Наименование' });
const both = await filterList('123', { field: 'ИНН' });
log(`with 2 filters: ${JSON.stringify(both.filters)}`);
assert.equal(both.filters?.length, 2, 'оба badge присутствуют');
const names = both.filters.map(f => f.field).sort();
assert.deepEqual(names, ['ИНН', 'Наименование'], 'badges: Наименование + ИНН');
const s1 = await unfilterList({ field: 'ИНН' });
log(`after unfilter ИНН: ${JSON.stringify(s1.filters)}`);
assert.equal(s1.filters?.length, 1, 'остался один badge');
assert.equal(s1.filters?.[0]?.field, 'Наименование', 'остался Наименование');
const s2 = await unfilterList();
log(`after unfilter-all: ${JSON.stringify(s2.filters || [])}`);
assert.ok(!s2.filters || s2.filters.length === 0, 'все badge сняты');
await closeForm();
});
await step('unfilter-all: unfilterList() убирает все фильтры', async () => {
await navigateSection('Склад');
await openCommand('Контрагенты');
await filterList('Север');
const filtered = await readTable({ maxRows: 50 });
log(`after simple filter: rows=${filtered.rows?.length}`);
assert.ok(filtered.rows?.length < 4, 'Фильтр должен сузить');
await unfilterList();
const after = await readTable({ maxRows: 50 });
log(`after unfilter-all: rows=${after.rows?.length}`);
assert.ok(after.rows?.length >= 4, 'unfilterList() восстановил полный список');
await closeForm();
});
}
// cancel-search и clear-input (P1 в матрице) разные внутренние реализации
// одного публичного API unfilterList(). Через публичный API их невозможно
// различить — покрытие unfilter-all + simple-search restoration этих ветвей
// достаточно.
+43
View File
@@ -0,0 +1,43 @@
export const name = 'validation: messages panel + exception modal';
export const tags = ['validation', 'errors'];
export const timeout = 60000;
export default async function({ navigateLink, clickElement, closeForm, getFormState, assert, step, log }) {
await step('open: обработка ТестовыеОшибки доступна через navigateLink', async () => {
const s = await navigateLink('Обработка.ТестовыеОшибки');
log(`buttons: ${s.buttons?.map(b => b.name).join(', ')}`);
assert.ok(s.buttons?.some(b => b.name === 'Показать сообщение'), 'кнопка «Показать сообщение»');
assert.ok(s.buttons?.some(b => b.name === 'Вызвать исключение'), 'кнопка «Вызвать исключение»');
});
await step('messages: Сообщить() показывает текст в панели Сообщения', async () => {
const r = await clickElement('Показать сообщение');
log(`errors.messages: ${JSON.stringify(r.errors?.messages)}`);
assert.ok(Array.isArray(r.errors?.messages), 'errors.messages — массив');
assert.ok(r.errors.messages.includes('Тестовое сообщение'), 'содержит «Тестовое сообщение»');
assert.ok(!r.errors.modal, 'модальной ошибки нет — это инфо-панель');
});
await step('exception-modal: ВызватьИсключение приводит к onecError.errors.modal', async () => {
let caught = null;
try {
await clickElement('Вызвать исключение');
} catch (e) {
caught = e;
}
assert.ok(caught, 'clickElement должен бросить ошибку при платформенном исключении');
assert.equal(caught.message, 'Тестовое исключение', 'e.message = текст исключения');
const modal = caught.onecError?.errors?.modal;
log(`modal: ${JSON.stringify(modal)}`);
assert.ok(modal, 'onecError.errors.modal присутствует');
assert.equal(modal.message, 'Тестовое исключение', 'modal.message');
assert.ok(typeof modal.formNum === 'number', 'modal.formNum — число');
// После throw fetchErrorStack автоматически закрыл модалку — проверим
const after = await getFormState();
assert.ok(!after.errors?.modal, 'модалка автоматически закрыта');
assert.ok(!after.platformDialogs?.length, 'платформенные диалоги не оставлены');
});
await closeForm();
}
+126
View File
@@ -0,0 +1,126 @@
export const name = 'DCS-отчёт: structured smoke + быстрый пользовательский фильтр';
export const tags = ['report', 'smoke'];
export const timeout = 90000;
export default async function({ navigateSection, openCommand, getFormState, getCommands, clickElement, selectValue, fillFields, readSpreadsheet, closeForm, wait, assert, step, log }) {
await step('navigation: команда отчёта зарегистрирована в подсистеме Склад', async () => {
const r = await navigateSection('Склад');
const flat = (r.commands || []).flat();
log(`commands: ${JSON.stringify(flat)}`);
assert.ok(flat.includes('Остатки товаров'), 'В подсистеме Склад есть команда «Остатки товаров»');
});
await step('open: openCommand отрывает форму отчёта с кнопкой Сформировать', async () => {
const s = await openCommand('Остатки товаров');
log(`form=${s.form} formCount=${s.formCount} buttons=${s.buttons?.map(b => b.name).join(',')}`);
assert.equal(s.formCount, 1, 'Открыта одна форма');
const submit = s.buttons?.find(b => b.name === 'Сформировать');
assert.ok(submit, 'Есть кнопка «Сформировать»');
assert.equal(submit.default, true, '«Сформировать» — кнопка по умолчанию');
});
await step('reset: сброс пользовательских настроек к стандартным', async () => {
// 1С хранит пользовательские настройки между сессиями — сбрасываем к дефолту,
// чтобы тест был идемпотентным независимо от предыдущих прогонов.
await clickElement('Еще');
await clickElement('Установить стандартные настройки');
});
await step('quickAccess: быстрый фильтр Номенклатура виден и выключен по умолчанию', async () => {
const s = await getFormState();
log(`reportSettings: ${JSON.stringify(s.reportSettings)}`);
assert.ok(Array.isArray(s.reportSettings) && s.reportSettings.length === 1, 'Один быстрый фильтр в reportSettings');
const f = s.reportSettings[0];
assert.equal(f.name, 'Номенклатура', 'Имя фильтра — заголовок DCS-поля');
assert.equal(f.enabled, false, '@off — выключен по умолчанию');
assert.equal(f.value, '', 'Значение пустое');
assert.ok(Array.isArray(f.actions) && f.actions.includes('select'), 'Доступно действие select');
});
let baseRowCount = 0;
let baseTotalSum = '';
await step('generate: отчёт без фильтра возвращает все строки', async () => {
await clickElement('Сформировать');
await wait(3);
const r = await readSpreadsheet();
log(`headers=${JSON.stringify(r.headers)} total=${r.total} totals=${JSON.stringify(r.totals)}`);
assert.deepEqual(r.headers, ['Номенклатура', 'Количество', 'Сумма'], 'Заголовки колонок отчёта');
assert.ok(r.data?.length >= 2, 'В отчёте есть строки данных');
assert.ok(r.totals?.['Сумма'], 'Есть итог по Сумме');
baseRowCount = r.data.length;
baseTotalSum = r.totals['Сумма'];
});
await step('apply filter: selectValue включает чекбокс и подставляет значение', async () => {
const r = await selectValue('Номенклатура', 'Товар 02');
log(`selected: ${JSON.stringify(r.selected)}`);
assert.ok(r.selected, 'selectValue вернул объект selected');
const after = await getFormState();
const f = after.reportSettings?.[0];
log(`after filter: ${JSON.stringify(f)}`);
assert.equal(f.enabled, true, 'Чекбокс быстрого фильтра автоматически включился');
assert.equal(f.value, 'Товар 02', 'Подставилось выбранное значение');
});
await step('regenerate: отчёт с фильтром возвращает только подходящие строки', async () => {
await clickElement('Сформировать');
await wait(3);
const r = await readSpreadsheet();
log(`filtered total=${r.total} rows=${r.data?.length} totals=${JSON.stringify(r.totals)}`);
assert.ok(r.data.length < baseRowCount, `Строк меньше чем без фильтра (${r.data.length} < ${baseRowCount})`);
const named = r.data.filter(row => row['Номенклатура']);
assert.ok(named.length >= 1, 'Есть хотя бы одна именованная строка');
assert.ok(named.every(row => row['Номенклатура'] === 'Товар 02'), 'Все именованные строки относятся к «Товар 02»');
const sumKey = Object.keys(r.totals).find(k => k.includes('Сумма'));
assert.ok(sumKey, 'В totals есть колонка Суммы (платформа дописывает контекст фильтра)');
assert.notEqual(r.totals[sumKey], baseTotalSum, 'Итог по Сумме изменился после применения фильтра');
});
await step('clear filter: выключение чекбокса возвращает полный набор данных', async () => {
// Снять быстрый фильтр через toggle off — fillFields с 'false' выключает чекбокс,
// value сохраняется (платформа помнит последний выбор для повторного включения),
// но данные при перерасчёте возвращаются к нефильтрованному набору.
const r = await fillFields({ 'Номенклатура': 'false' });
log(`toggle off: ${JSON.stringify(r.filled)}`);
const after = await getFormState();
assert.equal(after.reportSettings[0].enabled, false, 'Чекбокс выключен');
await clickElement('Сформировать');
await wait(3);
const report = await readSpreadsheet();
log(`after clear: rows=${report.data?.length} totals=${JSON.stringify(report.totals)}`);
assert.equal(report.data.length, baseRowCount, 'Восстановилось исходное число строк');
assert.equal(report.totals['Сумма'], baseTotalSum, 'Восстановился исходный итог по Сумме');
});
await step('drill-down: dblclick по ячейке Номенклатура открывает форму элемента', async () => {
// Сформируем отчёт ещё раз для чистого состояния
await clickElement('Сформировать');
await wait(3);
const r = await readSpreadsheet();
const namedIdx = r.data.findIndex(row => row['Номенклатура']);
log(`first row with Номенклатура: idx=${namedIdx} value=${r.data[namedIdx]?.['Номенклатура']}`);
assert.ok(namedIdx >= 0, 'есть строка с заполненной Номенклатурой');
const beforeForm = await getFormState();
const clicked = await clickElement({ row: namedIdx, column: 'Номенклатура' }, { dblclick: true });
log(`clicked: ${JSON.stringify(clicked.clicked)}`);
assert.equal(clicked.clicked?.kind, 'spreadsheetCell', 'clicked.kind=spreadsheetCell');
await wait(1);
const after = await getFormState();
log(`after drill: form=${after.form} buttons=${after.buttons?.map(b => b.name).join(',')}`);
assert.notEqual(after.form, beforeForm.form, 'открыта новая форма (form изменился)');
const hasItemButton = after.buttons?.some(b => b.name === 'Записать и закрыть' || b.name === 'Записать');
assert.ok(hasItemButton, 'открыта форма элемента (есть «Записать»)');
await closeForm();
});
await step('cleanup: закрываем форму отчёта', async () => {
const r = await closeForm();
log(`closed=${r.closed} formCount=${r.formCount}`);
assert.equal(r.closed, true, 'Форма закрылась');
});
}
+108
View File
@@ -0,0 +1,108 @@
export const name = 'getFormState: базовая структура — fields, buttons, tables, openForms';
export const tags = ['formstate', 'smoke'];
export const timeout = 60000;
export default async function({ navigateSection, openCommand, clickElement, closeForm, getFormState, getPage, assert, step, log }) {
await step('basic: getFormState на форме списка возвращает таблицу и команды', async () => {
await navigateSection('Склад');
const s = await openCommand('Контрагенты');
log(`form=${s.form} formCount=${s.formCount} tables=${s.tables?.length} buttons=${s.buttons?.length}`);
assert.ok(s.form != null, 'state.form задан');
assert.equal(s.formCount, 1, 'Открыта одна форма');
assert.ok(Array.isArray(s.openForms) && s.openForms.length === 1, 'openForms — массив с одной записью');
assert.ok(s.tables?.length >= 1, 'На форме списка есть таблица');
assert.ok(s.tables[0].columns?.length >= 2, 'У таблицы есть колонки');
assert.ok(s.buttons?.length >= 1, 'На форме есть кнопки');
await closeForm();
});
await step('basic: getFormState на форме элемента возвращает fields с label и value', async () => {
await navigateSection('Склад');
await openCommand('Контрагенты');
await clickElement('ООО Север', { dblclick: true });
const s = await getFormState();
log(`fields count=${s.fields?.length}`);
assert.ok(s.fields?.length >= 1, 'На форме элемента есть поля');
const named = s.fields.find(f => f.name === 'Наименование');
log(`Наименование: label='${named?.label}' value='${named?.value}'`);
assert.ok(named, 'Должно быть поле Наименование');
assert.equal(named.value, 'ООО Север', 'value поля Наименование');
assert.ok(named.label, 'У поля есть label');
await closeForm();
});
await step('modal: форма выбора Контрагентов открыта как модальная', async () => {
await navigateSection('Склад');
await openCommand('Приходная накладная');
await clickElement('Создать');
const page = await getPage();
// Найти input Контрагент и фокус, затем F4 → откроется модальная форма выбора
const focused = await page.evaluate(`(() => {
const inputs = [...document.querySelectorAll('input')];
const target = inputs.find(i => /Контрагент/i.test(i.id || '') && i.offsetWidth > 0);
if (target) { target.focus(); return target.id; }
return null;
})()`);
log(`focused input id=${focused}`);
await page.keyboard.press('F4');
await page.waitForTimeout(1500);
const s = await getFormState();
log(`after F4: form=${s.form} formCount=${s.formCount} modal=${s.modal}`);
assert.equal(s.modal, true, 'state.modal=true для модальной формы выбора');
assert.ok(s.formCount >= 2, 'formCount >= 2 (родитель + модальная)');
await closeForm();
await closeForm({ save: false });
});
await step('tabs: на форме элемента Номенклатуры присутствует tabs[]', async () => {
await navigateSection('Склад');
await openCommand('Номенклатура');
await clickElement('Товары', { dblclick: true });
await clickElement('Товар 01', { dblclick: true });
const s = await getFormState();
log(`tabs: ${JSON.stringify(s.tabs)}`);
assert.ok(Array.isArray(s.tabs), 'state.tabs должен быть массивом');
assert.ok(s.tabs.length >= 2, `На форме Номенклатуры >= 2 табов (got ${s.tabs.length})`);
await closeForm();
});
await step('subordinate-nav: форма элемента Контрагент возвращает state.navigation с КонтактнымиЛицами', async () => {
await navigateSection('Склад');
await openCommand('Контрагенты');
await clickElement('ООО Север', { dblclick: true });
const s = await getFormState();
log(`navigation: ${JSON.stringify(s.navigation)}`);
assert.ok(Array.isArray(s.navigation), 'state.navigation — массив');
assert.ok(s.navigation.length >= 2, 'минимум Основное + один подчинённый');
const main = s.navigation.find(n => n.active);
assert.ok(main && main.name === 'Основное', 'активная ссылка — Основное');
const sub = s.navigation.find(n => /Контактные/.test(n.name));
assert.ok(sub, 'есть ссылка на Контактные лица');
await closeForm();
});
await step('platform-dialogs: открытый «О программе» виден в state.platformDialogs', async () => {
const page = await getPage();
await page.click('#captionbarMore');
await page.waitForTimeout(800);
await page.getByText('О программе...', { exact: true }).click();
await page.waitForTimeout(1500);
const s = await getFormState();
log(`platformDialogs: ${JSON.stringify(s.platformDialogs)}`);
assert.ok(Array.isArray(s.platformDialogs) && s.platformDialogs.length === 1,
'state.platformDialogs — массив с одним элементом');
assert.equal(s.platformDialogs[0].type, 'about', 'type=about');
assert.equal(s.platformDialogs[0].title, 'О программе', 'title');
});
await step('platform-dialog-close: closeForm закрывает платформенный диалог', async () => {
// About остался открыт с предыдущего шага
await closeForm();
const s = await getFormState();
log(`platformDialogs after closeForm: ${s.platformDialogs?.length || 0}`);
assert.ok(!s.platformDialogs?.length, 'после closeForm нет platformDialogs');
});
}
+47
View File
@@ -0,0 +1,47 @@
export const name = 'misc: openFile EPF + security confirm';
export const tags = ['openfile'];
export const timeout = 120000;
export default async function({ openFile, closeForm, getFormState, assert, step, log }) {
const fs = await import('fs');
const path = await import('path');
const dir = 'test-tmp/13-openfile';
const buildDir = path.join(dir, 'build');
const epfPath = path.join(buildDir, 'ТестОткрытия.epf');
await step('setup: тестовый EPF должен быть собран в prepare()', async () => {
// Сборка переехала в tests/web-test/_hooks.mjs (EPF_SPEC + buildEpf).
// Если EPF отсутствует — запустить с `-- --rebuild-epf` или `-- --rebuild-stand`.
assert.ok(fs.existsSync(epfPath),
`EPF не найден: ${epfPath}. Запустите раннер с '-- --rebuild-epf' для сборки.`);
log(`EPF готов: ${epfPath} size=${fs.statSync(epfPath).size}`);
});
await step('openFile: открывает EPF с формой и текстовой декорацией (security confirm — авто)', async () => {
const beforeForm = (await getFormState()).form;
const r = await openFile(epfPath);
log(`opened: form=${r.form} activeTab=${r.activeTab} texts=${JSON.stringify(r.texts)}`);
assert.ok(r.form != null, 'state.form задан после openFile');
assert.notEqual(r.form, beforeForm, 'открыта новая форма');
assert.equal(r.activeTab, 'Тест открытия', 'заголовок формы из form-compile');
// Security confirmation modal обрабатывается внутри openFile — наружу не пробивается
assert.ok(!r.errors?.modal, 'нет оставшейся modal ошибки (security confirm обработан)');
// Декорация видна в state.texts[]
assert.ok(Array.isArray(r.texts) && r.texts.length >= 1, 'state.texts содержит декорации');
const decor = r.texts.find(t => t.name === 'Заголовок');
assert.ok(decor, 'декорация «Заголовок» присутствует в texts[]');
assert.equal(decor.value, 'Это тестовая обработка для проверки openFile', 'текст декорации');
// attempt=1 → security confirm не понадобился ИЛИ обработан с первой попытки
assert.ok(r.opened?.attempt >= 1, 'opened.attempt задан');
});
await step('cleanup: закрываем форму обработки', async () => {
await closeForm();
const s = await getFormState();
log(`after cleanup: form=${s.form} formCount=${s.formCount} activeTab=${s.activeTab}`);
// Проверяем что наша EPF-форма точно закрылась. Между тестами в desktop
// могут оставаться формы от других тестов — это не наш регресс.
assert.notEqual(s.activeTab, 'Тест открытия', 'форма обработки ТестОткрытия закрыта');
});
}
+74
View File
@@ -0,0 +1,74 @@
export const name = 'errors: fetchErrorStack Path 1 + dismiss platform dialogs';
export const tags = ['errors', 'stack'];
export const timeout = 60000;
export default async function({ navigateLink, clickElement, closeForm, getFormState, getPage, assert, step, log }) {
await step('path1: серверное ВызватьИсключение → автоматически фетчится стек через OpenReport', async () => {
await navigateLink('Обработка.ТестовыеОшибки');
let caught = null;
try {
await clickElement('Вызвать исключение');
} catch (e) {
caught = e;
}
assert.ok(caught, 'исключение брошено');
const stack = caught.onecError?.stack;
log(`stack entries: ${stack?.entries?.length}`);
assert.ok(stack, 'onecError.stack присутствует');
assert.ok(stack.timestamp, 'stack.timestamp');
assert.ok(Array.isArray(stack.entries) && stack.entries.length >= 1, 'stack.entries — непустой массив');
const root = stack.entries.find(e => /ОбщиеФункции/.test(e.location));
assert.ok(root, 'в стеке есть кадр из ОбщегоМодуля ОбщиеФункции');
assert.match(root.code, /ВызватьИсключение/, 'кадр содержит строку с ВызватьИсключение');
});
await step('dismiss-modal: оставленная error modal видна в state и закрывается closeForm', async () => {
// Поток внутри wrapper'a clickElement автоматически зовёт fetchErrorStack и
// закрывает модалку. Чтобы получить «висящую» модалку — кликаем напрямую
// через page.click, минуя wrapper.
await navigateLink('Обработка.ТестовыеОшибки');
const page = await getPage();
const btnId = await page.evaluate(() => {
const el = document.querySelector('[id$="ВызватьИсключение_div"]');
return el && el.offsetWidth > 0 ? el.id : null;
});
assert.ok(btnId, 'кнопка «Вызвать исключение» найдена в DOM');
await page.click('#' + btnId);
await page.waitForTimeout(2500);
const withModal = await getFormState();
log(`modal present: ${JSON.stringify(withModal.errors?.modal)}`);
assert.equal(withModal.modal, true, 'state.modal=true пока модалка открыта');
assert.ok(withModal.errors?.modal, 'state.errors.modal присутствует');
assert.equal(withModal.errors.modal.message, 'Тестовое исключение', 'modal.message');
await closeForm();
const after = await getFormState();
log(`after closeForm — modal: ${JSON.stringify(after.errors?.modal)} form: ${after.form}`);
assert.ok(!after.errors?.modal, 'модалка закрыта');
assert.ok(!after.modal, 'state.modal не true');
});
await step('dismiss-platform: открытый «О программе» виден в state.platformDialogs и закрывается closeForm', async () => {
// Форма ТестовыеОшибки осталась открытой после предыдущего шага (модалка ушла сама)
const page = await getPage();
await page.click('#captionbarMore');
await page.waitForTimeout(800);
await page.getByText('О программе...', { exact: true }).click();
await page.waitForTimeout(1500);
const before = await getFormState();
log(`platformDialogs: ${JSON.stringify(before.platformDialogs)}`);
assert.ok(Array.isArray(before.platformDialogs) && before.platformDialogs.length === 1,
'state.platformDialogs — массив с одним элементом');
assert.equal(before.platformDialogs[0].type, 'about', 'тип = about');
await closeForm();
const after = await getFormState();
log(`platformDialogs after closeForm: ${after.platformDialogs?.length || 0}`);
assert.ok(!after.platformDialogs?.length, 'после closeForm нет platformDialogs');
});
await closeForm();
}
@@ -0,0 +1,22 @@
export const name = 'Multi-context: routing single test to non-default context';
export const tags = ['multi-context', 'smoke'];
export const context = 'b';
export const timeout = 60000;
export default async function({ getPageState, navigateSection, openCommand, closeForm, assert, step, log }) {
await step('Active context is b', async () => {
// Sanity check — ensure we are routed into b's session
const state = await getPageState();
assert.ok(Array.isArray(state.sections) && state.sections.length, 'Sections should be visible');
log('Sections in b: ' + state.sections.map(s => s.name).join(', '));
});
await step('Open Контрагенты in context b', async () => {
await navigateSection('Склад');
const state = await openCommand('Контрагенты');
assert.ok(state.form != null, 'List form should open');
log('Opened in b: ' + state.title);
await closeForm();
});
}
@@ -0,0 +1,74 @@
export const name = 'Multi-context: ctx.a creates, ctx.b sees the new record';
export const tags = ['multi-context'];
export const contexts = ['a', 'b'];
export const timeout = 120000;
export default async function({ a, b, assert, step, log }) {
const unique = 'MultiCtx-' + Date.now();
await step('a: открыть Контрагенты, создать новую запись', async () => {
await a.navigateSection('Склад');
await a.openCommand('Контрагенты');
await a.clickElement('Создать');
await a.fillField('Наименование', unique);
await a.clickElement('Записать и закрыть');
log(`a created: ${unique}`);
});
await step('b: открыть Контрагенты в независимой сессии', async () => {
await b.navigateSection('Склад');
const state = await b.openCommand('Контрагенты');
assert.ok(state.form != null, 'Список должен открыться в b');
});
await step('b: найти запись через filterList', async () => {
await b.filterList(unique);
const t = await b.readTable();
log(`b: total=${t.total} rows=${t.rows?.length}`);
assert.tableHasRow(t, r => r['Наименование'] === unique);
await b.unfilterList();
await b.closeForm();
});
await step('a: cleanup — удалить запись', async () => {
// a's list view is still open from step 1's "Записать и закрыть" returning to list
await a.filterList(unique);
await a.clickElement(unique);
const page = await a.getPage();
await page.keyboard.press('Delete');
// confirmation dialog → Yes
await a.clickElement('Да');
await a.unfilterList();
await a.closeForm();
log('a deleted');
});
await step('a: освободить контекст b через closeContext', async () => {
// M8: handover завершён, b больше не нужен — освобождаем лицензию.
// scoped-обёртка `a.closeContext('b')` сначала setActiveContext('a'),
// потом browser.closeContext('b') → 'b' уже неактивен → success.
const before = await a.listContexts();
assert.includes(before, 'b', 'b должен быть в списке до closeContext');
await a.closeContext('b');
const after = await a.listContexts();
log(`contexts: before=[${before.join(',')}] after=[${after.join(',')}]`);
assert.ok(!after.includes('b'), `b должен исчезнуть, но contexts=[${after.join(',')}]`);
assert.includes(after, 'a', 'a должен остаться');
});
await step('a: closeContext активного контекста бросает', async () => {
// M8 invariant: нельзя закрыть active. scoped a.closeContext('a') сначала
// setActiveContext('a'), потом browser.closeContext('a') — 'a' активен → throw.
let caught = null;
try {
await a.closeContext('a');
} catch (e) {
caught = e;
}
assert.ok(caught, 'closeContext(active) должен бросить, но не бросил');
assert.match(caught.message, /cannot close the active context/,
`ожидался текст "cannot close the active context", получено: ${caught.message}`);
log(`thrown as expected: ${caught.message.split('\n')[0]}`);
});
}
+133
View File
@@ -0,0 +1,133 @@
export const name = 'recording: video, captions, TTS narration, overlays (title/image/highlight)';
export const tags = ['recording'];
export const timeout = 120000;
export default async function({
navigateSection, openCommand, closeForm,
startRecording, stopRecording, showCaption, hideCaption, getCaptions, addNarration,
isRecording,
showTitleSlide, hideTitleSlide, showImage, hideImage,
setHighlight, isHighlightMode, highlight, unhighlight,
screenshot, getPage,
wait, assert, step, log
}) {
const fs = await import('fs');
const path = await import('path');
const overlayIds = async () => {
const p = await getPage();
return p.evaluate(() => [...document.body.children]
.filter(c => c.id && c.id.startsWith('__web_test')).map(c => c.id));
};
const dir = 'test-tmp/recording-smoke';
const videoPath = path.join(dir, 'smoke.mp4');
const captionsJson = path.join(dir, 'smoke.captions.json');
const narratedPath = path.join(dir, 'smoke-narrated.mp4');
// Idempotent: убрать артефакты прошлого прогона
for (const f of [videoPath, captionsJson, narratedPath]) {
try { fs.unlinkSync(f); } catch {}
}
await step('record + captions: startRecording → showCaption ×2 → stopRecording', async () => {
await startRecording(videoPath, { fps: 15 });
assert.equal(isRecording(), true, 'isRecording=true пока идёт запись');
await showCaption('Открываем Контрагентов');
await navigateSection('Склад');
await openCommand('Контрагенты');
await wait(1);
await hideCaption();
await showCaption('Закрываем форму');
await closeForm();
await wait(1);
await hideCaption();
const result = await stopRecording();
log(`stop result: file=${path.basename(result.file)} duration=${result.duration}s size=${result.size}B captions=${result.captions}`);
assert.equal(isRecording(), false, 'isRecording=false после stopRecording');
assert.equal(result.captions, 2, 'два collected caption');
assert.ok(result.duration >= 3, `duration >= 3s (got ${result.duration})`);
assert.ok(result.size > 10000, `mp4 размер > 10KB (got ${result.size})`);
assert.ok(fs.existsSync(result.file), 'mp4 файл создан на диске');
assert.ok(fs.existsSync(captionsJson), '.captions.json создан рядом с mp4');
const captions = getCaptions();
assert.equal(captions.length, 2, 'getCaptions() возвращает 2 записи');
assert.equal(captions[0].text, 'Открываем Контрагентов', 'текст первой подписи');
assert.equal(captions[1].text, 'Закрываем форму', 'текст второй подписи');
assert.ok(captions[1].time > captions[0].time, 'time второй подписи > первой');
});
await step('narration: addNarration генерирует mp4 со звуковой дорожкой через edge TTS', async () => {
assert.ok(fs.existsSync(videoPath), 'исходный mp4 должен существовать');
const result = await addNarration(videoPath, { provider: 'edge', voice: 'ru-RU-DmitryNeural' });
log(`narration: file=${path.basename(result.file)} duration=${result.duration}s size=${result.size}B captions=${result.captions}`);
assert.equal(result.captions, 2, 'narration использовал 2 подписи');
assert.ok(result.size > 10000, `narrated mp4 > 10KB (got ${result.size})`);
assert.ok(fs.existsSync(result.file), 'narrated mp4 создан');
// narrated.mp4 должен быть больше исходного (добавлен аудио-трек)
const origSize = fs.statSync(videoPath).size;
assert.ok(result.size > origSize, `narrated (${result.size}) > original (${origSize}) — добавлен аудио-трек`);
});
await step('title-slide: showTitleSlide создаёт fullscreen overlay, hideTitleSlide убирает', async () => {
await showTitleSlide('Заголовок', { subtitle: 'подзаголовок' });
const p = await getPage();
const view = await p.evaluate(() => ({ w: window.innerWidth, h: window.innerHeight }));
const overlays = await p.evaluate(() => [...document.body.children]
.filter(c => c.id && c.id.startsWith('__web_test_title'))
.map(c => ({ id: c.id, w: c.offsetWidth, h: c.offsetHeight })));
log(`title overlays: ${JSON.stringify(overlays)}`);
assert.equal(overlays.length, 1, 'один title overlay');
assert.equal(overlays[0].w, view.w, 'overlay перекрывает всю ширину viewport');
assert.equal(overlays[0].h, view.h, 'overlay перекрывает всю высоту viewport');
await hideTitleSlide();
const after = await overlayIds();
assert.ok(!after.includes('__web_test_title'), 'title overlay удалён');
});
await step('image-overlay: showImage создаёт overlay, hideImage убирает', async () => {
// используем свежий screenshot как тестовую картинку
const imgPath = path.join(dir, 'sample.png');
const png = await screenshot();
fs.writeFileSync(imgPath, png);
await showImage(imgPath, { style: 'dark' });
const p = await getPage();
const overlays = await p.evaluate(() => [...document.body.children]
.filter(c => c.id && c.id.startsWith('__web_test_image'))
.map(c => ({ id: c.id, w: c.offsetWidth, h: c.offsetHeight })));
log(`image overlays: ${JSON.stringify(overlays)}`);
assert.equal(overlays.length, 1, 'один image overlay');
assert.ok(overlays[0].w > 0 && overlays[0].h > 0, 'overlay имеет размер');
await hideImage();
const after = await overlayIds();
assert.ok(!after.includes('__web_test_image'), 'image overlay удалён');
});
await step('highlight: setHighlight toggles isHighlightMode; manual highlight/unhighlight создают и убирают overlay', async () => {
assert.equal(isHighlightMode(), false, 'highlight mode выключен по умолчанию');
setHighlight(true);
assert.equal(isHighlightMode(), true, 'после setHighlight(true) — включён');
setHighlight(false);
assert.equal(isHighlightMode(), false, 'после setHighlight(false) — выключен');
// Manual highlight требует элемент на форме — откроем список
await navigateSection('Склад');
await openCommand('Контрагенты');
await highlight('Создать');
const p = await getPage();
const overlays = await p.evaluate(() => [...document.body.children]
.filter(c => c.id && c.id.startsWith('__web_test_highlight'))
.map(c => ({ id: c.id, w: c.offsetWidth, h: c.offsetHeight })));
log(`highlight overlays: ${JSON.stringify(overlays)}`);
assert.equal(overlays.length, 1, 'один highlight overlay');
assert.ok(overlays[0].w > 0 && overlays[0].h > 0, 'overlay позиционирован на элементе');
await unhighlight();
const after = await overlayIds();
assert.ok(!after.includes('__web_test_highlight'), 'highlight overlay удалён');
await closeForm();
});
}
+62
View File
@@ -0,0 +1,62 @@
export const name = 'tree-form: FormDataTree edit (ДеревоНоменклатуры obrabotka)';
export const tags = ['tree', 'table'];
export const timeout = 90000;
// ДеревоНоменклатуры obrabotka: реквизит формы Дерево типа ДеревоЗначений
// заполняется в ПриСозданииНаСервере рекурсивным обходом справочника Номенклатура.
// Колонка Цена — Number, editable; колонка Номенклатура — CatalogRef, readOnly.
// Покрывает: 05-table/edit-form (fillTableRow method:'direct' на FormDataTree-колонке)
// + 08-hierarchy/tree-edit (expand узла + edit Цены внутри expanded группы).
export default async function({ navigateLink, clickElement, closeForm, readTable, fillTableRow, assert, step, log }) {
await step('setup: открыть обработку ДеревоНоменклатуры', async () => {
const r = await navigateLink('Обработка.ДеревоНоменклатуры');
log(`form=${r.form} activeTab=${r.activeTab}`);
assert.equal(r.activeTab, 'Дерево номенклатуры', 'форма открыта');
assert.ok(r.tables?.some(t => t.name === 'Дерево'), 'таблица Дерево присутствует');
});
await step('read-roots: на верхнем уровне видны 2 группы (Товары, Услуги)', async () => {
const t = await readTable('Дерево');
log(`columns=${t.columns?.join(',')} rows=${t.rows?.length}`);
assert.deepEqual(t.columns, ['Номенклатура', 'Цена'], 'колонки: Номенклатура + Цена');
assert.equal(t.rows.length, 2, '2 корневые строки');
const names = t.rows.map(r => r['Номенклатура']);
assert.includes(names, 'Товары', 'есть Товары');
assert.includes(names, 'Услуги', 'есть Услуги');
assert.ok(t.rows.every(r => r._kind === 'group'), 'обе корневые — group (есть expand-стрелка)');
});
await step('expand: clickElement({expand}) раскрывает Товары — 15 элементов', async () => {
const r = await clickElement('Товары', { expand: true });
log(`clicked: ${JSON.stringify(r.clicked)}`);
assert.equal(r.clicked?.toggled, true, 'expand toggled');
const t = await readTable('Дерево');
log(`after expand: total=${t.total}`);
assert.ok(t.total >= 16, `Товары + 15 элементов (got ${t.total})`);
const tovar01 = t.rows.find(row => row['Номенклатура'] === 'Товар 01');
assert.ok(tovar01, 'Товар 01 виден внутри Товары');
assert.equal(tovar01['Цена'], '100,00', 'исходная Цена 100,00 (из справочника)');
});
await step('tree-edit: fillTableRow меняет Цену в развёрнутой группе', async () => {
// row:1 — это Товар 01 (row:0 — Товары после expand). Используем index, т.к.
// fillTableRow{row:'Товар 01'} ловит SyntaxError в JS-эвале — TODO в bug list.
const r = await fillTableRow({ Цена: 1500 }, { row: 1 });
log(`filled: ${JSON.stringify(r.filled)}`);
assert.equal(r.filled?.length, 1, '1 поле заполнено');
assert.equal(r.filled[0].field, 'Цена', 'поле Цена');
assert.equal(r.filled[0].method, 'direct', 'method=direct (in-place edit)');
assert.equal(r.filled[0].ok, true, 'ok=true');
const t = await readTable('Дерево');
const tovar01 = t.rows.find(row => row['Номенклатура'] === 'Товар 01');
assert.ok(tovar01, 'Товар 01 виден');
// 1С web использует non-breaking space ( ) как разделитель разрядов
assert.equal(tovar01['Цена'], '1 500,00', 'Цена обновилась до 1 500,00');
});
await step('cleanup: закрыть форму', async () => {
await closeForm();
});
}
+37
View File
@@ -0,0 +1,37 @@
[
{
"name": "License pool exhausted (1C)",
"matchedStatuses": ["failed", "broken"],
"messageRegex": ".*Не обнаружено свободной лицензии.*"
},
{
"name": "1C application error (modal)",
"matchedStatuses": ["failed"],
"messageRegex": ".*(ВызватьИсключение|В поле введены некорректные данные|Произошла ошибка|Ошибка при вызове).*"
},
{
"name": "Section panel icon-only (stand state)",
"matchedStatuses": ["failed"],
"messageRegex": ".*icon-only mode.*"
},
{
"name": "Navigation lookup miss",
"matchedStatuses": ["failed"],
"messageRegex": ".*(navigateSection|openCommand|navigateLink|switchTab).*not found.*"
},
{
"name": "Element not found",
"matchedStatuses": ["failed"],
"messageRegex": ".*(clickElement|fillField|fillFields|selectValue|closeForm|fillTableRow|deleteTableRow).*not found.*"
},
{
"name": "Test timeout",
"matchedStatuses": ["failed", "broken"],
"messageRegex": "Timeout \\(\\d+ms\\)"
},
{
"name": "Assertion failure",
"matchedStatuses": ["failed"],
"messageRegex": "(Expected|AssertionError|Field \".*\" not found in form|Form title .*does not contain|No row matching predicate|Form has errors).*"
}
]
+419
View File
@@ -0,0 +1,419 @@
// _hooks.mjs v0.5 — автономный стенд + testlevel-хуки + title slides + per-context badge
//
// `prepare()` поднимает изолированный стенд по smart-логике:
// 1) Если нужно пересоздавать БД (config-rebuild или --reload-data) — web-stop
// (Apache держит блокировку БД).
// 2) [config-hash изменился или --rebuild-config] → пересобрать XML.
// 3) [нужна пересборка БД] → drop+create+load+update.
// 4) [epf-hash изменился или --rebuild-epf] → пересобрать EPF.
// 5) Apache:
// - если БД пересоздавалась → web-publish + probe ready.
// - иначе probe-first: жив → ничего не делаем; мёртв → publish + probe.
//
// Идемпотентность — через sha256-локи в `tests/skills/.cache/webtest-stand/`.
// На warm-старте (ничего не менялось, Apache жив) prepare() сводится к ~200ms:
// чтение локов + probe.
//
// Поддерживаемые hookArgs (`node run.mjs test ... -- <args>`):
// --rebuild-config принудительно пересобрать XML + БД
// --reload-data принудительно пересоздать БД из существующего XML
// --rebuild-epf принудительно пересобрать EPF
// --rebuild-stand эквивалент всех трёх флагов сразу
//
// Cross-platform: на не-Windows можно задать env WEBTEST_HOOKS_RUNTIME=python,
// тогда зеркальные py-порты скиллов будут вызваны вместо ps1.
import { existsSync, mkdirSync, rmSync, readFileSync, writeFileSync, statSync } from 'fs';
import { join, resolve, dirname } from 'path';
import { fileURLToPath } from 'url';
import { createHash } from 'crypto';
import {
getProjectInfo,
loadBuildSteps,
platformLoadSteps,
runSteps,
execSkill,
resolveScript,
} from '../skills/build-webtest-db.mjs';
const __filename = fileURLToPath(import.meta.url);
const REPO_ROOT = resolve(dirname(__filename), '../..');
const LOCK_DIR = join(REPO_ROOT, 'tests/skills/.cache/webtest-stand');
// ── Configurable knobs ─────────────────────────────────────────────────────────
const APACHE_APPNAME = 'webtest-runner';
const APACHE_PORT = 9191;
const READY_URL = `http://localhost:${APACHE_PORT}/${APACHE_APPNAME}/ru_RU/`;
const READY_TIMEOUT = 30_000;
const RUNTIME = process.env.WEBTEST_HOOKS_RUNTIME || 'powershell';
// EPF spec: версионируется через epf.lock (sha256 от JSON.stringify(this)).
// Любое изменение → автоматический rebuild.
const EPF_SPEC = {
v8path: 'C:\\Program Files\\1cv8\\8.3.24.1691\\bin',
srcDir: 'test-tmp/13-openfile/src',
buildDir: 'test-tmp/13-openfile/build',
name: 'ТестОткрытия',
synonym: 'Тест открытия из файла',
formName: 'Форма',
form: {
title: 'Тест открытия',
elements: [
{ label: 'Заголовок', title: 'Это тестовая обработка для проверки openFile' },
],
},
};
// ── Args parsing ──────────────────────────────────────────────────────────────
function parseHookArgs(hookArgs) {
const out = { rebuildConfig: false, reloadData: false, rebuildEpf: false, rebuildStand: false };
for (const a of hookArgs || []) {
if (a === '--rebuild-config') out.rebuildConfig = true;
else if (a === '--reload-data') out.reloadData = true;
else if (a === '--rebuild-epf') out.rebuildEpf = true;
else if (a === '--rebuild-stand') out.rebuildStand = true;
}
if (out.rebuildStand) {
out.rebuildConfig = true;
out.reloadData = true;
out.rebuildEpf = true;
}
return out;
}
// ── Hash-lock helpers ─────────────────────────────────────────────────────────
function sha256(s) {
return createHash('sha256').update(s, 'utf8').digest('hex');
}
function readLock(name) {
const f = join(LOCK_DIR, `${name}.lock`);
return existsSync(f) ? readFileSync(f, 'utf8').trim() : null;
}
function writeLock(name, hash) {
mkdirSync(LOCK_DIR, { recursive: true });
writeFileSync(join(LOCK_DIR, `${name}.lock`), hash + '\n', 'utf8');
}
// ── Apache helpers ────────────────────────────────────────────────────────────
async function webStop(log) {
try {
const script = resolveScript('web-stop/scripts/web-stop', RUNTIME);
await execSkill(script, [], RUNTIME);
log('apache stopped');
} catch (e) {
log(`apache stop: ${e.message.split('\n')[0]}`);
}
}
async function webPublish(dbPath, v8path, log) {
const script = resolveScript('web-publish/scripts/web-publish', RUNTIME);
await execSkill(script, [
'-InfoBasePath', dbPath,
'-V8Path', v8path,
'-Port', String(APACHE_PORT),
'-AppName', APACHE_APPNAME,
], RUNTIME);
log(`apache published: ${READY_URL}`);
}
async function probeReady(url, timeoutMs, log) {
const t0 = Date.now();
let attempt = 0;
while (Date.now() - t0 < timeoutMs) {
attempt++;
try {
const res = await fetch(url, { signal: AbortSignal.timeout(2000) });
if (res.status >= 200 && res.status < 500) {
log(`ready after ${((Date.now() - t0) / 1000).toFixed(1)}s (status=${res.status}, attempts=${attempt})`);
return;
}
} catch { /* retry */ }
await new Promise(r => setTimeout(r, 500));
}
throw new Error(`Apache not ready at ${url} within ${timeoutMs}ms`);
}
// Лёгкий probe для одной попытки — для проверки «жив ли Apache уже сейчас».
// Возвращает true если в течение `timeoutMs` пришёл ответ 200-499 (т.е. сервер
// откликается). Не бросает — fail-quiet.
async function probeAlive(url, timeoutMs = 1500) {
try {
const res = await fetch(url, { signal: AbortSignal.timeout(timeoutMs) });
return res.status >= 200 && res.status < 500;
} catch {
return false;
}
}
// ── EPF build ─────────────────────────────────────────────────────────────────
async function buildEpf(spec, log) {
const srcDir = resolve(REPO_ROOT, spec.srcDir);
const buildDir = resolve(REPO_ROOT, spec.buildDir);
const srcXml = join(srcDir, `${spec.name}.xml`);
const epfPath = join(buildDir, `${spec.name}.epf`);
const formDir = join(srcDir, `${spec.name}/Forms/${spec.formName}`);
const formXml = join(formDir, 'Ext/Form.xml');
// Полный rebuild: чистим и собираем заново.
if (existsSync(srcDir)) rmSync(srcDir, { recursive: true, force: true, maxRetries: 5, retryDelay: 200 });
if (existsSync(buildDir)) rmSync(buildDir, { recursive: true, force: true, maxRetries: 5, retryDelay: 200 });
mkdirSync(srcDir, { recursive: true });
mkdirSync(buildDir, { recursive: true });
// 1. epf-init
await execSkill(
resolveScript('epf-init/scripts/init', RUNTIME),
['-Name', spec.name, '-Synonym', spec.synonym, '-SrcDir', srcDir],
RUNTIME,
);
log('epf-init OK');
// 2. form-add
await execSkill(
resolveScript('form-add/scripts/form-add', RUNTIME),
['-ObjectPath', srcXml, '-FormName', spec.formName],
RUNTIME,
);
log('form-add OK');
// 3. form-compile
const formJsonPath = join(buildDir, '__form.json');
writeFileSync(formJsonPath, JSON.stringify(spec.form, null, 2), 'utf8');
await execSkill(
resolveScript('form-compile/scripts/form-compile', RUNTIME),
['-JsonPath', formJsonPath, '-OutputPath', formXml],
RUNTIME,
);
rmSync(formJsonPath);
log('form-compile OK');
// 4. epf-build
await execSkill(
resolveScript('epf-build/scripts/epf-build', RUNTIME),
['-SourceFile', srcXml, '-OutputFile', epfPath, '-V8Path', spec.v8path],
RUNTIME,
);
if (!existsSync(epfPath)) throw new Error(`epf-build did not produce ${epfPath}`);
log(`epf-build OK (${statSync(epfPath).size} bytes)`);
return epfPath;
}
function epfArtifactExists(spec) {
const epfPath = resolve(REPO_ROOT, spec.buildDir, `${spec.name}.epf`);
return existsSync(epfPath);
}
// ── prepare / cleanup ─────────────────────────────────────────────────────────
export async function prepare({ hookArgs, log, config }) {
const flags = parseHookArgs(hookArgs);
const t0 = Date.now();
log(`stand prepare: flags=${JSON.stringify(flags)} runtime=${RUNTIME}`);
// Project info (paths, db registration)
const { v8path, v8exe, configSrc, dbPath } = getProjectInfo();
if (!existsSync(v8exe)) throw new Error(`1cv8.exe not found at ${v8exe} (check .v8-project.json v8path)`);
// Hashes
const buildSteps = await loadBuildSteps();
const configHash = sha256(JSON.stringify(buildSteps));
const epfHash = sha256(JSON.stringify(EPF_SPEC));
const prevConfig = readLock('config');
const prevEpf = readLock('epf');
const needConfig = flags.rebuildConfig || prevConfig !== configHash;
const needData = needConfig || flags.reloadData;
const needEpf = flags.rebuildEpf || prevEpf !== epfHash || !epfArtifactExists(EPF_SPEC);
log(`config-hash=${configHash.slice(0, 12)}... prev=${prevConfig?.slice(0, 12) || 'none'}... ${needConfig ? 'REBUILD' : 'skip'}`);
log(`epf-hash=${epfHash.slice(0, 12)}... prev=${prevEpf?.slice(0, 12) || 'none'}... ${needEpf ? 'REBUILD' : 'skip'}`);
log(`data-${needData ? 'RELOAD' : 'skip'}`);
// 1. Apache stop — только если будем пересоздавать БД (Apache держит lock-файл).
// На warm-старте (никаких rebuild) — НЕ трогаем Apache, иначе впустую отдадим
// 5-8 секунд на restart при каждом прогоне.
if (needData) {
await webStop(log);
}
// 2. Config rebuild
if (needConfig) {
log(`rebuild config XML → ${configSrc}`);
if (existsSync(configSrc)) rmSync(configSrc, { recursive: true, force: true, maxRetries: 5, retryDelay: 200 });
mkdirSync(configSrc, { recursive: true });
const paths = { workDir: configSrc, v8path, dbPath };
const r = await runSteps(buildSteps, paths, RUNTIME, log);
if (!r.ok) throw new Error(`config rebuild failed at step #${r.failedAt + 1}`);
writeLock('config', configHash);
}
// 3. DB reload
if (needData) {
log(`reload DB → ${dbPath}`);
if (existsSync(dbPath)) rmSync(dbPath, { recursive: true, force: true, maxRetries: 5, retryDelay: 200 });
const paths = { workDir: configSrc, v8path, dbPath };
const r = await runSteps(platformLoadSteps(), paths, RUNTIME, log);
if (!r.ok) throw new Error(`DB reload failed at step #${r.failedAt + 1}`);
}
// 4. EPF rebuild
if (needEpf) {
log('rebuild EPF');
await buildEpf(EPF_SPEC, log);
writeLock('epf', epfHash);
}
// 5. Apache: publish + probe (smart logic)
// - needData=true → Apache был остановлен в #1, нужно публиковать заново
// - needData=false → probe сначала: если жив, ничего не делаем (warm-старт);
// если мёртв (упал/не поднимали) → publish
if (needData) {
await webPublish(dbPath, v8path, log);
await probeReady(READY_URL, READY_TIMEOUT, log);
} else if (await probeAlive(READY_URL)) {
log(`apache already live at ${READY_URL} (warm start)`);
} else {
log(`apache not responding — publishing`);
await webPublish(dbPath, v8path, log);
await probeReady(READY_URL, READY_TIMEOUT, log);
}
log(`stand ready in ${((Date.now() - t0) / 1000).toFixed(1)}s`);
}
export async function cleanup({ log }) {
// MVP: оставляем стенд поднятым для отладки. Для full-shutdown — ручной /web-stop
// или следующий запуск с --rebuild-stand.
log('cleanup: stand left running (use /web-stop or run with `-- --rebuild-stand` to reset)');
}
// ── Testlevel hooks (M7.4) ────────────────────────────────────────────────────
//
// Shared mutable state, импортируется индикатором `00-hooks.test.mjs` для
// проверки порядка вызовов. Хуки — counter-only, никакой реальной работы:
// `prepare()` уже подготовил стенд, дефолтное приземление 1С после входа
// уже показывает панель разделов (разведка 2026-05-13: navigateSection
// в beforeAll не нужен).
//
// `events` — последовательность строк, по которой индикатор восстанавливает
// порядок (`beforeAll`, `beforeEach:01-navigation.test.mjs`, ...).
export const _state = {
beforeAll: 0,
afterAll: 0,
beforeEach: 0,
afterEach: 0,
afterOpenContext: 0,
beforeCloseContext: 0,
events: [],
lastTestResult: null,
};
export async function beforeAll(_ctx) {
_state.beforeAll++;
_state.events.push('beforeAll');
}
export async function afterAll(_ctx) {
_state.afterAll++;
_state.events.push('afterAll');
}
// Длительность показа title slide перед телом теста (секунды). Эмпирически
// 1.5с хватает чтобы в записанном видео слайд успел зацепиться кадром,
// и не слишком долго на тестах вроде 14-routing (~2.5с целиком).
const TITLE_SLIDE_SEC = 1.5;
export async function beforeEach(ctx) {
_state.beforeEach++;
_state.events.push(`beforeEach:${ctx.testInfo?.file || '?'}`);
// M7.5: title slide для `--record`-прогонов. Под обычным регрессом
// (isRecording === false) пропускаем — лишние ~1.5s × N тестов
// не нужны.
if (ctx.isRecording?.()) {
const info = ctx.testInfo;
const primary = info.contexts?.[info.primaryContext];
const subtitle = primary?.displayName || '';
try {
await ctx.showTitleSlide(info.name, { subtitle });
await ctx.wait(TITLE_SLIDE_SEC);
await ctx.hideTitleSlide();
} catch {
// Не валим тест из-за оформления — recorder/page-state могут
// не сложиться в редких сценариях (race на старте контекста).
}
}
}
export async function afterEach(ctx) {
_state.afterEach++;
// Снимок testResult без тяжёлого steps[]: индикатор проверяет только
// status/duration/attempts/error.
if (ctx.testResult) {
const { status, duration, attempts, error } = ctx.testResult;
_state.lastTestResult = { status, duration, attempts, error };
} else {
_state.lastTestResult = null;
}
_state.events.push(`afterEach:${ctx.testInfo?.file || '?'}:${ctx.testResult?.status || '?'}`);
}
// ── Per-context hooks (M8) ────────────────────────────────────────────────────
//
// `afterOpenContext` инжектит persistent DOM-badge с displayName в правый
// верхний угол страницы контекста — в записанном видео всегда видно, какая
// вкладка к какому пользователю относится. Badge переживает любые
// перерисовки 1С (это собственный div с z-index, не часть SPA).
//
// `beforeCloseContext` — counter-only (страница вот-вот закроется, делать
// что-либо с DOM бессмысленно).
async function injectContextBadge(ctx, name, spec) {
const label = spec?.displayName || name;
// ctx может быть scoped (auto-setActiveContext) или flat — в любом случае
// getPage() возвращает активную страницу, которая на момент afterOpenContext
// = только что созданный контекст.
const page = ctx.getPage?.();
if (!page) return;
await page.evaluate((text) => {
let div = document.getElementById('__web_test_ctx_badge');
if (!div) {
div = document.createElement('div');
div.id = '__web_test_ctx_badge';
document.body.appendChild(div);
}
div.style.cssText = [
'position:fixed', 'top:8px', 'right:8px',
'padding:4px 10px',
'background:rgba(30,30,46,0.85)', 'color:#fff',
'font:600 13px Segoe UI,Arial,sans-serif',
'border-radius:4px', 'box-shadow:0 2px 6px rgba(0,0,0,0.25)',
'z-index:999998', 'pointer-events:none',
'letter-spacing:0.3px',
].join(';');
div.textContent = text;
}, label);
}
export async function afterOpenContext(ctx, name, spec) {
_state.afterOpenContext++;
_state.events.push(`afterOpenContext:${name}`);
try {
await injectContextBadge(ctx, name, spec);
} catch {
// Не валим прогон если badge не сел — это чисто визуальный bonus.
}
}
export async function beforeCloseContext(_ctx, name, _spec) {
_state.beforeCloseContext++;
_state.events.push(`beforeCloseContext:${name}`);
}
+36
View File
@@ -0,0 +1,36 @@
// Default config for tests/web-test. CLI URL still overrides defaultContext URL.
// Two contexts pointing at the same webtest publication — represent two independent
// 1C sessions (different cookies), used by multi-context tests to simulate two users.
//
// AppName `webtest-runner` отличается от интерактивной публикации `webtest` на :8081 —
// автономный стенд (см. tests/web-test/_hooks.mjs) использует свой URL, чтобы не
// конфликтовать с ручной разведкой и работать поверх отдельного Apache на :9191.
export default {
contexts: {
// `displayName` — человекочитаемое имя контекста, видно хукам через
// testInfo.contexts[name].displayName (например для showTitleSlide).
// Custom-поля любого типа пробрасываются как есть.
a: { url: 'http://localhost:9191/webtest-runner/ru_RU', displayName: 'Пользователь A' },
b: { url: 'http://localhost:9191/webtest-runner/ru_RU', displayName: 'Пользователь B' },
},
defaultContext: 'a',
// isolation: 'tab' (default) — persistent context, tabs in one window, 1С extension loads.
// Cookies are shared between tabs but scope by URL path, so different vrd-publications
// give independent auth without extra isolation.
// isolation: 'window' — separate BrowserContext per slot, full cookie isolation,
// extension may not load (Playwright limitation). Use only when really needed.
timeout: 60000,
// Allure severity policy: inverted map "уровень → теги, попадающие в этот уровень".
// Резолв (run.mjs:resolveSeverity):
// 1. explicit `export const severity` в тесте — выигрывает всегда;
// 2. иначе max-rank среди тегов теста (стандартное имя severity или маппинг ниже);
// 3. иначе `defaultSeverity`.
// Тег не может быть в двух bucket'ах одновременно — валидация при загрузке конфига.
severity: {
critical: ['smoke', 'multi-context'],
minor: ['recording'],
// blocker / trivial — пустые, не используем
},
defaultSeverity: 'normal',
};