-
Notifications
You must be signed in to change notification settings - Fork 8
feat(analysis): implement phase 2 analyzer engine and registry #96
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,196 @@ | ||
| import { describe, it, expect, vi, beforeEach } from 'vitest'; | ||
| import { registry } from '../analyzers'; | ||
| import { runAnalysis } from '../analysis/analysis'; | ||
| import { formatTextOutput, formatJsonOutput } from '../analysis/output'; | ||
| import { clearConfig } from '../config/store'; | ||
|
|
||
| vi.mock('conf', () => { | ||
| const mockConfigStore = new Map<string, any>(); | ||
| const mockConfInstance = { | ||
| get store() { | ||
| return Object.fromEntries(mockConfigStore.entries()); | ||
| }, | ||
| set: vi.fn((key, val) => { | ||
| mockConfigStore.set(key, val); | ||
| }), | ||
| get: vi.fn((key) => mockConfigStore.get(key)), | ||
| delete: vi.fn((key) => { | ||
| mockConfigStore.delete(key); | ||
| }), | ||
| clear: vi.fn(() => { | ||
| mockConfigStore.clear(); | ||
| }), | ||
| }; | ||
| return { | ||
| default: class MockConf { | ||
| constructor() { | ||
| return mockConfInstance; | ||
| } | ||
| }, | ||
| }; | ||
| }); | ||
|
|
||
| describe('Analysis Engine', () => { | ||
| beforeEach(() => { | ||
| clearConfig(); | ||
| }); | ||
|
|
||
| it('runs no-op analyzers and returns OK when no issues are found', async () => { | ||
| const output = await runAnalysis({ | ||
| filters: ['Pod', 'Deployment'], | ||
| }); | ||
|
|
||
| expect(output.status).toBe('OK'); | ||
| expect(output.problems).toBe(0); | ||
| expect(output.results).toEqual([]); | ||
| expect(output.errors).toEqual([]); | ||
| }); | ||
|
|
||
| it('returns ProblemDetected when an analyzer reports issues', async () => { | ||
| const errorAnalyzer = { | ||
| name: 'ErroneousPod', | ||
| analyze: async () => [ | ||
| { | ||
| kind: 'Pod', | ||
| name: 'failing-pod', | ||
| namespace: 'kube-system', | ||
| errors: [{ text: 'CrashLoopBackOff', kubernetesDoc: 'See doc link' }], | ||
| details: 'Pod failed due to OOMKilled', | ||
| }, | ||
| ], | ||
| }; | ||
|
|
||
| registry.register(errorAnalyzer); | ||
|
|
||
| const output = await runAnalysis({ | ||
| filters: ['ErroneousPod'], | ||
| }); | ||
|
|
||
| expect(output.status).toBe('ProblemDetected'); | ||
| expect(output.problems).toBe(1); | ||
| expect(output.results.length).toBe(1); | ||
| expect(output.results[0].name).toBe('failing-pod'); | ||
| expect(output.results[0].errors[0].text).toBe('CrashLoopBackOff'); | ||
| }); | ||
|
|
||
| it('handles unknown filters by adding them as errors', async () => { | ||
| const output = await runAnalysis({ | ||
| filters: ['NonExistentFilter'], | ||
| }); | ||
|
|
||
| expect(output.errors).toContain('Unknown filter: NonExistentFilter'); | ||
| }); | ||
|
|
||
| it('respects concurrency limit settings', async () => { | ||
| let activeCalls = 0; | ||
| let maxConcurrentCalls = 0; | ||
|
|
||
| const delayAnalyzer1 = { | ||
| name: 'Delay1', | ||
| analyze: async () => { | ||
| activeCalls++; | ||
| maxConcurrentCalls = Math.max(maxConcurrentCalls, activeCalls); | ||
| await new Promise((resolve) => setTimeout(resolve, 50)); | ||
| activeCalls--; | ||
| return []; | ||
| }, | ||
| }; | ||
|
|
||
| const delayAnalyzer2 = { | ||
| name: 'Delay2', | ||
| analyze: async () => { | ||
| activeCalls++; | ||
| maxConcurrentCalls = Math.max(maxConcurrentCalls, activeCalls); | ||
| await new Promise((resolve) => setTimeout(resolve, 50)); | ||
| activeCalls--; | ||
| return []; | ||
| }, | ||
| }; | ||
|
|
||
| registry.register(delayAnalyzer1); | ||
| registry.register(delayAnalyzer2); | ||
|
|
||
| await runAnalysis({ | ||
| filters: ['Delay1', 'Delay2'], | ||
| maxConcurrency: 1, | ||
| }); | ||
|
|
||
| expect(maxConcurrentCalls).toBe(1); | ||
| }); | ||
|
Comment on lines
+84
to
+119
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🧹 Nitpick | 🔵 Trivial | ⚡ Quick win Make the concurrency test deterministic instead of time-based. Using real Suggested direction- await new Promise((resolve) => setTimeout(resolve, 50));
+ await gate.promise; // manually release in test to control overlap deterministicallyAs per coding guidelines, 🤖 Prompt for AI Agents |
||
|
|
||
| it('handles concurrency limit default fallback for invalid inputs', async () => { | ||
| const output = await runAnalysis({ | ||
| filters: ['Pod'], | ||
| maxConcurrency: -5, | ||
| }); | ||
| expect(output.status).toBe('OK'); | ||
| }); | ||
|
|
||
| it('formats text output correctly', async () => { | ||
| const testOutput = { | ||
| status: 'ProblemDetected' as const, | ||
| problems: 1, | ||
| errors: [], | ||
| results: [ | ||
| { | ||
| kind: 'Pod', | ||
| name: 'my-pod', | ||
| namespace: 'default', | ||
| errors: [{ text: 'OOMKilled', kubernetesDoc: 'https://k8s.io' }], | ||
| details: 'Resource exhausted', | ||
| }, | ||
| ], | ||
| }; | ||
|
|
||
| const formatted = formatTextOutput(testOutput); | ||
| expect(formatted).toContain('Status: ProblemDetected (1 problem)'); | ||
| expect(formatted).toContain('Pods:'); | ||
| expect(formatted).toContain('my-pod'); | ||
| expect(formatted).toContain('Error: OOMKilled'); | ||
| expect(formatted).toContain('Kubernetes Doc: https://k8s.io'); | ||
| }); | ||
|
|
||
| it('formats json output correctly', async () => { | ||
| const testOutput = { | ||
| status: 'OK' as const, | ||
| problems: 0, | ||
| errors: [], | ||
| results: [], | ||
| }; | ||
|
|
||
| const formatted = formatJsonOutput(testOutput); | ||
| const parsed = JSON.parse(formatted); | ||
| expect(parsed.status).toBe('OK'); | ||
| expect(parsed.problems).toBe(0); | ||
| }); | ||
|
|
||
| it('collects execution stats when withStats option is true', async () => { | ||
| const output = await runAnalysis({ | ||
| filters: ['Pod'], | ||
| withStats: true, | ||
| }); | ||
|
|
||
| expect(output.stats).toBeDefined(); | ||
| expect(output.stats!.length).toBe(1); | ||
| expect(output.stats![0].analyzer).toBe('Pod'); | ||
| expect(typeof output.stats![0].durationMs).toBe('number'); | ||
| }); | ||
|
|
||
| it('survives individual analyzer failure without stopping execution', async () => { | ||
| const failingAnalyzer = { | ||
| name: 'Failing', | ||
| analyze: async () => { | ||
| throw new Error('Something went wrong'); | ||
| }, | ||
| }; | ||
| registry.register(failingAnalyzer); | ||
|
|
||
| const output = await runAnalysis({ | ||
| filters: ['Failing', 'Pod'], | ||
| }); | ||
|
|
||
| expect(output.errors.length).toBe(1); | ||
| expect(output.errors[0]).toContain('Analyzer Failing failed: Something went wrong'); | ||
| expect(output.status).toBe('OK'); | ||
| }); | ||
| }); | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,67 @@ | ||
| import { describe, expect, it } from 'vitest'; | ||
| import { mergeLegacyConfig, notificationFromLegacy } from '../config/migration'; | ||
|
|
||
| describe('config migration', () => { | ||
| it('returns undefined for empty legacy input', () => { | ||
| expect(notificationFromLegacy({})).toBeUndefined(); | ||
| }); | ||
|
|
||
| it('converts legacy notification keys into the new notification shape', () => { | ||
| const notifications = notificationFromLegacy({ | ||
| notification_service: 'email', | ||
| discord_webhook: 'https://discord.com/api/webhooks/123/token', | ||
| email_host: 'smtp.test.com', | ||
| email_port: 587, | ||
| email_user: 'user@test.com', | ||
| email_to: 'to@test.com', | ||
| email_password: 'secret', | ||
| alert_cooldown: 120, | ||
| }); | ||
|
|
||
| expect(notifications).toEqual({ | ||
| service: 'email', | ||
| discordWebhook: 'https://discord.com/api/webhooks/123/token', | ||
| emailHost: 'smtp.test.com', | ||
| emailPort: 587, | ||
| emailUser: 'user@test.com', | ||
| emailTo: 'to@test.com', | ||
| emailPassword: 'secret', | ||
| alertCooldown: 120, | ||
| }); | ||
| }); | ||
|
|
||
| it('prefers explicit new notification config over migrated legacy values', () => { | ||
| const config = mergeLegacyConfig({ | ||
| notification_service: 'discord', | ||
| discord_webhook: 'legacy-webhook', | ||
| notifications: { | ||
| service: 'email', | ||
| discordWebhook: 'new-webhook', | ||
| }, | ||
| }); | ||
|
|
||
| expect(config.notifications).toMatchObject({ | ||
| service: 'email', | ||
| discordWebhook: 'new-webhook', | ||
| }); | ||
| }); | ||
|
Comment on lines
+33
to
+47
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🛠️ Refactor suggestion | 🟠 Major | ⚡ Quick win Add an explicit error-path test for This block validates precedence, but not malformed legacy input handling for As per coding guidelines, "Verify that tests cover both happy paths and error conditions." 🤖 Prompt for AI Agents |
||
|
|
||
| it('ignores malformed legacy notification values', () => { | ||
| const notifications = notificationFromLegacy({ | ||
| notification_service: { invalid: 'object' } as never, | ||
| email_port: 'not-a-number' as never, | ||
| alert_cooldown: 'not-a-number' as never, | ||
| }); | ||
|
|
||
| expect(notifications).toEqual({ | ||
| service: 'none', | ||
| emailPort: undefined, | ||
| alertCooldown: undefined, | ||
| discordWebhook: undefined, | ||
| emailHost: undefined, | ||
| emailUser: undefined, | ||
| emailTo: undefined, | ||
| emailPassword: undefined, | ||
| }); | ||
| }); | ||
| }); | ||
| Original file line number | Diff line number | Diff line change | ||
|---|---|---|---|---|
| @@ -0,0 +1,122 @@ | ||||
| import { beforeEach, describe, expect, it, vi } from 'vitest'; | ||||
|
|
||||
| vi.mock('conf', () => { | ||||
| const mockConfigStore = new Map<string, any>(); | ||||
| const mockConfInstance = { | ||||
| get store() { | ||||
| return Object.fromEntries(mockConfigStore.entries()); | ||||
| }, | ||||
| set: vi.fn((key, val) => { | ||||
| mockConfigStore.set(key, val); | ||||
| }), | ||||
| get: vi.fn((key) => mockConfigStore.get(key)), | ||||
| delete: vi.fn((key) => { | ||||
| mockConfigStore.delete(key); | ||||
| }), | ||||
| clear: vi.fn(() => { | ||||
| mockConfigStore.clear(); | ||||
| }), | ||||
| }; | ||||
|
|
||||
| (globalThis as any).mockConfigStore = mockConfigStore; | ||||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🛠️ Refactor suggestion | 🟠 Major | ⚡ Quick win Avoid leaking test mock state through Line 21 exposes mutable mock state globally and is unnecessary in this file. Keep the store closure-scoped to avoid cross-suite coupling/flakiness. Proposed minimal fix- (globalThis as any).mockConfigStore = mockConfigStore;As per coding guidelines, "Ensure mocks are clean and shared correctly." 📝 Committable suggestion
Suggested change
🤖 Prompt for AI Agents |
||||
|
|
||||
| return { | ||||
| default: class MockConf { | ||||
| constructor() { | ||||
| return mockConfInstance; | ||||
| } | ||||
| }, | ||||
| }; | ||||
| }); | ||||
| import { | ||||
| clearConfig, | ||||
| getActiveFilters, | ||||
| getCacheConfig, | ||||
| getConfig, | ||||
| getLegacyConfig, | ||||
| getLegacyValue, | ||||
| getOutputConfig, | ||||
| setActiveFilters, | ||||
| setAIConfig, | ||||
| setCacheConfig, | ||||
| setConfigValue, | ||||
| setKubernetesConfig, | ||||
| setLegacyValue, | ||||
| setOutputConfig, | ||||
| } from '../config/store'; | ||||
|
|
||||
| describe('config store', () => { | ||||
| beforeEach(() => { | ||||
| clearConfig(); | ||||
| vi.clearAllMocks(); | ||||
| }); | ||||
|
|
||||
| it('returns defaults for new configuration sections', () => { | ||||
| expect(getActiveFilters()).toEqual([]); | ||||
| expect(getCacheConfig()).toEqual({ type: 'file', enabled: true }); | ||||
| expect(getOutputConfig()).toEqual({ format: 'text', language: 'english' }); | ||||
| }); | ||||
|
|
||||
| it('stores and reads new typed configuration sections', () => { | ||||
| setAIConfig({ | ||||
| providers: [{ name: 'openai', model: 'gpt-4o' }], | ||||
| defaultProvider: 'openai', | ||||
| }); | ||||
| setActiveFilters(['Pod', 'Deployment']); | ||||
| setKubernetesConfig({ namespace: 'default', kubecontext: 'minikube' }); | ||||
| setCacheConfig({ type: 'file', enabled: false, path: '/tmp/kdm-cache' }); | ||||
| setOutputConfig({ format: 'json', language: 'english' }); | ||||
|
|
||||
| expect(getConfig()).toMatchObject({ | ||||
| ai: { | ||||
| providers: [{ name: 'openai', model: 'gpt-4o' }], | ||||
| defaultProvider: 'openai', | ||||
| }, | ||||
| activeFilters: ['Pod', 'Deployment'], | ||||
| kubernetes: { namespace: 'default', kubecontext: 'minikube' }, | ||||
| cache: { type: 'file', enabled: false, path: '/tmp/kdm-cache' }, | ||||
| output: { format: 'json', language: 'english' }, | ||||
| }); | ||||
| }); | ||||
|
|
||||
| it('keeps legacy notification reads compatible', () => { | ||||
| setLegacyValue('notification_service', 'discord'); | ||||
| setLegacyValue('discord_webhook', 'https://discord.com/api/webhooks/123/token'); | ||||
| setLegacyValue('alert_cooldown', 300); | ||||
|
|
||||
| expect(getLegacyConfig()).toEqual({ | ||||
| notification_service: 'discord', | ||||
| discord_webhook: 'https://discord.com/api/webhooks/123/token', | ||||
| alert_cooldown: 300, | ||||
| email_host: undefined, | ||||
| email_port: undefined, | ||||
| email_user: undefined, | ||||
| email_to: undefined, | ||||
| email_password: undefined, | ||||
| }); | ||||
| }); | ||||
|
|
||||
| it('keeps legacy value reads compatible with migrated notification config', () => { | ||||
| setConfigValue('notifications', { | ||||
| service: 'email', | ||||
| emailHost: 'smtp.test.com', | ||||
| emailPort: 587, | ||||
| emailUser: 'user@test.com', | ||||
| emailTo: 'to@test.com', | ||||
| }); | ||||
|
|
||||
| expect(getLegacyValue('notification_service')).toBe('email'); | ||||
| expect(getLegacyValue('email_host')).toBe('smtp.test.com'); | ||||
| expect(getLegacyValue('email_port')).toBe(587); | ||||
| }); | ||||
|
|
||||
| it('returns safe legacy defaults for malformed notification values', () => { | ||||
| setLegacyValue('notification_service', { invalid: 'object' } as never); | ||||
| setLegacyValue('alert_cooldown', 'not-a-number' as never); | ||||
|
|
||||
| expect(getLegacyConfig()).toMatchObject({ | ||||
| notification_service: 'none', | ||||
| alert_cooldown: undefined, | ||||
| }); | ||||
| }); | ||||
| }); | ||||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Reset analyzer registry state between tests to avoid cross-test pollution.
These tests mutate a shared singleton (
registry.register(...)) but only clear config inbeforeEach. That makes outcomes order-dependent across this file and other suites.Suggested fix
describe('Analysis Engine', () => { beforeEach(() => { clearConfig(); + // Ensure no analyzer registrations leak between tests. + // Use whichever API your registry exposes (e.g. clear/reset/unregisterAll). + registry.clear?.(); });As per coding guidelines,
src/__tests__/**/*.tsrequires: "Ensure mocks are clean and shared correctly."Also applies to: 63-63, 110-111, 186-186
🤖 Prompt for AI Agents