Files
houseplan-card/test/pre-push-gate.test.mjs
Claude af62bd4c64 infra: local contour — pre-push gate:small by default, sandbox bootstrap, test:chunk
- .githooks/pre-push + scripts/pre-push-gate.mjs --hook: gate:small runs by
  default for issue/* branches with an executable diff; C/D-only diffs and
  non-issue refs are skipped, HP_PREPUSH_GATE=0 turns it off, =1 forces it.
  A non-HEAD push with an executable diff is rejected (the gate checks the
  working tree). The gate runs with every GIT_* variable removed: git gives
  hooks GIT_DIR (and GIT_CONFIG_PARAMETERS with the pusher's -c), and unit
  tests that `git init` temporary repositories otherwise write into the real
  one — seen on the first live run. The manual #343 mode is unchanged.
- scripts/sandbox-bootstrap.sh: idempotent worktree / deps / chromium (npm
  @sparticuz/chromium@152.0.0 + Playwright shims) / bundle / check steps,
  no owner paths or credentials.
- scripts/test-chunk.mjs, npm run test:chunk -- N/M: round-robin over
  test/*.test.mjs sorted by code point.
- Tests, seven mutants, docs/DEVELOPMENT.md «Локальный контур за 5 минут»,
  docs/TESTING.md. package.json changed → bundle rebuilt (fingerprint input).

Issue: #633
User-Visible: no
2026-09-24 05:00:45 +03:00

229 lines
12 KiB
JavaScript
Raw Permalink 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.
import test from 'node:test';
import assert from 'node:assert/strict';
import { spawnSync } from 'node:child_process';
import { chmodSync, copyFileSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync, existsSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { fileURLToPath } from 'node:url';
import {
candidateRefs, gateEnv, gateMode, isDocsOrGeneratedOnly, parsePushLines, planHook, runHook,
} from '../scripts/pre-push-gate.mjs';
// #633. pre-push-gate был единственным нетестированным гейтом и включался
// только по HP_PREPUSH_GATE=1. Теперь хук сам гонит gate:small для веток issue/*
// с исполняемым диффом; эти тесты держат решение хука (чистая planHook), его
// код выхода (runHook) и настоящую связку .githooks/pre-push → gate-small на
// временном репозитории: красный набор обязан остановить push (AC1).
const A = 'a'.repeat(40);
const B = 'b'.repeat(40);
const ZERO = '0'.repeat(40);
const line = (local, sha, remote, remoteSha = ZERO) => `${local} ${sha} ${remote} ${remoteSha}`;
test('строки pre-push разбираются, пустые отбрасываются (#633)', () => {
const refs = parsePushLines(`${line('refs/heads/issue/1-x', A, 'refs/heads/issue/1-x')}\n\n \n`);
assert.deepEqual(refs, [{ localRef: 'refs/heads/issue/1-x', localSha: A, remoteRef: 'refs/heads/issue/1-x', remoteSha: ZERO }]);
assert.deepEqual(parsePushLines(''), []);
assert.deepEqual(parsePushLines(undefined), []);
});
test('HP_PREPUSH_GATE: 0 выключает, 1 форсирует, иначе — по умолчанию (#633)', () => {
for (const off of ['0', 'off', 'false', 'no', ' 0 ']) assert.equal(gateMode({ HP_PREPUSH_GATE: off }), 'off', off);
for (const on of ['1', 'on', 'true', 'yes']) assert.equal(gateMode({ HP_PREPUSH_GATE: on }), 'force', on);
assert.equal(gateMode({}), 'default');
assert.equal(gateMode({ HP_PREPUSH_GATE: '' }), 'default');
});
test('кандидаты — ветки issue/*, не теги и не удаления; force — любая ветка (#633)', () => {
const refs = parsePushLines([
line('refs/heads/issue/1-x', A, 'refs/heads/issue/1-x'),
line('refs/heads/dev', A, 'refs/heads/dev'),
line('refs/tags/v1', A, 'refs/tags/v1'),
line('(delete)', ZERO, 'refs/heads/issue/2-y', B),
].join('\n'));
assert.deepEqual(candidateRefs(refs, 'default').map((r) => r.remoteRef), ['refs/heads/issue/1-x']);
assert.deepEqual(candidateRefs(refs, 'force').map((r) => r.remoteRef), ['refs/heads/issue/1-x', 'refs/heads/dev']);
});
test('только класс C/D — «нечего проверять»; хоть один исполняемый файл — проверять (#633)', () => {
assert.equal(isDocsOrGeneratedOnly(['docs/reviews/CODE-REVIEW-1-r1.md', 'dist/houseplan-card.js']), true);
assert.equal(isDocsOrGeneratedOnly(['docs/TESTING.md', 'scripts/x.mjs']), false);
assert.equal(isDocsOrGeneratedOnly(['docs/TESTING.md', 'unknown.bin']), false, 'неизвестный класс — не «документация»');
assert.equal(isDocsOrGeneratedOnly([]), false);
});
const plan = (overrides = {}) => planHook({
mode: 'default',
refs: parsePushLines(line('refs/heads/issue/1-x', A, 'refs/heads/issue/1-x')),
headSha: A,
mergeBase: () => B,
changedFiles: () => ['scripts/x.mjs'],
...overrides,
});
test('planHook: исполняемый дифф ветки issue/* на HEAD — прогон с базой merge-base (#633)', () => {
assert.deepEqual(
{ action: plan().action, base: plan().base, ref: plan().ref },
{ action: 'run', base: B, ref: 'refs/heads/issue/1-x' },
);
});
test('planHook: пропуски названы причиной (#633)', () => {
assert.equal(plan({ mode: 'off' }).action, 'skip');
assert.match(plan({ mode: 'off' }).reason, /HP_PREPUSH_GATE=0/);
const dev = plan({ refs: parsePushLines(line('refs/heads/dev', A, 'refs/heads/dev')) });
assert.equal(dev.action, 'skip');
assert.match(dev.reason, /нет веток issue/);
const docs = plan({ changedFiles: () => ['docs/reviews/CODE-REVIEW-1-r1.md'] });
assert.equal(docs.action, 'skip');
assert.match(docs.reason, /только класс C\/D/);
assert.equal(plan({ changedFiles: () => [] }).action, 'skip');
});
test('planHook: force гонит и документацию, и не-issue ветку (#633)', () => {
assert.equal(plan({ mode: 'force', changedFiles: () => ['docs/x.md'] }).action, 'run');
assert.equal(plan({ mode: 'force', refs: parsePushLines(line('refs/heads/dev', A, 'refs/heads/dev')) }).action, 'run');
});
test('planHook: без merge-base или диффа — прогон против origin/dev, а не пропуск (#633)', () => {
const noBase = plan({ mergeBase: () => null });
assert.equal(noBase.action, 'run');
assert.equal(noBase.base, 'origin/dev');
assert.equal(plan({ changedFiles: () => null }).action, 'run');
});
test('planHook: пушится не HEAD — отказ, а не зелёный вердикт чужому дереву (#633)', () => {
const foreign = plan({ headSha: B });
assert.equal(foreign.action, 'reject');
assert.match(foreign.reason, /не HEAD/);
assert.match(foreign.reason, /HP_PREPUSH_GATE=0/);
// Не-HEAD ветка с одними документами не мешает: её нечего проверять.
assert.equal(plan({ headSha: B, changedFiles: () => ['docs/x.md'] }).action, 'skip');
});
const fakeGit = (head = A) => (args) => {
if (args[0] === 'rev-parse') return head;
if (args[0] === 'merge-base') return B;
if (args[0] === 'diff') return 'scripts/x.mjs\n';
return null;
};
test('runHook: красный gate:small — код 1, зелёный — 0, отказ не запускает набор (#633)', () => {
const stdin = line('refs/heads/issue/1-x', A, 'refs/heads/issue/1-x');
const calls = [];
const log = () => {};
assert.equal(runHook({ stdin, env: {}, log, git: fakeGit(), runGate: (base) => { calls.push(base); return 1; } }), 1);
assert.equal(runHook({ stdin, env: {}, log, git: fakeGit(), runGate: (base) => { calls.push(base); return 0; } }), 0);
assert.deepEqual(calls, [B, B]);
assert.equal(runHook({ stdin, env: {}, log, git: fakeGit(B), runGate: () => { throw new Error('не должен запускаться'); } }), 1);
assert.equal(runHook({ stdin, env: { HP_PREPUSH_GATE: '0' }, log, git: fakeGit(), runGate: () => { throw new Error('выключен'); } }), 0);
});
test('gateEnv снимает все GIT_*: набор не должен писать в репозиторий хука (#633)', () => {
const env = gateEnv({ GIT_DIR: '/repo/.git', GIT_CONFIG_PARAMETERS: "'credential.helper'='x'", git_work_tree: 'w', PATH: '/bin', HP_X: '1' });
assert.deepEqual(env, { PATH: '/bin', HP_X: '1' });
});
// ---- настоящая связка хука ------------------------------------------------
const REPO = fileURLToPath(new URL('..', import.meta.url));
// Всё, что исполняет хук: сам хук, pre-push-gate и его импорты, процессный гейт.
const HOOK_FILES = [
'.githooks/pre-push',
'scripts/pre-push-gate.mjs',
'scripts/branch-state.mjs',
'scripts/process-gate.mjs',
'scripts/validate-commit-provenance.mjs',
'scripts/spawn-portable.mjs',
];
// Заглушка набора: код выхода и журнал вызовов задаёт тест.
const GATE_STUB = `import { appendFileSync } from 'node:fs';
const leaked = Object.keys(process.env).filter((key) => /^GIT_/i.test(key)).sort().join(',');
if (process.env.HP_TEST_GATE_LOG) appendFileSync(process.env.HP_TEST_GATE_LOG, process.argv.slice(2).join(' ') + ' env:' + leaked + '\\n');
process.exit(Number(process.env.HP_TEST_GATE_EXIT ?? 0));
`;
test('настоящий .githooks/pre-push: красный gate:small останавливает push ветки issue/* (#633 AC1)', (t) => {
if (spawnSync('git', ['--version']).status !== 0) { t.skip('git недоступен'); return; }
const root = mkdtempSync(join(tmpdir(), 'hp-prepush-'));
const dir = join(root, 'work');
const origin = join(root, 'origin.git');
const gateLog = join(root, 'gate.log');
// gh без учётных данных: статус issue (п.8) проверяет CI, здесь он не нужен и
// не должен ходить в сеть.
// Свои GIT_* тест тоже не наследует: запущенный из хука (gate:small внутри
// pre-push), он иначе писал бы в репозиторий хука, а не во временный.
const env = { ...gateEnv(process.env), GH_TOKEN: '', GITHUB_TOKEN: '', GH_CONFIG_DIR: join(root, 'gh'), HP_TEST_GATE_LOG: gateLog };
delete env.HP_PREPUSH_GATE;
const git = (...args) => {
const r = spawnSync('git', ['-C', dir, ...args], { encoding: 'utf8', env });
assert.equal(r.status, 0, `git ${args.join(' ')}: ${r.stderr}`);
return r.stdout.trim();
};
const write = (rel, text) => {
mkdirSync(join(dir, rel, '..'), { recursive: true });
writeFileSync(join(dir, rel), text);
};
const commit = (message) => {
git('add', '-A');
git('-c', 'user.name=t', '-c', 'user.email=t@t', '-c', 'core.hooksPath=/dev/null', 'commit', '-q', '-m', message);
};
const push = (branch, extraEnv = {}) => spawnSync('git',
['-C', dir, '-c', 'core.hooksPath=.githooks', 'push', '-q', 'origin', branch],
{ encoding: 'utf8', env: { ...env, ...extraEnv } });
const remoteHas = (branch) => git('ls-remote', 'origin', `refs/heads/${branch}`) !== '';
const gateCalls = () => (existsSync(gateLog) ? readFileSync(gateLog, 'utf8').split('\n').filter(Boolean) : []);
try {
mkdirSync(dir, { recursive: true });
assert.equal(spawnSync('git', ['init', '-q', '--bare', origin], { env }).status, 0);
git('init', '-q', '-b', 'dev');
for (const file of HOOK_FILES) {
mkdirSync(join(dir, file, '..'), { recursive: true });
copyFileSync(join(REPO, file), join(dir, file));
}
chmodSync(join(dir, '.githooks/pre-push'), 0o755);
write('scripts/gate-small.mjs', GATE_STUB);
commit('Base');
git('remote', 'add', 'origin', origin);
git('push', '-q', '--no-verify', 'origin', 'dev');
git('fetch', '-q', 'origin');
const base = git('rev-parse', 'HEAD');
git('checkout', '-q', '-b', 'issue/1-x');
write('scripts/x.mjs', 'export const x = 1;\n');
commit('Add x\n\nIssue: #1\nUser-Visible: no');
const red = push('issue/1-x', { HP_TEST_GATE_EXIT: '1' });
assert.notEqual(red.status, 0, `красный набор пропустил push:\n${red.stderr}`);
assert.match(red.stderr, /gate:small красный/);
assert.equal(remoteHas('issue/1-x'), false, 'ветка не должна была доехать');
// Один прогон, от merge-base с origin/dev, и ни одной переменной git: хук
// выставляет GIT_DIR, и без gateEnv юниты набора писали бы в этот репозиторий.
assert.deepEqual(gateCalls(), [`--base=${base} env:`], 'набор гонится один раз, от merge-base, без GIT_*');
const green = push('issue/1-x', { HP_TEST_GATE_EXIT: '0' });
assert.equal(green.status, 0, green.stderr);
assert.equal(remoteHas('issue/1-x'), true);
assert.equal(gateCalls().length, 2);
write('scripts/y.mjs', 'export const y = 1;\n');
commit('Add y\n\nIssue: #1\nUser-Visible: no');
const off = push('issue/1-x', { HP_TEST_GATE_EXIT: '1', HP_PREPUSH_GATE: '0' });
assert.equal(off.status, 0, `HP_PREPUSH_GATE=0 не выключил набор:\n${off.stderr}`);
assert.match(off.stderr, /HP_PREPUSH_GATE=0/);
assert.equal(gateCalls().length, 2, 'выключенный набор не запускается');
git('checkout', '-q', '-b', 'issue/2-docs', 'dev');
write('docs/reviews/CODE-REVIEW-2-r1.md', '# review\n');
commit('docs: review document for #2');
const docs = push('issue/2-docs', { HP_TEST_GATE_EXIT: '1' });
assert.equal(docs.status, 0, `дифф класса C не должен гнать набор:\n${docs.stderr}`);
assert.match(docs.stderr, /только класс C\/D/);
assert.equal(gateCalls().length, 2);
} finally {
rmSync(root, { recursive: true, force: true });
}
});