From f91b569564daef5e580b8f786779aa6513fe286c Mon Sep 17 00:00:00 2001 From: Nick Shirokov Date: Mon, 18 May 2026 23:01:38 +0300 Subject: [PATCH] =?UTF-8?q?docs(web-test):=20=D1=81=D0=BF=D0=B5=D1=86?= =?UTF-8?q?=D0=B8=D1=84=D0=B8=D0=BA=D0=B0=D1=86=D0=B8=D1=8F=20=D1=80=D0=B5?= =?UTF-8?q?=D0=B3=D1=80=D0=B5=D1=81=D1=81-=D0=B4=D0=B2=D0=B8=D0=B6=D0=BA?= =?UTF-8?q?=D0=B0=20+=20=D1=87=D0=B8=D1=81=D1=82=D0=BA=D0=B0=20regress.md?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Новый канонический документ docs/web-test-regression-spec.md — техническое описание движка регрессионных тестов: CLI, формат тест-модулей, ctx-контракт, утверждения, три уровня хуков (инфра/тест/контекст), конфиг, контексты Playwright и режимы изоляции, форматы отчётов (JSON/Allure/JUnit), обнаружение тестов, ошибки/таймауты/повторы, анализ результатов, глоссарий. Документ предназначен для CI-интеграторов, ручного редактирования сгенерированных тестов и сопровождения самого движка. Без дорожной карты и внутренних self-тестов — только публичный контракт. regress.md в скилле почищен: добавлены контракт ctx и список утверждений (раньше модели приходилось читать исходники), срезаны дубликаты с SKILL.md (live recon, паттерны catalog/document), переформулированы анти-паттерны под специфику регресс-движка. Co-Authored-By: Claude Opus 4.7 (1M context) --- .claude/skills/web-test/regress.md | 311 +++++---- docs/web-test-regression-spec.md | 978 +++++++++++++++++++++++++++++ 2 files changed, 1127 insertions(+), 162 deletions(-) create mode 100644 docs/web-test-regression-spec.md diff --git a/.claude/skills/web-test/regress.md b/.claude/skills/web-test/regress.md index 086316df..92cd9832 100644 --- a/.claude/skills/web-test/regress.md +++ b/.claude/skills/web-test/regress.md @@ -15,7 +15,6 @@ Tests live next to the project they cover (not inside the skill). Convention: `t | 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` | @@ -25,73 +24,15 @@ Don't write a `.test.mjs` for a one-shot user request. Don't drive a regression ## Before writing tests — recon -Two layers, in order. Don't skip either. +Two layers, in order. -### 1. Static recon — metadata +**1. Static recon — metadata.** Never invent identifiers. For every metadata object the user mentions, run the matching info skill first: `/meta-info` (attributes/tabular sections), `/form-info` (form layout), `/skd-info` (DCS), `/mxl-info` (templates), `/role-info` (rights), `/subsystem-info` (composition / command interface). If the user names objects you can't find — stop and ask. -Never invent identifiers. For every metadata object the user mentions (or that you decide to cover), run the matching info skill first: +**2. Live recon — interactive walkthrough.** For any non-trivial scenario, walk the path live in `exec` mode before transcribing it. Metadata tells you what exists; the live walkthrough tells you what actually happens. Capture from `getFormState()`: exact button names (`'Провести и закрыть'`, not `'Сохранить'`), table section names for multi-grid forms, required fields, places where a real async wait is needed. Then transcribe the working sequence into `*.test.mjs`, wrapping logical chunks in `step('...', async () => { ... })`. -| 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` | +The mechanics of `exec` / `getFormState` / `fillFields` / `clickElement` are in [SKILL.md](SKILL.md) — read it before recon if you haven't already. -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. +When live recon is overkill: trivial reads (`navigateSection` + `readTable` + assert non-empty), or scenarios you've already proven once in this session. When it's essential: confirmation dialogs, posting/cancellation flows, reports with custom filters, multi-grid forms, user-customised forms. ## Suite layout @@ -99,30 +40,20 @@ When live recon is overkill: trivial reads (`navigateSection` + `readTable` + as ``` tests/ - web-test/ # engine self-tests (reserved if our repo layout) / # application regression — one per solution _hooks.mjs webtest.config.mjs + _allure/ # optional static Allure config 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). +Inside the application subfolder, organize by **feature**, not by metadata kind. Numeric prefixes on both folder and file enforce run order — discovery walks recursively and sorts files by full relative path; entries starting with `_` or `.` are skipped (so `_hooks.mjs`, `_allure/` won't be picked up as tests). ``` tests// - _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 @@ -132,15 +63,11 @@ tests// 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. +Per-folder `_hooks.mjs` / `webtest.config.mjs` inside the application subfolder are NOT supported — only the application-root copies are loaded. ## Test file anatomy @@ -185,18 +112,95 @@ export default async function(ctx) { } ``` -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'`) and not a transliteration. Same applies to `export const name` and `displayName` in `webtest.config.mjs`. -**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`. +## `ctx` contract + +The runner injects every `browser.mjs` export into `ctx` (all 1C action functions auto-detect platform errors — see SKILL.md), plus the test utilities below. + +### Test utilities + +```js +step(name, fn) // async wrapper. Records start/stop. Nested calls supported. + // On throw: marks the step failed, re-throws. + // On screenshot='every-step': captures after fn(). +log(...args) // adds a line to ctx.testInfo's output (goes into JSON / Allure + // attachment). Use instead of console.log inside tests. +assert.* // see "Assertions" below +``` + +### `ctx.testInfo` (always set, read-only) + +```js +{ + name, // 'Навигация по разделам' (with params substituted) + file, // '01-navigation.test.mjs' (basename) + filePath, // relative path inside testDir + tags, // ['nav', 'smoke'] + timeout, // ms + attempt, // 1..maxAttempts (1-based) + maxAttempts, // 1 + retry + param, // { ... } | undefined (only when export const params is set) + contexts: { // mirrors config.contexts; includes custom fields like displayName + clerk: { url, isolation, displayName, ... }, + manager: { ... }, + }, + primaryContext, // 'clerk' — name of the context active at test entry + // (= t.context for single, t.contexts[0] for multi) +} +``` + +### `ctx.testResult` (only in `afterEach`) + +```js +{ + status, // 'passed' | 'failed' + duration, // ms + attempts, // attempts actually executed + error, // { message, step?, screenshot? } | null + steps, // array of step results (each: { name, start, stop, status, error?, steps[] }) +} +``` + +### Context shape + +- **Single-context (default or `export const context = 'manager'`):** all API on `ctx` top-level — `ctx.clickElement(...)`, `ctx.getFormState()`, etc. +- **Multi-context (`export const contexts = ['clerk', 'manager']`):** each name is its own scoped namespace — `ctx.clerk.clickElement(...)`, `ctx.manager.fillFields(...)`. `step`, `assert`, `log`, `testInfo` stay top-level. Scoped methods auto-switch the active page before each call. + +## Assertions + +All on `ctx.assert`. Throw `AssertionError` with `.message`, `.actual`, `.expected`. No dependencies. + +```js +// generic +assert.ok(value, msg?) // truthy +assert.equal(actual, expected, msg?) // === +assert.notEqual(actual, expected, msg?) // !== +assert.deepEqual(actual, expected, msg?) // JSON-compare +assert.includes(haystack, needle, msg?) // string.includes / array.includes +assert.match(string, regex, msg?) // regex.test(string) +await assert.throws(asyncFn, msg?) // passes if fn throws (use await) + +// 1C-specific — operate on getFormState() / readTable() output +assert.formHasField(state, 'Контрагент', msg?) // state.fields[name] exists +assert.formTitle(state, expected, msg?) // state.title includes expected +assert.tableHasRow(table, predicate, msg?) // predicate: object (partial match) or fn(row) => bool + // object form: { 'Наименование': 'Тест' } + // fn form: r => r['Сумма'] > 100 +assert.tableRowCount(table, expected, msg?) // table.rows.length === expected +assert.noErrors(state, msg?) // !state.errors +``` + +Beyond these, just use plain JS (`throw new Error(...)`) — there's no custom matcher extension API. The 1C-specific helpers are the ones worth preferring over hand-rolled equivalents because their error messages name the actual fields/rows present, which speeds up triage. ## webtest.config.mjs ```js export default { - // Single-context: just url. + // Single-context shorthand: url: 'http://localhost:9191/myapp/ru_RU', - // OR multi-context: named contexts. Each test picks via `context`/`contexts` exports. + // OR multi-context: // contexts: { // clerk: { url: 'http://localhost:9191/myapp-clerk/ru_RU', displayName: 'Кладовщик' }, // manager: { url: 'http://localhost:9191/myapp-manager/ru_RU', displayName: 'Менеджер' }, @@ -205,7 +209,7 @@ export default { timeout: 30000, retries: 0, - screenshot: 'on-failure', + screenshot: 'on-failure', // 'every-step' | 'off' record: false, // Severity → tags mapping for Allure. Each tag at most one bucket. @@ -217,7 +221,7 @@ export default { }; ``` -CLI flags override config. Recommend latin context IDs + Russian `displayName` for video badges. +CLI flags override config. Use latin context IDs + Russian `displayName` for ergonomics — `ctx.testInfo.contexts.clerk.displayName` is friendlier than mixed-case Cyrillic keys. ## _hooks.mjs @@ -228,74 +232,44 @@ 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. + // hookArgs: everything after `--` on the CLI, as a string[]. Parse yourself. + const force = hookArgs.includes('--rebuild-stand'); + const dataArg = hookArgs.find(a => a.startsWith('--data='))?.slice('--data='.length); + log('preparing stand, force=', force, 'data=', dataArg); + // Idempotent hash-locks on inputs (config sources, EPF spec, DB dump) keep + // warm starts to a liveness probe. } -export async function cleanup({ log, config }) { - // Tear down or leave the stand running. Choose per project. -} +export async function cleanup({ log, config }) { /* optional */ } // Testlevel — runs with browser ctx. export async function beforeAll(ctx) { /* once after first context opens */ } export async function afterAll(ctx) { /* once before final teardown */ } export async function beforeEach(ctx) { /* ctx.testInfo is set */ } -export async function afterEach(ctx) { /* ctx.testResult is set */ } +export async function afterEach(ctx) { /* ctx.testInfo + ctx.testResult set */ } // Per-context — runs whenever a context is created/closed. export async function afterOpenContext(ctx, name, spec) { /* spec = config.contexts[name] */ } export async function beforeCloseContext(ctx, name, spec) { } ``` -Built-in state reset (`dismissPendingErrors` + close all forms) runs after `afterEach` automatically. Don't reimplement it. +Built-in state reset (`dismissPendingErrors` + close all forms) runs after `afterEach` automatically. Don't reimplement it in `afterEach`. + +Pass hook args after `--`: + +```bash +node $RUN test tests// --bail -- --rebuild-stand --data=demo + └─runner─┘ └────── hookArgs ─────────┘ +``` **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`. +- DB restore, publication, EPF build → `prepare()`. Make it idempotent (hash-locks). +- Test-specific seed data → per-test `setup`. - Shared session-wide warmup → `beforeAll`. ## Ready-to-paste patterns -### 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. +A minimal CRUD shape is in *Test file anatomy* above — use it as the rhythm for catalog/document tests, swapping in the right section/command/fields. The patterns below cover what's specific to the regression engine, not the browser API (those live in SKILL.md). ### DCS report @@ -335,7 +309,7 @@ export default async function({ clerk, manager, step, assert }) { }); await step('Кладовщик видит новый статус', async () => { const s = await clerk.getFormState(); - assert.equal(s.fields.find(f => f.name === 'Статус')?.value, 'Утверждён'); + assert.equal(s.fields['Статус']?.value, 'Утверждён'); }); await step('Освободить сессию кладовщика', async () => { await manager.closeContext('clerk'); // free a 1C license for the next test @@ -343,7 +317,7 @@ export default async function({ clerk, manager, step, assert }) { } ``` -License caveat: stock 1C allows ~2 web sessions concurrently. Close contexts you no longer need before the next multi-user test starts. +Close contexts you no longer need (`manager.closeContext('clerk')`) before the next multi-user test starts — frees a 1C web-client license and stops the previous role from holding state. ### Failing-test repro @@ -356,37 +330,50 @@ export default async function({ openCommand, clickElement, getFormState, assert, await clickElement('Создать'); await clickElement('Провести'); const s = await getFormState(); - assert.ok(s.errorModal || s.fields.find(f => f.name === 'Контрагент')?.required, + assert.ok(s.errorModal || s.fields['Контрагент']?.required, 'Должна быть ошибка валидации или поле помечено обязательным'); } ``` Write it red first, hand it to the user, fix the underlying issue, re-run green. +### Parameterised test + +```js +export const name = 'Заполнение поля {type}'; +export const params = [ + { type: 'String', field: 'Наименование', value: 'Тест' }, + { type: 'Number', field: 'Цена', value: '100.50' }, + { type: 'Date', field: 'ДатаПоступления', value: '01.01.2024' }, +]; + +export default async function({ fillFields, getFormState, assert }, { type, field, value }) { + await fillFields({ [field]: value }); + const state = await getFormState(); + assert.equal(state.fields[field]?.value, String(value)); +} +``` + +Each `params` entry becomes its own test in the report. `{key}` placeholders in `name` get substituted; without placeholders, a `[index]` suffix is added. `ctx.testInfo.param` carries the current row. + ## Running ```bash -node $RUN test tests// # full app suite -node $RUN test tests//03-goods-receipt/ # one feature folder +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// --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 +node $RUN test tests// -- --rebuild-stand # after `--` → hookArgs ``` Default report is JSON when `--report=…` is given. Allure needs `--format=allure` + a directory. JUnit similarly with `--format=junit`. -### Allure static config — `_allure/` directory +### Allure static config — `_allure/` -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. +The runner copies `/_allure/` into the report directory before generating Allure output. Drop in `categories.json` (regex-based failure classification — useful for 1C-specific buckets: license pool exhaustion, platform exceptions, runner timeouts, assertion failures), `environment.properties` (optional, often emitted dynamically by `prepare()`), `executor.json` (CI metadata, skip locally). The underscore prefix keeps the directory out of test discovery. ## Severity guidance @@ -404,26 +391,26 @@ Don't promote everything to `critical` — it loses signal in the Allure dashboa ## 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. +- **Sleeps as a substitute for assertions.** `wait(5)` after `openCommand` is fine; `wait(30)` because something flakes is a bug — wait on `getFormState` instead. - **Retry as a substitute for understanding.** "Not found" twice means the data isn't there or the label is wrong. Don't loop. -- **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. +- **Position-based row identification** (`rows[0]`) when the DB has shared seed data. Filter by a unique marker (`Date.now()` suffix) instead. +- **Hand-writing reset code in `afterEach`.** The runner already closes forms and dismisses errors after the hook. +- **Cross-test state assumptions.** Each test must start from the desktop and seed its own data. Order-of-execution coupling is a regression-suite trap. - **`tags: ['smoke']` on a 90-second test.** Smoke means fast. -- **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. +- **Skipping recon** because "I know what this catalog looks like." The project's customisation almost certainly differs from stock. + +(General browser-API anti-patterns — raw DOM, `clickElement('Закрыть')` instead of `closeForm()` — live in SKILL.md.) ## After a run — failure triage 1. Scan the JSON or Allure summary for `failed`. -2. For each failure, read `error.message` + `error.step` + screenshot (saved next to the report). +2. For each failure, read `error.message` + `error.step` + screenshot. 3. If `error.onecError.stack` is present — it's a 1C exception, look at the platform trace. 4. Classify: - **Test bug** — selector wrong, expectation wrong, race with no anchor → fix the test. - **Application bug** — actual misbehaviour reproduced → report to the user with the failing step name and the platform stack. - **Stand flake** — Apache timeout, login form not loading, license shortage → fix the hook idempotency or session-cleanup logic, not the test. -5. After fixes, re-run only the affected files (`node $RUN test tests/03-goods-receipt/`) before the full suite. +5. After fixes, re-run only the affected files before the full suite. Report back to the user with the classification, not raw failure dumps. diff --git a/docs/web-test-regression-spec.md b/docs/web-test-regression-spec.md new file mode 100644 index 00000000..806732c9 --- /dev/null +++ b/docs/web-test-regression-spec.md @@ -0,0 +1,978 @@ +# Регрессионное тестирование — спецификация + +Техническое описание движка регрессионных тестов: инструмент исполняет описанные кодом пользовательские сценарии в веб-клиенте прикладного решения на платформе 1С и сверяет результат с ожиданиями. + +Смежные документы: +- [web-test-regression-guide.md](web-test-regression-guide.md) — пользовательский гайд с быстрым стартом. +- [web-test-guide.md](web-test-guide.md) — справочник по browser-API (`clickElement`, `getFormState`, `readTable`, …), который используется внутри тестов. +- [web-test-recording-guide.md](web-test-recording-guide.md) — видеозапись, озвучка, overlays. + +--- + +## 1. Командная строка + +``` +node run.mjs test [url] [флаги] +``` + +| Флаг | По умолчанию | Описание | +|------|-------------|----------| +| `--tags=smoke,crud` | (все) | Фильтр тестов по тегам (пересечение) | +| `--grep=pattern` | (все) | Фильтр тестов по имени (регулярное выражение) | +| `--bail` | false | Остановиться при первом падении | +| `--retry=N` | 0 | Повторить упавшие тесты N раз | +| `--timeout=ms` | 30000 | Таймаут на тест (мс) | +| `--report=path` | (нет) | Записать отчёт в файл (JSON или XML для `--format=junit`) | +| `--format=fmt` | json | Формат отчёта: `json` / `allure` / `junit` | +| `--report-dir=path` | dirname(report) / testDir | Каталог для скриншотов, видео, Allure-результатов | +| `--screenshot=strategy` | on-failure | `on-failure` / `every-step` / `off` | +| `--record` | false | Записывать видео для каждого теста (mp4 в `--report-dir`) | +| `-- ` | — | Всё после `--` пробрасывается в `_hooks.mjs` как `hookArgs` (см. §6.1) | + +URL необязателен, если в каталоге тестов есть `webtest.config.mjs`. CLI URL переопределяет URL дефолтного контекста. + +### Валидация CLI + +- `--screenshot=` принимается только `on-failure | every-step | off`; при невалидном значении движок выводит ошибку и завершается с ненулевым кодом до старта прогона. +- `--format=` принимается только `json | allure | junit`; иначе — завершение с ошибкой. +- `--format=junit` требует `--report=` (иначе некуда писать XML); иначе — завершение с ошибкой. + +### Режим выполнения + +1. Загружается `webtest.config.mjs` (если есть). +2. Обнаруживаются файлы `*.test.mjs`, читается каждый, извлекаются метаданные. +3. Применяются фильтры `--tags` / `--grep` / `only`. Параметризованные тесты разворачиваются. +4. Запускается браузер и default-контекст (`chromium.launch()` либо `launchPersistentContext` в зависимости от `isolation`). +5. Тесты выполняются последовательно **в алфавитном порядке относительного пути файла** (внутри файла — в порядке экспорта). +6. Для каждого теста: лениво создаются нужные `BrowserContext`-ы (`ensureContext`), переключается активный, прогоняются хуки и тело, выполняется встроенный сброс состояния. +7. По завершении: финальная очистка контекстов с `beforeCloseContext`-хуками, закрытие браузера, `cleanup()`. + +--- + +## 2. Формат тест-модуля + +Каждый файл `*.test.mjs` — ES-модуль. + +### Экспорты + +| Экспорт | Тип | Обязателен | По умолчанию | Описание | +|---------|-----|-----------|-------------|----------| +| `name` | `string` | да | — | Читаемое имя теста | +| `default` | `async function(ctx, param?)` | да | — | Тело теста | +| `tags` | `string[]` | нет | `[]` | Теги для фильтрации | +| `timeout` | `number` | нет | 30000 | Таймаут теста (мс) | +| `skip` | `boolean \| string` | нет | false | Пропустить тест (строка = причина) | +| `only` | `boolean` | нет | false | Запустить только этот тест (отладка) | +| `context` | `string` | нет | defaultContext | Имя контекста из файла конфигурации | +| `contexts` | `string[]` | нет | — | Мульти-пользовательский процессный тест | +| `severity` | `string` | нет | — | `blocker` / `critical` / `normal` / `minor` / `trivial` | +| `params` | `object[]` | нет | — | Параметризация (см. §13) | +| `setup` | `async function(ctx)` | нет | — | Подготовка перед тестом | +| `teardown` | `async function(ctx)` | нет | — | Очистка после теста (выполняется всегда) | + +### Пример: тест с одним контекстом + +```js +export const name = 'CRUD справочника Контрагенты'; +export const tags = ['smoke', 'crud', 'catalog']; +export const timeout = 45000; + +export default async function({ navigateSection, openCommand, clickElement, + fillFields, readTable, closeForm, getFormState, assert, step, log }) { + + await step('Открыть список', async () => { + await navigateSection('Склад'); + await openCommand('Контрагенты'); + }); + + await step('Создать элемент', async () => { + await clickElement('Создать'); + await fillFields({ 'Наименование': 'Тест-' + Date.now() }); + await clickElement('Записать и закрыть'); + }); + + await step('Проверить в списке', async () => { + const table = await readTable(); + assert.tableHasRow(table, r => r['Наименование']?.startsWith('Тест-')); + log('Элемент найден в списке'); + }); +} +``` + +### Пример: мульти-контекстный процессный тест + +Рекомендация: латинский ID контекста + кириллический `displayName` в `webtest.config.mjs.contexts..displayName` (см. §7). + +```js +export const name = 'Согласование приходной накладной'; +export const contexts = ['clerk', 'manager']; +export const tags = ['process']; + +export default async function({ clerk, manager, step }) { + 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('Освобождаем контекст clerk', async () => { + await manager.closeContext('clerk'); // освободить лицензию 1С + }); +} +``` + +--- + +## 3. Объект контекста + +Каждая тестовая функция получает объект контекста `ctx`. + +### API браузера (все экспорты browser.mjs) + +Все функции обёрнуты авто-обнаружением 1С-ошибок (как в `executeScript`): +- При модальной/всплывающей ошибке 1С: скриншот → `fetchErrorStack` → исключение с заполненным `err.onecError`. +- Обёрнутые ACTION_FNS: `clickElement`, `fillFields`, `fillField`, `selectValue`, `fillTableRow`, `deleteTableRow`, `openCommand`, `navigateSection`, `navigateLink`, `openFile`, `closeForm`, `filterList`, `unfilterList`. + +Полный список доступных функций (по группам, детальное описание — в [web-test-guide.md](web-test-guide.md)): + +**Навигация:** `navigateSection`, `openCommand`, `switchTab`, `navigateLink`, `openFile` +**Состояние:** `getFormState`, `getPageState`, `getSections`, `getCommands` +**Таблицы:** `readTable`, `readSpreadsheet`, `fillTableRow`, `deleteTableRow` +**Поля:** `fillFields`, `fillField`, `selectValue` +**Действия:** `clickElement`, `closeForm`, `filterList`, `unfilterList` +**Ошибки:** `fetchErrorStack` +**Контексты:** `createContext`, `setActiveContext`, `closeContext`, `listContexts`, `hasContext`, `getActiveContext` +**Запись:** `startRecording`, `stopRecording`, `isRecording`, `addNarration`, `getCaptions` +**Презентация:** `showCaption`, `hideCaption`, `showTitleSlide`, `hideTitleSlide`, `showImage`, `hideImage`, `highlight`, `unhighlight`, `setHighlight`, `isHighlightMode` +**Утилиты:** `screenshot`, `wait`, `getPage`, `getSession`, `readFileSync`, `writeFileSync` + +> `dismissPendingErrors` — внутренняя функция (browser.mjs), на `ctx` не публикуется. Тест её не вызывает напрямую: она срабатывает автоматически перед каждым ACTION_FN и внутри встроенного сброса. + +### Тестовые утилиты + +- `step(name, fn)` — обёртка шага (см. §4) +- `assert.*` — хелперы утверждений (см. §5) +- `log(...args)` — добавить строку в вывод теста. Строки накапливаются в массив, склеиваются и попадают в JSON `tests[].output`. В Allure-отчёте `output` пишется в `statusDetails.trace` **только для упавших тестов**; для успешных теряется (отдельного вложения не создаётся). + +### Метаданные теста (`ctx.testInfo`) + +Декларативная информация о текущем тесте. Движок выставляет `ctx.testInfo` перед каждой попыткой (до `beforeEach`); хук и тело теста могут читать. Изменять не следует — объект используется самим движком при сборке отчёта. + +```js +ctx.testInfo = { + name, // 'Навигация по разделам' (с подставленными params) + file, // '01-navigation.test.mjs' (basename) + filePath, // '01-navigation.test.mjs' (relative к testDir, разделитель '/') + tags, // ['nav', 'smoke'] + timeout, // 60000 (ms) + attempt, // 1..maxAttempts (1-based) + maxAttempts, // 1 + retry + param, // { ... } | undefined (для export const params) + contexts: { // объект, всегда 1+ ключей; зеркалит config.contexts + a: { url, isolation, ...customFields }, + b: { ... }, + }, + primaryContext, // 'a' — имя контекста, активного на входе в тест + // (= t.context для single, t.contexts[0] для multi) +} +``` + +Доступ к специфике контекста: `testInfo.contexts[testInfo.primaryContext].displayName`. `primaryContext` — декларация теста; не зависит от текущего значения `getActiveContext()` (которое может меняться внутри теста). + +### Результат теста в afterEach (`ctx.testResult`) + +Только в `afterEach`. До запуска теста — `null`. После — заполняется движком перед вызовом хука: + +```js +ctx.testResult = { + status, // 'passed' | 'failed' + duration, // ms + attempts, // фактически выполнено попыток (1..maxAttempts) + error, // { message, step?, screenshot?, onecError? } | null + steps, // массив step-результатов (структура — см. §4) +} +``` + +В итоговый JSON-отчёт (`tests[]`) добавляются ещё `name`, `file`, `tags`, `contexts`, `severity`, `start`, `stop`, `output`, `screenshot`, `video` (см. §9). В `afterEach` они недоступны — движок собирает финальную запись после хука. + +### Мульти-контекст + +При `export const contexts = ['a', 'b']`: +- `ctx.a` и `ctx.b` — отдельные scoped-объекты, каждый с полным API браузера. Перед каждым вызовом scoped-обёртка переключает активный контекст через `setActiveContext`. +- `ctx.step`, `ctx.assert`, `ctx.log`, `ctx.testInfo`, `ctx.testResult` остаются на верхнем уровне. + +При single-context (`export const context = 'X'` или дефолт) API публикуется плоско на `ctx`. + +--- + +## 4. step(name, fn) — обёртка шага + +```js +await step('Имя шага', async () => { + // тело шага +}); +``` + +Поведение: +- Записывает метку `start` перед `fn()`. +- Записывает метку `stop` после `fn()` (успех или ошибка). +- При ошибке: устанавливает `status: 'failed'`, прикрепляет сообщение, пробрасывает исключение. +- При успехе: устанавливает `status: 'passed'`. +- Если стратегия скриншотов `every-step` — делает скриншот после `fn()`. +- Вложенные шаги поддерживаются (шаг внутри шага). +- Напрямую маппится на шаги Allure. + +Структура данных шага (для отчётов): + +```js +{ + name: 'Имя шага', + start: 1712345678000, // мс от эпохи + stop: 1712345679200, + status: 'passed' | 'failed', + error: 'сообщение' | undefined, + screenshot: 'путь' | undefined, + steps: [] // вложенные шаги +} +``` + +--- + +## 5. Утверждения (assertions) + +Простые хелперы без зависимостей. Бросают `AssertionError` со свойствами `.message`, `.actual`, `.expected`. + +### Общие + +```js +assert.ok(value, msg?) // истинность +assert.equal(actual, expected, msg?) // === +assert.notEqual(actual, expected, msg?) // !== +assert.deepEqual(actual, expected, msg?) // сравнение через JSON +assert.includes(haystack, needle, msg?) // string/array .includes() +assert.match(string, regex, msg?) // regex.test(string) +await assert.throws(asyncFn, msg?) // ожидает исключение из async fn +``` + +### Специфичные для 1С + +```js +assert.formHasField(state, fieldName, msg?) +// проверяет наличие state.fields[fieldName]; в сообщении об ошибке +// перечисляются доступные поля для быстрой диагностики + +assert.formTitle(state, expected, msg?) +// проверяет, что state.title содержит expected + +assert.tableHasRow(table, predicate, msg?) +// predicate: объект (частичное совпадение по ===) или функция row => bool +// объект: assert.tableHasRow(table, { 'Наименование': 'Тест' }) +// функция: assert.tableHasRow(table, r => r['Сумма'] > 100) + +assert.tableRowCount(table, expected, msg?) +// проверяет table.rows.length === expected + +assert.noErrors(state, msg?) +// проверяет !state.errors +``` + +Расширения assert API нет. Для нестандартных проверок — `throw new Error(...)` или комбинация существующих хелперов. + +--- + +## 6. Хуки + +Все хуки определяются в `_hooks.mjs` в корне каталога тестов. + +### Три уровня + +**Инфраструктурный уровень** (без браузера): +- `prepare({ hookArgs, log, config })` — до подключения (восстановление БД, публикация, загрузка данных). +- `cleanup({ hookArgs, log, config })` — после отключения (удаление публикации, очистка). + +Поля параметра: +- `hookArgs: string[]` — всё, что в командной строке передано после разделителя `--`, без интерпретации со стороны движка. Хук парсит сам (см. §6.1). +- `log: (...args) => void` — функция логирования движка (структурированный вывод с префиксом `[hooks]`). Использовать вместо `console.log`, чтобы не ломать формат отчёта. +- `config: object` — разобранный `webtest.config.mjs` (URL контекстов, режим изоляции, правила severity и т.д.). + +**Тестовый уровень** (с контекстом браузера): +- `beforeAll(ctx)` — после подключения, перед первым тестом. +- `afterAll(ctx)` — после последнего теста, до отключения. +- `beforeEach(ctx)` — перед каждым тестом. На входе уже доступен `ctx.testInfo` (см. §3). +- `afterEach(ctx)` — после каждого теста. Дополнительно доступен `ctx.testResult` с результатом завершившегося теста. + +**Контекстный уровень** (на каждый browser-контекст, жизненный цикл = создан → удалён): +- `afterOpenContext(ctx, name, spec)` — сразу после успешного `createContext`. `spec` — запись из `config.contexts[name]` со всеми пользовательскими полями (`displayName`, `url`, `isolation`, …). Полезно: вставка постоянного DOM-оверлея/бейджа, предварительная навигация в контексте, регистрация телеметрии. +- `beforeCloseContext(ctx, name, spec)` — перед `closeContext` (контекст ещё активен и работает). Полезно: сохранение остатков буферов, сбор метрик, последний скриншот. Срабатывает и при явном `ctx.closeContext(name)` из теста, и в финальной очистке движка перед `disconnect`. + +`closeContext(name)` валиден только когда `name !== getActiveContext()` — иначе бросается исключение. В scoped API (`ctx.a.closeContext('b')`) это естественно: scoped-обёртка сначала вызывает `setActiveContext('a')`, потом закрывает `'b'` — целевой контекст всегда неактивен. + +### Подавление ошибок в хуках + +Ошибки в `afterEach`, `teardown`, `afterAll` и `cleanup` ловятся и логируются движком, но не прерывают прогон и не помечают тест/прогон как failed. Логика: пост-хуки очистки должны быть устойчивы к собственным сбоям, чтобы один сломанный `teardown` не приводил к падению остальных тестов по цепочке. Если в этих хуках произошла фатальная для регресса проблема — бросайте отдельный `Error` в `beforeAll`/`beforeEach`, чтобы он прервал прогон, либо проверяйте состояние в самом тесте. + +### Порядок выполнения + +``` +prepare() // без браузера (восстановление БД, публикация) + browser.launch() // запуск процесса браузера + createContext(default) // первый контекст создан + afterOpenContext(ctx, default) // hook: контекст готов + beforeAll(ctx) // браузер готов, default-контекст создан + [lazy ensureContext(name)] // для multi-context тестов + afterOpenContext(ctx, name) + beforeEach(ctx) + test.setup(ctx) // подготовка теста + test.default(ctx) // тело теста (может вызвать ctx.closeContext) + [при ctx.closeContext(x)]: beforeCloseContext(ctx, x) → close(x) + test.teardown(ctx) // очистка теста (всегда) + afterEach(ctx) // всегда + [встроенный сброс] // всегда (для каждого живого контекста теста) + …следующий тест… + afterAll(ctx) + [для каждого оставшегося контекста]: beforeCloseContext(ctx, name, spec) + browser.close() // финальный disconnect (без явных closeContext — + // контексты умирают вместе с браузером) +cleanup() // без браузера (удаление публикации) +``` + +### Встроенный сброс состояния + +После каждого теста (после `afterEach`) движок гарантирует чистое состояние: + +```js +async function resetState(ctx) { + try { await ctx.dismissPendingErrors(); } catch {} // no-op на ctx (не экспортируется); + // внутренний dismiss всё равно отработает + // через ACTION_FN-обёртки ниже + + for (let i = 0; i < 10; i++) { + const state = await ctx.getFormState(); + if (state.form == null) break; // важно: == null, не !state.form — + // form может быть 0 (валидный idx фоновой формы) + try { await ctx.closeForm({ save: false }); } catch { break; } + } +} +``` + +Гарантирует, что каждый тест стартует с чистого рабочего стола, независимо от того, как завершился предыдущий (падение, таймаут, ошибка утверждения). Реимплементировать это в пользовательском `afterEach` не нужно. + +### Пример _hooks.mjs + +```js +import { execSync } from 'child_process'; + +export async function prepare({ hookArgs, log, config }) { + const force = hookArgs.includes('--rebuild-stand'); + const dataArg = hookArgs.find(a => a.startsWith('--data='))?.slice('--data='.length); + log('preparing stand, force=', force, 'data=', dataArg); + execSync('powershell.exe -File scripts/restore-db.ps1'); + execSync('powershell.exe -File scripts/publish.ps1'); +} + +export async function cleanup({ log }) { + log('cleaning up stand'); + execSync('powershell.exe -File scripts/unpublish.ps1'); +} + +export async function beforeAll(ctx) { + // По умолчанию 1С после входа уже показывает дефолтную секцию — навигация + // в beforeAll обычно не нужна. Хук удобен для общего setup'а который + // должен случиться один раз для всего прогона. +} + +export async function afterEach(ctx) { + // Доступен ctx.testResult — { status, duration, attempts, error, steps }. + // Встроенный сброс состояния выполняется ПОСЛЕ afterEach автоматически. +} + +export async function afterOpenContext(ctx, name, spec) { + // Удобно для persistent DOM-overlay'я с displayName (видно в видео, + // какая вкладка к какому пользователю относится). +} + +export async function beforeCloseContext(ctx, name, spec) { + // Срабатывает и при ctx.closeContext из теста, и в финальной очистке. +} +``` + +### 6.1. Проброс пользовательских флагов через `--` + +Движок не знает о пользовательских флагах хуков. Чтобы хуки получили разовые параметры без правки `webtest.config.mjs` или окружения, используется стандартная shell-конвенция `--` (как у `npm`, `cargo`, `pytest`): всё, что идёт после `--` в CLI движка, передаётся в `prepare` / `cleanup` через поле `hookArgs: string[]` без интерпретации. + +``` +node run.mjs test tests/myapp/ --bail -- --rebuild-stand --reload-data + └─ runner ─┘ └────── hookArgs ────────┘ +``` + +В этом примере движок получает `--bail`, а `hookArgs` хуков становится `['--rebuild-stand', '--reload-data']`. Парсинг этого массива — ответственность хуков. + +Если разделитель `--` не указан, `hookArgs` — пустой массив. Это позволяет движку и хукам развиваться независимо: новый встроенный флаг движка никогда не пересечётся с пользовательским. + +--- + +## 7. Файл конфигурации + +`webtest.config.mjs` в корне каталога тестов. Необязателен — если отсутствует, URL должен быть передан через CLI. + +```js +export default { + // Контексты: именованные URL для разных пользователей/ролей. + // Рекомендация: латинский ID контекста (`clerk`, `manager`) + кириллический + // `displayName` для UI/слайдов. Любые пользовательские поля пробрасываются как есть + // и доступны хукам через `ctx.testInfo.contexts[name]` (см. §3). + contexts: { + clerk: { url: 'http://localhost/app-clerk/ru_RU', displayName: 'Кладовщик' }, + manager: { url: 'http://localhost/app-manager/ru_RU', displayName: 'Менеджер' }, + admin: { url: 'http://localhost/app-admin/ru_RU', displayName: 'Админ' }, + }, + defaultContext: 'clerk', + + // Значения по умолчанию (переопределяются флагами CLI) + timeout: 30000, + retries: 0, + screenshot: 'on-failure', // 'every-step' | 'off' + record: false, + + // Дефолтный тег-фильтр. Применяется только если CLI не передал --tags. + // Удобно для сценариев «прогон по умолчанию = smoke», при этом --tags=full + // (или --tags=) с CLI прозрачно перекрывает. + tags: ['smoke'], + + // Дефолтный режим изоляции для контекстов, которые сами его не указали + // (config.contexts..isolation). См. §8. + isolation: 'tab', // 'tab' | 'window' + + // Allure severity policy (опционально). Маппинг наоборот: уровень → [теги]. + // Резолв см. §9 «Авто-эмиссия label-ов». + severity: { + critical: ['smoke', 'multi-context'], + minor: ['recording'], + // blocker / trivial — необязательны, можно опустить + }, + defaultSeverity: 'normal', +}; +``` + +**Упрощённая форма** (один контекст, без именованных): + +```js +export default { + url: 'http://localhost/app/ru_RU', + timeout: 30000, +}; +``` + +### Валидация файла конфигурации + +`severity` валидируется при загрузке: +- ключи — только из `blocker | critical | normal | minor | trivial`; +- значение каждого ключа — массив тегов; +- тег не может одновременно состоять в двух уровнях severity (явная ошибка с указанием конфликта); +- `defaultSeverity` — из стандартного набора. + +При нарушении любого правила движок выводит сообщение с указанием конфликта и завершается с ненулевым кодом до запуска тестов. + +Кириллица в ID контекстов работает, но смешанный регистр снижает читаемость кода (`testInfo.contexts.кладовщик.displayName` рядом с `testInfo.contexts.clerk.displayName`). Рекомендуем разделять технический ID и человекочитаемое имя. + +Флаги CLI всегда переопределяют значения из файла конфигурации. + +--- + +## 8. Контексты + +### Механизм: Playwright BrowserContext + +Один процесс браузера (`chromium.launch()`), несколько изолированных контекстов. Каждый контекст — отдельная сессия (куки, авторизация, состояние страницы). + +``` +browser (один процесс chromium) + ├─ BrowserContext "кладовщик" → page → http://localhost/app-clerk/ru_RU + ├─ BrowserContext "менеджер" → page → http://localhost/app-mgr/ru_RU + └─ BrowserContext "админ" → page → http://localhost/app-admin/ru_RU +``` + +Преимущества: +- **Мгновенное переключение** между пользователями (смена активного `page`). +- **Состояние сохраняется** — переключились на менеджера и обратно, у кладовщика все формы остались открытыми. +- **Нет переподключений** — каждая сессия живёт независимо. +- **Один процесс** — экономия ресурсов по сравнению с несколькими браузерами. + +### Одиночный контекст (по умолчанию) + +Большинство тестов. Один BrowserContext, один пользователь. Тест получает плоский `ctx` со всем API. + +```js +export const context = 'manager'; // необязательно, иначе defaultContext +export default async function({ clickElement, fillFields, … }) { } +``` + +### Порядок выполнения и переключение контекста + +Движок НЕ группирует тесты по контексту. Порядок выполнения — алфавитный по полному относительному пути файла (плюс порядок экспорта внутри файла). Для каждого теста: + +1. Через `ensureContext(name)` создаются BrowserContext-ы, упомянутые в `t.context` / `t.contexts` (если ещё не созданы). +2. `setActiveContext(primaryContext)` — активный контекст = первый объявленный (для single — `t.context || defaultContext`, для multi — `t.contexts[0]`). +3. После теста встроенный сброс пробегает по всем использованным контекстам. + +Контексты живут между тестами: переключение через `setActiveContext` — дешёвое, повторный вход в 1С не требуется. Закрываются явно (`closeContext`) или финальной очисткой движка перед закрытием браузера. + +### Мульти-контекст (процессные тесты) + +```js +export const contexts = ['clerk', 'manager']; +export default async function({ clerk, manager, step, assert }) { … } +``` + +Каждый именованный контекст — полноценный scoped-объект API со своим `page`. Тест оркестрирует переключение между пользователями. Состояние каждого пользователя сохраняется между переключениями: + +```js +await step('Кладовщик создаёт документ', async () => { + await clerk.openCommand('Приходные накладные'); + await clerk.clickElement('Создать'); + await clerk.fillFields({ 'Контрагент': 'ООО Поставщик' }); + await clerk.clickElement('Записать'); + // кладовщик стоит на форме документа +}); + +await step('Менеджер утверждает', async () => { + await manager.navigateSection('Согласование'); + await manager.clickElement('Утвердить'); +}); + +await step('Кладовщик проверяет статус', async () => { + // страница кладовщика ТА ЖЕ — форма открыта, навигация не нужна + const state = await clerk.getFormState(); + assert.equal(state.fields['Статус']?.value, 'Утверждён'); +}); +``` + +### Публичный контекстный API + +| Метод | Назначение | +|-------|-----------| +| `createContext(name, url, { isolation, extensionPath })` | Создаёт BrowserContext и переходит по URL. | +| `setActiveContext(name)` | Переключает активный слот; при активной записи дописывает последние кадры старой страницы и переподключает screencast. | +| `closeContext(name)` | Выход из 1С + закрытие (`page` для `tab`, `BrowserContext` для `window`), удаляет из реестра. Бросает исключение, если `name === active`. | +| `listContexts()` / `hasContext(name)` / `getActiveContext()` | Только для чтения. | + +### Режимы изоляции + +Поле `isolation` задаётся в двух местах: + +- **На уровне контекста:** `config.contexts..isolation` — приоритет 1. +- **На уровне файла конфигурации:** `config.isolation` — применяется к контекстам, у которых своего значения нет. По умолчанию `'tab'`. + +| Режим | Реализация | Окна | Cookies | 1С-расширение | +|-------|-----------|------|---------|---------------| +| `'tab'` (default) | `launchPersistentContext` + `newPage()` per context | 1 окно, N вкладок | общие по path | загружается надёжно | +| `'window'` | `chromium.launch()` + `newContext()` per context | N окон | полная изоляция | может не загружаться | + +Смешивать режимы в одном прогоне нельзя — `createContext` бросает явную ошибку. То есть `config.isolation` фактически становится режимом всего прогона, если хотя бы один контекст явно не переопределил его на тот же режим. + +### Закрытие неактивных контекстов + +`closeContext(name)` нельзя вызвать на активном контексте — будет исключение. В scoped API это естественно: вызывать `manager.closeContext('clerk')` (scoped-обёртка сначала переключает активный на `manager`, потом закрывает `clerk`). Если контекст лишний (роль больше не нужна в рамках теста / прогона) — закрывайте его сразу: освобождает лицензию платформы и снимает нагрузку со следующих тестов. + +--- + +## 9. Отчёты + +### JSON (нативный, по умолчанию) + +```json +{ + "runner": "web-test", + "url": "http://localhost/app/ru_RU", + "startedAt": "2026-04-05T10:00:00.000Z", + "finishedAt": "2026-04-05T10:05:30.000Z", + "duration": 330.0, + "summary": { + "total": 25, + "passed": 23, + "failed": 1, + "skipped": 1 + }, + "tests": [ + { + "name": "CRUD справочника Контрагенты", + "file": "02-catalog-crud.test.mjs", + "tags": ["smoke", "crud"], + "contexts": ["clerk"], + "severity": "critical", + "status": "passed", + "start": 1712345678000, + "stop": 1712345690300, + "duration": 12.3, + "attempts": 1, + "steps": [ + { + "name": "Открыть список", + "start": 1712345678000, + "stop": 1712345679200, + "status": "passed", + "steps": [] + } + ], + "output": "Элемент найден в списке", + "error": null, + "screenshot": null, + "video": null + }, + { + "name": "Обязательное поле", + "file": "10-validation.test.mjs", + "tags": ["validation"], + "contexts": ["clerk"], + "status": "failed", + "duration": 8.1, + "attempts": 2, + "steps": [ + { + "name": "Сохранить пустую форму", + "start": 1712345700000, + "stop": 1712345708100, + "status": "failed", + "error": "Ожидалось модальное окно ошибки, но форма сохранилась" + } + ], + "output": "", + "error": { + "message": "Ожидалось модальное окно ошибки, но форма сохранилась", + "step": "Сохранить пустую форму", + "screenshot": "error-shot-10.png" + }, + "screenshot": "error-shot-10.png" + } + ] +} +``` + +### Allure (`--format=allure --report-dir=allure-results/`) + +Отдельные JSON-файлы для каждого теста в каталоге `allure-results/`: + +```json +{ + "uuid": "сгенерированный-uuid", + "name": "CRUD справочника", + "fullName": "02-catalog-crud.test.mjs", + "status": "passed", + "stage": "finished", + "start": 1712345678000, + "stop": 1712345690300, + "labels": [ + { "name": "tag", "value": "smoke" }, + { "name": "tag", "value": "crud" }, + { "name": "suite", "value": "root" }, + { "name": "severity", "value": "critical" } + ], + "steps": [ + { + "name": "Открыть список", + "status": "passed", + "start": 1712345678000, + "stop": 1712345679200, + "steps": [] + } + ], + "attachments": [ + { + "name": "Скриншот при падении", + "source": "uuid-attachment.png", + "type": "image/png" + } + ] +} +``` + +Скриншоты/видео копируются в `allure-results/` с уникальными именами. + +#### Авто-эмиссия меток + +Движок всегда заполняет следующие метки (`labels`): + +- **`tag`** — по одному на каждый элемент `mod.tags[]`. Готовая фильтрация в Allure-отчёте без дополнительной разметки. +- **`suite`** — `dirname(t.filePath)`. Тесты в корне `testDir` идут под `'root'`, тесты в подкаталоге `sales/` — под `'sales'`. Это даёт левую группировку отчёта без ручной разметки. +- **`severity`** — резолв в порядке приоритета: + 1. `export const severity = 'critical'` в самом тесте, **если значение валидное** (одно из `blocker | critical | normal | minor | trivial`). Если экспорт задан, но значение невалидное — пункт пропускается и идём в (3); резолв через теги (пункт 2) при этом **не выполняется** (хотел бы автор иначе — он бы не объявлял `severity`). + 2. Иначе **максимальный ранг** среди тегов теста (стандартные имена `blocker | critical | normal | minor | trivial` напрямую, либо через `config.severity`-маппинг). + 3. Иначе `config.defaultSeverity` или `'normal'`. + + Ранги: `blocker(5) > critical(4) > normal(3) > minor(2) > trivial(1)`. Выбор по максимуму не зависит от порядка тегов в `mod.tags`. + +Пример: `tags: ['smoke', 'recording']` + `severity: { critical: ['smoke'], minor: ['recording'] }` → severity = `critical` (5 > 2). + +#### Доп. файлы Allure через `/_allure/` + +Движок ищет каталог `_allure/` рядом с тестами и копирует все его файлы в `reportDir` перед генерацией отчёта. Конвенция для статичной настройки Allure, для которой нет места внутри JSON-файла теста: + +| Файл | Назначение | +|------|-----------| +| `categories.json` | Классификация падений по regex (группировка failed-тестов в виджете Categories — «timeout», «license-flake», «1C modal» и т.п.). | +| `environment.properties` | `key=value` строки в виджет Environment (URL, версия 1С, ветка git, номер сборки). Часто формируется динамически из `prepare()`. | +| `executor.json` | CI/CD-метаданные (Jenkins URL, GitHub run-id и т.п.). | + +Подчёркивание в имени — параллель `_hooks.mjs` (инфраструктура, не тест). Сборщик тестов пропускает каталог `_allure/` по общему правилу (`startsWith('_')`). Если каталога нет — ничего не происходит, отчёт собирается обычным образом. + +Пример `categories.json` (минимальный): +```json +[ + { "name": "Timeout", "messageRegex": "Timeout \\(\\d+ms\\)" }, + { "name": "Assertion", "messageRegex": "(Expected|AssertionError).*" } +] +``` + +### JUnit XML (`--format=junit`) + +```xml + + + + + + + Стек вызовов… + + Скриншот: error-shot-10.png + + + +``` + +--- + +## 10. Консольный вывод + +``` +web-test — http://localhost/app/ru_RU +Запуск 25 тестов из tests/myapp/ + + ✓ Навигация по разделам (2.1s) + ✓ CRUD справочника Контрагенты (12.3s) + ├ Открыть список (1.2s) + ├ Создать элемент (8.0s) + └ Проверить в списке (3.1s) + ✗ Обязательное поле (8.1s) + ├ Открыть форму (2.0s) + └ ✗ Сохранить пустую форму (6.1s) + Ожидалось модальное окно ошибки, но форма сохранилась + скриншот: error-shot-10.png + ○ Составной тип (skip: не реализовано) + +23 passed, 1 failed, 1 skipped (2m 0.5s) +``` + +Для passed-тестов выводится одна строка `✓ name (duration)`. Шаги печатаются только для упавших — после строки `✗`, с отступом, плюс сообщение ошибки и путь к скриншоту. Полная картина по шагам — в JSON-отчёте (`--report=…`). + +--- + +## 11. Скриншоты и видео + +### Стратегия скриншотов + +| Стратегия | Поведение | +|-----------|----------| +| `on-failure` (по умолчанию) | Скриншот при падении теста, прикрепляется к ошибке. | +| `every-step` | Скриншот в конце каждого `step()`, плюс при падении. | +| `off` | Без автоматических скриншотов. | + +Скриншоты сохраняются в каталог отчёта по шаблону `{индекс-теста}-{имя-шага}.png`. В JSON-отчёте — путь относительно каталога отчёта. + +### Видеозапись + +При включённом `--record`: +- `startRecording()` перед каждым тестом. +- `stopRecording()` после каждого теста. +- Видео сохраняется как `{индекс-теста}-{имя-теста}.mp4`. +- Прикрепляется к отчёту (Allure: вложение видео). + +Подробности по записи (overlays, captions, narration) — см. [web-test-recording-guide.md](web-test-recording-guide.md). + +--- + +## 12. Сброс состояния + +Встроенный механизм, выполняется после `afterEach` (и `teardown`) каждого теста. Псевдокод и условие выхода — в §6 «Встроенный сброс состояния». + +Для мульти-контекстных тестов сброс пробегает по всем живым контекстам, использованным тестом. + +Гарантирует, что каждый тест стартует с чистого рабочего стола, независимо от того, как завершился предыдущий (падение, таймаут, ошибка утверждения). + +--- + +## 13. Параметризация + +```js +export const name = 'Заполнение поля {type}'; +export const params = [ + { type: 'String', field: 'Наименование', value: 'Тест' }, + { type: 'Number', field: 'Цена', value: '100.50' }, + { type: 'Date', field: 'ДатаПоступления', value: '01.01.2024' }, + { type: 'Boolean',field: 'Активен', value: true }, +]; + +export default async function({ fillFields, getFormState, assert }, { type, field, value }) { + await fillFields({ [field]: value }); + const state = await getFormState(); + assert.equal(state.fields[field]?.value, String(value)); +} +``` + +Параметры разворачиваются в отдельные тесты на этапе discovery: +- Имя теста формируется подстановкой через шаблон `{key}` в `mod.name`; если шаблона нет — суффикс `[index]`. +- Тест получает `param` вторым аргументом (`default(ctx, param)`). +- В отчётах каждый набор — отдельная запись со своим `name` и `param` в `testInfo`. +- `ctx.testInfo.param` доступен в теле теста и хуках. + +--- + +## 14. Обнаружение тестов + +`testDir` (первый позиционный аргумент после URL) — каталог, в котором живут тесты. Сборщик рекурсивно обходит дерево и собирает файлы по правилам ниже. + +``` +tests/myapp/ + _hooks.mjs # пропускается (префикс '_') + _allure/ # пропускается (префикс '_') + webtest.config.mjs # пропускается (не *.test.mjs) + sales/ + 01-order-create.test.mjs + 02-order-post.test.mjs + warehouse/ + 01-receipt.test.mjs +``` + +### Правила + +| Аспект | Поведение | +|--------|-----------| +| Обход | Рекурсивный; файлы и каталоги, имя которых начинается на `_` или `.`, пропускаются | +| Шаблон имени | Только `*.test.mjs` | +| Порядок | Сортировка по полному относительному пути (`sales/01` идёт до `warehouse/01`) | +| `file` в отчёте | `relative(testDir, file)` с разделителем `/`, например `sales/01-order-create.test.mjs` | +| Фильтр по пути с CLI | `node run.mjs test tests/myapp/sales/` запустит только подкаталог | +| Конкретный файл | `node run.mjs test tests/myapp/sales/01-order-create.test.mjs` | + +### Чего НЕТ (сознательное упрощение) + +- **`_hooks.mjs` на уровне подкаталога.** Движок ищет `_hooks.mjs` только в корне `testDir`. Подкаталоги свои хуки не получают. +- **`webtest.config.mjs` на уровне подкаталога.** Тоже только в корне. +- **Многоуровневой Suite-разметки из дерева каталогов.** Allure-метка `suite` строится только по первому уровню (`dirname(filePath)`); более глубокую группировку делайте через `tags`. +- **Контекста по умолчанию на уровне подкаталога.** Каждый тест объявляет `context` / `contexts` сам; от пути контексты не наследуются. + +### Конвенции + +1. **Папки — для организации**, не для механики. Общая подготовка — в глобальном `_hooks.mjs.beforeAll` или в `setup` / `teardown` конкретного теста. +2. **Группировку в отчётах** делайте через `tags: ['sales']`, не через путь. Это даёт фильтрацию (`--tags=sales`) и работает в Allure/JUnit без дополнительной разметки. +3. **«Запустить только sales»** — двумя путями: `tests/myapp/sales/` (по каталогу) или `--tags=sales` (по тегу). +4. **Сортировка по полному пути** означает, что `warehouse/01-x` запустится ПОСЛЕ `sales/02-y`. Для строгого глобального порядка используйте 3-значные префиксы (`010-`/`020-`/…) либо явные теги-фазы. + +--- + +## 15. Ошибки и трассировка + +### Авто-обнаружение 1С-ошибок + +Все ACTION_FNS (`clickElement`, `fillFields`, `fillField`, `selectValue`, `fillTableRow`, `deleteTableRow`, `openCommand`, `navigateSection`, `navigateLink`, `openFile`, `closeForm`, `filterList`, `unfilterList`) обёрнуты. После каждого вызова: + +1. Проверяется `state.errors.modal` / `balloon`. +2. Если есть — делается скриншот (до того, как `fetchErrorStack` закроет модалку). +3. Для модальных ошибок вызывается `fetchErrorStack` (две стратегии — Path 1 для платформенных исключений с кнопкой «Открыть отчёт», Path 2 для `ВызватьИсключение` через гамбургер-меню → О программе → Информация для тех. поддержки; см. [web-test-guide.md](web-test-guide.md)). +4. Бросается исключение со структурированным `err.onecError`: + ```js + err.onecError = { + step, // имя действия (например 'clickElement') + args, // аргументы, с которыми вызывалось + errors, // { modal?, balloon? } + formState, // снапшот getFormState + stack, // { raw, entries: [{ location, code }], timestamp } | null + screenshot, // путь к скриншоту + }; + ``` + +В отчёте это превращается в `error.onecError.stack` для упавшего теста. Разбор причин падения и категории — см. §16. + +### Платформенные модальные диалоги + +`getFormState()` возвращает `platformDialogs` — массив платформенных диалогов (About, Support Info, Error Report). `closeForm()` закрывает их. `dismissPendingErrors()` чистит ожидающие модалки автоматически (вызывается перед каждым ACTION_FN, плюс в встроенном сбросе после теста). + +Модальное окно платформенной ошибки сначала рендерится в переходном состоянии (~1 с), затем перерисовывается в стабильное. `fetchErrorStack` ждёт 1.5 с и перепроверяет `hasReport` перед выбором стратегии. + +### Таймауты + +- Глобальный таймаут теста: `mod.timeout` или `config.timeout` или CLI `--timeout=ms`. +- Таймаут срабатывает на уровне теста (`testFn()` + `setup` + `teardown`), не на уровне отдельного `step` или action. +- При таймауте: текущий step помечается failed, бросается ошибка с сообщением `Timeout (ms)`, далее запускается `afterEach` и встроенный сброс. + +### Повторы + +При `--retry=N` (или `config.retries`) упавший тест повторяется до `1 + N` раз. Для каждой попытки: +- `beforeEach` / `setup` / `default` / `teardown` / `afterEach` + встроенный сброс выполняются заново. +- `ctx.testInfo.attempt` инкрементируется. +- В отчёте фиксируется `attempts` — фактически выполнено попыток. +- Считается passed, если последняя попытка зелёная; иначе failed. + +`beforeAll` / `afterAll` / `prepare` / `cleanup` / `afterOpenContext` / `beforeCloseContext` не повторяются (это жизненный цикл всего прогона или контекста, не теста). + +--- + +## 16. Анализ результатов + +### Что лежит в записи об упавшем тесте + +JSON-отчёт (`tests[]`, полная структура — §9) для каждого падения содержит: + +- `error.message` — текст исключения. +- `error.step` — имя шага, на котором упало. +- `error.screenshot` — путь к скриншоту падения (если стратегия скриншотов не `off`). +- `error.onecError` (только для 1С-исключений) — структура с полями: `step` (имя действия, например `clickElement`), `args` (аргументы вызова), `errors` (модальное окно или balloon), `formState` (снимок формы на момент ошибки), `stack` — платформенный стек вызовов 1С с `entries[{location, code}]`. +- `steps[]` — пошаговая разбивка с метками времени, у каждого шага свой `status` и `error`. + +В Allure-отчёте те же данные лежат в `statusDetails` (текст ошибки и трассировка), скриншоты и видео — во вложениях, автоматическая группировка по причинам — через `categories.json` (§9). + +### Типовые причины падений + +Большинство падений на 1С-стенде сводится к трём причинам, и их полезно различать при разборе отчёта: + +- **Ошибка в тесте** — селектор не нашёл элемент, ожидание не сошлось, гонка без точки синхронизации. Признаки: падение стабильно повторяется на одном и том же шаге; после правки теста воспроизводимость исчезает. Действие — изменить тест. +- **Ошибка в прикладном решении** — реально воспроизведённое некорректное поведение конфигурации. Признаки: упал шаг, имитирующий пользовательскую операцию; в `error.onecError.stack` есть платформенный стек вызовов 1С с указанием на код решения. Действие — передать разработчику конфигурации, тест править не нужно. +- **Сбой стенда** — таймаут Apache, форма входа не загрузилась, не хватило веб-лицензий. Признаки: падение на навигации или входе; от прогона к прогону падает «то одно, то другое», без связи с содержанием теста. Действие — править инфраструктуру (`prepare()`, очистка сессий, идемпотентность хуков), не тесты. + +`categories.json` Allure (§9) удобно настраивать именно под эти три категории — regex по `error.message` уже даёт первичную классификацию в виджете Categories. + +--- + +## 17. Глоссарий + +| Термин | Определение | +|--------|-------------| +| **testDir** | Каталог тестов, переданный позиционным аргументом движку. Корень для discovery, `_hooks.mjs`, `webtest.config.mjs`, `_allure/`. | +| **Context (BrowserContext)** | Изолированная сессия Playwright. Куки/состояние/страница независимы. В рамках одного теста используется один или несколько контекстов. | +| **Active context** | Контекст, на котором сейчас оперируют функции browser-API. Переключается `setActiveContext`. | +| **Primary context** | Контекст, активный на входе в тест. Декларация (`mod.context` или `mod.contexts[0]`). Зафиксирован в `testInfo.primaryContext`. | +| **Default context** | Контекст из `config.defaultContext` (или единственный URL в упрощённой конфигурации). Используется, если тест не указал `context` / `contexts`. | +| **Scoped API** | Объект на `ctx.` в мульти-контекстных тестах — обёртки browser-функций, авто-переключающие контекст перед каждым вызовом. | +| **Action function (ACTION_FN)** | Browser-функция, обёрнутая авто-обнаружением 1С-ошибок. Список — в §3. | +| **Step** | Логический блок внутри теста, обёрнутый `step(name, fn)`. Маппится на Allure-step, попадает в `report.tests[].steps[]`. | +| **Reset state** | Встроенная пост-тестовая очистка: `dismissPendingErrors` + закрытие всех открытых форм до рабочего стола. Выполняется после `afterEach`. | +| **hookArgs** | Массив строк, переданных в `prepare` / `cleanup` после CLI-разделителя `--`. Движком не интерпретируются. | +| **Severity** | Уровень критичности теста (`blocker / critical / normal / minor / trivial`) для Allure. Резолвится из `mod.severity`, тегов, `config.severity`, `config.defaultSeverity`. | + +--- + +## См. также + +- [web-test-guide.md](web-test-guide.md) — browser API (`clickElement`, `getFormState`, `readTable`, …) и интерактивный режим. +- [web-test-recording-guide.md](web-test-recording-guide.md) — видеозапись, captions, narration, overlays. +- [web-test-regression-guide.md](web-test-regression-guide.md) — пользовательский гайд (на русском, с быстрым стартом). +- `/web-test` skill — `.claude/skills/web-test/SKILL.md`, `regress.md` (рабочая шпаргалка для модели).