20 KiB
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:
node $RUN test [url] <dir|file> [flags]
Tests live next to the project they cover (not inside the skill). Convention: tests/ at the project root, with _hooks.mjs and webtest.config.mjs at the suite root. Tests are ES modules with *.test.mjs suffix.
When to choose test over exec
| Goal | Mode |
|---|---|
| Explore a form, prototype a single step, debug one selector | exec (interactive session) |
| 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.
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.
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 () => { ... }).
The mechanics of exec / getFormState / fillFields / clickElement are in SKILL.md — read it before recon if you haven't already.
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
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/
<app-name>/ # application regression — one per solution
_hooks.mjs
webtest.config.mjs
_allure/ # optional static Allure config
01-login/
02-counterparties/
...
<another-app>/ # second solution, fully isolated
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/<app-name>/
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
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
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('Тест '));
});
}
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.
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
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)
{
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)
{
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 onctxtop-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,testInfostay 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.
// 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
export default {
// Single-context shorthand:
url: 'http://localhost:9191/myapp/ru_RU',
// OR multi-context:
// 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', // 'every-step' | 'off'
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. Use latin context IDs + Russian displayName for ergonomics — ctx.testInfo.contexts.clerk.displayName is friendlier than mixed-case Cyrillic keys.
_hooks.mjs
Two layers. Infra hooks run without a browser; testlevel hooks receive ctx.
import { execSync } from 'child_process';
// Infra — runs once around the whole suite.
export async function prepare({ hookArgs, log, config }) {
// 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 }) { /* 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.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 in afterEach.
Pass hook args after --:
node $RUN test tests/<app-name>/ --bail -- --rebuild-stand --data=demo
└─runner─┘ └────── hookArgs ─────────┘
Where to put data 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
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
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
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['Статус']?.value, 'Утверждён');
});
await step('Освободить сессию кладовщика', async () => {
await manager.closeContext('clerk'); // free a 1C license for the next test
});
}
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
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['Контрагент']?.required,
'Должна быть ошибка валидации или поле помечено обязательным');
}
Write it red first, hand it to the user, fix the underlying issue, re-run green.
Parameterised test
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
node $RUN test tests/<app-name>/ # full app suite
node $RUN test tests/<app-name>/03-goods-receipt/ # one feature folder
node $RUN test tests/<app-name>/02-counterparties/01-create.test.mjs # one file
node $RUN test tests/<app-name>/ --tags=smoke # by tag (intersection)
node $RUN test tests/<app-name>/ --grep='накладн' # by name regex
node $RUN test tests/<app-name>/ --bail --retry=1 # stop on first fail, allow 1 retry
node $RUN test tests/<app-name>/ --report=allure-results --format=allure --report-dir=allure-results
node $RUN test tests/<app-name>/ -- --rebuild-stand # 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/
The runner copies <testDir>/_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
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)afteropenCommandis fine;wait(30)because something flakes is a bug — wait ongetFormStateinstead. - Retry as a substitute for understanding. "Not found" twice means the data isn't there or the label is wrong. Don't loop.
- 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.- 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
- Scan the JSON or Allure summary for
failed. - For each failure, read
error.message+error.step+ screenshot. - If
error.onecError.stackis present — it's a 1C exception, look at the platform trace. - 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.
- After fixes, re-run only the affected files before the full suite.
Report back to the user with the classification, not raw failure dumps.
Reference
- Browser API: SKILL.md
- Video and narration: recording.md