Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
196 changes: 196 additions & 0 deletions src/__tests__/analysis.test.ts
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();
});

Comment on lines +33 to +37
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Reset analyzer registry state between tests to avoid cross-test pollution.

These tests mutate a shared singleton (registry.register(...)) but only clear config in beforeEach. 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__/**/*.ts requires: "Ensure mocks are clean and shared correctly."

Also applies to: 63-63, 110-111, 186-186

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/__tests__/analysis.test.ts` around lines 33 - 37, Tests are leaking a
shared analyzer registry state because only clearConfig() is called in
beforeEach; update the test setup to also reset the analyzer registry between
tests by calling the registry reset method (e.g., registry.clear() or
registry.reset()) in beforeEach (and/or afterEach) so any registry.register(...)
calls are undone; if no reset method exists, add a registry.reset/clear
implementation on the registry singleton and call that from the test beforeEach
blocks where registry.register is used (references: registry.register).

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
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The 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 setTimeout(50) can be flaky/slower in CI. Prefer controlled promises or fake timers so concurrency assertions don’t depend on scheduler timing.

Suggested direction
- await new Promise((resolve) => setTimeout(resolve, 50));
+ await gate.promise; // manually release in test to control overlap deterministically

As per coding guidelines, src/__tests__/**/*.ts requires: "Ensure mocks are clean and shared correctly."

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/__tests__/analysis.test.ts` around lines 84 - 119, The test using real
setTimeouts in the 'respects concurrency limit settings' case is flaky; replace
the time-based delays with controlled promises so concurrency is deterministic:
create controllable promise resolvers for delayAnalyzer1 and delayAnalyzer2
(referenced as delayAnalyzer1, delayAnalyzer2) that increment/decrement
activeCalls inside their analyze methods and await their respective
externally-resolved promises, register them via registry.register(...), call
runAnalysis({ filters: ['Delay1','Delay2'], maxConcurrency: 1 }), then resolve
the promises in the desired order to assert maxConcurrentCalls === 1; ensure any
test-level mocks or fake timers are cleaned/reset per the project test
guidelines.


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');
});
});
67 changes: 67 additions & 0 deletions src/__tests__/config-migration.test.ts
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
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🛠️ Refactor suggestion | 🟠 Major | ⚡ Quick win

Add an explicit error-path test for mergeLegacyConfig.

This block validates precedence, but not malformed legacy input handling for mergeLegacyConfig itself. Add one test asserting safe fallback behavior (e.g., service: 'none') when legacy values are invalid.

As per coding guidelines, "Verify that tests cover both happy paths and error conditions."

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/__tests__/config-migration.test.ts` around lines 33 - 47, Add a new unit
test in src/__tests__/config-migration.test.ts that covers the error path of
mergeLegacyConfig: call mergeLegacyConfig with malformed/invalid legacy fields
(e.g., notification_service set to an unexpected value or discord_webhook set to
a non-string) and assert the function safely falls back to the default
notifications object (for example notifications.service === 'none' and
notifications.discordWebhook undefined). Reference mergeLegacyConfig in the test
name and assertions so reviewers can find the handling for legacy -> new mapping
and validate the safe fallback behavior.


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,
});
});
});
122 changes: 122 additions & 0 deletions src/__tests__/config-store.test.ts
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;
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🛠️ Refactor suggestion | 🟠 Major | ⚡ Quick win

Avoid leaking test mock state through globalThis.

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

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
(globalThis as any).mockConfigStore = mockConfigStore;
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/__tests__/config-store.test.ts` at line 21, Remove the global leak by
deleting the assignment "(globalThis as any).mockConfigStore = mockConfigStore"
and keep mockConfigStore as a closure-scoped test variable; if other suites need
access, expose it via a dedicated test helper or setup file (e.g., export a
getMockConfigStore helper or register it in jest.setup.ts) rather than writing
to globalThis so mock state stays isolated to the test scope.


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,
});
});
});
Loading