mirror of
https://github.com/Nikolay-Shirokov/cc-1c-skills.git
synced 2026-07-23 05:01:01 +03:00
Compare commits
4
Commits
w-2026-07-19
..
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8826a88427 | ||
|
|
9b65dccd8a | ||
|
|
6256c48c05 | ||
|
|
90d8263a05 |
@@ -129,7 +129,7 @@ Switch to an already-open tab/window (fuzzy match).
|
|||||||
|
|
||||||
### Reading form state
|
### Reading form state
|
||||||
|
|
||||||
#### `getFormState()` → `{ form, formCount, openForms, fields, buttons, tabs, navigation?, table, tables, filters, reportSettings? }`
|
#### `getFormState()` → `{ form, formCount, openForms, title, fields, buttons, tabs, navigation?, table, tables, filters, reportSettings? }`
|
||||||
Returns current form structure. This is the primary way to understand what's on screen.
|
Returns current form structure. This is the primary way to understand what's on screen.
|
||||||
|
|
||||||
**form** — active form number, or `null` when no form is open (desktop).
|
**form** — active form number, or `null` when no form is open (desktop).
|
||||||
@@ -142,6 +142,8 @@ Returns current form structure. This is the primary way to understand what's on
|
|||||||
|
|
||||||
**openTabs** — array of `{ name, active? }` from the open-windows tab bar. Only present when the tab bar is enabled in 1C settings. Do NOT rely on this — use `formCount`/`openForms` instead.
|
**openTabs** — array of `{ name, active? }` from the open-windows tab bar. Only present when the tab bar is enabled in 1C settings. Do NOT rely on this — use `formCount`/`openForms` instead.
|
||||||
|
|
||||||
|
**title** — caption of the active form (`"Контрагенты"`, `"Заказ поставщику ТД00-000052 от 05.07.2022"`). Read from the form's own header, which does not depend on the open-windows tab bar; when the form shows no header, falls back to the active tab's caption, and is `null` when neither is available.
|
||||||
|
|
||||||
**fields** — each field has: `name`, `value`, `label?`, `actions?` (select, clear, open), `required?` (true for unfilled mandatory fields), `disabled?` (control is unavailable). `buttons[]` carry `disabled?` too.
|
**fields** — each field has: `name`, `value`, `label?`, `actions?` (select, clear, open), `required?` (true for unfilled mandatory fields), `disabled?` (control is unavailable). `buttons[]` carry `disabled?` too.
|
||||||
|
|
||||||
**navigation** — form navigation panel links (for objects with subordinate catalogs): `[{ name, active? }]`. Clickable via `clickElement()`. Only present when the form has a navigation panel (e.g. "Основное", "Объекты метаданных", "Подсистемы").
|
**navigation** — form navigation panel links (for objects with subordinate catalogs): `[{ name, active? }]`. Clickable via `clickElement()`. Only present when the form has a navigation panel (e.g. "Основное", "Объекты метаданных", "Подсистемы").
|
||||||
@@ -170,7 +172,7 @@ const form = await getFormState();
|
|||||||
|
|
||||||
**confirmation** — if present, a Yes/No dialog is shown. Call `clickElement('Да')` or `clickElement('Нет')`.
|
**confirmation** — if present, a Yes/No dialog is shown. Call `clickElement('Да')` or `clickElement('Нет')`.
|
||||||
|
|
||||||
**errors.stateText** — array of SpreadsheetDocument state messages (e.g. `"Не установлено значение параметра \"X\""`, `"Отчет не сформирован..."`, `"Изменились настройки..."`). Present when the report area shows an info bar instead of data.
|
**errors.stateText** — array of SpreadsheetDocument state messages (e.g. `"Не установлено значение параметра \"X\""`, `"Отчет не сформирован..."`, `"Изменились настройки..."`). Present when the report area shows an info bar instead of data. The same info bar carries `"Поиск..."` while a list is still searching — actions do not return while it is up, so a filtered list never hands you the previous rows.
|
||||||
|
|
||||||
### Reading data
|
### Reading data
|
||||||
|
|
||||||
|
|||||||
@@ -10,6 +10,8 @@ node $RUN test <dir|file>... [flags]
|
|||||||
|
|
||||||
Positional args are test paths (files and/or dirs, multiple allowed). URL is NOT positional — it comes from `webtest.config.mjs`; override with `--url=<url>`.
|
Positional args are test paths (files and/or dirs, multiple allowed). URL is NOT positional — it comes from `webtest.config.mjs`; override with `--url=<url>`.
|
||||||
|
|
||||||
|
`webtest.config.mjs` and `_hooks.mjs` always come from the suite root, whatever path you pass: `test tests/myapp/sales/` and `test tests/myapp/sales/01-order.test.mjs` both run under the config and hooks of `tests/myapp/`, no `--url=` needed. Paths from two different suites in one run are refused — pass one suite and narrow with `--grep=` / `--tags=`.
|
||||||
|
|
||||||
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.
|
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`
|
## When to choose `test` over `exec`
|
||||||
@@ -69,7 +71,7 @@ tests/<app-name>/
|
|||||||
01-end-to-end.test.mjs # multi-user
|
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, whichever subfolder you point the runner at.
|
||||||
|
|
||||||
## Test file anatomy
|
## Test file anatomy
|
||||||
|
|
||||||
@@ -184,8 +186,8 @@ assert.match(string, regex, msg?) // regex.test(string)
|
|||||||
await assert.throws(asyncFn, msg?) // passes if fn throws (use await)
|
await assert.throws(asyncFn, msg?) // passes if fn throws (use await)
|
||||||
|
|
||||||
// 1C-specific — operate on getFormState() / readTable() output
|
// 1C-specific — operate on getFormState() / readTable() output
|
||||||
assert.formHasField(state, 'Контрагент', msg?) // state.fields[name] exists
|
assert.formHasField(state, 'Контрагент', msg?) // fields[] contains a field with that name
|
||||||
assert.formTitle(state, expected, msg?) // state.title includes expected
|
assert.formTitle(state, expected, msg?) // state.title includes expected (null title → fails saying so)
|
||||||
assert.tableHasRow(table, predicate, msg?) // predicate: object (partial match) or fn(row) => bool
|
assert.tableHasRow(table, predicate, msg?) // predicate: object (partial match) or fn(row) => bool
|
||||||
// object form: { 'Наименование': 'Тест' }
|
// object form: { 'Наименование': 'Тест' }
|
||||||
// fn form: r => r['Сумма'] > 100
|
// fn form: r => r['Сумма'] > 100
|
||||||
@@ -316,7 +318,7 @@ export default async function({ clerk, manager, step, assert }) {
|
|||||||
});
|
});
|
||||||
await step('Кладовщик видит новый статус', async () => {
|
await step('Кладовщик видит новый статус', async () => {
|
||||||
const s = await clerk.getFormState();
|
const s = await clerk.getFormState();
|
||||||
assert.equal(s.fields['Статус']?.value, 'Утверждён');
|
assert.equal(s.fields.find(f => f.name === 'Статус')?.value, 'Утверждён');
|
||||||
});
|
});
|
||||||
await step('Освободить сессию кладовщика', async () => {
|
await step('Освободить сессию кладовщика', async () => {
|
||||||
await manager.closeContext('clerk'); // free a 1C license for the next test
|
await manager.closeContext('clerk'); // free a 1C license for the next test
|
||||||
@@ -339,7 +341,7 @@ export default async function({ openCommand, clickElement, getFormState, assert,
|
|||||||
await clickElement('Создать');
|
await clickElement('Создать');
|
||||||
await clickElement('Провести');
|
await clickElement('Провести');
|
||||||
const s = await getFormState();
|
const s = await getFormState();
|
||||||
assert.ok(s.errorModal || s.fields['Контрагент']?.required,
|
assert.ok(s.errorModal || s.fields.find(f => f.name === 'Контрагент')?.required,
|
||||||
'Должна быть ошибка валидации или поле помечено обязательным');
|
'Должна быть ошибка валидации или поле помечено обязательным');
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
@@ -359,7 +361,7 @@ export const params = [
|
|||||||
export default async function({ fillFields, getFormState, assert }, { type, field, value }) {
|
export default async function({ fillFields, getFormState, assert }, { type, field, value }) {
|
||||||
await fillFields({ [field]: value });
|
await fillFields({ [field]: value });
|
||||||
const state = await getFormState();
|
const state = await getFormState();
|
||||||
assert.equal(state.fields[field]?.value, String(value));
|
assert.equal(state.fields.find(f => f.name === field)?.value, String(value));
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
// web-test cli/commands/test v1.8 — regression test runner
|
// web-test cli/commands/test v1.9 — regression test runner
|
||||||
// Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
|
// Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
|
||||||
import { existsSync, writeFileSync, mkdirSync, renameSync, copyFileSync, unlinkSync } from 'fs';
|
import { existsSync, writeFileSync, mkdirSync, renameSync, copyFileSync, unlinkSync } from 'fs';
|
||||||
import { resolve, dirname, basename, relative } from 'path';
|
import { resolve, dirname, basename, relative } from 'path';
|
||||||
@@ -9,6 +9,7 @@ import { createAssertions } from '../test-runner/assertions.mjs';
|
|||||||
import { buildSeverityIndex } from '../test-runner/severity.mjs';
|
import { buildSeverityIndex } from '../test-runner/severity.mjs';
|
||||||
import { writeAllure, buildJUnit, syncAllureExtras } from '../test-runner/reporters.mjs';
|
import { writeAllure, buildJUnit, syncAllureExtras } from '../test-runner/reporters.mjs';
|
||||||
import { discoverTests, resetState } from '../test-runner/discover.mjs';
|
import { discoverTests, resetState } from '../test-runner/discover.mjs';
|
||||||
|
import { findSuiteRoot, startDirOf } from '../test-runner/suite-root.mjs';
|
||||||
import { planEviction, touchLru, dropLru } from '../test-runner/context-pool.mjs';
|
import { planEviction, touchLru, dropLru } from '../test-runner/context-pool.mjs';
|
||||||
|
|
||||||
export async function cmdTest(rawArgs) {
|
export async function cmdTest(rawArgs) {
|
||||||
@@ -78,12 +79,24 @@ export async function cmdTest(rawArgs) {
|
|||||||
die(`Test path not found: "${p}". To run a subset use --grep= / --tags=, or pass an existing dir/file.`);
|
die(`Test path not found: "${p}". To run a subset use --grep= / --tags=, or pass an existing dir/file.`);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Load config if exists. config (webtest.config.mjs) and hooks (_hooks.mjs) resolve from
|
// Suite root — the directory `webtest.config.mjs`, `_hooks.mjs`, `_allure/` and report paths
|
||||||
// the FIRST path's directory — list paths from the same suite folder.
|
// all hang off. It is NOT the passed path: walking up to the nearest marker is what makes
|
||||||
const firstPath = resolve(testPaths[0]);
|
// `test tests/myapp/sales/` work, as docs/web-test-regression-spec.md has always promised.
|
||||||
const isFile = firstPath.endsWith('.test.mjs');
|
// Resolving from the passed path instead lost the hooks of any subfolder run — silently, so
|
||||||
const testDir = isFile ? dirname(firstPath) : firstPath;
|
// the run went ahead against an unprepared stand.
|
||||||
const configPath = resolve(testDir, 'webtest.config.mjs');
|
const startDirs = testPaths.map(p => startDirOf(p));
|
||||||
|
const roots = startDirs.map(d => findSuiteRoot(d));
|
||||||
|
// Paths from different suites must not share hooks — that used to resolve to "first path
|
||||||
|
// wins", silently running suite B's tests under suite A's preparation.
|
||||||
|
const distinct = [...new Set(roots.map(r => r?.root ?? null))];
|
||||||
|
if (distinct.length > 1) {
|
||||||
|
const lines = testPaths.map((p, i) => ` ${p} → ${roots[i]?.root ?? '(корень не найден)'}`);
|
||||||
|
die(`Paths belong to different suites — config and hooks would be ambiguous:\n${lines.join('\n')}\n` +
|
||||||
|
`Run them separately, or pass one suite root and narrow with --grep= / --tags=.`);
|
||||||
|
}
|
||||||
|
const suiteRoot = roots[0]?.root ?? startDirs[0];
|
||||||
|
const suiteRootFound = !!roots[0];
|
||||||
|
const configPath = resolve(suiteRoot, 'webtest.config.mjs');
|
||||||
let config = {};
|
let config = {};
|
||||||
if (existsSync(configPath)) {
|
if (existsSync(configPath)) {
|
||||||
const mod = await import('file:///' + configPath.replace(/\\/g, '/'));
|
const mod = await import('file:///' + configPath.replace(/\\/g, '/'));
|
||||||
@@ -103,7 +116,16 @@ export async function cmdTest(rawArgs) {
|
|||||||
if (url) contextSpecs[defaultContextName] = { ...contextSpecs[defaultContextName], url };
|
if (url) contextSpecs[defaultContextName] = { ...contextSpecs[defaultContextName], url };
|
||||||
} else {
|
} else {
|
||||||
const fallbackUrl = url || config.url;
|
const fallbackUrl = url || config.url;
|
||||||
if (!fallbackUrl) die('No URL provided and no webtest.config.mjs found');
|
// Name the real problem: with no suite root there is no config to take a URL from — and,
|
||||||
|
// more dangerously, no `_hooks.mjs` either. The old wording talked only about the URL and
|
||||||
|
// sent readers looking in the wrong place.
|
||||||
|
if (!fallbackUrl) {
|
||||||
|
die(suiteRootFound
|
||||||
|
? `No URL: ${configPath} defines neither "contexts" nor "url", and --url= was not given.`
|
||||||
|
: `Suite root not found above "${testPaths[0]}" — no webtest.config.mjs / _hooks.mjs up to ` +
|
||||||
|
`the repository (or working) directory, so there is no URL and no stand preparation.\n` +
|
||||||
|
`Pass the suite root (e.g. tests/myapp/) and narrow with --grep= / --tags=, or give --url=.`);
|
||||||
|
}
|
||||||
contextSpecs.default = { url: fallbackUrl };
|
contextSpecs.default = { url: fallbackUrl };
|
||||||
}
|
}
|
||||||
if (!contextSpecs[defaultContextName]) {
|
if (!contextSpecs[defaultContextName]) {
|
||||||
@@ -175,7 +197,7 @@ export async function cmdTest(rawArgs) {
|
|||||||
}
|
}
|
||||||
const reportDir = opts.reportDir
|
const reportDir = opts.reportDir
|
||||||
? resolve(opts.reportDir)
|
? resolve(opts.reportDir)
|
||||||
: (opts.report && !reportToStdout ? dirname(resolve(opts.report)) : testDir);
|
: (opts.report && !reportToStdout ? dirname(resolve(opts.report)) : suiteRoot);
|
||||||
if (opts.screenshot !== 'off') {
|
if (opts.screenshot !== 'off') {
|
||||||
try { mkdirSync(reportDir, { recursive: true }); } catch {}
|
try { mkdirSync(reportDir, { recursive: true }); } catch {}
|
||||||
// 1C-error screenshots (taken inside the action wrapper) default to a single
|
// 1C-error screenshots (taken inside the action wrapper) default to a single
|
||||||
@@ -194,7 +216,10 @@ export async function cmdTest(rawArgs) {
|
|||||||
for (const file of testFiles) {
|
for (const file of testFiles) {
|
||||||
const mod = await import('file:///' + file.replace(/\\/g, '/'));
|
const mod = await import('file:///' + file.replace(/\\/g, '/'));
|
||||||
const base = {
|
const base = {
|
||||||
file: relative(testDir, file).replace(/\\/g, '/'),
|
// Relative to the SUITE ROOT, not to the passed path — otherwise the same test gets a
|
||||||
|
// different id depending on how it was launched (`sales/01-x.test.mjs` vs `01-x.test.mjs`),
|
||||||
|
// and Allure history / JUnit trends treat the two as unrelated tests.
|
||||||
|
file: relative(suiteRoot, file).replace(/\\/g, '/'),
|
||||||
name: mod.name || basename(file, '.test.mjs'),
|
name: mod.name || basename(file, '.test.mjs'),
|
||||||
tags: mod.tags || [],
|
tags: mod.tags || [],
|
||||||
timeout: mod.timeout || opts.timeout,
|
timeout: mod.timeout || opts.timeout,
|
||||||
@@ -229,7 +254,7 @@ export async function cmdTest(rawArgs) {
|
|||||||
});
|
});
|
||||||
|
|
||||||
// Load hooks
|
// Load hooks
|
||||||
const hooksPath = resolve(testDir, '_hooks.mjs');
|
const hooksPath = resolve(suiteRoot, '_hooks.mjs');
|
||||||
let hooks = {};
|
let hooks = {};
|
||||||
if (existsSync(hooksPath)) {
|
if (existsSync(hooksPath)) {
|
||||||
hooks = await import('file:///' + hooksPath.replace(/\\/g, '/'));
|
hooks = await import('file:///' + hooksPath.replace(/\\/g, '/'));
|
||||||
@@ -239,7 +264,17 @@ export async function cmdTest(rawArgs) {
|
|||||||
// In `--report -` mode the machine JSON/XML takes over stdout, so progress moves to stderr.
|
// In `--report -` mode the machine JSON/XML takes over stdout, so progress moves to stderr.
|
||||||
const W = reportToStdout ? process.stderr : process.stdout;
|
const W = reportToStdout ? process.stderr : process.stdout;
|
||||||
W.write(`\nweb-test -- ${url}\n`);
|
W.write(`\nweb-test -- ${url}\n`);
|
||||||
W.write(`Running ${filtered.length} tests from ${relative(process.cwd(), testDir).replace(/\\/g, '/') || '.'}/\n\n`);
|
// Always name the resolved suite root: a climb that landed on the wrong directory is then
|
||||||
|
// visible in the first line of output instead of being diagnosed from symptoms later.
|
||||||
|
const rel = (p) => relative(process.cwd(), p).replace(/\\/g, '/') || '.';
|
||||||
|
const shownPaths = testPaths.map(p => rel(resolve(p))).filter(p => p !== rel(suiteRoot));
|
||||||
|
W.write(`Running ${filtered.length} tests from ${rel(suiteRoot)}/`);
|
||||||
|
W.write(shownPaths.length ? ` (paths: ${shownPaths.join(', ')})\n\n` : `\n\n`);
|
||||||
|
if (!suiteRootFound) {
|
||||||
|
// Not fatal — a one-off test outside any suite is legitimate. But a missing suite root also
|
||||||
|
// means no `_hooks.mjs` was even looked for above, so the stand is whatever it was.
|
||||||
|
process.stderr.write(`! no suite root (webtest.config.mjs / _hooks.mjs) found above ${rel(startDirs[0])} — running without hooks\n`);
|
||||||
|
}
|
||||||
|
|
||||||
const startedAt = new Date().toISOString();
|
const startedAt = new Date().toISOString();
|
||||||
const results = [];
|
const results = [];
|
||||||
@@ -819,10 +854,10 @@ export async function cmdTest(rawArgs) {
|
|||||||
if (opts.format === 'allure') {
|
if (opts.format === 'allure') {
|
||||||
// Guard against a result-producing path that skipped recordResult; normally a no-op.
|
// Guard against a result-producing path that skipped recordResult; normally a no-op.
|
||||||
if (!allureWritten) writeAllure(results, reportDir, severityIndex);
|
if (!allureWritten) writeAllure(results, reportDir, severityIndex);
|
||||||
syncAllureExtras(testDir, reportDir);
|
syncAllureExtras(suiteRoot, reportDir);
|
||||||
} else if (opts.format === 'junit') {
|
} else if (opts.format === 'junit') {
|
||||||
if (reportToStdout) process.stdout.write(buildJUnit(report, testDir) + '\n');
|
if (reportToStdout) process.stdout.write(buildJUnit(report, suiteRoot) + '\n');
|
||||||
else writeFileSync(resolve(opts.report), buildJUnit(report, testDir));
|
else writeFileSync(resolve(opts.report), buildJUnit(report, suiteRoot));
|
||||||
} else if (reportToStdout) {
|
} else if (reportToStdout) {
|
||||||
out(report);
|
out(report);
|
||||||
} else if (opts.report) {
|
} else if (opts.report) {
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
// web-test cli/test-runner/assertions v1.0 — ctx.assert API
|
// web-test cli/test-runner/assertions v1.1 — ctx.assert API
|
||||||
// Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
|
// Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
|
||||||
|
|
||||||
export function createAssertions() {
|
export function createAssertions() {
|
||||||
@@ -37,11 +37,23 @@ export function createAssertions() {
|
|||||||
throw new AssertionError(msg || 'Expected function to throw');
|
throw new AssertionError(msg || 'Expected function to throw');
|
||||||
},
|
},
|
||||||
// 1C-specific
|
// 1C-specific
|
||||||
|
// `fields` is an ARRAY of { name, value, ... } — indexing it by field name yields undefined,
|
||||||
|
// which used to make this assertion throw on every call, including the valid ones.
|
||||||
formHasField(state, fieldName, msg) {
|
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);
|
const names = (state?.fields || []).map(f => f.name);
|
||||||
|
if (!names.includes(fieldName)) {
|
||||||
|
throw new AssertionError(msg || `Field "${fieldName}" not found in form. Available: ${names.join(', ')}`, null, fieldName);
|
||||||
|
}
|
||||||
},
|
},
|
||||||
formTitle(state, expected, msg) {
|
formTitle(state, expected, msg) {
|
||||||
if (!state?.title?.includes(expected)) throw new AssertionError(msg || `Form title "${state?.title}" does not contain "${expected}"`, state?.title, expected);
|
// `title` is null when the form exposes no caption and the open-windows panel is off —
|
||||||
|
// say so instead of reporting a mismatch against "null".
|
||||||
|
if (state?.title == null) {
|
||||||
|
throw new AssertionError(msg || `Form title is not available (state.title is null), expected it to contain "${expected}"`, null, expected);
|
||||||
|
}
|
||||||
|
if (!state.title.includes(expected)) {
|
||||||
|
throw new AssertionError(msg || `Form title "${state.title}" does not contain "${expected}"`, state.title, expected);
|
||||||
|
}
|
||||||
},
|
},
|
||||||
tableHasRow(table, predicate, msg) {
|
tableHasRow(table, predicate, msg) {
|
||||||
const rows = table?.rows || [];
|
const rows = table?.rows || [];
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
// web-test cli/test-runner/discover v1.3 — test file discovery + state reset between tests
|
// web-test cli/test-runner/discover v1.4 — test file discovery + state reset between tests
|
||||||
// Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
|
// Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
|
||||||
import { existsSync, readdirSync } from 'fs';
|
import { existsSync, readdirSync } from 'fs';
|
||||||
import { resolve } from 'path';
|
import { resolve } from 'path';
|
||||||
@@ -73,7 +73,9 @@ export async function resetState(ctx) {
|
|||||||
return {
|
return {
|
||||||
clean: false, attempts, lastError,
|
clean: false, attempts, lastError,
|
||||||
form: state.form,
|
form: state.form,
|
||||||
title: state.activeTab || null,
|
// state.title is the form's own caption; activeTab reads the open-windows panel, which the
|
||||||
|
// user can switch off — keep it only as the fallback it always was.
|
||||||
|
title: state.title || state.activeTab || null,
|
||||||
modal: !!state.modal,
|
modal: !!state.modal,
|
||||||
};
|
};
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
|
|||||||
@@ -0,0 +1,55 @@
|
|||||||
|
// web-test cli/test-runner/suite-root v1.0 — locate the suite root above a given test path
|
||||||
|
// Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
|
||||||
|
import { existsSync, statSync } from 'fs';
|
||||||
|
import { resolve, dirname } from 'path';
|
||||||
|
|
||||||
|
// Files that MARK a suite root. Both count, not just the config: `webtest.config.mjs` is
|
||||||
|
// optional (a single-URL suite may pass --url= instead), and a suite that ships only
|
||||||
|
// `_hooks.mjs` must still be found — otherwise its stand preparation is silently skipped,
|
||||||
|
// which is worse than any URL error.
|
||||||
|
const MARKERS = ['webtest.config.mjs', '_hooks.mjs'];
|
||||||
|
|
||||||
|
// Files that BOUND the climb. A boundary never selects a root — it only stops the search,
|
||||||
|
// so a wrong boundary degrades to "root not found" (= the pre-v1.9 behaviour plus a clear
|
||||||
|
// message) and can never produce a wrong root. `package.json` is deliberately absent: it
|
||||||
|
// occurs nested and would stop the climb below a legitimate suite root.
|
||||||
|
const BOUNDARIES = ['.git', '.v8-project.json'];
|
||||||
|
|
||||||
|
const isDir = (p) => { try { return statSync(p).isDirectory(); } catch { return false; } };
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Walk up from `startPath` looking for a suite root.
|
||||||
|
*
|
||||||
|
* @param {string} startPath A test file or directory (absolute or cwd-relative).
|
||||||
|
* @param {{cwd?: string}} [opts]
|
||||||
|
* @returns {{root: string, marker: string} | null} null when no marker was found within bounds.
|
||||||
|
*
|
||||||
|
* Stops after examining the first directory that contains `.git` / `.v8-project.json`
|
||||||
|
* (that directory IS examined for markers), or — when neither is met — after examining `cwd`.
|
||||||
|
* A path outside `cwd` degenerates to the filesystem root; the marker requirement still
|
||||||
|
* makes a wrong hit unlikely, and the resolved root is printed in the run banner.
|
||||||
|
*/
|
||||||
|
export function findSuiteRoot(startPath, { cwd = process.cwd() } = {}) {
|
||||||
|
const full = resolve(startPath);
|
||||||
|
let dir = isDir(full) ? full : dirname(full);
|
||||||
|
const cwdAbs = resolve(cwd);
|
||||||
|
|
||||||
|
while (true) {
|
||||||
|
for (const m of MARKERS) {
|
||||||
|
if (existsSync(resolve(dir, m))) return { root: dir, marker: m };
|
||||||
|
}
|
||||||
|
const atBoundary = BOUNDARIES.some(b => existsSync(resolve(dir, b))) || dir === cwdAbs;
|
||||||
|
const parent = dirname(dir);
|
||||||
|
if (atBoundary || parent === dir) return null;
|
||||||
|
dir = parent;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The directory a path contributes to root resolution — its own dir for a file, itself for
|
||||||
|
* a directory. Also the fallback root when no marker is found (pre-v1.9 behaviour).
|
||||||
|
*/
|
||||||
|
export function startDirOf(testPath) {
|
||||||
|
const full = resolve(testPath);
|
||||||
|
return isDir(full) ? full : dirname(full);
|
||||||
|
}
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
// web-test dom/form-state v1.0 — combined detectForm + readForm + open tabs
|
// web-test dom/form-state v1.1 — combined detectForm + readForm + open tabs + form caption
|
||||||
// Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
|
// Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
|
||||||
import { DETECT_FORM_FN, DETECT_FORMS_FN, READ_FORM_FN } from './_shared.mjs';
|
import { DETECT_FORM_FN, DETECT_FORMS_FN, READ_FORM_FN } from './_shared.mjs';
|
||||||
|
|
||||||
@@ -26,7 +26,27 @@ export function getFormStateScript() {
|
|||||||
openTabs.push(entry);
|
openTabs.push(entry);
|
||||||
});
|
});
|
||||||
const activeTab = openTabs.find(t => t.active)?.name || null;
|
const activeTab = openTabs.find(t => t.active)?.name || null;
|
||||||
const result = { form: formNum, activeTab, openForms: meta.allForms, formCount: meta.formCount, ...formData };
|
// Caption of the ACTIVE form. Lives in an attribute, not in text — the div itself is empty:
|
||||||
|
// <div class="toplineBox" data-title="Контрагенты">
|
||||||
|
// <div id="VW_page1headerTopLine_title" class="toplineBoxTitle" title="Контрагенты"></div>
|
||||||
|
// Header numbering (VW_page<M>) does not match form numbering (form<N>), so the header cannot
|
||||||
|
// be picked by form number. Several headers can be visible at once — with a selection form up,
|
||||||
|
// BOTH the parent form's header and the pop-up's are visible — so "first visible" would report
|
||||||
|
// the parent's caption for the pop-up: a plausible, wrong answer.
|
||||||
|
// Priority is therefore the one already measured for the close cross (dom/forms.mjs
|
||||||
|
// closeCrossScript): floating window (ps<N>, highest index = topmost) → the form's own header →
|
||||||
|
// and only then the open-windows tab, which the user can switch off in 1C settings.
|
||||||
|
// Anchored on ids, not on the visible text, so a non-Russian locale keeps working.
|
||||||
|
const heads = [...document.querySelectorAll('[id*="headerTopLine_title"]')]
|
||||||
|
.filter(e => e.offsetWidth > 0 && e.offsetHeight > 0);
|
||||||
|
const floating = heads.filter(e => /ps\\d+headerTopLine_title$/.test(e.id));
|
||||||
|
const own = heads.filter(e => /^VW_page\\d+headerTopLine_title$/.test(e.id));
|
||||||
|
const head = floating.pop() || own.pop() || null;
|
||||||
|
let title = head
|
||||||
|
? (head.getAttribute('title') || head.parentElement?.getAttribute('data-title') || null)
|
||||||
|
: null;
|
||||||
|
if (!title) title = activeTab;
|
||||||
|
const result = { form: formNum, activeTab, title, openForms: meta.allForms, formCount: meta.formCount, ...formData };
|
||||||
if (meta.modal) result.modal = true;
|
if (meta.modal) result.modal = true;
|
||||||
if (openTabs.length) result.openTabs = openTabs;
|
if (openTabs.length) result.openTabs = openTabs;
|
||||||
return result;
|
return result;
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
// web-test core/state v1.17 — module-level state for the web-test engine.
|
// web-test core/state v1.18 — module-level state for the web-test engine.
|
||||||
// Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
|
// Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
|
||||||
//
|
//
|
||||||
// Holds the single browser/page/recorder slot plus the multi-context registry,
|
// Holds the single browser/page/recorder slot plus the multi-context registry,
|
||||||
@@ -80,6 +80,11 @@ export const ACTION_WAIT = 2000; // fallback minimum wait
|
|||||||
export const MAX_WAIT = 10000; // max wait for stability
|
export const MAX_WAIT = 10000; // max wait for stability
|
||||||
export const POLL_INTERVAL = 200; // polling interval
|
export const POLL_INTERVAL = 200; // polling interval
|
||||||
export const STABLE_CYCLES = 3; // consecutive stable cycles needed
|
export const STABLE_CYCLES = 3; // consecutive stable cycles needed
|
||||||
|
// Ceiling for the case where 1C is VISIBLY still working (its "Поиск…" state window is up).
|
||||||
|
// Higher than MAX_WAIT on purpose: a search on a production-sized list legitimately runs for
|
||||||
|
// tens of seconds, and returning mid-search hands the caller the previous rows — a wrong
|
||||||
|
// result that looks like a right one. Bounded so a wedged operation still ends the wait.
|
||||||
|
export const BUSY_MAX_WAIT = 60000;
|
||||||
|
|
||||||
// 1C browser extension ID (stable across versions, defined by key in manifest.json)
|
// 1C browser extension ID (stable across versions, defined by key in manifest.json)
|
||||||
export const EXT_ID = 'pbhelknnhilelbnhfpcjlcabhmfangik';
|
export const EXT_ID = 'pbhelknnhilelbnhfpcjlcabhmfangik';
|
||||||
|
|||||||
@@ -1,30 +1,49 @@
|
|||||||
// web-test core/wait v1.17 — Smart wait helpers: DOM stability polling, JS-expression polling, CDP network monitor.
|
// web-test core/wait v1.18 — Smart wait helpers: DOM stability polling, JS-expression polling, CDP network monitor.
|
||||||
// Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
|
// Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
|
||||||
|
|
||||||
import { page, MAX_WAIT, POLL_INTERVAL, STABLE_CYCLES } from './state.mjs';
|
import { page, MAX_WAIT, BUSY_MAX_WAIT, POLL_INTERVAL, STABLE_CYCLES } from './state.mjs';
|
||||||
import { detectFormScript } from '../../dom.mjs';
|
import { detectFormScript } from '../../dom.mjs';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Smart wait: poll until DOM is stable and no loading indicators are visible.
|
* Smart wait: poll until DOM is stable and no loading indicators are visible.
|
||||||
* Checks: form number change, loading indicators, DOM stability.
|
* Checks: form number change, loading indicators, busy state window, DOM stability.
|
||||||
* @param {number|null} previousFormNum — form number before the action (null = don't check)
|
* @param {number|null} previousFormNum — form number before the action (null = don't check)
|
||||||
*/
|
*/
|
||||||
export async function waitForStable(previousFormNum = null) {
|
export async function waitForStable(previousFormNum = null) {
|
||||||
let stableCount = 0;
|
let stableCount = 0;
|
||||||
let lastSnapshot = '';
|
let lastSnapshot = '';
|
||||||
const start = Date.now();
|
const start = Date.now();
|
||||||
|
let deadline = start + MAX_WAIT;
|
||||||
|
|
||||||
while (Date.now() - start < MAX_WAIT) {
|
while (Date.now() < deadline) {
|
||||||
await page.waitForTimeout(POLL_INTERVAL);
|
await page.waitForTimeout(POLL_INTERVAL);
|
||||||
|
|
||||||
// Check for loading indicators
|
// Check for loading indicators
|
||||||
const status = await page.evaluate(`(() => {
|
const status = await page.evaluate(`(() => {
|
||||||
const loading = document.querySelector('.loadingImage, .waitCurtain, .progressBar');
|
const loading = document.querySelector('.loadingImage, .waitCurtain, .progressBar');
|
||||||
const isLoading = loading && loading.offsetWidth > 0;
|
const isLoading = loading && loading.offsetWidth > 0;
|
||||||
|
// While a dynamic list is still searching, 1C floats a state window over the grid
|
||||||
|
// ("Поиск…") — and NOTHING else in the DOM says so: the old rows stay put, the element
|
||||||
|
// counters below don't move, so the page looks perfectly stable with stale data.
|
||||||
|
// Match busy markers by text, never "state window present": the same carrier also holds
|
||||||
|
// TERMINAL report messages ("Отчет не сформирован", "Не установлено значение параметра"),
|
||||||
|
// and waiting for those to disappear would hang until the timeout on every report.
|
||||||
|
const busy = [...document.querySelectorAll('.stateWindowSupportSurface')].some(el =>
|
||||||
|
el.offsetWidth > 0 && /^\\s*(Поиск|Ожид|Searching|Please wait)/i.test(el.innerText || ''));
|
||||||
const formCount = document.querySelectorAll('input.editInput[id], a.press[id]').length;
|
const formCount = document.querySelectorAll('input.editInput[id], a.press[id]').length;
|
||||||
return { isLoading, formCount };
|
return { isLoading, busy, formCount };
|
||||||
})()`);
|
})()`);
|
||||||
|
|
||||||
|
// A visible busy indicator outranks DOM stability — it is the only evidence we get that the
|
||||||
|
// server is still working. Push the deadline while it lasts (bounded by BUSY_MAX_WAIT) instead
|
||||||
|
// of reporting "stable": returning mid-search is what handed a caller the previous rows and
|
||||||
|
// let the next click open the wrong document.
|
||||||
|
if (status.busy) {
|
||||||
|
deadline = Math.min(Date.now() + MAX_WAIT, start + BUSY_MAX_WAIT);
|
||||||
|
stableCount = 0;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
if (status.isLoading) {
|
if (status.isLoading) {
|
||||||
stableCount = 0;
|
stableCount = 0;
|
||||||
continue;
|
continue;
|
||||||
|
|||||||
@@ -32,6 +32,9 @@ __pycache__/
|
|||||||
.claude/skills/web-test/scripts/node_modules/
|
.claude/skills/web-test/scripts/node_modules/
|
||||||
.claude/skills/web-test/.browser-session.json
|
.claude/skills/web-test/.browser-session.json
|
||||||
|
|
||||||
|
# Маркер отработавшего prepare() в фикстуре _suite-root
|
||||||
|
tests/web-test/_suite-root/prepare-ran.txt
|
||||||
|
|
||||||
# Скриншоты и видео (артефакты тестирования web-test)
|
# Скриншоты и видео (артефакты тестирования web-test)
|
||||||
*.png
|
*.png
|
||||||
*.mp4
|
*.mp4
|
||||||
|
|||||||
@@ -254,6 +254,15 @@ export default async function({
|
|||||||
23 passed, 1 failed, 0 skipped (3m 42s)
|
23 passed, 1 failed, 0 skipped (3m 42s)
|
||||||
```
|
```
|
||||||
|
|
||||||
|
### Прогон части набора
|
||||||
|
|
||||||
|
```
|
||||||
|
> Прогони только сценарии из папки 03-приходные-накладные
|
||||||
|
> Прогони только тесты с тегом контрагенты
|
||||||
|
```
|
||||||
|
|
||||||
|
Подмножество выбирается тремя способами: путём к подпапке или отдельному файлу, тегом (`--tags=`) или фильтром по имени теста (`--grep=`). Путь к подпапке работает наравне с остальными — конфиг и подготовка стенда всё равно берутся из корня набора (папки приложения в `tests/`), движок находит его сам, поднимаясь вверх. Отдельного URL или флагов для этого не нужно.
|
||||||
|
|
||||||
### Подробный отчёт
|
### Подробный отчёт
|
||||||
|
|
||||||
```
|
```
|
||||||
|
|||||||
@@ -29,12 +29,22 @@ node run.mjs test <dir|file>... [флаги]
|
|||||||
| `--report=path` | (нет) | Записать машинный отчёт в файл (JSON или XML для `--format=junit`) |
|
| `--report=path` | (нет) | Записать машинный отчёт в файл (JSON или XML для `--format=junit`) |
|
||||||
| `--report=-` | (нет) | Машинный отчёт в stdout (`-` = stdout); человеческий прогресс уходит в stderr |
|
| `--report=-` | (нет) | Машинный отчёт в stdout (`-` = stdout); человеческий прогресс уходит в stderr |
|
||||||
| `--format=fmt` | json | Формат отчёта: `json` / `allure` / `junit` |
|
| `--format=fmt` | json | Формат отчёта: `json` / `allure` / `junit` |
|
||||||
| `--report-dir=path` | dirname(report) / testDir | Каталог для скриншотов, видео, Allure-результатов |
|
| `--report-dir=path` | dirname(report) / корень сьюта | Каталог для скриншотов, видео, Allure-результатов |
|
||||||
| `--screenshot=strategy` | on-failure | `on-failure` / `every-step` / `off` |
|
| `--screenshot=strategy` | on-failure | `on-failure` / `every-step` / `off` |
|
||||||
| `--record` | false | Записывать видео для каждого теста (mp4 в `--report-dir`) |
|
| `--record` | false | Записывать видео для каждого теста (mp4 в `--report-dir`) |
|
||||||
| `-- <hookArgs…>` | — | Всё после `--` пробрасывается в `_hooks.mjs` как `hookArgs` (см. §6.1) |
|
| `-- <hookArgs…>` | — | Всё после `--` пробрасывается в `_hooks.mjs` как `hookArgs` (см. §6.1) |
|
||||||
|
|
||||||
URL не передаётся позиционно — он берётся из `webtest.config.mjs` в каталоге тестов, а флаг `--url=` переопределяет URL дефолтного контекста. `webtest.config.mjs` и `_hooks.mjs` резолвятся от каталога **первого** пути, поэтому перечисляемые файлы должны лежать в одной папке сьюта.
|
URL не передаётся позиционно — он берётся из `webtest.config.mjs`, а флаг `--url=` переопределяет URL дефолтного контекста.
|
||||||
|
|
||||||
|
### Резолв корня сьюта
|
||||||
|
|
||||||
|
`webtest.config.mjs` и `_hooks.mjs` резолвятся не от переданного пути, а от **корня сьюта**: от каталога пути движок поднимается вверх до первого каталога, где лежит `webtest.config.mjs` или `_hooks.mjs`. Именно поэтому запуск подкаталога (`test tests/myapp/sales/`) и отдельного файла работает без `--url=`. Подъём ограничен каталогом с `.git` или `.v8-project.json` (сам каталог проверяется), а если их нет — текущим рабочим каталогом; выше поиск не идёт. Не нашли маркер — корнем считается переданный каталог (тогда хуков нет, и движок пишет об этом предупреждение в stderr).
|
||||||
|
|
||||||
|
Маркером служат **оба** файла, а не только конфиг: конфиг необязателен (§7), и сьют, у которого есть только `_hooks.mjs`, иначе молча остался бы без подготовки стенда.
|
||||||
|
|
||||||
|
Если переданные пути принадлежат разным сьютам (корни не совпали) — прогон не стартует: конфиг и хуки были бы взяты от первого пути, то есть чужие. Запускайте сьюты отдельно.
|
||||||
|
|
||||||
|
Найденный корень печатается в шапке прогона, рядом — переданные пути, если они от него отличаются.
|
||||||
|
|
||||||
### Валидация CLI
|
### Валидация CLI
|
||||||
|
|
||||||
@@ -200,7 +210,7 @@ export default async function({ clerk, manager, step }) {
|
|||||||
ctx.testInfo = {
|
ctx.testInfo = {
|
||||||
name, // 'Навигация по разделам' (с подставленными params)
|
name, // 'Навигация по разделам' (с подставленными params)
|
||||||
file, // '01-navigation.test.mjs' (basename)
|
file, // '01-navigation.test.mjs' (basename)
|
||||||
filePath, // '01-navigation.test.mjs' (relative к testDir, разделитель '/')
|
filePath, // '01-navigation.test.mjs' (relative к корню сьюта, разделитель '/')
|
||||||
tags, // ['nav', 'smoke']
|
tags, // ['nav', 'smoke']
|
||||||
timeout, // 60000 (ms)
|
timeout, // 60000 (ms)
|
||||||
attempt, // 1..maxAttempts (1-based)
|
attempt, // 1..maxAttempts (1-based)
|
||||||
@@ -296,11 +306,13 @@ await assert.throws(asyncFn, msg?) // ожидает исключение
|
|||||||
|
|
||||||
```js
|
```js
|
||||||
assert.formHasField(state, fieldName, msg?)
|
assert.formHasField(state, fieldName, msg?)
|
||||||
// проверяет наличие state.fields[fieldName]; в сообщении об ошибке
|
// проверяет, что в массиве state.fields есть поле с таким name;
|
||||||
// перечисляются доступные поля для быстрой диагностики
|
// в сообщении об ошибке перечисляются доступные поля для быстрой диагностики
|
||||||
|
|
||||||
assert.formTitle(state, expected, msg?)
|
assert.formTitle(state, expected, msg?)
|
||||||
// проверяет, что state.title содержит expected
|
// проверяет, что state.title СОДЕРЖИТ expected (подстрока, не строгое равенство).
|
||||||
|
// state.title — заголовок активной формы: сначала из шапки формы, при её отсутствии —
|
||||||
|
// из панели открытых окон; null, если недоступны оба (тогда ассерт падает с этим фактом)
|
||||||
|
|
||||||
assert.tableHasRow(table, predicate, msg?)
|
assert.tableHasRow(table, predicate, msg?)
|
||||||
// predicate: объект (частичное совпадение по ===) или функция row => bool
|
// predicate: объект (частичное совпадение по ===) или функция row => bool
|
||||||
@@ -320,7 +332,7 @@ assert.noErrors(state, msg?)
|
|||||||
|
|
||||||
## 6. Хуки
|
## 6. Хуки
|
||||||
|
|
||||||
Все хуки определяются в `_hooks.mjs` в корне каталога тестов.
|
Все хуки определяются в `_hooks.mjs` в корне сьюта (§1 «Резолв корня сьюта»).
|
||||||
|
|
||||||
### Три уровня
|
### Три уровня
|
||||||
|
|
||||||
@@ -451,7 +463,7 @@ node run.mjs test tests/myapp/ --bail -- --rebuild-stand --reload-data
|
|||||||
|
|
||||||
## 7. Файл конфигурации
|
## 7. Файл конфигурации
|
||||||
|
|
||||||
`webtest.config.mjs` в корне каталога тестов. Необязателен — если отсутствует, URL должен быть передан через CLI.
|
`webtest.config.mjs` в корне сьюта (§1 «Резолв корня сьюта»). Необязателен — если отсутствует, URL должен быть передан через CLI.
|
||||||
|
|
||||||
```js
|
```js
|
||||||
export default {
|
export default {
|
||||||
@@ -599,7 +611,7 @@ await step('Менеджер утверждает', async () => {
|
|||||||
await step('Кладовщик проверяет статус', async () => {
|
await step('Кладовщик проверяет статус', async () => {
|
||||||
// страница кладовщика ТА ЖЕ — форма открыта, навигация не нужна
|
// страница кладовщика ТА ЖЕ — форма открыта, навигация не нужна
|
||||||
const state = await clerk.getFormState();
|
const state = await clerk.getFormState();
|
||||||
assert.equal(state.fields['Статус']?.value, 'Утверждён');
|
assert.equal(state.fields.find(f => f.name === 'Статус')?.value, 'Утверждён');
|
||||||
});
|
});
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -782,7 +794,7 @@ await step('Кладовщик проверяет статус', async () => {
|
|||||||
Движок всегда заполняет следующие метки (`labels`):
|
Движок всегда заполняет следующие метки (`labels`):
|
||||||
|
|
||||||
- **`tag`** — по одному на каждый элемент `mod.tags[]`. Готовая фильтрация в Allure-отчёте без дополнительной разметки.
|
- **`tag`** — по одному на каждый элемент `mod.tags[]`. Готовая фильтрация в Allure-отчёте без дополнительной разметки.
|
||||||
- **`suite`** — `dirname(t.filePath)`. Тесты в корне `testDir` идут под `'root'`, тесты в подкаталоге `sales/` — под `'sales'`. Это даёт левую группировку отчёта без ручной разметки.
|
- **`suite`** — `dirname(t.filePath)`. Тесты в корне сьюта идут под `'root'`, тесты в подкаталоге `sales/` — под `'sales'`. Это даёт левую группировку отчёта без ручной разметки.
|
||||||
- **`severity`** — резолв в порядке приоритета:
|
- **`severity`** — резолв в порядке приоритета:
|
||||||
1. `export const severity = 'critical'` в самом тесте, **если значение валидное** (одно из `blocker | critical | normal | minor | trivial`). Если экспорт задан, но значение невалидное — пункт пропускается и идём в (3); резолв через теги (пункт 2) при этом **не выполняется** (хотел бы автор иначе — он бы не объявлял `severity`).
|
1. `export const severity = 'critical'` в самом тесте, **если значение валидное** (одно из `blocker | critical | normal | minor | trivial`). Если экспорт задан, но значение невалидное — пункт пропускается и идём в (3); резолв через теги (пункт 2) при этом **не выполняется** (хотел бы автор иначе — он бы не объявлял `severity`).
|
||||||
2. Иначе **максимальный ранг** среди тегов теста (стандартные имена `blocker | critical | normal | minor | trivial` напрямую, либо через `config.severity`-маппинг).
|
2. Иначе **максимальный ранг** среди тегов теста (стандартные имена `blocker | critical | normal | minor | trivial` напрямую, либо через `config.severity`-маппинг).
|
||||||
@@ -792,9 +804,9 @@ await step('Кладовщик проверяет статус', async () => {
|
|||||||
|
|
||||||
Пример: `tags: ['smoke', 'recording']` + `severity: { critical: ['smoke'], minor: ['recording'] }` → severity = `critical` (5 > 2).
|
Пример: `tags: ['smoke', 'recording']` + `severity: { critical: ['smoke'], minor: ['recording'] }` → severity = `critical` (5 > 2).
|
||||||
|
|
||||||
#### Доп. файлы Allure через `<testDir>/_allure/`
|
#### Доп. файлы Allure через `<корень сьюта>/_allure/`
|
||||||
|
|
||||||
Движок ищет каталог `_allure/` рядом с тестами и копирует все его файлы в `reportDir` перед генерацией отчёта. Конвенция для статичной настройки Allure, для которой нет места внутри JSON-файла теста:
|
Движок ищет каталог `_allure/` в корне сьюта и копирует все его файлы в `reportDir` перед генерацией отчёта. Конвенция для статичной настройки Allure, для которой нет места внутри JSON-файла теста:
|
||||||
|
|
||||||
| Файл | Назначение |
|
| Файл | Назначение |
|
||||||
|------|-----------|
|
|------|-----------|
|
||||||
@@ -908,7 +920,7 @@ export const params = [
|
|||||||
export default async function({ fillFields, getFormState, assert }, { type, field, value }) {
|
export default async function({ fillFields, getFormState, assert }, { type, field, value }) {
|
||||||
await fillFields({ [field]: value });
|
await fillFields({ [field]: value });
|
||||||
const state = await getFormState();
|
const state = await getFormState();
|
||||||
assert.equal(state.fields[field]?.value, String(value));
|
assert.equal(state.fields.find(f => f.name === field)?.value, String(value));
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -922,7 +934,7 @@ export default async function({ fillFields, getFormState, assert }, { type, fiel
|
|||||||
|
|
||||||
## 14. Обнаружение тестов
|
## 14. Обнаружение тестов
|
||||||
|
|
||||||
`testDir` (первый позиционный аргумент после URL) — каталог, в котором живут тесты. Сборщик рекурсивно обходит дерево и собирает файлы по правилам ниже.
|
Позиционные аргументы — пути к тестам; каталог, от которого считаются относительные пути в отчёте, — корень сьюта (§1 «Резолв корня сьюта»). Сборщик рекурсивно обходит дерево и собирает файлы по правилам ниже.
|
||||||
|
|
||||||
```
|
```
|
||||||
tests/myapp/
|
tests/myapp/
|
||||||
@@ -944,14 +956,13 @@ tests/myapp/
|
|||||||
| Шаблон имени | Только `*.test.mjs` |
|
| Шаблон имени | Только `*.test.mjs` |
|
||||||
| Несколько путей | `node run.mjs test a.test.mjs b.test.mjs dir/` — наборы объединяются, дублируются и сортируются |
|
| Несколько путей | `node run.mjs test a.test.mjs b.test.mjs dir/` — наборы объединяются, дублируются и сортируются |
|
||||||
| Порядок | Сортировка по полному относительному пути (`sales/01` идёт до `warehouse/01`) |
|
| Порядок | Сортировка по полному относительному пути (`sales/01` идёт до `warehouse/01`) |
|
||||||
| `file` в отчёте | `relative(testDir, file)` с разделителем `/`, например `sales/01-order-create.test.mjs` |
|
| `file` в отчёте | `relative(<корень сьюта>, file)` с разделителем `/`, например `sales/01-order-create.test.mjs` |
|
||||||
| Фильтр по пути с CLI | `node run.mjs test tests/myapp/sales/` запустит только подкаталог |
|
| Фильтр по пути с CLI | `node run.mjs test tests/myapp/sales/` запустит только подкаталог |
|
||||||
| Конкретный файл | `node run.mjs test tests/myapp/sales/01-order-create.test.mjs` |
|
| Конкретный файл | `node run.mjs test tests/myapp/sales/01-order-create.test.mjs` |
|
||||||
|
|
||||||
### Чего НЕТ (сознательное упрощение)
|
### Чего НЕТ (сознательное упрощение)
|
||||||
|
|
||||||
- **`_hooks.mjs` на уровне подкаталога.** Движок ищет `_hooks.mjs` только в корне `testDir`. Подкаталоги свои хуки не получают.
|
- **`_hooks.mjs` / `webtest.config.mjs` на уровне подкаталога.** Оба берутся только из корня сьюта — того каталога, где лежит ближайший из них (§1 «Резолв корня сьюта»). Подкаталоги своих копий не получают; вложенный каталог со своим `webtest.config.mjs` — это уже отдельный сьют.
|
||||||
- **`webtest.config.mjs` на уровне подкаталога.** Тоже только в корне.
|
|
||||||
- **Многоуровневой Suite-разметки из дерева каталогов.** Allure-метка `suite` строится только по первому уровню (`dirname(filePath)`); более глубокую группировку делайте через `tags`.
|
- **Многоуровневой Suite-разметки из дерева каталогов.** Allure-метка `suite` строится только по первому уровню (`dirname(filePath)`); более глубокую группировку делайте через `tags`.
|
||||||
- **Контекста по умолчанию на уровне подкаталога.** Каждый тест объявляет `context` / `contexts` сам; от пути контексты не наследуются.
|
- **Контекста по умолчанию на уровне подкаталога.** Каждый тест объявляет `context` / `contexts` сам; от пути контексты не наследуются.
|
||||||
|
|
||||||
@@ -1117,7 +1128,8 @@ JSON-отчёт (`tests[]`, полная структура — §9) для ка
|
|||||||
|
|
||||||
| Термин | Определение |
|
| Термин | Определение |
|
||||||
|--------|-------------|
|
|--------|-------------|
|
||||||
| **testDir** | Каталог тестов, переданный позиционным аргументом движку. Корень для discovery, `_hooks.mjs`, `webtest.config.mjs`, `_allure/`. |
|
| **Test path** | Путь к тесту или каталогу тестов, переданный позиционным аргументом. Корень только для discovery — что именно запускать. |
|
||||||
|
| **Suite root (корень сьюта)** | Каталог, найденный подъёмом от test path до первого `webtest.config.mjs` / `_hooks.mjs` (§1). От него берутся конфиг, хуки, `_allure/`, каталог отчёта по умолчанию и относительные пути `file` в отчёте. Не зависит от того, запустили сьют целиком или один его подкаталог, — поэтому ID теста в отчёте стабилен. |
|
||||||
| **Context (BrowserContext)** | Изолированная сессия Playwright. Куки/состояние/страница независимы. В рамках одного теста используется один или несколько контекстов. |
|
| **Context (BrowserContext)** | Изолированная сессия Playwright. Куки/состояние/страница независимы. В рамках одного теста используется один или несколько контекстов. |
|
||||||
| **Active context** | Контекст, на котором сейчас оперируют функции browser-API. Переключается `setActiveContext`. |
|
| **Active context** | Контекст, на котором сейчас оперируют функции browser-API. Переключается `setActiveContext`. |
|
||||||
| **Primary context** | Контекст, активный на входе в тест. Декларация (`mod.context` или `mod.contexts[0]`). Зафиксирован в `testInfo.primaryContext`. |
|
| **Primary context** | Контекст, активный на входе в тест. Декларация (`mod.context` или `mod.contexts[0]`). Зафиксирован в `testInfo.primaryContext`. |
|
||||||
|
|||||||
@@ -40,7 +40,7 @@ export default async function({ navigateSection, openCommand, filterList, unfilt
|
|||||||
await filterList('ООО Север', { field: 'Наименование', exact: true });
|
await filterList('ООО Север', { field: 'Наименование', exact: true });
|
||||||
const t = await readTable({ maxRows: 50 });
|
const t = await readTable({ maxRows: 50 });
|
||||||
log(`exact 'ООО Север': rows=${t.rows?.length} names=${t.rows?.map(r => r['Наименование']).join(',')}`);
|
log(`exact 'ООО Север': rows=${t.rows?.length} names=${t.rows?.map(r => r['Наименование']).join(',')}`);
|
||||||
assert.equal(t.rows?.length, 1, 'exact:true должен дать строго 1 совпадение');
|
assert.tableRowCount(t, 1, 'exact:true должен дать строго 1 совпадение');
|
||||||
assert.equal(t.rows[0]['Наименование'], 'ООО Север', 'Это должно быть ООО Север');
|
assert.equal(t.rows[0]['Наименование'], 'ООО Север', 'Это должно быть ООО Север');
|
||||||
await unfilterList();
|
await unfilterList();
|
||||||
await closeForm();
|
await closeForm();
|
||||||
|
|||||||
@@ -14,6 +14,10 @@ export default async function({ navigateSection, openCommand, clickElement, clos
|
|||||||
assert.ok(s.tables?.length >= 1, 'На форме списка есть таблица');
|
assert.ok(s.tables?.length >= 1, 'На форме списка есть таблица');
|
||||||
assert.ok(s.tables[0].columns?.length >= 2, 'У таблицы есть колонки');
|
assert.ok(s.tables[0].columns?.length >= 2, 'У таблицы есть колонки');
|
||||||
assert.ok(s.buttons?.length >= 1, 'На форме есть кнопки');
|
assert.ok(s.buttons?.length >= 1, 'На форме есть кнопки');
|
||||||
|
// title читается из шапки формы (атрибут .toplineBoxTitle), а не из панели открытых окон —
|
||||||
|
// она отключаема в настройках 1С. Панель остаётся запасным источником.
|
||||||
|
assert.formTitle(s, 'Контрагенты', 'Заголовок формы списка');
|
||||||
|
assert.noErrors(s, 'На только что открытой форме списка ошибок быть не должно');
|
||||||
await closeForm();
|
await closeForm();
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -26,6 +30,8 @@ export default async function({ navigateSection, openCommand, clickElement, clos
|
|||||||
assert.ok(s.fields?.length >= 1, 'На форме элемента есть поля');
|
assert.ok(s.fields?.length >= 1, 'На форме элемента есть поля');
|
||||||
const named = s.fields.find(f => f.name === 'Наименование');
|
const named = s.fields.find(f => f.name === 'Наименование');
|
||||||
log(`Наименование: label='${named?.label}' value='${named?.value}'`);
|
log(`Наименование: label='${named?.label}' value='${named?.value}'`);
|
||||||
|
assert.formHasField(s, 'Наименование', 'formHasField ищет по массиву fields, а не по ключу');
|
||||||
|
assert.formTitle(s, 'Контрагент', 'Заголовок формы элемента');
|
||||||
assert.ok(named, 'Должно быть поле Наименование');
|
assert.ok(named, 'Должно быть поле Наименование');
|
||||||
assert.equal(named.value, 'ООО Север', 'value поля Наименование');
|
assert.equal(named.value, 'ООО Север', 'value поля Наименование');
|
||||||
assert.ok(named.label, 'У поля есть label');
|
assert.ok(named.label, 'У поля есть label');
|
||||||
@@ -52,6 +58,9 @@ export default async function({ navigateSection, openCommand, clickElement, clos
|
|||||||
log(`after F4: form=${s.form} formCount=${s.formCount} modal=${s.modal}`);
|
log(`after F4: form=${s.form} formCount=${s.formCount} modal=${s.modal}`);
|
||||||
assert.equal(s.modal, true, 'state.modal=true для модальной формы выбора');
|
assert.equal(s.modal, true, 'state.modal=true для модальной формы выбора');
|
||||||
assert.ok(s.formCount >= 2, 'formCount >= 2 (родитель + модальная)');
|
assert.ok(s.formCount >= 2, 'formCount >= 2 (родитель + модальная)');
|
||||||
|
// Заголовок берётся у всплывающего окна, а не у родителя: видимы обе шапки, а панель
|
||||||
|
// открытых окон в этот момент показывает родителя ('Приходная накладная').
|
||||||
|
assert.formTitle(s, 'Выбор контрагента', 'title всплывающего окна, а не родительской формы');
|
||||||
|
|
||||||
await closeForm();
|
await closeForm();
|
||||||
await closeForm({ save: false });
|
await closeForm({ save: false });
|
||||||
|
|||||||
@@ -0,0 +1,15 @@
|
|||||||
|
// Hooks for the suite-root fixture. They prepare nothing — the point is that they RUN at all
|
||||||
|
// when the runner was pointed at `nested/`. Losing hooks is the silent half of the bug this
|
||||||
|
// fixture guards: without them a run proceeds against an unprepared stand and still goes green.
|
||||||
|
// `prepare` writes a marker file the caller can assert on.
|
||||||
|
import { writeFileSync } from 'fs';
|
||||||
|
import { resolve, dirname } from 'path';
|
||||||
|
import { fileURLToPath } from 'url';
|
||||||
|
|
||||||
|
const HERE = dirname(fileURLToPath(import.meta.url));
|
||||||
|
export const MARKER = resolve(HERE, 'prepare-ran.txt');
|
||||||
|
|
||||||
|
export async function prepare({ log }) {
|
||||||
|
writeFileSync(MARKER, new Date().toISOString() + '\n');
|
||||||
|
log('suite-root fixture: prepare() ran — hooks were resolved from the suite root');
|
||||||
|
}
|
||||||
@@ -0,0 +1,119 @@
|
|||||||
|
// web-test _suite-root/check v1.0 — offline verdict for the suite-root resolver
|
||||||
|
// Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
|
||||||
|
//
|
||||||
|
// findSuiteRoot decides where `webtest.config.mjs` and `_hooks.mjs` are loaded from. Getting it
|
||||||
|
// wrong is silent: the run picks up someone else's hooks, or none at all, and still goes green.
|
||||||
|
// Every rule of the climb is pinned here on throw-away trees — no 1C stand, no browser.
|
||||||
|
//
|
||||||
|
// node tests/web-test/_suite-root/check.mjs
|
||||||
|
//
|
||||||
|
// Exit codes: 0 — resolver behaves; 1 — a rule regressed.
|
||||||
|
import { mkdirSync, writeFileSync, rmSync } from 'fs';
|
||||||
|
import { resolve, dirname } from 'path';
|
||||||
|
import { fileURLToPath } from 'url';
|
||||||
|
import { tmpdir } from 'os';
|
||||||
|
|
||||||
|
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||||
|
const REPO = resolve(__dirname, '../../..');
|
||||||
|
const { findSuiteRoot } = await import(
|
||||||
|
'file:///' + resolve(REPO, '.claude/skills/web-test/scripts/cli/test-runner/suite-root.mjs').replace(/\\/g, '/')
|
||||||
|
);
|
||||||
|
|
||||||
|
const BASE = resolve(tmpdir(), 'web-test-suite-root-check');
|
||||||
|
rmSync(BASE, { recursive: true, force: true });
|
||||||
|
|
||||||
|
/** Build a tree from a list of relative paths; a path ending in `/` is a dir, otherwise a file. */
|
||||||
|
function tree(name, entries) {
|
||||||
|
const root = resolve(BASE, name);
|
||||||
|
for (const e of entries) {
|
||||||
|
const full = resolve(root, e);
|
||||||
|
if (e.endsWith('/')) mkdirSync(full, { recursive: true });
|
||||||
|
else { mkdirSync(dirname(full), { recursive: true }); writeFileSync(full, '// fixture\n'); }
|
||||||
|
}
|
||||||
|
return root;
|
||||||
|
}
|
||||||
|
|
||||||
|
let failed = 0;
|
||||||
|
function check(label, actual, expected) {
|
||||||
|
const a = actual === null ? 'null' : actual;
|
||||||
|
const e = expected === null ? 'null' : expected;
|
||||||
|
if (a === e) { console.log(` ok ${label}`); return; }
|
||||||
|
console.log(` FAIL ${label}\n expected: ${e}\n actual: ${a}`);
|
||||||
|
failed++;
|
||||||
|
}
|
||||||
|
const rootOf = (r) => (r ? r.root : null);
|
||||||
|
|
||||||
|
// 1. Config one level up — the IRG case: `test tests/irg/00-smoke/`.
|
||||||
|
{
|
||||||
|
const t = tree('t1', ['suite/webtest.config.mjs', 'suite/00-smoke/x.test.mjs']);
|
||||||
|
check('config one level up', rootOf(findSuiteRoot(resolve(t, 'suite/00-smoke'), { cwd: t })), resolve(t, 'suite'));
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Two levels up, and the marker is _hooks.mjs only — a suite with no config still resolves,
|
||||||
|
// otherwise its stand preparation would be silently skipped.
|
||||||
|
{
|
||||||
|
const t = tree('t2', ['suite/_hooks.mjs', 'suite/a/b/x.test.mjs']);
|
||||||
|
check('hooks-only marker, two levels up', rootOf(findSuiteRoot(resolve(t, 'suite/a/b'), { cwd: t })), resolve(t, 'suite'));
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. A file path resolves from its own directory.
|
||||||
|
{
|
||||||
|
const t = tree('t3', ['suite/webtest.config.mjs', 'suite/a/x.test.mjs']);
|
||||||
|
check('file path → its directory', rootOf(findSuiteRoot(resolve(t, 'suite/a/x.test.mjs'), { cwd: t })), resolve(t, 'suite'));
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4. Nearest marker wins — a nested suite is not swallowed by its parent (the `_hang/` case).
|
||||||
|
{
|
||||||
|
const t = tree('t4', ['suite/webtest.config.mjs', 'suite/inner/webtest.config.mjs', 'suite/inner/x.test.mjs']);
|
||||||
|
check('nearest marker wins', rootOf(findSuiteRoot(resolve(t, 'suite/inner'), { cwd: t })), resolve(t, 'suite/inner'));
|
||||||
|
}
|
||||||
|
|
||||||
|
// 5-6. Boundaries stop the climb: a config above `.git` / `.v8-project.json` is NOT ours.
|
||||||
|
{
|
||||||
|
const t = tree('t5', ['webtest.config.mjs', 'proj/.git/', 'proj/a/x.test.mjs']);
|
||||||
|
check('.git bounds the climb', rootOf(findSuiteRoot(resolve(t, 'proj/a'), { cwd: t })), null);
|
||||||
|
}
|
||||||
|
{
|
||||||
|
const t = tree('t6', ['webtest.config.mjs', 'proj/.v8-project.json', 'proj/a/x.test.mjs']);
|
||||||
|
check('.v8-project.json bounds the climb', rootOf(findSuiteRoot(resolve(t, 'proj/a'), { cwd: t })), null);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 7. The boundary directory is itself examined — a marker sitting next to `.git` is found.
|
||||||
|
{
|
||||||
|
const t = tree('t7', ['proj/.git/', 'proj/webtest.config.mjs', 'proj/a/x.test.mjs']);
|
||||||
|
check('boundary dir is inclusive', rootOf(findSuiteRoot(resolve(t, 'proj/a'), { cwd: t })), resolve(t, 'proj'));
|
||||||
|
}
|
||||||
|
|
||||||
|
// 8. With no repo marker, cwd bounds the climb — nothing above the working directory is picked up.
|
||||||
|
{
|
||||||
|
const t = tree('t8', ['webtest.config.mjs', 'work/a/x.test.mjs']);
|
||||||
|
check('cwd bounds the climb', rootOf(findSuiteRoot(resolve(t, 'work/a'), { cwd: resolve(t, 'work') })), null);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 9. cwd is inclusive too.
|
||||||
|
{
|
||||||
|
const t = tree('t9', ['work/webtest.config.mjs', 'work/a/x.test.mjs']);
|
||||||
|
check('cwd is inclusive', rootOf(findSuiteRoot(resolve(t, 'work/a'), { cwd: resolve(t, 'work') })), resolve(t, 'work'));
|
||||||
|
}
|
||||||
|
|
||||||
|
// 10. No marker anywhere → null, and the caller falls back to the passed directory.
|
||||||
|
{
|
||||||
|
const t = tree('t10', ['proj/.git/', 'proj/a/x.test.mjs']);
|
||||||
|
check('no marker → null', rootOf(findSuiteRoot(resolve(t, 'proj/a'), { cwd: t })), null);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 11. Real repo: this fixture's own directory carries the markers, so `nested/` climbs one level.
|
||||||
|
{
|
||||||
|
const here = resolve(REPO, 'tests/web-test/_suite-root/nested');
|
||||||
|
check('repo fixture: nested/ → _suite-root/', rootOf(findSuiteRoot(here, { cwd: REPO })), dirname(here));
|
||||||
|
}
|
||||||
|
|
||||||
|
// 12. Real repo: the flat suite resolves to itself.
|
||||||
|
{
|
||||||
|
const suite = resolve(REPO, 'tests/web-test');
|
||||||
|
check('repo: tests/web-test/ → itself', rootOf(findSuiteRoot(suite, { cwd: REPO })), suite);
|
||||||
|
}
|
||||||
|
|
||||||
|
rmSync(BASE, { recursive: true, force: true });
|
||||||
|
console.log(failed ? `\n${failed} check(s) FAILED\n` : '\nall checks passed\n');
|
||||||
|
process.exit(failed ? 1 : 0);
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
// The only test of the suite-root fixture. Kept as light as possible — it is not here to test
|
||||||
|
// the application, it is here to be run as `test tests/web-test/_suite-root/nested/` and prove
|
||||||
|
// that config (URL) and hooks were resolved one level up. Before v1.9 that invocation died with
|
||||||
|
// "No URL provided and no webtest.config.mjs found".
|
||||||
|
import { existsSync } from 'fs';
|
||||||
|
import { resolve, dirname } from 'path';
|
||||||
|
import { fileURLToPath } from 'url';
|
||||||
|
|
||||||
|
const SUITE_ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '..');
|
||||||
|
|
||||||
|
export const name = 'suite-root: конфиг и хуки подхвачены на уровень выше';
|
||||||
|
export const tags = ['suite-root'];
|
||||||
|
export const timeout = 60000;
|
||||||
|
|
||||||
|
export default async function({ getPageState, assert, log }) {
|
||||||
|
const state = await getPageState();
|
||||||
|
const names = (state.sections || []).map(s => s.name);
|
||||||
|
log('sections: ' + names.join(', '));
|
||||||
|
assert.ok(names.length >= 1, 'сеанс открыт → URL взят из конфига в корне сюиты');
|
||||||
|
assert.ok(existsSync(resolve(SUITE_ROOT, 'prepare-ran.txt')),
|
||||||
|
'prepare() отработал → _hooks.mjs взят из корня сюиты, а не потерян');
|
||||||
|
}
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
// Config for the suite-root fixture only. Its whole job is to sit HERE, one level above
|
||||||
|
// `nested/`, so that `run.mjs test tests/web-test/_suite-root/nested/` has to climb to find it.
|
||||||
|
// The stand must already be published (this fixture exercises path resolution, not the stand).
|
||||||
|
export default {
|
||||||
|
contexts: {
|
||||||
|
a: { url: 'http://localhost:9191/webtest-runner/ru_RU' },
|
||||||
|
},
|
||||||
|
defaultContext: 'a',
|
||||||
|
timeout: 60000,
|
||||||
|
};
|
||||||
Reference in New Issue
Block a user