397777089e
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
77 lines
4.5 KiB
JavaScript
77 lines
4.5 KiB
JavaScript
// tools/guard-block-log.test.mjs
|
||
import { describe, it, expect } from 'vitest';
|
||
import { join } from 'node:path';
|
||
import { buildGuardBlockEntry, logGuardBlock, loadRecentBlocks, loadRecentEscapes } from './guard-block-log.mjs';
|
||
|
||
// OS-agnostic memFs: нормализует \\→/ (node:path.join на Windows даёт backslash; сиды — forward slash).
|
||
function memFs(seed = {}) {
|
||
const norm = (p) => String(p).replace(/\\/g, '/');
|
||
const s = new Map(Object.entries(seed).map(([k, v]) => [norm(k), v]));
|
||
return { s,
|
||
existsSync: (p) => s.has(norm(p)),
|
||
readdirSync: (d) => { const nd = norm(d); return [...s.keys()].filter((k) => k.startsWith(nd + '/')).map((k) => k.slice(nd.length + 1)); },
|
||
readFileSync: (p) => { const np = norm(p); if (!s.has(np)) { const e = new Error('ENOENT'); e.code = 'ENOENT'; throw e; } return s.get(np); },
|
||
appendFileSync: (p, d) => { const np = norm(p); s.set(np, (s.get(np) || '') + d); },
|
||
mkdirSync: () => {} };
|
||
}
|
||
const DIR = '/rt';
|
||
|
||
describe('buildGuardBlockEntry (pure)', () => {
|
||
it('собирает {ts,machine,action,reason}', () => {
|
||
const e = buildGuardBlockEntry({ machine: 'М5 Пол', action: 'bash:git push --force', reason: 'необратимое', now: 1000 });
|
||
expect(e).toEqual({ ts: 1000, machine: 'М5 Пол', action: 'bash:git push --force', reason: 'необратимое' });
|
||
});
|
||
});
|
||
|
||
describe('logGuardBlock (fail-quiet append)', () => {
|
||
it('пишет строку в guard-blocks-<sess>.jsonl, action из canonicalAction', () => {
|
||
const fs = memFs();
|
||
logGuardBlock({ tool_name: 'Bash', tool_input: { command: 'git push --force' }, session_id: 's1' },
|
||
'М5 Пол', 'необратимое', { fsImpl: fs, runtimeDir: DIR, now: 5 });
|
||
const raw = fs.s.get(join(DIR, 'guard-blocks-s1.jsonl').replace(/\\/g, '/'));
|
||
const rec = JSON.parse(raw.trim());
|
||
expect(rec.machine).toBe('М5 Пол');
|
||
expect(rec.reason).toBe('необратимое');
|
||
expect(rec.action).toBe('bash:git push --force'); // нормализовано
|
||
expect(rec.ts).toBe(5);
|
||
});
|
||
it('никогда не бросает (битый fs / битый event)', () => {
|
||
const throwFs = { appendFileSync: () => { throw new Error('disk'); }, mkdirSync: () => {} };
|
||
expect(() => logGuardBlock(null, 'X', 'y', { fsImpl: throwFs, runtimeDir: DIR, now: 1 })).not.toThrow();
|
||
});
|
||
});
|
||
|
||
describe('loadRecentBlocks (скан session-файлов, окно+сорт+cap)', () => {
|
||
it('собирает из всех guard-blocks-*.jsonl, окно, сорт desc, cap, ts→ISO', () => {
|
||
const fs = memFs({
|
||
[join(DIR, 'guard-blocks-a.jsonl')]: JSON.stringify({ ts: 100, machine: 'М5 Пол', action: 'bash:rm', reason: 'r1' }) + '\n',
|
||
[join(DIR, 'guard-blocks-b.jsonl')]: JSON.stringify({ ts: 300, machine: 'М2 Стена', action: 'write:x', reason: 'r2' }) + '\n'
|
||
+ JSON.stringify({ ts: 50, machine: 'М2 Стена', action: 'write:y', reason: 'old' }) + '\n',
|
||
[join(DIR, 'other.jsonl')]: 'ignored\n',
|
||
});
|
||
const r = loadRecentBlocks({ fsImpl: fs, runtimeDir: DIR, now: 350, windowMs: 1000, limit: 10 });
|
||
expect(r.map((x) => x.action)).toEqual(['write:x', 'bash:rm', 'write:y']); // desc by ts
|
||
expect(r[0].ts).toBe(new Date(300).toISOString());
|
||
});
|
||
it('окно отсекает старое; cap ограничивает; нет файлов → []', () => {
|
||
const fs = memFs({ [join(DIR, 'guard-blocks-a.jsonl')]: JSON.stringify({ ts: 10, machine: 'M', action: 'a', reason: 'r' }) + '\n' });
|
||
expect(loadRecentBlocks({ fsImpl: fs, runtimeDir: DIR, now: 100000, windowMs: 1000, limit: 10 })).toEqual([]);
|
||
expect(loadRecentBlocks({ fsImpl: memFs(), runtimeDir: DIR, now: 1, windowMs: 1000, limit: 10 })).toEqual([]);
|
||
});
|
||
});
|
||
|
||
describe('loadRecentEscapes (askuser-decisions floor_escape)', () => {
|
||
it('собирает floor_escape из всех askuser-decisions-*.jsonl, reason=label', () => {
|
||
const fs = memFs({
|
||
[join(DIR, 'askuser-decisions-s1.jsonl')]:
|
||
JSON.stringify({ type: 'floor_escape', action: 'bash:git push', ts: 200 }) + '\n'
|
||
+ JSON.stringify({ type: 'approve_git_operation', action: 'x', ts: 210 }) + '\n', // не floor_escape
|
||
});
|
||
const r = loadRecentEscapes({ fsImpl: fs, runtimeDir: DIR, now: 250, windowMs: 1000, limit: 10 });
|
||
expect(r).toHaveLength(1);
|
||
expect(r[0].action).toBe('bash:git push');
|
||
expect(r[0].reason).toBe('escape владельца');
|
||
expect(r[0].ts).toBe(new Date(200).toISOString());
|
||
});
|
||
});
|