mirror of
https://github.com/Nikolay-Shirokov/cc-1c-skills.git
synced 2026-07-26 22:51:03 +03:00
feat(web-test): auto-suite + severity-резолвер для Allure
run.mjs: - buildSeverityIndex(config) — валидация config.severity (inverted map «уровень → [теги]») при загрузке: ключи только из blocker|critical| normal|minor|trivial, теги не дублируются между bucket'ами, defaultSeverity тоже валидируется. fail-fast через die. - resolveSeverity(t, severityIndex): 1. mod.severity если задан и валидный — выигрывает. 2. max-rank среди тегов (стандартные имена severity или маппинг). 3. config.defaultSeverity или 'normal'. Rank: blocker(5) > critical(4) > normal(3) > minor(2) > trivial(1). Max-wins инвариантен к порядку тегов. - writeAllure: добавлены labels suite (= dirname(t.file) или 'root') + severity. Тег `tag` остался как раньше. - testResult пробрасывает t.severity (для passed/failed веток). - SEVERITY_RANK/LEVELS объявлены в модульной шапке (top-level await на cmdTest начинается до конца тела модуля, TDZ-аккуратность). webtest.config.mjs: severity policy для нашего сьюта (smoke + multi-context → critical, recording → minor, defaultSeverity = normal). spec.md §7: раздел про severity-policy в конфиге с валидацией. spec.md §9: «Авто-эмиссия label-ов» — tag/suite/severity + правила резолва. Регресс 19/19 ✓ (9m 7.6s). Распределение по уровням после исправления 'record' → 'recording' в маппинге: 13 critical / 5 normal / 1 minor. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.7
parent
a55195ab66
commit
fc76407877
@@ -26,6 +26,12 @@ import { randomUUID } from 'crypto';
|
|||||||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||||
const SESSION_FILE = resolve(__dirname, '..', '.browser-session.json');
|
const SESSION_FILE = resolve(__dirname, '..', '.browser-session.json');
|
||||||
|
|
||||||
|
// Allure severity policy. Declared early so buildSeverityIndex (called inside
|
||||||
|
// cmdTest) can use these constants — top-level const are not hoisted, and
|
||||||
|
// cmdTest is invoked synchronously below via `await cmdTest(rawArgs)`.
|
||||||
|
const SEVERITY_RANK = { blocker: 5, critical: 4, normal: 3, minor: 2, trivial: 1 };
|
||||||
|
const SEVERITY_LEVELS = Object.keys(SEVERITY_RANK);
|
||||||
|
|
||||||
const [,, cmd, ...rawArgs] = process.argv;
|
const [,, cmd, ...rawArgs] = process.argv;
|
||||||
const flags = { noRecord: rawArgs.includes('--no-record') };
|
const flags = { noRecord: rawArgs.includes('--no-record') };
|
||||||
const args = rawArgs.filter(a => !a.startsWith('--'));
|
const args = rawArgs.filter(a => !a.startsWith('--'));
|
||||||
@@ -401,6 +407,8 @@ async function cmdTest(rawArgs) {
|
|||||||
const mod = await import('file:///' + configPath.replace(/\\/g, '/'));
|
const mod = await import('file:///' + configPath.replace(/\\/g, '/'));
|
||||||
config = mod.default || {};
|
config = mod.default || {};
|
||||||
}
|
}
|
||||||
|
// Validate severity policy at config load (fail-fast on misconfig).
|
||||||
|
const severityIndex = buildSeverityIndex(config);
|
||||||
// Build context registry: name → url. Supports config.contexts or single config.url / CLI url.
|
// Build context registry: name → url. Supports config.contexts or single config.url / CLI url.
|
||||||
// CLI url overrides default context's url.
|
// CLI url overrides default context's url.
|
||||||
const contextSpecs = {}; // name → { url, isolation }
|
const contextSpecs = {}; // name → { url, isolation }
|
||||||
@@ -467,6 +475,7 @@ async function cmdTest(rawArgs) {
|
|||||||
param: undefined,
|
param: undefined,
|
||||||
context: mod.context || null,
|
context: mod.context || null,
|
||||||
contexts: Array.isArray(mod.contexts) ? mod.contexts : null,
|
contexts: Array.isArray(mod.contexts) ? mod.contexts : null,
|
||||||
|
severity: typeof mod.severity === 'string' ? mod.severity : null,
|
||||||
};
|
};
|
||||||
if (base.only) hasOnly = true;
|
if (base.only) hasOnly = true;
|
||||||
if (Array.isArray(mod.params) && mod.params.length) {
|
if (Array.isArray(mod.params) && mod.params.length) {
|
||||||
@@ -701,7 +710,7 @@ async function cmdTest(rawArgs) {
|
|||||||
try { await browser.stopRecording(); } catch {}
|
try { await browser.stopRecording(); } catch {}
|
||||||
}
|
}
|
||||||
const dur = elapsed(t0);
|
const dur = elapsed(t0);
|
||||||
testResult = { name: t.name, file: t.file, tags: t.tags, contexts: testContextNames, status: 'passed', duration: dur, attempts: attempt, start: t0, stop: Date.now(), steps, output: output.join('\n'), error: null, screenshot: null, video: videoFile };
|
testResult = { name: t.name, file: t.file, tags: t.tags, contexts: testContextNames, severity: t.severity, status: 'passed', duration: dur, attempts: attempt, start: t0, stop: Date.now(), steps, output: output.join('\n'), error: null, screenshot: null, video: videoFile };
|
||||||
lastError = null;
|
lastError = null;
|
||||||
break;
|
break;
|
||||||
|
|
||||||
@@ -735,7 +744,7 @@ async function cmdTest(rawArgs) {
|
|||||||
}
|
}
|
||||||
lastError = { message: e.message, step: e.onecError?.step, screenshot: shotFile };
|
lastError = { message: e.message, step: e.onecError?.step, screenshot: shotFile };
|
||||||
const dur = elapsed(t0);
|
const dur = elapsed(t0);
|
||||||
testResult = { name: t.name, file: t.file, tags: t.tags, contexts: testContextNames, status: 'failed', duration: dur, attempts: attempt, start: t0, stop: Date.now(), steps, output: output.join('\n'), error: lastError, screenshot: shotFile, video: videoFile };
|
testResult = { name: t.name, file: t.file, tags: t.tags, contexts: testContextNames, severity: t.severity, status: 'failed', duration: dur, attempts: attempt, start: t0, stop: Date.now(), steps, output: output.join('\n'), error: lastError, screenshot: shotFile, video: videoFile };
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -811,7 +820,7 @@ async function cmdTest(rawArgs) {
|
|||||||
out(report);
|
out(report);
|
||||||
|
|
||||||
if (opts.format === 'allure') {
|
if (opts.format === 'allure') {
|
||||||
writeAllure(results, reportDir);
|
writeAllure(results, reportDir, severityIndex);
|
||||||
} else if (opts.format === 'junit') {
|
} else if (opts.format === 'junit') {
|
||||||
writeFileSync(resolve(opts.report), buildJUnit(report, testDir));
|
writeFileSync(resolve(opts.report), buildJUnit(report, testDir));
|
||||||
} else if (opts.report) {
|
} else if (opts.report) {
|
||||||
@@ -821,10 +830,15 @@ async function cmdTest(rawArgs) {
|
|||||||
if (failCount > 0) process.exit(1);
|
if (failCount > 0) process.exit(1);
|
||||||
}
|
}
|
||||||
|
|
||||||
function writeAllure(results, reportDir) {
|
function writeAllure(results, reportDir, severityIndex) {
|
||||||
for (const tr of results) {
|
for (const tr of results) {
|
||||||
if (tr.status === 'skipped') continue; // Allure ignores skipped without start/stop
|
if (tr.status === 'skipped') continue; // Allure ignores skipped without start/stop
|
||||||
const uuid = randomUUID();
|
const uuid = randomUUID();
|
||||||
|
// suite: dirname(t.file) даёт автогруппировку отчёта по подкаталогам.
|
||||||
|
// Плоский слой тестов в корне группируется под 'root'.
|
||||||
|
const suite = dirname(tr.file);
|
||||||
|
const suiteLabel = (suite && suite !== '.') ? suite : 'root';
|
||||||
|
const severity = resolveSeverity(tr, severityIndex);
|
||||||
const out = {
|
const out = {
|
||||||
uuid,
|
uuid,
|
||||||
name: tr.name,
|
name: tr.name,
|
||||||
@@ -833,7 +847,11 @@ function writeAllure(results, reportDir) {
|
|||||||
stage: 'finished',
|
stage: 'finished',
|
||||||
start: tr.start,
|
start: tr.start,
|
||||||
stop: tr.stop,
|
stop: tr.stop,
|
||||||
labels: (tr.tags || []).map(t => ({ name: 'tag', value: t })),
|
labels: [
|
||||||
|
...(tr.tags || []).map(t => ({ name: 'tag', value: t })),
|
||||||
|
{ name: 'suite', value: suiteLabel },
|
||||||
|
{ name: 'severity', value: severity },
|
||||||
|
],
|
||||||
steps: (tr.steps || []).map(allureStep),
|
steps: (tr.steps || []).map(allureStep),
|
||||||
attachments: [
|
attachments: [
|
||||||
...(tr.screenshot ? [{ name: 'Screenshot on failure', source: basename(tr.screenshot), type: 'image/png' }] : []),
|
...(tr.screenshot ? [{ name: 'Screenshot on failure', source: basename(tr.screenshot), type: 'image/png' }] : []),
|
||||||
@@ -963,6 +981,71 @@ function formatDuration(seconds) {
|
|||||||
return `${m}m ${s}s`;
|
return `${m}m ${s}s`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// Severity (Allure label policy) — constants live at module top.
|
||||||
|
// ============================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validate config.severity (inverted map: severity → [tags]) at config load time.
|
||||||
|
* Returns:
|
||||||
|
* - tagToSeverity: Map<tag, severity> (precomputed lookup for the resolver)
|
||||||
|
* - defaultSeverity: string (validated, defaults to 'normal')
|
||||||
|
* Throws (via die) on invalid keys, invalid default, or duplicate tag across buckets.
|
||||||
|
*/
|
||||||
|
function buildSeverityIndex(config) {
|
||||||
|
const tagToSeverity = new Map();
|
||||||
|
const sev = config.severity || {};
|
||||||
|
if (typeof sev !== 'object' || Array.isArray(sev)) {
|
||||||
|
die(`config.severity must be an object, got ${typeof sev}`);
|
||||||
|
}
|
||||||
|
for (const [level, tags] of Object.entries(sev)) {
|
||||||
|
if (!SEVERITY_LEVELS.includes(level)) {
|
||||||
|
die(`config.severity: unknown level "${level}". Allowed: ${SEVERITY_LEVELS.join('|')}`);
|
||||||
|
}
|
||||||
|
if (!Array.isArray(tags)) {
|
||||||
|
die(`config.severity.${level} must be an array of tag names, got ${typeof tags}`);
|
||||||
|
}
|
||||||
|
for (const tag of tags) {
|
||||||
|
if (tagToSeverity.has(tag)) {
|
||||||
|
die(`config.severity: tag "${tag}" listed under both "${tagToSeverity.get(tag)}" and "${level}" — pick one`);
|
||||||
|
}
|
||||||
|
tagToSeverity.set(tag, level);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const def = config.defaultSeverity || 'normal';
|
||||||
|
if (!SEVERITY_LEVELS.includes(def)) {
|
||||||
|
die(`config.defaultSeverity: "${def}" is not a valid level. Allowed: ${SEVERITY_LEVELS.join('|')}`);
|
||||||
|
}
|
||||||
|
return { tagToSeverity, defaultSeverity: def };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolve a test's severity. Precedence:
|
||||||
|
* 1. explicit `export const severity` from the test module
|
||||||
|
* 2. max-rank severity found among tags (either standard severity name, or mapped via config)
|
||||||
|
* 3. defaultSeverity from config (or 'normal' if not set)
|
||||||
|
* Returns one of SEVERITY_LEVELS.
|
||||||
|
*/
|
||||||
|
function resolveSeverity(t, severityIndex) {
|
||||||
|
if (t.severity) {
|
||||||
|
if (!SEVERITY_LEVELS.includes(t.severity)) {
|
||||||
|
// Не валим тест — просто игнорируем некорректное значение, дефолтим.
|
||||||
|
return severityIndex.defaultSeverity;
|
||||||
|
}
|
||||||
|
return t.severity;
|
||||||
|
}
|
||||||
|
let best = null;
|
||||||
|
for (const tag of t.tags || []) {
|
||||||
|
let candidate = null;
|
||||||
|
if (SEVERITY_LEVELS.includes(tag)) candidate = tag;
|
||||||
|
else if (severityIndex.tagToSeverity.has(tag)) candidate = severityIndex.tagToSeverity.get(tag);
|
||||||
|
if (candidate && (best === null || SEVERITY_RANK[candidate] > SEVERITY_RANK[best])) {
|
||||||
|
best = candidate;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return best || severityIndex.defaultSeverity;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
// ============================================================
|
// ============================================================
|
||||||
// assertions
|
// assertions
|
||||||
|
|||||||
@@ -484,9 +484,26 @@ export default {
|
|||||||
retries: 0,
|
retries: 0,
|
||||||
screenshot: 'on-failure', // 'every-step' | 'off'
|
screenshot: 'on-failure', // 'every-step' | 'off'
|
||||||
record: false,
|
record: false,
|
||||||
|
|
||||||
|
// Allure severity policy (опционально). Inverted map: уровень → [теги].
|
||||||
|
// Резолв см. §9 «Severity».
|
||||||
|
severity: {
|
||||||
|
critical: ['smoke', 'multi-context'],
|
||||||
|
minor: ['recording'],
|
||||||
|
// blocker / trivial — необязательны, можно опустить
|
||||||
|
},
|
||||||
|
defaultSeverity: 'normal', // если ничего не подошло
|
||||||
};
|
};
|
||||||
```
|
```
|
||||||
|
|
||||||
|
`severity` валидируется при загрузке конфига:
|
||||||
|
- ключи — только из `blocker|critical|normal|minor|trivial`;
|
||||||
|
- значение каждого ключа — массив тегов;
|
||||||
|
- тег не может быть в двух bucket'ах одновременно (явная ошибка с указанием конфликта);
|
||||||
|
- `defaultSeverity` — из стандартного набора.
|
||||||
|
|
||||||
|
При нарушении любого правила раннер `die`-ает с понятным сообщением до запуска тестов.
|
||||||
|
|
||||||
Кириллица в ID контекстов работает, но смешанный регистр затрудняет ergonomics
|
Кириллица в ID контекстов работает, но смешанный регистр затрудняет ergonomics
|
||||||
(`testInfo.contexts.кладовщик.displayName` vs `testInfo.contexts.clerk.displayName`).
|
(`testInfo.contexts.кладовщик.displayName` vs `testInfo.contexts.clerk.displayName`).
|
||||||
Рекомендуем разделять технический ID и человекочитаемое имя.
|
Рекомендуем разделять технический ID и человекочитаемое имя.
|
||||||
@@ -720,6 +737,21 @@ await step('Кладовщик проверяет статус', async () => {
|
|||||||
|
|
||||||
Скриншоты/видео копируются в `allure-results/` с уникальными именами.
|
Скриншоты/видео копируются в `allure-results/` с уникальными именами.
|
||||||
|
|
||||||
|
#### Авто-эмиссия label-ов
|
||||||
|
|
||||||
|
Раннер всегда заполняет следующие labels:
|
||||||
|
|
||||||
|
- **`tag`** — по одному label-у на каждый элемент `mod.tags[]`. Бесплатная фильтрация в Allure-дашборде.
|
||||||
|
- **`suite`** — `dirname(t.file)`. Тесты в корне `testDir` идут под `'root'`, тесты в подкаталоге `sales/` — под `'sales'`. Это даёт левую группировку отчёта без ручной разметки.
|
||||||
|
- **`severity`** — резолв в порядке приоритета:
|
||||||
|
1. `export const severity = 'critical'` в самом тесте (если задано и значение валидное);
|
||||||
|
2. иначе **max-rank** среди тегов теста (стандартные имена `blocker|critical|normal|minor|trivial` напрямую, либо через `config.severity`-маппинг);
|
||||||
|
3. иначе `config.defaultSeverity` или `'normal'`.
|
||||||
|
|
||||||
|
Rank: `blocker(5) > critical(4) > normal(3) > minor(2) > trivial(1)`. Max-wins инвариантен к порядку тегов в `mod.tags`.
|
||||||
|
|
||||||
|
Пример: `tags: ['smoke', 'recording']` + `severity: { critical: ['smoke'], minor: ['recording'] }` → severity = `critical` (5 > 2).
|
||||||
|
|
||||||
### JUnit XML (`--format=junit`)
|
### JUnit XML (`--format=junit`)
|
||||||
|
|
||||||
```xml
|
```xml
|
||||||
|
|||||||
@@ -20,4 +20,17 @@ export default {
|
|||||||
// isolation: 'window' — separate BrowserContext per slot, full cookie isolation,
|
// isolation: 'window' — separate BrowserContext per slot, full cookie isolation,
|
||||||
// extension may not load (Playwright limitation). Use only when really needed.
|
// extension may not load (Playwright limitation). Use only when really needed.
|
||||||
timeout: 60000,
|
timeout: 60000,
|
||||||
|
|
||||||
|
// Allure severity policy: inverted map "уровень → теги, попадающие в этот уровень".
|
||||||
|
// Резолв (run.mjs:resolveSeverity):
|
||||||
|
// 1. explicit `export const severity` в тесте — выигрывает всегда;
|
||||||
|
// 2. иначе max-rank среди тегов теста (стандартное имя severity или маппинг ниже);
|
||||||
|
// 3. иначе `defaultSeverity`.
|
||||||
|
// Тег не может быть в двух bucket'ах одновременно — валидация при загрузке конфига.
|
||||||
|
severity: {
|
||||||
|
critical: ['smoke', 'multi-context'],
|
||||||
|
minor: ['recording'],
|
||||||
|
// blocker / trivial — пустые, не используем
|
||||||
|
},
|
||||||
|
defaultSeverity: 'normal',
|
||||||
};
|
};
|
||||||
|
|||||||
Reference in New Issue
Block a user