Initial commit
This commit is contained in:
250
hooks/hooks-app/__tests__/config.test.ts
Normal file
250
hooks/hooks-app/__tests__/config.test.ts
Normal file
@@ -0,0 +1,250 @@
|
||||
// plugin/hooks/hooks-app/__tests__/config.test.ts
|
||||
import { loadConfig, resolvePluginPath } from '../src/config';
|
||||
import * as fs from 'fs/promises';
|
||||
import * as path from 'path';
|
||||
import * as os from 'os';
|
||||
|
||||
describe('Config Loading', () => {
|
||||
let testDir: string;
|
||||
|
||||
beforeEach(async () => {
|
||||
testDir = await fs.mkdtemp(path.join(os.tmpdir(), 'hooks-test-'));
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await fs.rm(testDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
test('returns plugin defaults when no project config exists', async () => {
|
||||
// Config loader now returns plugin defaults when no project config exists
|
||||
// This provides fallback behavior without requiring every project to have gates.json
|
||||
const config = await loadConfig(testDir);
|
||||
expect(config).not.toBeNull();
|
||||
// Verify it's actually plugin defaults by checking for expected structure
|
||||
expect(config?.hooks).toBeDefined();
|
||||
expect(config?.gates).toBeDefined();
|
||||
});
|
||||
|
||||
test('loads .claude/gates.json with highest priority', async () => {
|
||||
const claudeDir = path.join(testDir, '.claude');
|
||||
await fs.mkdir(claudeDir);
|
||||
|
||||
const config1 = { hooks: {}, gates: { test: { command: 'claude-config' } } };
|
||||
const config2 = { hooks: {}, gates: { test: { command: 'root-config' } } };
|
||||
|
||||
await fs.writeFile(path.join(claudeDir, 'gates.json'), JSON.stringify(config1));
|
||||
await fs.writeFile(path.join(testDir, 'gates.json'), JSON.stringify(config2));
|
||||
|
||||
const config = await loadConfig(testDir);
|
||||
expect(config?.gates.test.command).toBe('claude-config');
|
||||
});
|
||||
|
||||
test('loads gates.json from root when .claude does not exist', async () => {
|
||||
const config1 = { hooks: {}, gates: { test: { command: 'root-config' } } };
|
||||
await fs.writeFile(path.join(testDir, 'gates.json'), JSON.stringify(config1));
|
||||
|
||||
const config = await loadConfig(testDir);
|
||||
expect(config?.gates.test.command).toBe('root-config');
|
||||
});
|
||||
|
||||
test('parses valid JSON config', async () => {
|
||||
const configObj = {
|
||||
hooks: {
|
||||
PostToolUse: {
|
||||
enabled_tools: ['Edit', 'Write'],
|
||||
gates: ['format', 'test']
|
||||
}
|
||||
},
|
||||
gates: {
|
||||
format: { command: 'npm run format', on_pass: 'CONTINUE' },
|
||||
test: { command: 'npm test', on_pass: 'CONTINUE' }
|
||||
}
|
||||
};
|
||||
|
||||
await fs.writeFile(path.join(testDir, 'gates.json'), JSON.stringify(configObj));
|
||||
|
||||
const config = await loadConfig(testDir);
|
||||
expect(config?.hooks.PostToolUse.enabled_tools).toEqual(['Edit', 'Write']);
|
||||
expect(config?.gates.format.command).toBe('npm run format');
|
||||
});
|
||||
|
||||
test('rejects unknown hook event', async () => {
|
||||
const configObj = {
|
||||
hooks: {
|
||||
UnknownEvent: { gates: [] }
|
||||
},
|
||||
gates: {}
|
||||
};
|
||||
|
||||
await fs.writeFile(path.join(testDir, 'gates.json'), JSON.stringify(configObj));
|
||||
|
||||
await expect(loadConfig(testDir)).rejects.toThrow('Unknown hook event');
|
||||
});
|
||||
|
||||
test('rejects undefined gate reference', async () => {
|
||||
const configObj = {
|
||||
hooks: {
|
||||
PostToolUse: { gates: ['nonexistent'] }
|
||||
},
|
||||
gates: {}
|
||||
};
|
||||
|
||||
await fs.writeFile(path.join(testDir, 'gates.json'), JSON.stringify(configObj));
|
||||
|
||||
await expect(loadConfig(testDir)).rejects.toThrow('references undefined gate');
|
||||
});
|
||||
|
||||
test('rejects invalid action', async () => {
|
||||
const configObj = {
|
||||
hooks: {
|
||||
PostToolUse: { gates: ['test'] }
|
||||
},
|
||||
gates: {
|
||||
test: { command: 'echo test', on_pass: 'INVALID' }
|
||||
}
|
||||
};
|
||||
|
||||
await fs.writeFile(path.join(testDir, 'gates.json'), JSON.stringify(configObj));
|
||||
|
||||
await expect(loadConfig(testDir)).rejects.toThrow(
|
||||
'is not CONTINUE/BLOCK/STOP or valid gate name'
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Plugin Path Resolution', () => {
|
||||
test('resolves sibling plugin using CLAUDE_PLUGIN_ROOT', () => {
|
||||
const originalEnv = process.env.CLAUDE_PLUGIN_ROOT;
|
||||
process.env.CLAUDE_PLUGIN_ROOT = '/home/user/.claude/plugins/turboshovel';
|
||||
|
||||
try {
|
||||
const result = resolvePluginPath('cipherpowers');
|
||||
expect(result).toBe('/home/user/.claude/plugins/cipherpowers');
|
||||
} finally {
|
||||
process.env.CLAUDE_PLUGIN_ROOT = originalEnv;
|
||||
}
|
||||
});
|
||||
|
||||
test('throws when CLAUDE_PLUGIN_ROOT not set', () => {
|
||||
const originalEnv = process.env.CLAUDE_PLUGIN_ROOT;
|
||||
delete process.env.CLAUDE_PLUGIN_ROOT;
|
||||
|
||||
try {
|
||||
expect(() => resolvePluginPath('cipherpowers')).toThrow(
|
||||
'Cannot resolve plugin path: CLAUDE_PLUGIN_ROOT not set'
|
||||
);
|
||||
} finally {
|
||||
process.env.CLAUDE_PLUGIN_ROOT = originalEnv;
|
||||
}
|
||||
});
|
||||
|
||||
test('rejects plugin names with path separators', () => {
|
||||
const originalEnv = process.env.CLAUDE_PLUGIN_ROOT;
|
||||
process.env.CLAUDE_PLUGIN_ROOT = '/home/user/.claude/plugins/turboshovel';
|
||||
|
||||
try {
|
||||
expect(() => resolvePluginPath('../etc')).toThrow(
|
||||
"Invalid plugin name: '../etc' (must not contain path separators)"
|
||||
);
|
||||
expect(() => resolvePluginPath('foo/bar')).toThrow(
|
||||
"Invalid plugin name: 'foo/bar' (must not contain path separators)"
|
||||
);
|
||||
expect(() => resolvePluginPath('foo\\bar')).toThrow(
|
||||
"Invalid plugin name: 'foo\\bar' (must not contain path separators)"
|
||||
);
|
||||
} finally {
|
||||
process.env.CLAUDE_PLUGIN_ROOT = originalEnv;
|
||||
}
|
||||
});
|
||||
|
||||
test('rejects plugin names with parent directory references', () => {
|
||||
const originalEnv = process.env.CLAUDE_PLUGIN_ROOT;
|
||||
process.env.CLAUDE_PLUGIN_ROOT = '/home/user/.claude/plugins/turboshovel';
|
||||
|
||||
try {
|
||||
expect(() => resolvePluginPath('..')).toThrow(
|
||||
"Invalid plugin name: '..' (must not contain path separators)"
|
||||
);
|
||||
expect(() => resolvePluginPath('..foo')).toThrow(
|
||||
"Invalid plugin name: '..foo' (must not contain path separators)"
|
||||
);
|
||||
} finally {
|
||||
process.env.CLAUDE_PLUGIN_ROOT = originalEnv;
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('Gate Config Validation', () => {
|
||||
let testDir: string;
|
||||
|
||||
beforeEach(async () => {
|
||||
testDir = await fs.mkdtemp(path.join(os.tmpdir(), 'hooks-test-'));
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await fs.rm(testDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
test('rejects gate with plugin but no gate name', async () => {
|
||||
const configObj = {
|
||||
hooks: { PostToolUse: { gates: ['test'] } },
|
||||
gates: {
|
||||
test: { plugin: 'cipherpowers' } // Missing gate field
|
||||
}
|
||||
};
|
||||
|
||||
await fs.writeFile(path.join(testDir, 'gates.json'), JSON.stringify(configObj));
|
||||
await expect(loadConfig(testDir)).rejects.toThrow(
|
||||
"Gate 'test' has 'plugin' but missing 'gate' field"
|
||||
);
|
||||
});
|
||||
|
||||
test('rejects gate with gate name but no plugin', async () => {
|
||||
const configObj = {
|
||||
hooks: { PostToolUse: { gates: ['test'] } },
|
||||
gates: {
|
||||
test: { gate: 'plan-compliance' } // Missing plugin field
|
||||
}
|
||||
};
|
||||
|
||||
await fs.writeFile(path.join(testDir, 'gates.json'), JSON.stringify(configObj));
|
||||
await expect(loadConfig(testDir)).rejects.toThrow(
|
||||
"Gate 'test' has 'gate' but missing 'plugin' field"
|
||||
);
|
||||
});
|
||||
|
||||
test('rejects gate with both command and plugin', async () => {
|
||||
const configObj = {
|
||||
hooks: { PostToolUse: { gates: ['test'] } },
|
||||
gates: {
|
||||
test: {
|
||||
plugin: 'cipherpowers',
|
||||
gate: 'plan-compliance',
|
||||
command: 'npm run lint' // Conflicting
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
await fs.writeFile(path.join(testDir, 'gates.json'), JSON.stringify(configObj));
|
||||
await expect(loadConfig(testDir)).rejects.toThrow(
|
||||
"Gate 'test' cannot have both 'command' and 'plugin/gate'"
|
||||
);
|
||||
});
|
||||
|
||||
test('accepts valid plugin gate reference', async () => {
|
||||
const configObj = {
|
||||
hooks: {},
|
||||
gates: {
|
||||
test: { plugin: 'cipherpowers', gate: 'plan-compliance' }
|
||||
}
|
||||
};
|
||||
|
||||
await fs.writeFile(path.join(testDir, 'gates.json'), JSON.stringify(configObj));
|
||||
// Should not throw validation error for structure
|
||||
// (May fail later when trying to resolve plugin, which is acceptable)
|
||||
const config = await loadConfig(testDir);
|
||||
expect(config).not.toBeNull();
|
||||
expect(config?.gates.test.plugin).toBe('cipherpowers');
|
||||
expect(config?.gates.test.gate).toBe('plan-compliance');
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user