krona-staff/scripts/test/accept-branch.mjs
grok 644ea7c6cb test(accept): scripts/test/accept-branch.mjs — приёмка веток одной командой
Почему: TASK-GROK-3 п.1–2 — приёмка вручную пропускала красные интеграционные тесты
(ошибка в beforeAll/afterAll даёт «Tests 2 passed» при «Test Files 1 failed») и
конфликты между ветками. Скрипт: worktree на ветку + оверлей текущего стенда, npm run check,
каждый tests/integration/*.test.ts на свежей мигрированной БД (красный по строке Test Files),
e2e и разница с основой по ID, попарный git merge-tree --write-tree, проверка
«форматирование без поведения» (merge-base → prettier с конфигом основы → сравнение, плюс
совпадение лишних строк с другими ветками). Роли создаются один раз, дальше --roles keep;
с E2E_MIGRATOR_URL/E2E_APP_URL роли не трогаются вовсе.
Проверено: на 1df2019 + 8baad55 + 513cdff вывод совпал с ожиданиями владельца
(created_by / Called end on pool more than once при Tests 2 passed / SYNC-C0 зелёный /
конфликты в src/domain/sync/service.ts); синтетическая prettier-only ветка — «ТОЛЬКО ФОРМАТИРОВАНИЕ».
2026-09-26 16:06:59 +07:00

669 lines
26 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

#!/usr/bin/env node
// Приёмка веток исполнителей одной командой (TASK-GROK-3 п.1–2).
//
// node scripts/test/accept-branch.mjs [опции] <ветка> [<ветка>…]
//
// Для основы (--base) и каждой ветки:
// 1. git worktree в $ACCEPT_ROOT (по умолчанию ../krona-staff-acceptance), detached HEAD;
// node_modules — symlink на текущий репозиторий, если package-lock.json совпадает, иначе npm ci;
// 2. оверлей ТЕКУЩЕГО стенда: tests/e2e/** и scripts/test/** из этого checkout
// (tests/integration, src, миграции — версии ветки, не трогаются);
// 3. `npm run check` без переменных БД;
// 4. КАЖДЫЙ tests/integration/*.test.ts отдельно на свежей мигрированной БД PostgreSQL;
// красный — по строке `Test Files` (ловит ошибки beforeAll/afterAll, при которых
// `Tests N passed`), плюс ненулевой exit;
// 5. e2e (scripts/test/run-e2e.mjs, миграции roundtrip) и сравнение с основой по ID сценария;
// 6. попарно `git merge-tree --write-tree` — все ветки между собой и с основой;
// 7. «форматирование без поведения» для веток с `format` в имени и для --format-check <ветка>:
// версия файла из merge-base → prettier (конфиг основы) → сравнение с версией ветки.
// Итог — markdown в stdout (и в --out). Логи — в tests/e2e/evidence/accept/<время>/.
//
// БД: по умолчанию E2E_PG_ADMIN_URL (bootstrap-админ) один раз создаёт роли со случайными
// паролями, дальше только пересоздаёт БД `krona_acc_it`/`krona_acc_e2e` (provision-pg --roles keep).
// Если заданы E2E_MIGRATOR_URL и E2E_APP_URL — роли не создаются и пароли не меняются,
// все прогоны идут последовательно в БД из этих URL со сбросом через откат всех миграций.
//
// Опции: --base <ref> (origin/codex/bootstrap-platform-foundation), --format-check <ref>
// (повторяемая; ветка проверяется только на формат, без worktree), --skip-check,
// --skip-integration, --skip-e2e, --out <файл.md>.
// Ветка задаётся как `origin/<имя>`, `<имя>` (ищется origin/<имя>, затем локальный ref) или SHA.
import { spawn, spawnSync } from 'node:child_process';
import { createHash } from 'node:crypto';
import {
cpSync,
existsSync,
mkdirSync,
mkdtempSync,
readFileSync,
readdirSync,
rmSync,
symlinkSync,
writeFileSync,
} from 'node:fs';
import { tmpdir } from 'node:os';
import path from 'node:path';
import * as prettier from 'prettier';
const repoRoot = path.resolve(import.meta.dirname, '../..');
const acceptRoot = path.resolve(
process.env.ACCEPT_ROOT ?? path.join(repoRoot, '..', 'krona-staff-acceptance'),
);
// ---------- аргументы ----------
const argv = process.argv.slice(2);
const opts = {
base: 'origin/codex/bootstrap-platform-foundation',
formatOnly: [],
skipCheck: false,
skipIntegration: false,
skipE2e: false,
out: null,
branches: [],
};
for (let i = 0; i < argv.length; i += 1) {
const arg = argv[i];
if (arg === '--base') opts.base = argv[++i];
else if (arg === '--format-check') opts.formatOnly.push(argv[++i]);
else if (arg === '--skip-check') opts.skipCheck = true;
else if (arg === '--skip-integration') opts.skipIntegration = true;
else if (arg === '--skip-e2e') opts.skipE2e = true;
else if (arg === '--out') opts.out = argv[++i];
else if (arg === '-h' || arg === '--help') {
console.log(readFileSync(new URL(import.meta.url), 'utf8').split('\nimport ')[0]);
process.exit(0);
} else if (arg.startsWith('--')) {
console.error(`неизвестная опция ${arg}`);
process.exit(2);
} else opts.branches.push(arg);
}
if (opts.branches.length === 0 && opts.formatOnly.length === 0) {
console.error('Usage: node scripts/test/accept-branch.mjs [--base ref] <branch> [<branch>…]');
process.exit(2);
}
// ---------- утилиты ----------
const stamp = new Date().toISOString().replace(/[:.]/g, '-').slice(0, 19);
const logDir = path.join(repoRoot, 'tests/e2e/evidence/accept', stamp);
mkdirSync(logDir, { recursive: true });
const progress = (msg) => process.stderr.write(`[accept] ${msg}\n`);
const stripAnsi = (s) => s.replace(/\x1b\[[0-9;]*m/g, '');
function git(args, { cwd = repoRoot, allowFail = false } = {}) {
const r = spawnSync('git', args, { cwd, encoding: 'utf8', maxBuffer: 64 * 1024 * 1024 });
if (r.status !== 0 && !allowFail) {
throw new Error(`git ${args.join(' ')} → exit ${r.status}: ${r.stderr}`);
}
return { code: r.status, out: r.stdout, err: r.stderr };
}
function run(command, args, { cwd, env, logFile, timeoutMs = 20 * 60_000 }) {
return new Promise((resolve) => {
const child = spawn(command, args, { cwd, env });
let out = '';
const timer = setTimeout(() => {
out += `\n[accept] TIMEOUT ${timeoutMs} ms — процесс убит\n`;
child.kill('SIGKILL');
}, timeoutMs);
for (const stream of [child.stdout, child.stderr]) {
stream.on('data', (chunk) => {
out += chunk.toString();
});
}
child.on('close', (code) => {
clearTimeout(timer);
const clean = stripAnsi(out);
if (logFile) writeFileSync(logFile, `$ ${[command, ...args].join(' ')}\n${redact(clean)}`);
resolve({ code: code ?? 1, out: clean });
});
});
}
/** Пароли в URL не должны попасть в логи/отчёт. */
function redact(text) {
return text.replace(/(postgres(?:ql)?:\/\/[^:\s/]+:)[^@\s]+@/g, '$1***@');
}
/** Окружение без переменных БД и секретов стенда. */
function cleanEnv(extra = {}) {
const env = {};
for (const [key, value] of Object.entries(process.env)) {
if (/^(DATABASE_|E2E_|TEST_DATABASE|POSTGRES_|KRONA_)/.test(key)) continue;
env[key] = value;
}
return { ...env, NO_COLOR: '1', FORCE_COLOR: '0', ...extra };
}
function resolveRef(name) {
const candidates = name.startsWith('origin/') ? [name] : [`origin/${name}`, name];
for (const candidate of candidates) {
const r = git(['rev-parse', '--verify', '--quiet', `${candidate}^{commit}`], {
allowFail: true,
});
if (r.code === 0) return { name, ref: candidate, sha: r.out.trim() };
}
throw new Error(`ветка ${name} не найдена (ни origin/${name}, ни локально)`);
}
const slugOf = (name) =>
name
.replace(/^origin\//, '')
.replace(/[^A-Za-z0-9._-]+/g, '-')
.slice(0, 80);
const short = (sha) => sha.slice(0, 7);
function firstError(text) {
const lines = text.split('\n');
const patterns = [
/((?:[A-Za-z]+Error|error): .+)$/,
/(AssertionError.+)$/,
/(Error: .+)$/,
/(FAIL .+)$/,
];
for (const pattern of patterns) {
for (const line of lines) {
const m = pattern.exec(line.trim());
if (m) return m[1].replace(/\|/g, '\\|').slice(0, 180);
}
}
return '';
}
// ---------- БД ----------
const presetMigrator = process.env.E2E_MIGRATOR_URL;
const presetApp = process.env.E2E_APP_URL;
const adminUrl = process.env.E2E_PG_ADMIN_URL;
const dbMode = presetMigrator && presetApp ? 'preset' : 'admin';
let roleUrls = null;
function withDatabase(url, database) {
const u = new URL(url);
u.pathname = `/${database}`;
return u.toString();
}
async function prepareRoles() {
if (dbMode === 'preset') {
roleUrls = { migrator: presetMigrator, app: presetApp };
return 'роли из E2E_MIGRATOR_URL/E2E_APP_URL (не пересоздавались)';
}
if (!adminUrl) {
throw new Error('нужен E2E_PG_ADMIN_URL или пара E2E_MIGRATOR_URL + E2E_APP_URL');
}
const dir = mkdtempSync(path.join(tmpdir(), 'krona-accept-'));
const envFile = path.join(dir, 'db.env');
const r = await run(
process.execPath,
['scripts/test/provision-pg.mjs', '--out', envFile, '--db', 'krona_acc_roles'],
{ cwd: repoRoot, env: { ...cleanEnv(), E2E_PG_ADMIN_URL: adminUrl } },
);
if (r.code !== 0) throw new Error(`provision: ${r.out}`);
const env = Object.fromEntries(
readFileSync(envFile, 'utf8')
.split('\n')
.filter((l) => l.includes('='))
.map((l) => [l.slice(0, l.indexOf('=')), l.slice(l.indexOf('=') + 1)]),
);
rmSync(dir, { recursive: true, force: true });
roleUrls = { migrator: env.DATABASE_MIGRATION_URL, app: env.DATABASE_URL };
return 'роли созданы один раз через E2E_PG_ADMIN_URL (provision-pg), дальше --roles keep';
}
/** Чистая БД: в admin-режиме пересоздаётся, в preset — одна и та же (сброс откатом миграций). */
async function freshDatabase(name) {
if (dbMode === 'preset') return { ...roleUrls, reset: 'roundtrip' };
const r = await run(
process.execPath,
['scripts/test/provision-pg.mjs', '--roles', 'keep', '--db', name],
{ cwd: repoRoot, env: { ...cleanEnv(), E2E_PG_ADMIN_URL: adminUrl } },
);
if (r.code !== 0) throw new Error(`provision --roles keep ${name}: ${r.out}`);
return {
migrator: withDatabase(roleUrls.migrator, name),
app: withDatabase(roleUrls.app, name),
admin: withDatabase(adminUrl, name),
reset: 'up',
};
}
// ---------- worktree ----------
const repoLockHash = createHash('sha256')
.update(readFileSync(path.join(repoRoot, 'package-lock.json')))
.digest('hex');
async function prepareWorktree(target) {
const dir = path.join(acceptRoot, `wt-${slugOf(target.name)}`);
mkdirSync(acceptRoot, { recursive: true });
const registered = git(['worktree', 'list', '--porcelain'])
.out.split('\n')
.some((l) => l === `worktree ${dir}`);
if (registered) git(['worktree', 'remove', '--force', dir]);
else if (existsSync(dir)) throw new Error(`${dir} существует, но не является worktree`);
git(['worktree', 'add', '--detach', '--force', dir, target.sha]);
const lock = git(['show', `${target.sha}:package-lock.json`]).out;
const lockSame = createHash('sha256').update(lock).digest('hex') === repoLockHash;
if (lockSame) {
symlinkSync(path.join(repoRoot, 'node_modules'), path.join(dir, 'node_modules'), 'dir');
} else {
progress(`${target.name}: package-lock.json отличается — npm ci`);
const r = await run('npm', ['ci', '--no-audit', '--no-fund'], {
cwd: dir,
env: cleanEnv(),
logFile: path.join(logDir, `${slugOf(target.name)}-npm-ci.txt`),
});
if (r.code !== 0) throw new Error(`${target.name}: npm ci exit ${r.code}`);
}
// Оверлей стенда: tests/e2e/** (без evidence) и scripts/test/**.
const overlayTouched = git([
'diff',
'--name-only',
`${opts.baseTarget.sha}...${target.sha}`,
'--',
'tests/e2e',
'scripts/test',
]).out.trim();
for (const rel of ['tests/e2e', 'scripts/test']) {
rmSync(path.join(dir, rel), { recursive: true, force: true });
cpSync(path.join(repoRoot, rel), path.join(dir, rel), {
recursive: true,
filter: (src) => !src.includes(`${path.sep}tests${path.sep}e2e${path.sep}evidence`),
});
}
return {
dir,
deps: lockSame ? 'symlink' : 'npm ci',
overlayTouched: overlayTouched ? overlayTouched.split('\n') : [],
};
}
// ---------- шаги ----------
async function stepCheck(target, wt) {
progress(`${target.name}: npm run check`);
const r = await run('npm', ['run', 'check'], {
cwd: wt.dir,
env: cleanEnv(),
logFile: path.join(logDir, `${slugOf(target.name)}-check.txt`),
});
const tests = /Tests\s+(.+)$/m.exec(r.out)?.[1]?.trim() ?? '';
return { ok: r.code === 0, code: r.code, tests, error: r.code === 0 ? '' : firstError(r.out) };
}
async function migrate(wt, db, logFile) {
const r = await run(process.execPath, ['scripts/test/migrate-roundtrip.mjs', db.reset], {
cwd: wt.dir,
env: cleanEnv({ DATABASE_MIGRATION_URL: db.migrator }),
logFile,
});
return r;
}
async function stepIntegration(target, wt) {
const dir = path.join(wt.dir, 'tests/integration');
const files = existsSync(dir)
? readdirSync(dir)
.filter((f) => f.endsWith('.test.ts'))
.sort()
: [];
const results = [];
for (const file of files) {
progress(`${target.name}: integration ${file}`);
const slug = `${slugOf(target.name)}-it-${file.replace(/\.test\.ts$/, '')}`;
const db = await freshDatabase('krona_acc_it');
const m = await migrate(wt, db, path.join(logDir, `${slug}-migrate.txt`));
if (m.code !== 0) {
results.push({
file,
ok: false,
files: '',
tests: '',
error: `миграции: ${firstError(m.out)}`,
});
continue;
}
const r = await run(
process.execPath,
[path.join(wt.dir, 'node_modules/vitest/vitest.mjs'), 'run', `tests/integration/${file}`],
{
cwd: wt.dir,
env: cleanEnv({
DATABASE_TEST_URL: db.app,
DATABASE_TEST_MIGRATION_URL: db.migrator,
DATABASE_MIGRATION_URL: db.migrator,
}),
logFile: path.join(logDir, `${slug}.txt`),
},
);
const filesLine = /Test Files\s+(.+)$/m.exec(r.out)?.[1]?.trim() ?? '(нет строки Test Files)';
const testsLine = /^\s*Tests\s+(.+)$/m.exec(r.out)?.[1]?.trim() ?? '';
const ok = r.code === 0 && /\bpassed\b/.test(filesLine) && !/failed/.test(filesLine);
results.push({
file,
ok,
files: filesLine,
tests: testsLine,
error: ok ? '' : firstError(r.out),
});
}
return results;
}
function loadE2e(file) {
const map = new Map();
for (const f of JSON.parse(readFileSync(file, 'utf8')).testResults) {
for (const t of f.assertionResults) {
const id = /^\[([A-Z0-9/-]+)\]/.exec(t.title)?.[1] ?? 'UNTAGGED';
const e = map.get(id) ?? { pass: 0, fail: 0, msg: '' };
if (t.status === 'passed') e.pass += 1;
else if (t.status === 'failed') {
e.fail += 1;
e.msg ||= stripAnsi(t.failureMessages?.[0] ?? '')
.split('\n')[0]
.replace(/\s+/g, ' ')
.replace(/\|/g, '\\|')
.slice(0, 160);
}
map.set(id, e);
}
}
return map;
}
async function stepE2e(target, wt) {
progress(`${target.name}: e2e`);
const db = await freshDatabase('krona_acc_e2e');
const slug = slugOf(target.name);
const env = cleanEnv({
E2E_MIGRATOR_URL: db.migrator,
E2E_APP_URL: db.app,
E2E_MIGRATIONS: 'roundtrip',
...(db.admin ? { E2E_DB_ADMIN_URL: db.admin } : {}),
});
const r = await run(process.execPath, ['scripts/test/run-e2e.mjs'], {
cwd: wt.dir,
env,
logFile: path.join(logDir, `${slug}-e2e.txt`),
});
const jsonPath = path.join(wt.dir, 'tests/e2e/evidence/e2e-results.json');
if (!existsSync(jsonPath))
return { ok: false, code: r.code, map: null, error: firstError(r.out) };
cpSync(jsonPath, path.join(logDir, `${slug}-e2e-results.json`));
const map = loadE2e(jsonPath);
const red = [...map.values()].filter((e) => e.fail > 0).length;
const verdict = /\[run-e2e\] (OK|FAIL)[^\n]*/.exec(r.out)?.[0] ?? '';
return { ok: r.code === 0, code: r.code, map, red, green: map.size - red, verdict };
}
function diffE2e(baseMap, map) {
const color = (e) => (e ? (e.fail ? 'RED' : 'GREEN') : 'нет');
const turnedGreen = [];
const turnedRed = [];
const other = [];
for (const id of [...new Set([...baseMap.keys(), ...map.keys()])].sort()) {
const a = color(baseMap.get(id));
const b = color(map.get(id));
if (a === b) continue;
if (a === 'RED' && b === 'GREEN') turnedGreen.push(id);
else if (a === 'GREEN' && b === 'RED') turnedRed.push(`${id} (${map.get(id).msg})`);
else other.push(`${id}: ${a}→${b}`);
}
return { turnedGreen, turnedRed, other };
}
function mergeTree(a, b) {
const r = git(['merge-tree', '--write-tree', '--name-only', '--no-messages', a.sha, b.sha], {
allowFail: true,
});
if (r.code === 0) return { status: 'чисто', files: [] };
if (r.code === 1) {
const files = r.out.split('\n').slice(1).filter(Boolean);
return { status: 'КОНФЛИКТ', files };
}
return { status: `ошибка git (exit ${r.code})`, files: [r.err.trim()] };
}
// ---------- форматирование без поведения ----------
async function prettierConfigFor(file) {
const anchor = path.join(opts.baseWorktree ?? repoRoot, file);
const config = (await prettier.resolveConfig(anchor)) ?? {};
const info = await prettier.getFileInfo(anchor, {
ignorePath: path.join(opts.baseWorktree ?? repoRoot, '.prettierignore'),
});
return { config, info };
}
async function normalize(content, file) {
const { config, info } = await prettierConfigFor(file);
if (info.ignored || !info.inferredParser) return content;
return prettier.format(content, { ...config, filepath: file });
}
function showFile(sha, file) {
const r = git(['show', `${sha}:${file}`], { allowFail: true });
return r.code === 0 ? r.out : null;
}
function changedLines(before, after) {
const dir = mkdtempSync(path.join(tmpdir(), 'krona-fmt-'));
writeFileSync(path.join(dir, 'a'), before);
writeFileSync(path.join(dir, 'b'), after);
const r = git(['diff', '--no-index', '-U0', '--no-color', 'a', 'b'], {
cwd: dir,
allowFail: true,
});
rmSync(dir, { recursive: true, force: true });
return r.out
.split('\n')
.filter((l) => /^[+-]/.test(l) && !/^(\+\+\+|---) /.test(l))
.map((l) => `${l[0]}${l.slice(1).trim()}`)
.filter((l) => l.length > 1);
}
async function formatCheck(target, others) {
const mergeBase = git(['merge-base', opts.baseTarget.sha, target.sha]).out.trim();
const changes = git(['diff', '--name-status', '--no-renames', mergeBase, target.sha])
.out.trim()
.split('\n')
.filter(Boolean)
.map((l) => ({ status: l[0], file: l.split('\t')[1] }));
const rows = [];
for (const { status, file } of changes) {
const touchesConfig = /(^|\/)\.prettier(rc|ignore)|prettier\.config\./.test(file);
if (status === 'D') {
rows.push({ file, verdict: 'удалён', lines: 0, same: [] });
continue;
}
const after = showFile(target.sha, file);
const before = status === 'A' ? null : showFile(mergeBase, file);
if (before === null) {
rows.push({ file, verdict: 'новый файл', lines: 0, same: [] });
continue;
}
const formatted = await normalize(before, file);
if (formatted === after && !touchesConfig) continue; // чистое форматирование
const lines = changedLines(formatted, after);
const same = [];
for (const other of others) {
const otherContent = showFile(other.sha, file);
if (otherContent === null) continue;
const otherMergeBase = git(['merge-base', opts.baseTarget.sha, other.sha]).out.trim();
const otherBase = showFile(otherMergeBase, file) ?? '';
const otherLines = new Set(
changedLines(await normalize(otherBase, file), await normalize(otherContent, file)),
);
const matched = lines.filter((l) => otherLines.has(l)).length;
if (matched > 0) same.push(`${other.name}: ${matched}/${lines.length}`);
}
rows.push({
file,
verdict: touchesConfig ? 'меняет конфиг prettier' : 'не совпадает с prettier(база)',
lines: lines.length,
same,
});
}
return { mergeBase, files: changes.length, rows };
}
// ---------- main ----------
opts.baseTarget = resolveRef(opts.base);
const targets = opts.branches.map(resolveRef);
const formatTargets = [
...targets.filter((t) => /format/i.test(t.name)),
...opts.formatOnly.map(resolveRef),
];
const harnessHead = git(['rev-parse', '--short', 'HEAD']).out.trim();
const harnessDirty = git(['status', '--porcelain', '--', 'tests/e2e', 'scripts/test']).out.trim()
? ' (+ незакоммиченные изменения стенда)'
: '';
const needRuns = !(opts.skipCheck && opts.skipIntegration && opts.skipE2e) && targets.length > 0;
let dbNote = 'не использовалась';
if (needRuns && !(opts.skipIntegration && opts.skipE2e)) dbNote = await prepareRoles();
const results = new Map();
const all = [opts.baseTarget, ...targets];
for (const target of all) {
const res = { target };
results.set(target, res);
if (!needRuns && target !== opts.baseTarget) continue;
try {
const wt = await prepareWorktree(target);
res.wt = wt;
if (target === opts.baseTarget) opts.baseWorktree = wt.dir;
if (!needRuns) continue;
if (!opts.skipCheck) res.check = await stepCheck(target, wt);
if (!opts.skipIntegration) res.integration = await stepIntegration(target, wt);
if (!opts.skipE2e) res.e2e = await stepE2e(target, wt);
} catch (error) {
res.fatal = redact(String(error?.message ?? error)).slice(0, 300);
progress(`${target.name}: ${res.fatal}`);
}
}
const formatResults = [];
for (const target of formatTargets) {
progress(`${target.name}: проверка форматирования`);
const others = targets.filter((t) => t !== target && !/format/i.test(t.name));
formatResults.push({ target, ...(await formatCheck(target, others)) });
}
// ---------- markdown ----------
const md = [];
const baseRes = results.get(opts.baseTarget);
md.push(`## accept-branch — ${new Date().toISOString().slice(0, 16).replace('T', ' ')} UTC`);
md.push('');
md.push(
`База: \`${opts.baseTarget.ref}\` = ${short(opts.baseTarget.sha)}; стенд (оверлей tests/e2e, scripts/test): ${harnessHead}${harnessDirty}; БД: ${dbNote}. Логи: \`${path.relative(repoRoot, logDir)}/\`.`,
);
md.push('');
if (needRuns) {
md.push('### Сводка');
md.push('');
md.push(
'| Ветка | SHA | npm run check | integration (красные файлы) | e2e red/green | e2e: стало зелёным | e2e: стало красным |',
);
md.push('|---|---|---|---|---|---|---|');
for (const res of results.values()) {
const t = res.target;
const isBase = t === opts.baseTarget;
const name = isBase ? `${t.ref} (база)` : t.ref;
if (res.fatal) {
md.push(`| ${name} | ${short(t.sha)} | ОШИБКА: ${res.fatal} | | | | |`);
continue;
}
const check = res.check
? res.check.ok
? `OK (${res.check.tests})`
: `FAIL exit=${res.check.code}: ${res.check.error}`
: 'не запускался';
const reds = res.integration?.filter((r) => !r.ok).map((r) => r.file) ?? [];
const integration = res.integration
? `${res.integration.length - reds.length}/${res.integration.length} зелёных${reds.length ? `; FAIL: ${reds.join(', ')}` : ''}`
: 'не запускались';
let e2e = 'не запускался';
let green = '';
let red = '';
if (res.e2e?.map) {
e2e = `${res.e2e.red}/${res.e2e.green}`;
if (!isBase && baseRes.e2e?.map) {
const d = diffE2e(baseRes.e2e.map, res.e2e.map);
green = d.turnedGreen.join(', ') || '—';
red = [...d.turnedRed, ...d.other].join('; ') || '—';
}
} else if (res.e2e) e2e = `стенд не поднялся: ${res.e2e.error}`;
if (isBase) {
green = 'n/a';
red = 'n/a';
}
md.push(
`| ${name} | ${short(t.sha)} | ${check} | ${integration} | ${e2e} | ${green} | ${red} |`,
);
}
md.push('');
if (
results.values().next().value.integration ||
targets.some((t) => results.get(t).integration)
) {
md.push('### Интеграционные тесты (каждый файл отдельно, свежая БД)');
md.push('');
md.push('| Ветка | Файл | Итог | Test Files | Tests | Первая ошибка |');
md.push('|---|---|---|---|---|---|');
for (const res of results.values()) {
for (const r of res.integration ?? []) {
md.push(
`| ${res.target.ref} | ${r.file} | ${r.ok ? 'OK' : 'FAIL'} | ${r.files} | ${r.tests} | ${r.error} |`,
);
}
}
md.push('');
}
const overlayNotes = [...results.values()].filter((r) => r.wt?.overlayTouched.length);
if (overlayNotes.length) {
md.push('Внимание: ветка меняла файлы стенда, при приёмке они заменены оверлеем:');
for (const r of overlayNotes) md.push(`- ${r.target.ref}: ${r.wt.overlayTouched.join(', ')}`);
md.push('');
}
const npmCi = [...results.values()].filter((r) => r.wt?.deps === 'npm ci');
if (npmCi.length)
md.push(`npm ci (lock отличается): ${npmCi.map((r) => r.target.ref).join(', ')}\n`);
}
if (targets.length > 0) {
md.push('### Попарные слияния (`git merge-tree --write-tree`)');
md.push('');
md.push('| Ветка A | Ветка B | Результат | Конфликтные файлы |');
md.push('|---|---|---|---|');
const pairs = [];
for (const t of targets) pairs.push([opts.baseTarget, t]);
for (let i = 0; i < targets.length; i += 1) {
for (let j = i + 1; j < targets.length; j += 1) pairs.push([targets[i], targets[j]]);
}
for (const [a, b] of pairs) {
const r = mergeTree(a, b);
md.push(`| ${a.ref} | ${b.ref} | ${r.status} | ${r.files.join(', ')} |`);
}
md.push('');
}
if (formatResults.length) {
md.push('### Форматирование без поведения');
md.push('');
for (const f of formatResults) {
const verdict = f.rows.length === 0 ? 'ТОЛЬКО ФОРМАТИРОВАНИЕ' : 'ЕСТЬ ИЗМЕНЕНИЯ СВЕРХ PRETTIER';
md.push(
`**${f.target.ref}** (${short(f.target.sha)}), merge-base ${short(f.mergeBase)}, изменено файлов: ${f.files} — ${verdict}`,
);
md.push('');
if (f.rows.length) {
md.push('| Файл | Почему | Строк сверх prettier | Эти же строки в ветке |');
md.push('|---|---|---|---|');
for (const r of f.rows) {
md.push(`| ${r.file} | ${r.verdict} | ${r.lines} | ${r.same.join('; ') || '—'} |`);
}
md.push('');
}
}
}
const text = redact(md.join('\n'));
writeFileSync(path.join(logDir, 'summary.md'), text);
if (opts.out) writeFileSync(opts.out, text);
console.log(text);