Files
houseplan-card/test/pre-push-gate.test.mjs
Claudeandclaude[bot] 88c4e4e9ae infra(process): bundle and review index change only on the way to dev (#657)
Решения владельца: 1б — индекс ревью не пересобирается в ветке задачи,
только коммитами, идущими в dev; 2б — бандл меняет только кандидат
беты/релиза, стенд dev берёт его из артефакта Validate.

- scripts/bundle-policy.mjs: коммит, трогающий dist/** или
  custom_components/houseplan/frontend/**, обязан нести Release:
  (хук commit-msg и история в CI через validate-commit-provenance;
  коммиты с датой автора до 2026-09-27 не судятся); --verify судит
  целостность свежей сборки всегда, побайтовую сверку с закоммиченной
  копией — только на коммите, меняющем бандл, или кандидате; --clean.
- release-prerelease: публикация отказывает, если отпечаток исходников
  в закоммиченном манифесте не равен отпечатку дерева (хотфикс поверх
  кандидата без пересборки).
- bundle-sync: по умолчанию только demo/srv/assets; --release
  (npm run bundle:release) — ещё и custom_components.
- rebase-on-dev: конфликт в бандле берёт копию dev, без пересборки
  и amend.
- validate.yml: job dev_build публикует card-bundle головы dev в
  сиротскую ветку dev-build (scripts/dev-build.mjs); стенд накладывает
  её demo/stand/update-dev-bundle.sh.
- _process.yml: индекс ревью больше не пересобирается при приведении
  к dev и при публикации документа в ветку задачи.
- golden-wsl-artifact/golden-container: сборка перед съёмкой не
  считается правкой источника, после — bundle:clean.
- test/bundle-tree-committed: судит закоммиченный снимок, не диск.
- 11 мутантов в реестре; PROCESS/AGENTS/DEVELOPMENT/AUTHOR/REVIEWER.

Issue: #657
User-Visible: no
2026-09-26 07:28:23 +00:00

231 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/bundle-policy.mjs', // #657: правило бандла в проверке происхождения
'scripts/bundle-tree.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 });
}
});