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\tForItem') 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 @@ + + +
+ + Форма + + + ru + Форма + + + + Managed + false + + PlatformApplication + MobilePlatformApplication + + + +
+
\ 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 @@ + +
+ + <v8:item> + <v8:lang>ru</v8:lang> + <v8:content>Запрет ручного ввода</v8:content> + </v8:item> + + false + + + + ОбычноеПоле + + <v8:item> + <v8:lang>ru</v8:lang> + <v8:content>Обычное поле</v8:content> + </v8:item> + + + + + + ПолеБезРучногоВвода + + <v8:item> + <v8:lang>ru</v8:lang> + <v8:content>Только через выбор</v8:content> + </v8:item> + + false + + + + + + + + cfg:DataProcessorObject.ЗапретРучногоВвода + + true + + + + <v8:item> + <v8:lang>ru</v8:lang> + <v8:content>Обычное поле</v8:content> + </v8:item> + + + xs:string + + 100 + Variable + + + + + + <v8:item> + <v8:lang>ru</v8:lang> + <v8:content>Поле без ручного ввода</v8:content> + </v8:item> + + + xs:string + + 100 + Variable + + + + + 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', +};