mirror of
https://github.com/proffesor-for-testing/agentic-qe.git
synced 2026-09-19 08:45:47 +08:00
2253d05618
Remove the nested v3/ subdirectory by moving all active source code, tests, configs, and assets directly to the project root. Archive v2/ (dead code, 847MB) to v2-archive.tar.gz. Key changes: - Move v3/src/ → src/, v3/tests/ → tests/, v3/packages/ → packages/ - Move v3/tsconfig.json, v3/vitest.config.ts to root - Merge v3/package.json into root package.json (ESM, scripts, deps, exports) - Merge v3/scripts/ into scripts/, v3/implementation/ into docs/implementation/ - Update all CI workflows to remove "cd v3 &&" and v3/ path prefixes - Fix all init installer path resolution (3-level-up → 2-level-up) - Fix hooks to use "node ./dist/cli/bundle.js" instead of bare "aqe" - Update settings.json ADR/DDD directories to docs/implementation/ - Fix helper scripts (statusline, adr-compliance, ddd-tracker, guidance-hooks) - Update 58 agent definitions: aqe/v3/ → aqe/ memory namespaces - Update 15 guidance shards: v3/src/ → src/ path references - Fix sync interfaces: remove v3/ and v2/ path references - Fix test path calculations (process.cwd() + '..' no longer needed) - Fix flaky perf test threshold (500ms → 1000ms for CI environments) - Fix complexity-analyzer test to match ADR-051 keyword-based eligibility Build passes, 17,901 tests pass, CLI and MCP bundles verified. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
488 lines
16 KiB
TypeScript
488 lines
16 KiB
TypeScript
/**
|
|
* Unit tests for QualityGateEnforcer and TaskCompletedHook
|
|
* ADR-064, Phase 1C: Quality gate validation and pattern training
|
|
*/
|
|
|
|
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
|
import {
|
|
QualityGateEnforcer,
|
|
DEFAULT_QUALITY_GATE_CONFIG,
|
|
type TaskResult,
|
|
type TaskMetrics,
|
|
type QualityGateConfig,
|
|
} from '../../src/hooks/quality-gate-enforcer.js';
|
|
import {
|
|
TaskCompletedHook,
|
|
createTaskCompletedHook,
|
|
type PatternStore,
|
|
type ExtractedPattern,
|
|
type CompletionAction,
|
|
type TaskCompletedHookConfig,
|
|
} from '../../src/hooks/task-completed-hook.js';
|
|
|
|
// ============================================================================
|
|
// Helpers
|
|
// ============================================================================
|
|
|
|
/** Build a TaskResult for testing */
|
|
function makeResult(overrides: Partial<TaskResult> = {}): TaskResult {
|
|
return {
|
|
taskId: overrides.taskId ?? 'task-1',
|
|
agentId: overrides.agentId ?? 'agent-1',
|
|
domain: overrides.domain ?? 'test-generation',
|
|
type: overrides.type ?? 'generate',
|
|
status: overrides.status ?? 'completed',
|
|
output: overrides.output ?? {},
|
|
metrics: overrides.metrics ?? {
|
|
testsPassed: 10,
|
|
testsFailed: 0,
|
|
coverageChange: 0.05,
|
|
securityIssues: 0,
|
|
performanceMs: 1000,
|
|
},
|
|
duration: overrides.duration ?? 2000,
|
|
timestamp: overrides.timestamp ?? Date.now(),
|
|
artifacts: overrides.artifacts,
|
|
};
|
|
}
|
|
|
|
/** Create a mock PatternStore */
|
|
function mockPatternStore(): PatternStore & {
|
|
store: ReturnType<typeof vi.fn>;
|
|
recordOutcome: ReturnType<typeof vi.fn>;
|
|
} {
|
|
let counter = 0;
|
|
return {
|
|
store: vi.fn().mockImplementation(async () => `pattern-${++counter}`),
|
|
recordOutcome: vi.fn().mockResolvedValue(undefined),
|
|
};
|
|
}
|
|
|
|
// ============================================================================
|
|
// QualityGateEnforcer Tests
|
|
// ============================================================================
|
|
|
|
describe('QualityGateEnforcer', () => {
|
|
|
|
it('should have sensible default config', () => {
|
|
const enforcer = new QualityGateEnforcer();
|
|
const config = enforcer.getConfig();
|
|
expect(config.enabled).toBe(true);
|
|
expect(config.minQualityScore).toBe(0.6);
|
|
expect(config.rejectOnFailure).toBe(true);
|
|
expect(config.gates).toHaveLength(4);
|
|
});
|
|
|
|
it('should pass when all gates are satisfied', () => {
|
|
const enforcer = new QualityGateEnforcer();
|
|
const result = makeResult({
|
|
metrics: {
|
|
testsPassed: 10,
|
|
testsFailed: 0,
|
|
coverageChange: 0.1,
|
|
securityIssues: 0,
|
|
performanceMs: 1000,
|
|
},
|
|
});
|
|
|
|
const gate = enforcer.evaluate(result);
|
|
expect(gate.passed).toBe(true);
|
|
expect(gate.exitCode).toBe(0);
|
|
expect(gate.score).toBeGreaterThan(0);
|
|
});
|
|
|
|
it('should fail when required gate fails with exit code 2', () => {
|
|
const enforcer = new QualityGateEnforcer();
|
|
// test-pass-rate gate is required with threshold 0.8
|
|
const result = makeResult({
|
|
metrics: {
|
|
testsPassed: 2,
|
|
testsFailed: 8, // 20% pass rate - fails required gate
|
|
coverageChange: 0.1,
|
|
securityIssues: 0,
|
|
performanceMs: 1000,
|
|
},
|
|
});
|
|
|
|
const gate = enforcer.evaluate(result);
|
|
expect(gate.passed).toBe(false);
|
|
expect(gate.exitCode).toBe(2);
|
|
expect(gate.reason).toContain('test-pass-rate');
|
|
});
|
|
|
|
it('should compute weighted score correctly', () => {
|
|
const enforcer = new QualityGateEnforcer({
|
|
gates: [
|
|
{ name: 'coverage', type: 'coverage', threshold: 0.0, weight: 0.5, required: false },
|
|
{ name: 'security', type: 'security', threshold: 0.0, weight: 0.5, required: false },
|
|
],
|
|
minQualityScore: 0.0,
|
|
});
|
|
|
|
const result = makeResult({
|
|
metrics: { coverageChange: 0.5, securityIssues: 0 },
|
|
});
|
|
|
|
const gate = enforcer.evaluate(result);
|
|
expect(gate.passed).toBe(true);
|
|
// Both gates pass -> score should be 1.0
|
|
expect(gate.score).toBeCloseTo(1.0, 1);
|
|
});
|
|
|
|
it('should auto-fail a task with failed status', () => {
|
|
const enforcer = new QualityGateEnforcer();
|
|
const result = makeResult({ status: 'failed' });
|
|
|
|
const gate = enforcer.evaluate(result);
|
|
expect(gate.passed).toBe(false);
|
|
expect(gate.exitCode).toBe(2);
|
|
expect(gate.reason).toContain('failed status');
|
|
});
|
|
|
|
it('should compute partial credit for near-threshold values', () => {
|
|
const enforcer = new QualityGateEnforcer({
|
|
gates: [
|
|
{ name: 'test-pass-rate', type: 'test-pass-rate', threshold: 0.8, weight: 1.0, required: false },
|
|
],
|
|
minQualityScore: 0.0,
|
|
});
|
|
|
|
// 70% pass rate is below 80% threshold but close
|
|
const result = makeResult({
|
|
metrics: { testsPassed: 7, testsFailed: 3 },
|
|
});
|
|
|
|
const gate = enforcer.evaluate(result);
|
|
// Gate fails but partial credit: 0.7/0.8 = 0.875
|
|
expect(gate.passed).toBe(true); // score above minQualityScore 0.0 and not required
|
|
expect(gate.score).toBeGreaterThan(0);
|
|
expect(gate.score).toBeLessThan(1.0);
|
|
});
|
|
|
|
it('should auto-pass when gates are disabled', () => {
|
|
const enforcer = new QualityGateEnforcer({ enabled: false });
|
|
const result = makeResult({ status: 'completed' });
|
|
|
|
const gate = enforcer.evaluate(result);
|
|
expect(gate.passed).toBe(true);
|
|
expect(gate.score).toBe(1.0);
|
|
expect(gate.reason).toContain('disabled');
|
|
});
|
|
|
|
it('should handle security gate with issues above threshold', () => {
|
|
const enforcer = new QualityGateEnforcer({
|
|
gates: [
|
|
{ name: 'security', type: 'security', threshold: 0, weight: 1.0, required: true },
|
|
],
|
|
});
|
|
|
|
const result = makeResult({
|
|
metrics: { securityIssues: 3 },
|
|
});
|
|
|
|
const gate = enforcer.evaluate(result);
|
|
expect(gate.passed).toBe(false);
|
|
expect(gate.reason).toContain('security');
|
|
});
|
|
|
|
it('should handle performance gate exceeding threshold', () => {
|
|
const enforcer = new QualityGateEnforcer({
|
|
gates: [
|
|
{ name: 'performance', type: 'performance', threshold: 5000, weight: 1.0, required: true },
|
|
],
|
|
});
|
|
|
|
const result = makeResult({
|
|
metrics: { performanceMs: 10000 },
|
|
});
|
|
|
|
const gate = enforcer.evaluate(result);
|
|
expect(gate.passed).toBe(false);
|
|
expect(gate.reason).toContain('performance');
|
|
});
|
|
});
|
|
|
|
// ============================================================================
|
|
// TaskCompletedHook Tests
|
|
// ============================================================================
|
|
|
|
describe('TaskCompletedHook', () => {
|
|
let store: ReturnType<typeof mockPatternStore>;
|
|
|
|
beforeEach(() => {
|
|
store = mockPatternStore();
|
|
});
|
|
|
|
afterEach(() => {
|
|
vi.restoreAllMocks();
|
|
});
|
|
|
|
// ---------- Accept / Reject ----------
|
|
|
|
it('should accept when quality gates pass', async () => {
|
|
const hook = createTaskCompletedHook({}, store);
|
|
const result = makeResult({
|
|
metrics: {
|
|
testsPassed: 10,
|
|
testsFailed: 0,
|
|
coverageChange: 0.1,
|
|
securityIssues: 0,
|
|
performanceMs: 1000,
|
|
},
|
|
});
|
|
|
|
const action = await hook.onTaskCompleted(result);
|
|
expect(action.action).toBe('accept');
|
|
});
|
|
|
|
it('should reject when quality gates fail', async () => {
|
|
const hook = createTaskCompletedHook({}, store);
|
|
const result = makeResult({
|
|
status: 'failed',
|
|
});
|
|
|
|
const action = await hook.onTaskCompleted(result);
|
|
expect(action.action).toBe('reject');
|
|
expect((action as { exitCode: number }).exitCode).toBe(2);
|
|
});
|
|
|
|
it('should reject when required gate fails', async () => {
|
|
const hook = createTaskCompletedHook({}, store);
|
|
// Pass rate below threshold and required
|
|
const result = makeResult({
|
|
metrics: {
|
|
testsPassed: 1,
|
|
testsFailed: 9,
|
|
securityIssues: 0,
|
|
coverageChange: 0,
|
|
performanceMs: 1000,
|
|
},
|
|
});
|
|
|
|
const action = await hook.onTaskCompleted(result);
|
|
expect(action.action).toBe('reject');
|
|
});
|
|
|
|
// ---------- Pattern extraction ----------
|
|
|
|
it('should extract patterns from test-generation domain', async () => {
|
|
const hook = createTaskCompletedHook({}, store);
|
|
const result = makeResult({
|
|
domain: 'test-generation',
|
|
output: {
|
|
testCode: 'describe("suite", () => { it("works", () => expect(true)); });',
|
|
template: 'vitest template content here with enough chars',
|
|
},
|
|
metrics: { testsPassed: 10, testsFailed: 0, securityIssues: 0 },
|
|
});
|
|
|
|
const action = await hook.onTaskCompleted(result);
|
|
expect(action.action).toBe('accept');
|
|
expect((action as { patternsExtracted: number }).patternsExtracted).toBeGreaterThan(0);
|
|
expect(store.store).toHaveBeenCalled();
|
|
});
|
|
|
|
it('should extract patterns from security-compliance domain', async () => {
|
|
const hook = createTaskCompletedHook({}, store);
|
|
const result = makeResult({
|
|
domain: 'security-compliance',
|
|
output: {
|
|
findings: 'Found XSS vulnerability in input handling component',
|
|
fixes: 'Applied sanitization using DOMPurify library throughout',
|
|
},
|
|
metrics: { testsPassed: 5, testsFailed: 0, securityIssues: 0 },
|
|
});
|
|
|
|
const action = await hook.onTaskCompleted(result);
|
|
expect(action.action).toBe('accept');
|
|
expect((action as { patternsExtracted: number }).patternsExtracted).toBeGreaterThan(0);
|
|
|
|
const storedPattern = store.store.mock.calls[0][0] as ExtractedPattern;
|
|
expect(storedPattern.domain).toBe('security-compliance');
|
|
});
|
|
|
|
it('should use generic fallback for unconfigured but listed domains', async () => {
|
|
// A domain in patternDomains but not in EXTRACTION_STRATEGIES would
|
|
// require custom patternDomains config. We'll test extractPatterns directly.
|
|
const hook = new TaskCompletedHook(
|
|
{ patternDomains: ['custom-domain'] },
|
|
store,
|
|
);
|
|
const result = makeResult({
|
|
domain: 'custom-domain',
|
|
output: {
|
|
analysis: 'Some detailed analysis content that is long enough to extract',
|
|
},
|
|
metrics: { testsPassed: 5, testsFailed: 0, securityIssues: 0 },
|
|
});
|
|
|
|
const patterns = hook.extractPatterns(result);
|
|
expect(patterns.length).toBeGreaterThan(0);
|
|
expect(patterns[0].type).toBe('generic');
|
|
expect(patterns[0].domain).toBe('custom-domain');
|
|
});
|
|
|
|
it('should call patternStore.store with extracted patterns', async () => {
|
|
const hook = createTaskCompletedHook({}, store);
|
|
const result = makeResult({
|
|
domain: 'test-generation',
|
|
output: {
|
|
testCode: 'describe("test", () => { it("should work", () => {}); });',
|
|
},
|
|
metrics: { testsPassed: 10, testsFailed: 0, securityIssues: 0 },
|
|
});
|
|
|
|
await hook.onTaskCompleted(result);
|
|
expect(store.store).toHaveBeenCalled();
|
|
expect(store.recordOutcome).toHaveBeenCalled();
|
|
|
|
// Verify recordOutcome was called with success=true for completed tasks
|
|
const outcomeCall = store.recordOutcome.mock.calls[0];
|
|
expect(outcomeCall[1]).toBe(true); // success = true because status is 'completed'
|
|
});
|
|
|
|
// ---------- Completion callbacks ----------
|
|
|
|
it('should fire completion callbacks on accept', async () => {
|
|
const handler = vi.fn();
|
|
const hook = createTaskCompletedHook({}, store);
|
|
hook.onCompletion(handler);
|
|
|
|
const result = makeResult({
|
|
metrics: { testsPassed: 10, testsFailed: 0, securityIssues: 0, performanceMs: 1000 },
|
|
});
|
|
await hook.onTaskCompleted(result);
|
|
|
|
expect(handler).toHaveBeenCalledTimes(1);
|
|
expect(handler).toHaveBeenCalledWith('task-1', expect.objectContaining({ action: 'accept' }));
|
|
});
|
|
|
|
it('should fire completion callbacks on reject', async () => {
|
|
const handler = vi.fn();
|
|
const hook = createTaskCompletedHook({}, store);
|
|
hook.onCompletion(handler);
|
|
|
|
const result = makeResult({ status: 'failed' });
|
|
await hook.onTaskCompleted(result);
|
|
|
|
expect(handler).toHaveBeenCalledWith('task-1', expect.objectContaining({ action: 'reject' }));
|
|
});
|
|
|
|
it('should allow removing a completion handler', async () => {
|
|
const handler = vi.fn();
|
|
const hook = createTaskCompletedHook({}, store);
|
|
hook.onCompletion(handler);
|
|
hook.offCompletion(handler);
|
|
|
|
await hook.onTaskCompleted(makeResult());
|
|
expect(handler).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('should not fire callbacks when emitEvents is false', async () => {
|
|
const handler = vi.fn();
|
|
const hook = createTaskCompletedHook({ emitEvents: false }, store);
|
|
hook.onCompletion(handler);
|
|
|
|
await hook.onTaskCompleted(makeResult({
|
|
metrics: { testsPassed: 10, testsFailed: 0, securityIssues: 0, performanceMs: 1000 },
|
|
}));
|
|
expect(handler).not.toHaveBeenCalled();
|
|
});
|
|
|
|
// ---------- Stats tracking ----------
|
|
|
|
it('should track accepted/rejected/patternsExtracted stats', async () => {
|
|
const hook = createTaskCompletedHook({}, store);
|
|
|
|
// Accepted task
|
|
await hook.onTaskCompleted(makeResult({
|
|
metrics: { testsPassed: 10, testsFailed: 0, securityIssues: 0, performanceMs: 1000 },
|
|
}));
|
|
|
|
// Rejected task
|
|
await hook.onTaskCompleted(makeResult({ status: 'failed', taskId: 'task-fail' }));
|
|
|
|
const stats = hook.getStats();
|
|
expect(stats.totalProcessed).toBe(2);
|
|
expect(stats.accepted).toBe(1);
|
|
expect(stats.rejected).toBe(1);
|
|
});
|
|
|
|
it('should reset stats correctly', async () => {
|
|
const hook = createTaskCompletedHook({}, store);
|
|
await hook.onTaskCompleted(makeResult({
|
|
metrics: { testsPassed: 10, testsFailed: 0, securityIssues: 0, performanceMs: 1000 },
|
|
}));
|
|
hook.resetStats();
|
|
|
|
const stats = hook.getStats();
|
|
expect(stats.totalProcessed).toBe(0);
|
|
expect(stats.accepted).toBe(0);
|
|
expect(stats.rejected).toBe(0);
|
|
expect(stats.patternsExtracted).toBe(0);
|
|
});
|
|
|
|
// ---------- Config: trainPatterns ----------
|
|
|
|
it('should skip pattern extraction when trainPatterns is false', async () => {
|
|
const hook = createTaskCompletedHook({ trainPatterns: false }, store);
|
|
const result = makeResult({
|
|
domain: 'test-generation',
|
|
output: { testCode: 'describe("suite", () => { it("works", () => {}); });' },
|
|
metrics: { testsPassed: 10, testsFailed: 0, securityIssues: 0, performanceMs: 1000 },
|
|
});
|
|
|
|
const action = await hook.onTaskCompleted(result);
|
|
expect(action.action).toBe('accept');
|
|
expect((action as { patternsExtracted: number }).patternsExtracted).toBe(0);
|
|
expect(store.store).not.toHaveBeenCalled();
|
|
});
|
|
|
|
// ---------- maxPatternsPerTask ----------
|
|
|
|
it('should limit extraction count by maxPatternsPerTask', () => {
|
|
const hook = new TaskCompletedHook({ maxPatternsPerTask: 2 }, store);
|
|
const result = makeResult({
|
|
domain: 'test-generation',
|
|
output: {
|
|
testCode: 'describe("a", () => { it("aaa", () => {}); });',
|
|
template: 'template content with enough chars for extraction',
|
|
assertions: 'assertion pattern content with enough characters',
|
|
mocks: 'mock pattern content that is long enough to extract',
|
|
},
|
|
metrics: { testsPassed: 10, testsFailed: 0, securityIssues: 0 },
|
|
});
|
|
|
|
const patterns = hook.extractPatterns(result);
|
|
expect(patterns.length).toBeLessThanOrEqual(2);
|
|
});
|
|
|
|
it('should not extract patterns from domains not in patternDomains', () => {
|
|
const hook = new TaskCompletedHook(
|
|
{ patternDomains: ['test-generation'] },
|
|
store,
|
|
);
|
|
const result = makeResult({
|
|
domain: 'unknown-domain',
|
|
output: { data: 'plenty of content that is long enough to extract from' },
|
|
});
|
|
|
|
const patterns = hook.extractPatterns(result);
|
|
expect(patterns).toHaveLength(0);
|
|
});
|
|
|
|
it('should skip content shorter than 10 characters', () => {
|
|
const hook = new TaskCompletedHook({}, store);
|
|
const result = makeResult({
|
|
domain: 'test-generation',
|
|
output: {
|
|
testCode: 'short', // too short
|
|
template: null, // null
|
|
},
|
|
});
|
|
|
|
const patterns = hook.extractPatterns(result);
|
|
expect(patterns).toHaveLength(0);
|
|
});
|
|
});
|