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:
parent
aa7f5f8bbd
commit
644ea7c6cb
1 changed files with 669 additions and 0 deletions
669
scripts/test/accept-branch.mjs
Normal file
669
scripts/test/accept-branch.mjs
Normal 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);
|
||||||
Loading…
Reference in a new issue