Files
portal/tools/enforce-verify-before-push.test.mjs
T
Дмитрий f44de52e08 fix(hooks): extractTestMetrics — recognise Vitest "passed | N skipped" formats
Pre-fix all three regexes in extractTestMetrics fell through when Vitest
output contained " | N skipped" between "passed" and "(TOTAL)" — so any
test suite with .skip()'ed tests produced sentinel result=fail (false
negative), blocking subsequent git commit.

Two new patterns:
- "Tests  N passed | M skipped (TOTAL)"
- "Tests  X failed | N passed | M skipped (TOTAL)"

Companion tests in tools/enforce-verify-record.test.mjs (new file matches
TDD-gate basename heuristic) and tools/enforce-verify-before-push.test.mjs.

Verified RED to GREEN: 38/38 tests pass after fix.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-26 19:25:44 +03:00

176 lines
6.7 KiB
JavaScript

import { describe, it, expect } from 'vitest';
import { decide } from './enforce-verify-before-push.mjs';
import { decideRecord, extractTestMetrics } from './enforce-verify-record.mjs';
describe('enforce-verify-record / decideRecord', () => {
it('returns null for non-Bash', () => {
expect(decideRecord({ toolName: 'Edit', command: 'foo' })).toBeNull();
});
it('returns null for non-test command', () => {
expect(decideRecord({ toolName: 'Bash', command: 'git status', exitCode: 0, stdout: '' })).toBeNull();
});
it('returns null for narrow vitest (specific test file)', () => {
expect(decideRecord({ toolName: 'Bash', command: 'npx vitest run tools/foo.test.mjs', exitCode: 0, stdout: '' })).toBeNull();
});
it('records PASS on full vitest run with all-passed summary', () => {
const rec = decideRecord({
toolName: 'Bash', command: 'npx vitest run', exitCode: 0,
stdout: 'Tests 3708 passed (3708)',
});
expect(rec.result).toBe('pass');
expect(rec.tests_total).toBe(3708);
expect(rec.tests_passed).toBe(3708);
});
it('records FAIL on full vitest run with failed summary', () => {
const rec = decideRecord({
toolName: 'Bash', command: 'npx vitest run', exitCode: 1,
stdout: 'Tests 3 failed | 600 passed (603)',
});
expect(rec.result).toBe('fail');
expect(rec.tests_failed).toBe(3);
});
it('records PASS when exit=1 but tests_failed=0 (infra file-load failures)', () => {
// E.g. worktree CRLF copies of test files crash to load → exit code 1
// but all actual tests passed.
const rec = decideRecord({
toolName: 'Bash', command: 'npx vitest run', exitCode: 1,
stdout: 'Test Files 95 failed | 411 passed (506)\n Tests 8091 passed (8091)',
});
expect(rec.result).toBe('pass');
});
it('records pest', () => {
const rec = decideRecord({
toolName: 'Bash', command: 'composer test', exitCode: 0,
stdout: 'Tests: 742 passed (1908 assertions)',
});
expect(rec.result).toBe('pass');
});
});
describe('enforce-verify-record / extractTestMetrics', () => {
it('parses vitest all-passed', () => {
expect(extractTestMetrics('Tests 3708 passed (3708)')).toMatchObject({
tests_passed: 3708, tests_total: 3708, tests_failed: 0,
});
});
it('parses vitest mixed failure', () => {
expect(extractTestMetrics('Tests 1 failed | 631 passed (632)')).toMatchObject({
tests_failed: 1, tests_passed: 631, tests_total: 632,
});
});
it('parses vitest passed with skipped', () => {
// Vitest 4.x summary when some tests are .skip()'ed:
// "Tests 924 passed | 3 skipped (927)"
// Previously fell through all regexes → result=fail (false negative).
expect(extractTestMetrics('Tests 924 passed | 3 skipped (927)')).toMatchObject({
tests_passed: 924, tests_failed: 0, tests_total: 927,
});
});
it('parses vitest failed+passed+skipped triplet', () => {
expect(extractTestMetrics('Tests 1 failed | 920 passed | 3 skipped (924)')).toMatchObject({
tests_failed: 1, tests_passed: 920, tests_total: 924,
});
});
});
describe('enforce-verify-before-push / decide', () => {
it('allows non-Bash', () => {
expect(decide({ toolName: 'Edit', command: '' }).block).toBe(false);
});
it('allows non-git Bash', () => {
expect(decide({ toolName: 'Bash', command: 'ls -la' }).block).toBe(false);
});
it('blocks git commit without sentinel', () => {
const r = decide({ toolName: 'Bash', command: 'git commit -m "x"' });
expect(r.block).toBe(true);
expect(r.message).toMatch(/No verification/);
});
it('blocks git push without sentinel', () => {
expect(decide({ toolName: 'Bash', command: 'git push origin main' }).block).toBe(true);
});
it('blocks when sentinel result=fail', () => {
const r = decide({
toolName: 'Bash', command: 'git commit -m "x"',
sentinel: { result: 'fail', exit_code: 1, tests_passed: 600, tests_total: 603, tests_failed: 3 },
sentinelAge: 60,
});
expect(r.block).toBe(true);
expect(r.message).toMatch(/FAILED/);
});
it('blocks when sentinel is stale', () => {
const r = decide({
toolName: 'Bash', command: 'git commit -m "x"',
sentinel: { result: 'pass' },
sentinelAge: 60 * 60, // 1 hour > 30 min
});
expect(r.block).toBe(true);
expect(r.message).toMatch(/stale/);
});
it('allows when sentinel is fresh + pass', () => {
const r = decide({
toolName: 'Bash', command: 'git commit -m "x"',
sentinel: { result: 'pass' },
sentinelAge: 120,
});
expect(r.block).toBe(false);
});
it('allows when override phrase present', () => {
const r = decide({
toolName: 'Bash', command: 'git push',
sentinel: null,
override: { phrase: 'срочно', suppresses: ['verify-before-push'] },
});
expect(r.block).toBe(false);
});
it('emits helpful diagnostic when override phrase matched but justification missing', () => {
// Silent-reject bug fix: user typed "ремонт инфраструктуры" but forgot
// the "ремонт: <reason>" line. Old behaviour: generic "No verification artifact".
// New behaviour: explicit "phrase found but missing 'ремонт: <reason>' line".
const r = decide({
toolName: 'Bash', command: 'git commit -m "x"',
sentinel: null,
override: null,
overrideAttempt: {
phrase: 'ремонт инфраструктуры',
requires_justification: 'ремонт:',
suppresses: ['verify-before-push'],
},
});
expect(r.block).toBe(true);
expect(r.message).toMatch(/ремонт инфраструктуры/);
expect(r.message).toMatch(/ремонт:/);
expect(r.message).toMatch(/justification|причин/i);
});
it('falls back to generic message when overrideAttempt is null (phrase not even typed)', () => {
const r = decide({
toolName: 'Bash', command: 'git commit -m "x"',
sentinel: null,
override: null,
overrideAttempt: null,
});
expect(r.block).toBe(true);
expect(r.message).toMatch(/No verification/);
});
it('does NOT emit override-missing-justification diagnostic for overrides without requires_justification', () => {
// "срочно" doesn't need justification — if it matched, override would've been set.
// overrideAttempt without requires_justification means something else (logic bug),
// fall through to normal sentinel checks.
const r = decide({
toolName: 'Bash', command: 'git commit -m "x"',
sentinel: null,
override: null,
overrideAttempt: {
phrase: 'срочно',
suppresses: ['verify-before-push'],
// no requires_justification
},
});
expect(r.block).toBe(true);
expect(r.message).toMatch(/No verification/);
});
});