251 lines
8.0 KiB
TypeScript
251 lines
8.0 KiB
TypeScript
// 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');
|
|
});
|
|
});
|