import { describe, it, expect } from 'vitest'; import { buildHookMap, resolveScriptCounts, extractScriptName } from './observer-hook-resolver.mjs'; describe('extractScriptName', () => { it('extracts tools/X.mjs from "node tools/observer-stop-hook.mjs"', () => { expect(extractScriptName('node tools/observer-stop-hook.mjs')).toBe('tools/observer-stop-hook.mjs'); }); it('extracts tools/X.mjs from quoted path with cwd', () => { expect(extractScriptName('node "C:/моя/проекты/портал crm/Документация/tools/subagent-prompt-prefix.mjs"')) .toBe('tools/subagent-prompt-prefix.mjs'); }); it('extracts npx package name', () => { expect(extractScriptName('npx -y markdownlint-cli2 --fix file.md')).toBe('markdownlint-cli2'); }); it('falls back to inline: for node -e inline scripts', () => { const result = extractScriptName('node -e "const f=process.env.X; if(f) process.stderr.write(\'warn\');"'); expect(result).toMatch(/^inline:[0-9a-f]{16}$/); }); it('inline fallback is stable across whitespace formatting', () => { const a = extractScriptName('node -e "const f = 1;\n\nif(f) process.exit(0);"'); const b = extractScriptName('node -e "const f = 1; if(f) process.exit(0);"'); expect(a).toBe(b); }); it('inline fallback differs for different commands', () => { const a = extractScriptName('node -e "process.exit(0);"'); const b = extractScriptName('node -e "process.exit(1);"'); expect(a).not.toBe(b); }); it('extracts tools/X.mjs from Windows backslash path', () => { expect(extractScriptName('node tools\\observer-stop-hook.mjs')).toBe('tools/observer-stop-hook.mjs'); }); it('extracts tools/X.mjs from full Windows abs path with backslashes', () => { expect(extractScriptName('node C:\\path\\tools\\foo.mjs')).toBe('tools/foo.mjs'); }); }); describe('buildHookMap', () => { it('returns empty Map for empty settings', () => { expect(buildHookMap({}).size).toBe(0); }); it('handles missing hooks key', () => { expect(buildHookMap({ permissions: {} }).size).toBe(0); }); it('builds matcher → [scripts] for single-matcher single-script', () => { const settings = { hooks: { PreToolUse: [ { matcher: 'Bash', hooks: [{ type: 'command', command: 'node tools/foo.mjs' }] }, ], }, }; const map = buildHookMap(settings); expect(map.get('PreToolUse:Bash')).toEqual(['tools/foo.mjs']); }); it('aggregates multiple scripts per matcher', () => { const settings = { hooks: { PreToolUse: [ { matcher: 'Bash', hooks: [ { type: 'command', command: 'node tools/foo.mjs' }, { type: 'command', command: 'node tools/bar.mjs' }, ]}, ], }, }; expect(buildHookMap(settings).get('PreToolUse:Bash')).toEqual(['tools/foo.mjs', 'tools/bar.mjs']); }); it('uses event name without matcher for UserPromptSubmit-style hooks', () => { const settings = { hooks: { UserPromptSubmit: [ { hooks: [{ type: 'command', command: 'node tools/economy.mjs' }] }, ], }, }; expect(buildHookMap(settings).get('UserPromptSubmit')).toEqual(['tools/economy.mjs']); }); it('merges project + user settings (project takes precedence on dup matcher)', () => { const project = { hooks: { PreToolUse: [{ matcher: 'Bash', hooks: [{ type: 'command', command: 'node tools/a.mjs' }] }] }, }; const user = { hooks: { PreToolUse: [{ matcher: 'Bash', hooks: [{ type: 'command', command: 'node tools/b.mjs' }] }] }, }; const map = buildHookMap(project, user); expect(map.get('PreToolUse:Bash')).toEqual(['tools/a.mjs', 'tools/b.mjs']); }); it('splits combined matcher "Edit|Write" into two map entries', () => { const settings = { hooks: { PreToolUse: [ { matcher: 'Edit|Write', hooks: [{ type: 'command', command: 'node tools/guard.mjs' }] }, ], }, }; const map = buildHookMap(settings); expect(map.get('PreToolUse:Edit')).toEqual(['tools/guard.mjs']); expect(map.get('PreToolUse:Write')).toEqual(['tools/guard.mjs']); expect(map.get('PreToolUse:Edit|Write')).toBeUndefined(); }); it('trims whitespace around matchers split on |', () => { const settings = { hooks: { PreToolUse: [ { matcher: 'Edit | Write', hooks: [{ type: 'command', command: 'node tools/g.mjs' }] }, ], }, }; const map = buildHookMap(settings); expect(map.get('PreToolUse:Edit')).toEqual(['tools/g.mjs']); expect(map.get('PreToolUse:Write')).toEqual(['tools/g.mjs']); }); }); describe('resolveScriptCounts', () => { it('returns {} for empty matcherCounts', () => { expect(resolveScriptCounts({}, new Map())).toEqual({}); }); it('returns {} when matcher not in map', () => { expect(resolveScriptCounts({ 'PreToolUse:Bash': 5 }, new Map())).toEqual({}); }); it('duplicates count for each script on the matcher', () => { const map = new Map([['PreToolUse:Bash', ['tools/a.mjs', 'tools/b.mjs']]]); expect(resolveScriptCounts({ 'PreToolUse:Bash': 5 }, map)).toEqual({ 'tools/a.mjs': 5, 'tools/b.mjs': 5, }); }); it('sums across multiple matchers that share a script', () => { const map = new Map([ ['PreToolUse:Bash', ['tools/x.mjs']], ['PostToolUse:Bash', ['tools/x.mjs']], ]); expect(resolveScriptCounts({ 'PreToolUse:Bash': 3, 'PostToolUse:Bash': 2 }, map)) .toEqual({ 'tools/x.mjs': 5 }); }); });