Initial commit

This commit is contained in:
Zhongwei Li
2025-11-30 09:02:16 +08:00
commit 6ae6ce0730
49 changed files with 6362 additions and 0 deletions

View 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');
});
});