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 ветка — «ТОЛЬКО ФОРМАТИРОВАНИЕ».
This commit is contained in:
grok 2026-09-26 16:06:59 +07:00
parent aa7f5f8bbd
commit 644ea7c6cb

View file

@ -0,0 +1,669 @@
#!/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);