diff --git a/.claude/skills/form-compile/scripts/form-compile.ps1 b/.claude/skills/form-compile/scripts/form-compile.ps1
index 1e2ca8aa..242cd62a 100644
--- a/.claude/skills/form-compile/scripts/form-compile.ps1
+++ b/.claude/skills/form-compile/scripts/form-compile.ps1
@@ -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 "$innertrue" }
if ($el.dropListButton -eq $true) { X "$innertrue" }
if ($el.markIncomplete -eq $true) { X "$innertrue" }
+ if ($el.textEdit -eq $false) { X "$innerfalse" }
if ($el.skipOnInput -eq $true) { X "$innertrue" }
$hasAmw = $el.PSObject.Properties.Name -contains 'autoMaxWidth'
if ($hasAmw) {
diff --git a/.claude/skills/form-compile/scripts/form-compile.py b/.claude/skills/form-compile/scripts/form-compile.py
index 992c83f3..97a48a31 100644
--- a/.claude/skills/form-compile/scripts/form-compile.py
+++ b/.claude/skills/form-compile/scripts/form-compile.py
@@ -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}true')
if el.get('markIncomplete') is True:
lines.append(f'{inner}true')
+ if el.get('textEdit') is False:
+ lines.append(f'{inner}false')
if el.get('skipOnInput') is True:
lines.append(f'{inner}true')
if 'autoMaxWidth' in el:
diff --git a/.claude/skills/meta-compile/scripts/meta-compile.ps1 b/.claude/skills/meta-compile/scripts/meta-compile.ps1
index b2918df4..3c227d7b 100644
--- a/.claude/skills/meta-compile/scripts/meta-compile.ps1
+++ b/.claude/skills/meta-compile/scripts/meta-compile.ps1
@@ -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`tAuto"
X "$indent`t`t"
X "$indent`t`t"
- X "$indent`t`tAuto"
+ $chi = if ($parsed.choiceHistoryOnInput) { $parsed.choiceHistoryOnInput } else { "Auto" }
+ X "$indent`t`t$chi"
# Use — only for catalog top-level attributes
if ($context -eq "catalog") {
diff --git a/.claude/skills/meta-compile/scripts/meta-compile.py b/.claude/skills/meta-compile/scripts/meta-compile.py
index 196397e8..6e8a07a4 100644
--- a/.claude/skills/meta-compile/scripts/meta-compile.py
+++ b/.claude/skills/meta-compile/scripts/meta-compile.py
@@ -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\tAuto')
X(f'{indent}\t\t')
X(f'{indent}\t\t')
- X(f'{indent}\t\tAuto')
+ chi = parsed.get('choiceHistoryOnInput') or 'Auto'
+ X(f'{indent}\t\t{chi}')
if context == 'catalog':
X(f'{indent}\t\t')
if context not in ('processor', 'processor-tabular'):
diff --git a/.claude/skills/web-test/SKILL.md b/.claude/skills/web-test/SKILL.md
index 4924db26..6a1c52b2 100644
--- a/.claude/skills/web-test/SKILL.md
+++ b/.claude/skills/web-test/SKILL.md
@@ -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.
diff --git a/.claude/skills/web-test/regress.md b/.claude/skills/web-test/regress.md
new file mode 100644
index 00000000..086316df
--- /dev/null
+++ b/.claude/skills/web-test/regress.md
@@ -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]
[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)
+ / # application regression — one per solution
+ _hooks.mjs
+ webtest.config.mjs
+ 01-login/
+ 02-counterparties/
+ ...
+ / # second solution, fully isolated
+ _hooks.mjs
+ ...
+```
+
+`` is the project/extension slug (`acc-payroll`, `erp-customisation`, etc.). Pick something stable and pass it on the CLI:
+
+```bash
+node $RUN test tests//
+```
+
+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//
+ _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// # full app suite
+node $RUN test tests//03-goods-receipt/ # one feature folder
+node $RUN test tests//02-counterparties/01-create.test.mjs # one file
+node $RUN test tests// --tags=smoke # by tag (intersection)
+node $RUN test tests// --grep='накладн' # by name regex
+node $RUN test tests// --bail --retry=1 # stop on first fail, allow 1 retry
+node $RUN test tests// --report=allure-results --format=allure --report-dir=allure-results
+node $RUN test tests// -- --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 `/_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)
diff --git a/.claude/skills/web-test/scripts/browser.mjs b/.claude/skills/web-test/scripts/browser.mjs
index 8611c72c..a01fce78 100644
--- a/.claude/skills/web-test/scripts/browser.mjs
+++ b/.claude/skills/web-test/scripts/browser.mjs
@@ -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);
}
/**
diff --git a/.claude/skills/web-test/scripts/run.mjs b/.claude/skills/web-test/scripts/run.mjs
index 1566b7a4..90622990 100644
--- a/.claude/skills/web-test/scripts/run.mjs
+++ b/.claude/skills/web-test/scripts/run.mjs
@@ -1,5 +1,5 @@
#!/usr/bin/env node
-// web-test run v1.3 — CLI runner for 1C web client automation
+// web-test run v1.12 — CLI runner for 1C web client automation
// Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
/**
* CLI runner for 1C web client automation.
@@ -14,16 +14,24 @@
* node src/run.mjs shot [file] — take screenshot
* node src/run.mjs stop — logout + close browser
* node src/run.mjs status — check session
+ * node src/run.mjs test [url] — run regression tests
*/
import http from 'http';
import * as browser from './browser.mjs';
-import { readFileSync, writeFileSync, unlinkSync, existsSync } from 'fs';
-import { resolve, dirname } from 'path';
+import { readFileSync, writeFileSync, unlinkSync, existsSync, readdirSync, mkdirSync, copyFileSync, statSync } from 'fs';
+import { resolve, dirname, basename, relative } from 'path';
import { fileURLToPath } from 'url';
+import { randomUUID } from 'crypto';
const __dirname = dirname(fileURLToPath(import.meta.url));
const SESSION_FILE = resolve(__dirname, '..', '.browser-session.json');
+// Allure severity policy. Declared early so buildSeverityIndex (called inside
+// cmdTest) can use these constants — top-level const are not hoisted, and
+// cmdTest is invoked synchronously below via `await cmdTest(rawArgs)`.
+const SEVERITY_RANK = { blocker: 5, critical: 4, normal: 3, minor: 2, trivial: 1 };
+const SEVERITY_LEVELS = Object.keys(SEVERITY_RANK);
+
const [,, cmd, ...rawArgs] = process.argv;
const flags = { noRecord: rawArgs.includes('--no-record') };
const args = rawArgs.filter(a => !a.startsWith('--'));
@@ -35,6 +43,7 @@ switch (cmd) {
case 'shot': await cmdShot(args[0]); break;
case 'stop': await cmdStop(); break;
case 'status': cmdStatus(); break;
+ case 'test': await cmdTest(rawArgs); break;
default: usage();
}
@@ -101,6 +110,93 @@ async function handleRequest(req, res) {
}
}
+// ============================================================
+// buildContext: assemble browser API with error wrapping
+// ============================================================
+
+/**
+ * Build a per-context wrapper: same shape as buildContext output, but every call
+ * is prefixed with `setActiveContext(name)` so the test can interleave actions
+ * across contexts (`ctx.a.click(...); ctx.b.click(...)`).
+ */
+function buildScopedContext(name) {
+ const inner = buildContext({ noRecord: false });
+ const scoped = {};
+ for (const [k, v] of Object.entries(inner)) {
+ if (typeof v === 'function') {
+ scoped[k] = async (...args) => {
+ await browser.setActiveContext(name);
+ return v(...args);
+ };
+ } else {
+ scoped[k] = v;
+ }
+ }
+ return scoped;
+}
+
+function buildContext({ noRecord = false } = {}) {
+ const ctx = {};
+ for (const [k, v] of Object.entries(browser)) {
+ if (k !== 'default') ctx[k] = v;
+ }
+ ctx.writeFileSync = writeFileSync;
+ ctx.readFileSync = readFileSync;
+
+ // --no-record: stub recording/narration functions to return safe defaults
+ if (noRecord) {
+ const noop = async () => {};
+ ctx.startRecording = noop;
+ ctx.stopRecording = async () => ({ file: null, duration: 0, size: 0 });
+ ctx.addNarration = async () => ({ file: null, duration: 0, size: 0, captions: 0 });
+ for (const fn of ['showCaption', 'hideCaption']) {
+ ctx[fn] = noop;
+ }
+ ctx.isRecording = () => false;
+ ctx.getCaptions = () => [];
+ }
+
+ // Wrap action functions to auto-detect 1C errors (modal, balloon)
+ // and stop execution immediately with diagnostic info
+ const ACTION_FNS = [
+ 'clickElement', 'fillFields', 'fillField', 'selectValue', 'fillTableRow',
+ 'deleteTableRow', 'openCommand', 'navigateSection', 'navigateLink', 'openFile',
+ 'closeForm', 'filterList', 'unfilterList'
+ ];
+ for (const name of ACTION_FNS) {
+ if (typeof ctx[name] !== 'function') continue;
+ const orig = ctx[name];
+ ctx[name] = async (...args) => {
+ const result = await orig(...args);
+ const errors = result?.errors;
+ if (errors?.modal || errors?.balloon) {
+ // Screenshot while the error modal is still visible (before fetchErrorStack closes it)
+ let errorShot;
+ try {
+ const png = await ctx.screenshot();
+ errorShot = resolve(__dirname, '..', 'error-shot.png');
+ writeFileSync(errorShot, png);
+ } catch {}
+ // Try to fetch call stack for modal errors before throwing
+ let stack = null;
+ if (errors?.modal && typeof ctx.fetchErrorStack === 'function') {
+ try {
+ stack = await ctx.fetchErrorStack(errors.modal.formNum, errors.modal.hasReport);
+ } catch { /* don't fail if stack fetch fails */ }
+ }
+ const msg = errors.modal?.message || errors.balloon?.message || 'Unknown 1C error';
+ const err = new Error(msg);
+ err.onecError = { step: name, args, errors, formState: result, stack, screenshot: errorShot };
+ throw err;
+ }
+ return result;
+ };
+ }
+
+ return ctx;
+}
+
+
async function executeScript(code, { noRecord } = {}) {
const output = [];
const origLog = console.log;
@@ -110,71 +206,15 @@ async function executeScript(code, { noRecord } = {}) {
const t0 = Date.now();
try {
- // Build sandbox: all browser.mjs exports + useful Node globals
- const exports = {};
- for (const [k, v] of Object.entries(browser)) {
- if (k !== 'default') exports[k] = v;
- }
- exports.writeFileSync = writeFileSync;
- exports.readFileSync = readFileSync;
-
- // --no-record: stub recording/narration functions to return safe defaults
- if (noRecord) {
- const noop = async () => {};
- exports.startRecording = noop;
- exports.stopRecording = async () => ({ file: null, duration: 0, size: 0 });
- exports.addNarration = async () => ({ file: null, duration: 0, size: 0, captions: 0 });
- for (const fn of ['showCaption', 'hideCaption']) {
- exports[fn] = noop;
- }
- exports.isRecording = () => false;
- exports.getCaptions = () => [];
- }
-
- // Wrap action functions to auto-detect 1C errors (modal, balloon)
- // and stop execution immediately with diagnostic info
- const ACTION_FNS = [
- 'clickElement', 'fillFields', 'fillField', 'selectValue', 'fillTableRow',
- 'deleteTableRow', 'openCommand', 'navigateSection', 'navigateLink', 'openFile',
- 'closeForm', 'filterList', 'unfilterList'
- ];
- for (const name of ACTION_FNS) {
- if (typeof exports[name] !== 'function') continue;
- const orig = exports[name];
- exports[name] = async (...args) => {
- const result = await orig(...args);
- const errors = result?.errors;
- if (errors?.modal || errors?.balloon) {
- // Screenshot while the error modal is still visible (before fetchErrorStack closes it)
- let errorShot;
- try {
- const png = await exports.screenshot();
- errorShot = resolve(__dirname, '..', 'error-shot.png');
- writeFileSync(errorShot, png);
- } catch {}
- // Try to fetch call stack for modal errors before throwing
- let stack = null;
- if (errors?.modal && typeof exports.fetchErrorStack === 'function') {
- try {
- stack = await exports.fetchErrorStack(errors.modal.formNum, errors.modal.hasReport);
- } catch { /* don't fail if stack fetch fails */ }
- }
- const msg = errors.modal?.message || errors.balloon?.message || 'Unknown 1C error';
- const err = new Error(msg);
- err.onecError = { step: name, args, errors, formState: result, stack, screenshot: errorShot };
- throw err;
- }
- return result;
- };
- }
+ const ctx = buildContext({ noRecord });
// Normalize Windows backslash paths to prevent JS parse errors
// (e.g. C:\Users\... → \u triggers "Invalid Unicode escape sequence")
code = code.replace(/[A-Za-z]:\\[^\s'"`;\n)}\]]+/g, m => m.replace(/\\/g, '/'));
const AsyncFunction = Object.getPrototypeOf(async function(){}).constructor;
- const fn = new AsyncFunction(...Object.keys(exports), code);
- await fn(...Object.values(exports));
+ const fn = new AsyncFunction(...Object.keys(ctx), code);
+ await fn(...Object.values(ctx));
console.log = origLog;
console.error = origErr;
@@ -317,6 +357,786 @@ function cmdStatus() {
}
+// ============================================================
+// test: run regression tests
+// ============================================================
+
+async function cmdTest(rawArgs) {
+ // Split off everything after `--` — those args belong to user-defined hooks
+ // (see spec §6: "all arguments after `--` are forwarded verbatim to _hooks.mjs
+ // via the hookArgs field; the runner does not interpret them").
+ const sepIdx = rawArgs.indexOf('--');
+ const ownArgs = sepIdx >= 0 ? rawArgs.slice(0, sepIdx) : rawArgs;
+ const hookArgs = sepIdx >= 0 ? rawArgs.slice(sepIdx + 1) : [];
+
+ // Parse flags
+ const opts = { bail: false, retry: 0, timeout: 30000, report: null, format: 'json', screenshot: null, reportDir: null, record: false };
+ let tags = null, grep = null;
+ const positional = [];
+ for (const a of ownArgs) {
+ if (a.startsWith('--tags=')) tags = a.slice(7).split(',');
+ else if (a.startsWith('--grep=')) grep = new RegExp(a.slice(7), 'i');
+ else if (a === '--bail') opts.bail = true;
+ else if (a.startsWith('--retry=')) opts.retry = parseInt(a.slice(8)) || 0;
+ else if (a.startsWith('--timeout=')) opts.timeout = parseInt(a.slice(10)) || 30000;
+ else if (a.startsWith('--report=')) opts.report = a.slice(9);
+ else if (a.startsWith('--format=')) opts.format = a.slice(9);
+ else if (a.startsWith('--screenshot=')) opts.screenshot = a.slice(13);
+ else if (a.startsWith('--report-dir=')) opts.reportDir = a.slice(13);
+ else if (a === '--record') opts.record = true;
+ else if (!a.startsWith('--')) positional.push(a);
+ }
+
+ // Determine URL and test path
+ let url, testPath;
+ if (positional.length === 2) {
+ url = positional[0];
+ testPath = resolve(positional[1]);
+ } else if (positional.length === 1) {
+ testPath = resolve(positional[0]);
+ } else {
+ die('Usage: node run.mjs test [url] [--tags=...] [--bail] [--retry=N] [--timeout=ms] [--report=path]');
+ }
+
+ // Load config if exists
+ const isFile = testPath.endsWith('.test.mjs');
+ const testDir = isFile ? dirname(testPath) : testPath;
+ const configPath = resolve(testDir, 'webtest.config.mjs');
+ let config = {};
+ if (existsSync(configPath)) {
+ const mod = await import('file:///' + configPath.replace(/\\/g, '/'));
+ config = mod.default || {};
+ }
+ // Validate severity policy at config load (fail-fast on misconfig).
+ const severityIndex = buildSeverityIndex(config);
+ // Build context registry: name → url. Supports config.contexts or single config.url / CLI url.
+ // CLI url overrides default context's url.
+ const contextSpecs = {}; // name → { url, isolation }
+ let defaultContextName = 'default';
+ const defaultIsolation = config.isolation || 'tab';
+ if (config.contexts && typeof config.contexts === 'object' && Object.keys(config.contexts).length) {
+ for (const [n, spec] of Object.entries(config.contexts)) {
+ contextSpecs[n] = { ...spec };
+ }
+ defaultContextName = config.defaultContext || Object.keys(config.contexts)[0];
+ if (url) contextSpecs[defaultContextName] = { ...contextSpecs[defaultContextName], url }; // CLI override of default url (preserve custom fields)
+ } else {
+ const fallbackUrl = url || config.url;
+ if (!fallbackUrl) die('No URL provided and no webtest.config.mjs found');
+ contextSpecs.default = { url: fallbackUrl };
+ }
+ if (!contextSpecs[defaultContextName]) {
+ die(`defaultContext "${defaultContextName}" not found in contexts: [${Object.keys(contextSpecs).join(', ')}]`);
+ }
+ if (!url) url = contextSpecs[defaultContextName].url;
+
+ // Apply config defaults (CLI flags override)
+ if (!tags && config.tags) tags = config.tags;
+ opts.timeout = ownArgs.some(a => a.startsWith('--timeout=')) ? opts.timeout : (config.timeout || opts.timeout);
+ opts.retry = ownArgs.some(a => a.startsWith('--retry=')) ? opts.retry : (config.retries || opts.retry);
+ opts.record = opts.record || !!config.record;
+ opts.screenshot = opts.screenshot || config.screenshot || 'on-failure';
+ if (!['on-failure', 'every-step', 'off'].includes(opts.screenshot)) {
+ die(`Invalid --screenshot=${opts.screenshot} (expected on-failure|every-step|off)`);
+ }
+ if (!['json', 'allure', 'junit'].includes(opts.format)) {
+ die(`Invalid --format=${opts.format} (expected json|allure|junit)`);
+ }
+ if (opts.format === 'junit' && !opts.report) {
+ die('--format=junit requires --report=path.xml');
+ }
+ // Resolve report directory: --report-dir, else dirname(--report), else testDir
+ const reportDir = opts.reportDir
+ ? resolve(opts.reportDir)
+ : (opts.report ? dirname(resolve(opts.report)) : testDir);
+ if (opts.screenshot !== 'off') {
+ try { mkdirSync(reportDir, { recursive: true }); } catch {}
+ }
+
+ // Discover test files
+ const testFiles = discoverTests(testPath);
+ if (!testFiles.length) die(`No *.test.mjs files found in ${testPath}`);
+
+ // Import and filter tests
+ const tests = [];
+ let hasOnly = false;
+ for (const file of testFiles) {
+ const mod = await import('file:///' + file.replace(/\\/g, '/'));
+ const base = {
+ file: relative(testDir, file).replace(/\\/g, '/'),
+ name: mod.name || basename(file, '.test.mjs'),
+ tags: mod.tags || [],
+ timeout: mod.timeout || opts.timeout,
+ skip: mod.skip || false,
+ only: mod.only || false,
+ setup: mod.setup,
+ teardown: mod.teardown,
+ fn: mod.default,
+ param: undefined,
+ context: mod.context || null,
+ contexts: Array.isArray(mod.contexts) ? mod.contexts : null,
+ severity: typeof mod.severity === 'string' ? mod.severity : null,
+ };
+ if (base.only) hasOnly = true;
+ if (Array.isArray(mod.params) && mod.params.length) {
+ for (let i = 0; i < mod.params.length; i++) {
+ const p = mod.params[i];
+ const name = base.name.includes('{') ? interpolate(base.name, p) : `${base.name}[${i}]`;
+ tests.push({ ...base, name, param: p });
+ }
+ } else {
+ tests.push(base);
+ }
+ }
+
+ // Filter
+ const filtered = tests.filter(t => {
+ if (hasOnly && !t.only) return false;
+ if (tags && !tags.some(tag => t.tags.includes(tag))) return false;
+ if (grep && !grep.test(t.name)) return false;
+ return true;
+ });
+
+ // Load hooks
+ const hooksPath = resolve(testDir, '_hooks.mjs');
+ let hooks = {};
+ if (existsSync(hooksPath)) {
+ hooks = await import('file:///' + hooksPath.replace(/\\/g, '/'));
+ }
+
+ // Console header
+ const W = process.stderr;
+ W.write(`\nweb-test -- ${url}\n`);
+ W.write(`Running ${filtered.length} tests from ${relative(process.cwd(), testDir).replace(/\\/g, '/') || '.'}/\n\n`);
+
+ const startedAt = new Date().toISOString();
+ const results = [];
+ let passCount = 0, failCount = 0, skipCount = 0;
+
+ // Prepare: infrastructure hooks (no browser)
+ // Spec §6: prepare receives { hookArgs, log, config } — see ExternalDoc.
+ const hookLog = (...a) => W.write(`[hooks] ${a.map(String).join(' ')}\n`);
+ const hookEnv = { hookArgs, log: hookLog, config };
+ if (hooks.prepare) await hooks.prepare(hookEnv);
+
+ // Lazy context creation: ensures the named browser context exists, creating it on first request.
+ // Fires `afterOpenContext(ctx, name, spec)` once per context — right after createContext succeeds.
+ // The hook receives the same `ctx` that tests use (assembled below), so it can access browser API.
+ async function ensureContext(name) {
+ if (browser.hasContext(name)) return;
+ const spec = contextSpecs[name];
+ if (!spec) throw new Error(`Unknown context "${name}". Defined: [${Object.keys(contextSpecs).join(', ')}]`);
+ await browser.createContext(name, spec.url, { isolation: spec.isolation || defaultIsolation });
+ if (hooks.afterOpenContext && hookCtx) {
+ try { await hooks.afterOpenContext(hookCtx, name, spec); }
+ catch (e) { hookLog(`afterOpenContext("${name}") threw: ${e.message.split('\n')[0]}`); }
+ }
+ }
+
+ // `hookCtx` is set after buildContext below; ensureContext is also called before ctx exists
+ // (for the default context), so we tolerate `hookCtx === undefined` there — the default
+ // context's afterOpenContext fires once ctx is built, in the explicit call below.
+ let hookCtx = null;
+
+ // Wrap `target.closeContext` so calling it from a test fires `beforeCloseContext(ctx, name, spec)`
+ // before delegating to the bare browser.closeContext. Applied to the flat ctx and each scoped
+ // context (ctx.a / ctx.b) so `await a.closeContext('b')` triggers the hook.
+ function wrapCloseContextHook(target) {
+ const orig = target.closeContext;
+ if (typeof orig !== 'function') return;
+ target.closeContext = async (name) => {
+ if (hooks.beforeCloseContext) {
+ try { await hooks.beforeCloseContext(target, name, contextSpecs[name]); }
+ catch (e) { hookLog(`beforeCloseContext("${name}") threw: ${e.message.split('\n')[0]}`); }
+ }
+ return await orig(name);
+ };
+ }
+
+ try {
+ // Connect: create the default context up front (so beforeAll has a working browser)
+ await ensureContext(defaultContextName);
+
+ // Build context — flat API for single-context tests; reused across tests via setActiveContext.
+ // noRecord: false → tests get full API (showCaption, startRecording, etc.). The runner manages
+ // its own recording via --record; if a test author calls startRecording while the runner already
+ // records, browser.startRecording throws "Already recording" (loud failure beats silent no-op).
+ const ctx = buildContext({ noRecord: false });
+ ctx.assert = createAssertions();
+ ctx.log = (...a) => { /* per-test, overridden below */ };
+ wrapCloseContextHook(ctx);
+ hookCtx = ctx;
+
+ // Default context was created BEFORE hookCtx existed → fire afterOpenContext now.
+ if (hooks.afterOpenContext) {
+ try { await hooks.afterOpenContext(ctx, defaultContextName, contextSpecs[defaultContextName]); }
+ catch (e) { hookLog(`afterOpenContext("${defaultContextName}") threw: ${e.message.split('\n')[0]}`); }
+ }
+
+ // beforeAll
+ if (hooks.beforeAll) await hooks.beforeAll(ctx);
+
+ // Execute tests
+ let testIdx = 0;
+ for (const t of filtered) {
+ testIdx++;
+ // Declared contexts — нужны и в skip-ветке, и в основной, чтобы все
+ // testResult-записи в отчёте всегда содержали `contexts` поле.
+ const declaredContexts = t.contexts && t.contexts.length
+ ? t.contexts
+ : [t.context || defaultContextName];
+
+ if (t.skip) {
+ const reason = typeof t.skip === 'string' ? t.skip : '';
+ W.write(` \u25CB ${t.name}${reason ? ` (skip: ${reason})` : ' (skip)'}\n`);
+ results.push({ name: t.name, file: t.file, tags: t.tags, contexts: declaredContexts, status: 'skipped', duration: 0, attempts: 0, steps: [], output: '', error: null, screenshot: null });
+ skipCount++;
+ continue;
+ }
+
+ // Resolve test's contexts: multi (t.contexts) or single (t.context || default).
+ // Lazy-create them and set active to the primary one.
+ const testContextNames = declaredContexts;
+ try {
+ for (const cn of testContextNames) await ensureContext(cn);
+ await browser.setActiveContext(testContextNames[0]);
+ } catch (e) {
+ W.write(` ✗ ${t.name} (context setup failed: ${e.message})\n`);
+ results.push({ name: t.name, file: t.file, tags: t.tags, contexts: declaredContexts, status: 'failed', duration: 0, attempts: 0, steps: [], output: '', error: { message: e.message }, screenshot: null });
+ failCount++;
+ if (opts.bail) break;
+ continue;
+ }
+
+ let lastError = null;
+ let testResult = null;
+ const maxAttempts = 1 + opts.retry;
+
+ for (let attempt = 1; attempt <= maxAttempts; attempt++) {
+ const output = [];
+ let steps = [];
+ let currentSteps = steps;
+ let stepIdx = 0;
+ const t0 = Date.now();
+
+ // testInfo — declarative metadata about the current test, visible
+ // to test body and hooks (beforeEach/afterEach). Overwritten on
+ // each attempt and each test (no delete, mirrors ctx.log/step lifecycle).
+ ctx.testInfo = {
+ name: t.name,
+ file: basename(t.file),
+ filePath: t.file,
+ tags: t.tags,
+ timeout: t.timeout,
+ attempt,
+ maxAttempts,
+ param: t.param,
+ contexts: Object.fromEntries(testContextNames.map(n => [n, contextSpecs[n]])),
+ primaryContext: testContextNames[0],
+ };
+ ctx.testResult = null; // set right before afterEach
+
+ let videoFile = null;
+ if (opts.record) {
+ videoFile = resolve(reportDir, `${testIdx}-${slugify(t.name)}.mp4`);
+ try { await browser.startRecording(videoFile, { force: true }); } catch { videoFile = null; }
+ }
+
+ // Wire up per-test log and step
+ ctx.log = (...a) => output.push(a.map(String).join(' '));
+ ctx.step = async (name, fn) => {
+ const s = { name, start: Date.now(), status: 'passed', steps: [] };
+ currentSteps.push(s);
+ const prev = currentSteps;
+ currentSteps = s.steps;
+ stepIdx++;
+ const myIdx = stepIdx;
+ try {
+ await fn();
+ } catch (e) {
+ s.status = 'failed';
+ s.error = e.message;
+ throw e;
+ } finally {
+ s.stop = Date.now();
+ currentSteps = prev;
+ if (opts.screenshot === 'every-step' && s.status === 'passed') {
+ try {
+ const slug = slugify(name);
+ const file = resolve(reportDir, `${testIdx}-${myIdx}-${slug}.png`);
+ const png = await browser.screenshot();
+ writeFileSync(file, png);
+ s.screenshot = file;
+ } catch {}
+ }
+ }
+ };
+
+ // For multi-context tests, expose ctx. per-context wrappers
+ const scopedKeys = [];
+ if (t.contexts && t.contexts.length) {
+ for (const cn of t.contexts) {
+ ctx[cn] = buildScopedContext(cn);
+ wrapCloseContextHook(ctx[cn]);
+ scopedKeys.push(cn);
+ }
+ }
+
+ try {
+ // beforeEach
+ if (hooks.beforeEach) await hooks.beforeEach(ctx);
+ // per-test setup
+ if (t.setup) await t.setup(ctx);
+
+ // Run test with timeout
+ await Promise.race([
+ t.fn(ctx, t.param),
+ new Promise((_, reject) => setTimeout(() => reject(new Error(`Timeout (${t.timeout}ms)`)), t.timeout)),
+ ]);
+
+ // per-test teardown
+ if (t.teardown) try { await t.teardown(ctx); } catch {}
+ // Expose testResult to afterEach (preliminary — full testResult assembled below).
+ ctx.testResult = { status: 'passed', duration: elapsed(t0), attempts: attempt, error: null, steps };
+ // afterEach
+ if (hooks.afterEach) try { await hooks.afterEach(ctx); } catch {}
+ // Built-in state reset across all contexts the test used
+ for (const cn of testContextNames) {
+ try { await browser.setActiveContext(cn); await resetState(ctx); } catch {}
+ }
+ for (const k of scopedKeys) delete ctx[k];
+
+ if (videoFile) {
+ try { await browser.stopRecording(); } catch {}
+ }
+ const dur = elapsed(t0);
+ testResult = { name: t.name, file: t.file, tags: t.tags, contexts: testContextNames, severity: t.severity, status: 'passed', duration: dur, attempts: attempt, start: t0, stop: Date.now(), steps, output: output.join('\n'), error: null, screenshot: null, video: videoFile };
+ lastError = null;
+ break;
+
+ } catch (e) {
+ // Screenshot on failure FIRST — before teardown/afterEach/resetState reset the UI.
+ // Otherwise the shot captures an empty desktop instead of the failure context.
+ let shotFile = e.onecError?.screenshot;
+ if (!shotFile && opts.screenshot !== 'off') {
+ try {
+ const png = await browser.screenshot();
+ shotFile = resolve(reportDir, `error-${testIdx}-${slugify(t.file.replace(/\.test\.mjs$/, ''))}.png`);
+ writeFileSync(shotFile, png);
+ } catch {}
+ }
+
+ // per-test teardown (always)
+ if (t.teardown) try { await t.teardown(ctx); } catch {}
+ // Expose preliminary testResult to afterEach (final testResult assembled below).
+ const errInfo = { message: e.message, step: e.onecError?.step, screenshot: shotFile };
+ ctx.testResult = { status: 'failed', duration: elapsed(t0), attempts: attempt, error: errInfo, steps };
+ // afterEach (always)
+ if (hooks.afterEach) try { await hooks.afterEach(ctx); } catch {}
+ // Built-in state reset across all contexts the test used
+ for (const cn of testContextNames) {
+ try { await browser.setActiveContext(cn); await resetState(ctx); } catch {}
+ }
+ for (const k of scopedKeys) delete ctx[k];
+
+ if (videoFile) {
+ try { await browser.stopRecording(); } catch {}
+ }
+ lastError = { message: e.message, step: e.onecError?.step, screenshot: shotFile };
+ const dur = elapsed(t0);
+ testResult = { name: t.name, file: t.file, tags: t.tags, contexts: testContextNames, severity: t.severity, status: 'failed', duration: dur, attempts: attempt, start: t0, stop: Date.now(), steps, output: output.join('\n'), error: lastError, screenshot: shotFile, video: videoFile };
+ }
+ }
+
+ results.push(testResult);
+
+ // Console output
+ if (testResult.status === 'passed') {
+ passCount++;
+ W.write(` \u2713 ${t.name} (${testResult.duration}s)\n`);
+ } else {
+ failCount++;
+ W.write(` \u2717 ${t.name} (${testResult.duration}s)\n`);
+ // Show failed steps
+ printSteps(W, testResult.steps, ' ');
+ if (lastError?.message) W.write(` ${lastError.message}\n`);
+ if (lastError?.screenshot) W.write(` screenshot: ${lastError.screenshot}\n`);
+ }
+
+ if (opts.bail && testResult.status === 'failed') break;
+ }
+
+ // afterAll
+ if (hooks.afterAll) try { await hooks.afterAll(ctx); } catch {}
+
+ } finally {
+ // Per-context teardown: fire beforeCloseContext for every remaining slot, then close.
+ // Mirror the `ctx.a.closeContext('b')` invariant: active is some OTHER context while
+ // closing `name`. We keep the first registered context (the default) as the survivor —
+ // it stays active, hooks fire against it, the other slots are closed one by one.
+ // The default itself is closed by disconnect() (no surviving context to switch to).
+ try {
+ const remaining = browser.listContexts();
+ if (remaining.length > 0) {
+ const survivor = remaining[0];
+ try { await browser.setActiveContext(survivor); } catch {}
+ for (let i = remaining.length - 1; i >= 1; i--) {
+ const name = remaining[i];
+ if (hooks.beforeCloseContext && hookCtx) {
+ try { await hooks.beforeCloseContext(hookCtx, name, contextSpecs[name]); }
+ catch (e) { hookLog(`beforeCloseContext("${name}") threw: ${e.message.split('\n')[0]}`); }
+ }
+ try { await browser.closeContext(name); }
+ catch (e) { hookLog(`closeContext("${name}") failed: ${e.message.split('\n')[0]}`); }
+ }
+ // Fire beforeCloseContext for the survivor too — disconnect() actually closes it.
+ if (hooks.beforeCloseContext && hookCtx) {
+ try { await hooks.beforeCloseContext(hookCtx, survivor, contextSpecs[survivor]); }
+ catch (e) { hookLog(`beforeCloseContext("${survivor}") threw: ${e.message.split('\n')[0]}`); }
+ }
+ }
+ } catch (e) {
+ hookLog(`final teardown loop failed: ${e.message.split('\n')[0]}`);
+ }
+ // Disconnect — closes the last remaining context + browser.
+ try { await browser.disconnect(); } catch {}
+ // Cleanup: infrastructure hooks (same signature as prepare)
+ if (hooks.cleanup) try { await hooks.cleanup(hookEnv); } catch {}
+ }
+
+ const finishedAt = new Date().toISOString();
+ const totalDuration = results.reduce((s, r) => s + r.duration, 0);
+
+ // Summary
+ W.write(`\n${passCount} passed, ${failCount} failed, ${skipCount} skipped (${formatDuration(totalDuration)})\n\n`);
+
+ // JSON report
+ const report = {
+ runner: 'web-test', url, startedAt, finishedAt,
+ duration: totalDuration,
+ summary: { total: results.length, passed: passCount, failed: failCount, skipped: skipCount },
+ tests: results,
+ };
+ out(report);
+
+ if (opts.format === 'allure') {
+ writeAllure(results, reportDir, severityIndex);
+ syncAllureExtras(testDir, reportDir);
+ } else if (opts.format === 'junit') {
+ writeFileSync(resolve(opts.report), buildJUnit(report, testDir));
+ } else if (opts.report) {
+ writeFileSync(resolve(opts.report), JSON.stringify(report, null, 2));
+ }
+
+ if (failCount > 0) process.exit(1);
+}
+
+/**
+ * Copy any files from `/_allure/` into `reportDir`. Convention for
+ * Allure customization that doesn't fit inside per-test JSON:
+ * - `categories.json` — failure classification (regex → bucket)
+ * - `environment.properties` — values shown in the Environment widget
+ * - `executor.json` — CI/CD metadata
+ * Underscored folder mirrors `_hooks.mjs` convention (infra, not a test).
+ * Silent if folder absent.
+ */
+function syncAllureExtras(testDir, reportDir) {
+ const extrasDir = resolve(testDir, '_allure');
+ if (!existsSync(extrasDir)) return;
+ try {
+ if (!statSync(extrasDir).isDirectory()) return;
+ } catch { return; }
+ for (const entry of readdirSync(extrasDir, { withFileTypes: true })) {
+ if (!entry.isFile()) continue;
+ try { copyFileSync(resolve(extrasDir, entry.name), resolve(reportDir, entry.name)); }
+ catch { /* best-effort */ }
+ }
+}
+
+function writeAllure(results, reportDir, severityIndex) {
+ for (const tr of results) {
+ if (tr.status === 'skipped') continue; // Allure ignores skipped without start/stop
+ const uuid = randomUUID();
+ // suite: dirname(t.file) даёт автогруппировку отчёта по подкаталогам.
+ // Плоский слой тестов в корне группируется под 'root'.
+ const suite = dirname(tr.file);
+ const suiteLabel = (suite && suite !== '.') ? suite : 'root';
+ const severity = resolveSeverity(tr, severityIndex);
+ const out = {
+ uuid,
+ name: tr.name,
+ fullName: tr.file,
+ status: tr.status,
+ stage: 'finished',
+ start: tr.start,
+ stop: tr.stop,
+ labels: [
+ ...(tr.tags || []).map(t => ({ name: 'tag', value: t })),
+ { name: 'suite', value: suiteLabel },
+ { name: 'severity', value: severity },
+ ],
+ steps: (tr.steps || []).map(allureStep),
+ attachments: [
+ ...(tr.screenshot ? [{ name: 'Screenshot on failure', source: basename(tr.screenshot), type: 'image/png' }] : []),
+ ...(tr.video ? [{ name: 'Video', source: basename(tr.video), type: 'video/mp4' }] : []),
+ ],
+ };
+ if (tr.status === 'failed' && tr.error) {
+ out.statusDetails = { message: tr.error.message || '', trace: tr.output || '' };
+ }
+ writeFileSync(resolve(reportDir, `${uuid}-result.json`), JSON.stringify(out, null, 2));
+ }
+}
+
+function allureStep(s) {
+ const out = {
+ name: s.name,
+ status: s.status,
+ stage: 'finished',
+ start: s.start,
+ stop: s.stop,
+ steps: (s.steps || []).map(allureStep),
+ };
+ if (s.screenshot) {
+ out.attachments = [{ name: 'Screenshot', source: basename(s.screenshot), type: 'image/png' }];
+ }
+ if (s.status === 'failed' && s.error) {
+ out.statusDetails = { message: s.error, trace: s.error };
+ }
+ return out;
+}
+
+function xmlEscape(s) {
+ return String(s == null ? '' : s)
+ .replace(/&/g, '&').replace(//g, '>')
+ .replace(/"/g, '"').replace(/'/g, ''');
+}
+
+function buildJUnit(report, testDir) {
+ const { summary, duration, tests } = report;
+ const suiteName = relative(process.cwd(), testDir).replace(/\\/g, '/') || '.';
+ const lines = [''];
+ lines.push(``);
+ lines.push(` `);
+ for (const t of tests) {
+ const attrs = `name="${xmlEscape(t.name)}" classname="${xmlEscape(t.file)}" time="${(t.duration || 0).toFixed(3)}"`;
+ if (t.status === 'passed') {
+ lines.push(` `);
+ } else if (t.status === 'skipped') {
+ lines.push(` `);
+ } else {
+ lines.push(` `);
+ const msg = t.error?.message || '';
+ const trace = t.output || '';
+ lines.push(` ${xmlEscape(trace)}`);
+ if (t.screenshot) lines.push(` screenshot: ${xmlEscape(t.screenshot)}`);
+ lines.push(` `);
+ }
+ }
+ lines.push(` `);
+ lines.push(``);
+ return lines.join('\n');
+}
+
+function discoverTests(testPath) {
+ if (testPath.endsWith('.test.mjs')) return existsSync(testPath) ? [testPath] : [];
+ const files = [];
+ function walk(dir) {
+ for (const entry of readdirSync(dir, { withFileTypes: true })) {
+ if (entry.name.startsWith('_') || entry.name.startsWith('.')) continue;
+ const full = resolve(dir, entry.name);
+ if (entry.isDirectory()) walk(full);
+ else if (entry.name.endsWith('.test.mjs')) files.push(full);
+ }
+ }
+ walk(testPath);
+ return files.sort();
+}
+
+async function resetState(ctx) {
+ try { if (typeof ctx.dismissPendingErrors === 'function') await ctx.dismissPendingErrors(); } catch {}
+ for (let i = 0; i < 10; i++) {
+ try {
+ const state = await ctx.getFormState();
+ // form === null means no form open (desktop). form === 0 is a real background form
+ // 1C exposes in some states — must still close it to fully reset.
+ if (state.form == null) break;
+ await ctx.closeForm({ save: false });
+ } catch { break; }
+ }
+}
+
+function printSteps(W, steps, indent) {
+ for (let i = 0; i < steps.length; i++) {
+ const s = steps[i];
+ const last = i === steps.length - 1;
+ const prefix = last ? '\u2514' : '\u251C';
+ const mark = s.status === 'failed' ? '\u2717 ' : '';
+ W.write(`${indent}${prefix} ${mark}${s.name} (${elapsed2(s.start, s.stop)}s)\n`);
+ if (s.error && s.status === 'failed') {
+ W.write(`${indent} ${s.error}\n`);
+ }
+ if (s.steps.length) printSteps(W, s.steps, indent + ' ');
+ }
+}
+
+function elapsed2(start, stop) {
+ return Math.round(((stop || Date.now()) - start) / 100) / 10;
+}
+
+function interpolate(template, params) {
+ return String(template).replace(/\{(\w+)\}/g, (_, key) =>
+ params[key] !== undefined ? String(params[key]) : `{${key}}`);
+}
+
+function slugify(s) {
+ return String(s).trim()
+ .replace(/[\s/\\:*?"<>|]+/g, '-')
+ .replace(/-+/g, '-')
+ .replace(/^-|-$/g, '')
+ .slice(0, 60) || 'step';
+}
+
+function formatDuration(seconds) {
+ if (seconds < 60) return `${Math.round(seconds * 10) / 10}s`;
+ const m = Math.floor(seconds / 60);
+ const s = Math.round((seconds - m * 60) * 10) / 10;
+ return `${m}m ${s}s`;
+}
+
+// ============================================================
+// Severity (Allure label policy) — constants live at module top.
+// ============================================================
+
+/**
+ * Validate config.severity (inverted map: severity → [tags]) at config load time.
+ * Returns:
+ * - tagToSeverity: Map (precomputed lookup for the resolver)
+ * - defaultSeverity: string (validated, defaults to 'normal')
+ * Throws (via die) on invalid keys, invalid default, or duplicate tag across buckets.
+ */
+function buildSeverityIndex(config) {
+ const tagToSeverity = new Map();
+ const sev = config.severity || {};
+ if (typeof sev !== 'object' || Array.isArray(sev)) {
+ die(`config.severity must be an object, got ${typeof sev}`);
+ }
+ for (const [level, tags] of Object.entries(sev)) {
+ if (!SEVERITY_LEVELS.includes(level)) {
+ die(`config.severity: unknown level "${level}". Allowed: ${SEVERITY_LEVELS.join('|')}`);
+ }
+ if (!Array.isArray(tags)) {
+ die(`config.severity.${level} must be an array of tag names, got ${typeof tags}`);
+ }
+ for (const tag of tags) {
+ if (tagToSeverity.has(tag)) {
+ die(`config.severity: tag "${tag}" listed under both "${tagToSeverity.get(tag)}" and "${level}" — pick one`);
+ }
+ tagToSeverity.set(tag, level);
+ }
+ }
+ const def = config.defaultSeverity || 'normal';
+ if (!SEVERITY_LEVELS.includes(def)) {
+ die(`config.defaultSeverity: "${def}" is not a valid level. Allowed: ${SEVERITY_LEVELS.join('|')}`);
+ }
+ return { tagToSeverity, defaultSeverity: def };
+}
+
+/**
+ * Resolve a test's severity. Precedence:
+ * 1. explicit `export const severity` from the test module
+ * 2. max-rank severity found among tags (either standard severity name, or mapped via config)
+ * 3. defaultSeverity from config (or 'normal' if not set)
+ * Returns one of SEVERITY_LEVELS.
+ */
+function resolveSeverity(t, severityIndex) {
+ if (t.severity) {
+ if (!SEVERITY_LEVELS.includes(t.severity)) {
+ // Не валим тест — просто игнорируем некорректное значение, дефолтим.
+ return severityIndex.defaultSeverity;
+ }
+ return t.severity;
+ }
+ let best = null;
+ for (const tag of t.tags || []) {
+ let candidate = null;
+ if (SEVERITY_LEVELS.includes(tag)) candidate = tag;
+ else if (severityIndex.tagToSeverity.has(tag)) candidate = severityIndex.tagToSeverity.get(tag);
+ if (candidate && (best === null || SEVERITY_RANK[candidate] > SEVERITY_RANK[best])) {
+ best = candidate;
+ }
+ }
+ return best || severityIndex.defaultSeverity;
+}
+
+
+// ============================================================
+// assertions
+// ============================================================
+
+function createAssertions() {
+ class AssertionError extends Error {
+ constructor(msg, actual, expected) {
+ super(msg);
+ this.name = 'AssertionError';
+ this.actual = actual;
+ this.expected = expected;
+ }
+ }
+
+ return {
+ ok(value, msg) {
+ if (!value) throw new AssertionError(msg || `Expected truthy, got ${JSON.stringify(value)}`, value, true);
+ },
+ equal(actual, expected, msg) {
+ if (actual !== expected) throw new AssertionError(msg || `Expected ${JSON.stringify(expected)}, got ${JSON.stringify(actual)}`, actual, expected);
+ },
+ notEqual(actual, expected, msg) {
+ if (actual === expected) throw new AssertionError(msg || `Expected not ${JSON.stringify(expected)}`, actual, expected);
+ },
+ deepEqual(actual, expected, msg) {
+ const a = JSON.stringify(actual), b = JSON.stringify(expected);
+ if (a !== b) throw new AssertionError(msg || `Deep equal failed:\n actual: ${a}\n expected: ${b}`, actual, expected);
+ },
+ includes(haystack, needle, msg) {
+ const h = Array.isArray(haystack) ? haystack : String(haystack);
+ if (!h.includes(needle)) throw new AssertionError(msg || `Expected ${JSON.stringify(h)} to include ${JSON.stringify(needle)}`, haystack, needle);
+ },
+ match(string, regex, msg) {
+ if (!regex.test(string)) throw new AssertionError(msg || `Expected ${JSON.stringify(string)} to match ${regex}`, string, regex);
+ },
+ async throws(fn, msg) {
+ try { await fn(); } catch { return; }
+ throw new AssertionError(msg || 'Expected function to throw');
+ },
+ // 1C-specific
+ formHasField(state, fieldName, msg) {
+ if (!state?.fields?.[fieldName]) throw new AssertionError(msg || `Field "${fieldName}" not found in form. Available: ${Object.keys(state?.fields || {}).join(', ')}`, null, fieldName);
+ },
+ formTitle(state, expected, msg) {
+ if (!state?.title?.includes(expected)) throw new AssertionError(msg || `Form title "${state?.title}" does not contain "${expected}"`, state?.title, expected);
+ },
+ tableHasRow(table, predicate, msg) {
+ const rows = table?.rows || [];
+ let found;
+ if (typeof predicate === 'function') {
+ found = rows.some(predicate);
+ } else {
+ found = rows.some(r => Object.entries(predicate).every(([k, v]) => r[k] === v));
+ }
+ if (!found) throw new AssertionError(msg || `No row matching predicate in table (${rows.length} rows)`, null, predicate);
+ },
+ tableRowCount(table, expected, msg) {
+ const actual = table?.rows?.length ?? 0;
+ if (actual !== expected) throw new AssertionError(msg || `Expected ${expected} rows, got ${actual}`, actual, expected);
+ },
+ noErrors(state, msg) {
+ if (state?.errors) throw new AssertionError(msg || `Form has errors: ${JSON.stringify(state.errors)}`, state.errors, null);
+ },
+ };
+}
+
+
// ============================================================
// helpers
// ============================================================
@@ -363,7 +1183,7 @@ function die(msg) {
}
function usage() {
- die(`Usage: node src/run.mjs [args]
+ die(`Usage: node run.mjs [args]
Commands:
start Launch browser and connect to 1C web client
@@ -372,7 +1192,23 @@ Commands:
shot [file] Take screenshot (default: shot.png)
stop Logout and close browser
status Check session status
+ test [url] Run regression tests (*.test.mjs)
Options for exec:
- --no-record Skip video recording (record() becomes no-op)`);
+ --no-record Skip video recording (record() becomes no-op)
+
+Options for test:
+ --tags=smoke,crud Filter tests by tags
+ --grep=pattern Filter tests by name (regex)
+ --bail Stop on first failure
+ --retry=N Retry failed tests N times
+ --timeout=ms Per-test timeout (default: 30000)
+ --report=path Write JSON report to file
+ --report-dir=path Directory for screenshots and other artifacts
+ --screenshot=mode on-failure (default) | every-step | off
+ --format=fmt json (default) | allure | junit
+ --record Record video for each test (mp4 in report-dir)
+ -- Everything after \`--\` is forwarded to _hooks.mjs
+ prepare/cleanup as hookArgs (runner does not parse it).
+ Example: ... tests/web-test/ -- --rebuild-stand`);
}
diff --git a/docs/web-test-regression-guide.md b/docs/web-test-regression-guide.md
new file mode 100644
index 00000000..046d7edf
--- /dev/null
+++ b/docs/web-test-regression-guide.md
@@ -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).
diff --git a/tests/skills/build-webtest-db.mjs b/tests/skills/build-webtest-db.mjs
new file mode 100644
index 00000000..addfd7d6
--- /dev/null
+++ b/tests/skills/build-webtest-db.mjs
@@ -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} 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);
+ });
+}
diff --git a/tests/skills/cases/form-compile/snapshots/text-edit-flag/Configuration.xml b/tests/skills/cases/form-compile/snapshots/text-edit-flag/Configuration.xml
new file mode 100644
index 00000000..d34f407a
--- /dev/null
+++ b/tests/skills/cases/form-compile/snapshots/text-edit-flag/Configuration.xml
@@ -0,0 +1,252 @@
+
+
+
+
+
+ UUID-002
+ UUID-003
+
+
+ UUID-004
+ UUID-005
+
+
+ UUID-006
+ UUID-007
+
+
+ UUID-008
+ UUID-009
+
+
+ UUID-010
+ UUID-011
+
+
+ UUID-012
+ UUID-013
+
+
+ UUID-014
+ UUID-015
+
+
+
+ TestConfig
+
+
+ ru
+ TestConfig
+
+
+
+
+ Version8_3_24
+ ManagedApplication
+
+ PlatformApplication
+
+ Russian
+
+
+
+
+ false
+ false
+ false
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Biometrics
+ true
+
+
+ Location
+ false
+
+
+ BackgroundLocation
+ false
+
+
+ BluetoothPrinters
+ false
+
+
+ WiFiPrinters
+ false
+
+
+ Contacts
+ false
+
+
+ Calendars
+ false
+
+
+ PushNotifications
+ false
+
+
+ LocalNotifications
+ false
+
+
+ InAppPurchases
+ false
+
+
+ PersonalComputerFileExchange
+ false
+
+
+ Ads
+ false
+
+
+ NumberDialing
+ false
+
+
+ CallProcessing
+ false
+
+
+ CallLog
+ false
+
+
+ AutoSendSMS
+ false
+
+
+ ReceiveSMS
+ false
+
+
+ SMSLog
+ false
+
+
+ Camera
+ false
+
+
+ Microphone
+ false
+
+
+ MusicLibrary
+ false
+
+
+ PictureAndVideoLibraries
+ false
+
+
+ AudioPlaybackAndVibration
+ false
+
+
+ BackgroundAudioPlaybackAndVibration
+ false
+
+
+ InstallPackages
+ false
+
+
+ OSBackup
+ true
+
+
+ ApplicationUsageStatistics
+ false
+
+
+ BarcodeScanning
+ false
+
+
+ BackgroundAudioRecording
+ false
+
+
+ AllFilesAccess
+ false
+
+
+ Videoconferences
+ false
+
+
+ NFC
+ false
+
+
+ DocumentScanning
+ false
+
+
+ SpeechToText
+ false
+
+
+ Geofences
+ false
+
+
+ IncomingShareRequests
+ false
+
+
+ AllIncomingShareRequestsTypesProcessing
+ false
+
+
+
+
+
+ Normal
+
+
+ Language.Русский
+
+
+
+
+
+ Managed
+ NotAutoFree
+ DontUse
+ DontUse
+ TaxiEnableVersion8_2
+ DontUse
+ Version8_3_24
+
+
+
+ Русский
+ ЗапретРучногоВвода
+
+
+
\ No newline at end of file
diff --git a/tests/skills/cases/form-compile/snapshots/text-edit-flag/DataProcessors/ЗапретРучногоВвода.xml b/tests/skills/cases/form-compile/snapshots/text-edit-flag/DataProcessors/ЗапретРучногоВвода.xml
new file mode 100644
index 00000000..6a3fca59
--- /dev/null
+++ b/tests/skills/cases/form-compile/snapshots/text-edit-flag/DataProcessors/ЗапретРучногоВвода.xml
@@ -0,0 +1,34 @@
+
+
+
+
+
+ UUID-002
+ UUID-003
+
+
+ UUID-004
+ UUID-005
+
+
+
+ ЗапретРучногоВвода
+
+
+ ru
+ Запрет ручного ввода
+
+
+
+ false
+ DataProcessor.ЗапретРучногоВвода.Form.Форма
+
+ false
+
+
+
+
+
+
+
+
diff --git a/tests/skills/cases/form-compile/snapshots/text-edit-flag/DataProcessors/ЗапретРучногоВвода/Ext/ManagerModule.bsl b/tests/skills/cases/form-compile/snapshots/text-edit-flag/DataProcessors/ЗапретРучногоВвода/Ext/ManagerModule.bsl
new file mode 100644
index 00000000..e69de29b
diff --git a/tests/skills/cases/form-compile/snapshots/text-edit-flag/DataProcessors/ЗапретРучногоВвода/Ext/ObjectModule.bsl b/tests/skills/cases/form-compile/snapshots/text-edit-flag/DataProcessors/ЗапретРучногоВвода/Ext/ObjectModule.bsl
new file mode 100644
index 00000000..e69de29b
diff --git a/tests/skills/cases/form-compile/snapshots/text-edit-flag/DataProcessors/ЗапретРучногоВвода/Forms/Форма.xml b/tests/skills/cases/form-compile/snapshots/text-edit-flag/DataProcessors/ЗапретРучногоВвода/Forms/Форма.xml
new file mode 100644
index 00000000..dffeea01
--- /dev/null
+++ b/tests/skills/cases/form-compile/snapshots/text-edit-flag/DataProcessors/ЗапретРучногоВвода/Forms/Форма.xml
@@ -0,0 +1,22 @@
+
+
+
+
\ No newline at end of file
diff --git a/tests/skills/cases/form-compile/snapshots/text-edit-flag/DataProcessors/ЗапретРучногоВвода/Forms/Форма/Ext/Form.xml b/tests/skills/cases/form-compile/snapshots/text-edit-flag/DataProcessors/ЗапретРучногоВвода/Forms/Форма/Ext/Form.xml
new file mode 100644
index 00000000..eef7f0e2
--- /dev/null
+++ b/tests/skills/cases/form-compile/snapshots/text-edit-flag/DataProcessors/ЗапретРучногоВвода/Forms/Форма/Ext/Form.xml
@@ -0,0 +1,74 @@
+
+
diff --git a/tests/skills/cases/form-compile/snapshots/text-edit-flag/DataProcessors/ЗапретРучногоВвода/Forms/Форма/Ext/Form/Module.bsl b/tests/skills/cases/form-compile/snapshots/text-edit-flag/DataProcessors/ЗапретРучногоВвода/Forms/Форма/Ext/Form/Module.bsl
new file mode 100644
index 00000000..8ead4cec
--- /dev/null
+++ b/tests/skills/cases/form-compile/snapshots/text-edit-flag/DataProcessors/ЗапретРучногоВвода/Forms/Форма/Ext/Form/Module.bsl
@@ -0,0 +1,19 @@
+#Область ОбработчикиСобытийФормы
+
+#КонецОбласти
+
+#Область ОбработчикиСобытийЭлементовФормы
+
+#КонецОбласти
+
+#Область ОбработчикиКомандФормы
+
+#КонецОбласти
+
+#Область ОбработчикиОповещений
+
+#КонецОбласти
+
+#Область СлужебныеПроцедурыИФункции
+
+#КонецОбласти
\ No newline at end of file
diff --git a/tests/skills/cases/form-compile/snapshots/text-edit-flag/Ext/ClientApplicationInterface.xml b/tests/skills/cases/form-compile/snapshots/text-edit-flag/Ext/ClientApplicationInterface.xml
new file mode 100644
index 00000000..3c1161b2
--- /dev/null
+++ b/tests/skills/cases/form-compile/snapshots/text-edit-flag/Ext/ClientApplicationInterface.xml
@@ -0,0 +1,18 @@
+
+
+
+
+ UUID-002
+
+
+
+
+ UUID-004
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/tests/skills/cases/form-compile/snapshots/text-edit-flag/Languages/Русский.xml b/tests/skills/cases/form-compile/snapshots/text-edit-flag/Languages/Русский.xml
new file mode 100644
index 00000000..37c60d78
--- /dev/null
+++ b/tests/skills/cases/form-compile/snapshots/text-edit-flag/Languages/Русский.xml
@@ -0,0 +1,16 @@
+
+
+
+
+ Русский
+
+
+ ru
+ Русский
+
+
+
+ ru
+
+
+
\ No newline at end of file
diff --git a/tests/skills/cases/form-compile/text-edit-flag.json b/tests/skills/cases/form-compile/text-edit-flag.json
new file mode 100644
index 00000000..2059e168
--- /dev/null
+++ b/tests/skills/cases/form-compile/text-edit-flag.json
@@ -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)" }
+ ]
+ }
+}
diff --git a/tests/skills/integration/build-webtest-config.test.mjs b/tests/skills/integration/build-webtest-config.test.mjs
new file mode 100644
index 00000000..9c4121d6
--- /dev/null
+++ b/tests/skills/integration/build-webtest-config.test.mjs
@@ -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}' },
+ },
+];
diff --git a/tests/skills/runner.mjs b/tests/skills/runner.mjs
index fd0b228e..4b629d8b 100644
--- a/tests/skills/runner.mjs
+++ b/tests/skills/runner.mjs
@@ -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) {
diff --git a/tests/web-test/00-hooks.test.mjs b/tests/web-test/00-hooks.test.mjs
new file mode 100644
index 00000000..8ea90411
--- /dev/null
+++ b/tests/web-test/00-hooks.test.mjs
@@ -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}`);
+}
diff --git a/tests/web-test/01-navigation.test.mjs b/tests/web-test/01-navigation.test.mjs
new file mode 100644
index 00000000..665e30dc
--- /dev/null
+++ b/tests/web-test/01-navigation.test.mjs
@@ -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 должен кидать ошибку для несуществующего таба');
+ });
+}
diff --git a/tests/web-test/02-crud.test.mjs b/tests/web-test/02-crud.test.mjs
new file mode 100644
index 00000000..66704796
--- /dev/null
+++ b/tests/web-test/02-crud.test.mjs
@@ -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();
+ });
+}
diff --git a/tests/web-test/03-fillfields.test.mjs b/tests/web-test/03-fillfields.test.mjs
new file mode 100644
index 00000000..119d7cca
--- /dev/null
+++ b/tests/web-test/03-fillfields.test.mjs
@@ -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 });
+ });
+}
diff --git a/tests/web-test/04-selectvalue.test.mjs b/tests/web-test/04-selectvalue.test.mjs
new file mode 100644
index 00000000..7ac476a8
--- /dev/null
+++ b/tests/web-test/04-selectvalue.test.mjs
@@ -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). Откладывается до расширения синтетики.
diff --git a/tests/web-test/05-table.test.mjs b/tests/web-test/05-table.test.mjs
new file mode 100644
index 00000000..3285c5e7
--- /dev/null
+++ b/tests/web-test/05-table.test.mjs
@@ -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 });
+ });
+}
diff --git a/tests/web-test/06-document.test.mjs b/tests/web-test/06-document.test.mjs
new file mode 100644
index 00000000..6dd6b9c1
--- /dev/null
+++ b/tests/web-test/06-document.test.mjs
@@ -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}'`);
+ });
+}
diff --git a/tests/web-test/07-tabs.test.mjs b/tests/web-test/07-tabs.test.mjs
new file mode 100644
index 00000000..2a80d279
--- /dev/null
+++ b/tests/web-test/07-tabs.test.mjs
@@ -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 });
+ });
+}
diff --git a/tests/web-test/08-hierarchy.test.mjs b/tests/web-test/08-hierarchy.test.mjs
new file mode 100644
index 00000000..6b3171c8
--- /dev/null
+++ b/tests/web-test/08-hierarchy.test.mjs
@@ -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();
+ });
+}
diff --git a/tests/web-test/09-filter.test.mjs b/tests/web-test/09-filter.test.mjs
new file mode 100644
index 00000000..6df7b5b6
--- /dev/null
+++ b/tests/web-test/09-filter.test.mjs
@@ -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 этих ветвей
+// достаточно.
diff --git a/tests/web-test/10-validation.test.mjs b/tests/web-test/10-validation.test.mjs
new file mode 100644
index 00000000..d2ad8207
--- /dev/null
+++ b/tests/web-test/10-validation.test.mjs
@@ -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();
+}
diff --git a/tests/web-test/11-report.test.mjs b/tests/web-test/11-report.test.mjs
new file mode 100644
index 00000000..dad62c06
--- /dev/null
+++ b/tests/web-test/11-report.test.mjs
@@ -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, 'Форма закрылась');
+ });
+}
diff --git a/tests/web-test/12-formstate.test.mjs b/tests/web-test/12-formstate.test.mjs
new file mode 100644
index 00000000..a917ba26
--- /dev/null
+++ b/tests/web-test/12-formstate.test.mjs
@@ -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');
+ });
+}
diff --git a/tests/web-test/13-misc.test.mjs b/tests/web-test/13-misc.test.mjs
new file mode 100644
index 00000000..b63d4e15
--- /dev/null
+++ b/tests/web-test/13-misc.test.mjs
@@ -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, 'Тест открытия', 'форма обработки ТестОткрытия закрыта');
+ });
+}
diff --git a/tests/web-test/14-errors-stack.test.mjs b/tests/web-test/14-errors-stack.test.mjs
new file mode 100644
index 00000000..92ab8b34
--- /dev/null
+++ b/tests/web-test/14-errors-stack.test.mjs
@@ -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();
+}
diff --git a/tests/web-test/14-multi-context-routing.test.mjs b/tests/web-test/14-multi-context-routing.test.mjs
new file mode 100644
index 00000000..546c9608
--- /dev/null
+++ b/tests/web-test/14-multi-context-routing.test.mjs
@@ -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();
+ });
+}
diff --git a/tests/web-test/15-multi-context-handover.test.mjs b/tests/web-test/15-multi-context-handover.test.mjs
new file mode 100644
index 00000000..1beec8b1
--- /dev/null
+++ b/tests/web-test/15-multi-context-handover.test.mjs
@@ -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]}`);
+ });
+}
diff --git a/tests/web-test/15-recording.test.mjs b/tests/web-test/15-recording.test.mjs
new file mode 100644
index 00000000..b346a956
--- /dev/null
+++ b/tests/web-test/15-recording.test.mjs
@@ -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();
+ });
+}
diff --git a/tests/web-test/16-tree-form.test.mjs b/tests/web-test/16-tree-form.test.mjs
new file mode 100644
index 00000000..5866eeaf
--- /dev/null
+++ b/tests/web-test/16-tree-form.test.mjs
@@ -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();
+ });
+}
diff --git a/tests/web-test/_allure/categories.json b/tests/web-test/_allure/categories.json
new file mode 100644
index 00000000..2cb13af2
--- /dev/null
+++ b/tests/web-test/_allure/categories.json
@@ -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).*"
+ }
+]
diff --git a/tests/web-test/_hooks.mjs b/tests/web-test/_hooks.mjs
new file mode 100644
index 00000000..212a73bd
--- /dev/null
+++ b/tests/web-test/_hooks.mjs
@@ -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 ... -- `):
+// --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}`);
+}
diff --git a/tests/web-test/webtest.config.mjs b/tests/web-test/webtest.config.mjs
new file mode 100644
index 00000000..e08bd6e2
--- /dev/null
+++ b/tests/web-test/webtest.config.mjs
@@ -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',
+};