test(ai-diagnostics): add 94 tests for error-normalization, clustering, query-parser
This commit is contained in:
parent
9471d4c56f
commit
267f8af3a4
@ -0,0 +1,189 @@
|
|||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
import {
|
||||||
|
runHDBSCAN,
|
||||||
|
runDBSCAN,
|
||||||
|
autoSelectParameters,
|
||||||
|
calculateClusterQuality,
|
||||||
|
} from './clustering.js';
|
||||||
|
|
||||||
|
// ── Helpers ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function makePoint(id: string, embedding: number[], errorType = 'TypeError') {
|
||||||
|
return {
|
||||||
|
id,
|
||||||
|
embedding,
|
||||||
|
metadata: {
|
||||||
|
errorType,
|
||||||
|
messageTemplate: `Error in ${id}`,
|
||||||
|
stackSignature: `sig_${id}`,
|
||||||
|
productId: 'test',
|
||||||
|
firstSeenAt: new Date(Date.now() - Math.random() * 86400000).toISOString(),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function makeCluster(center: number[], spread: number, count: number, prefix: string) {
|
||||||
|
return Array.from({ length: count }, (_, i) =>
|
||||||
|
makePoint(
|
||||||
|
`${prefix}_${i}`,
|
||||||
|
center.map(c => c + (Math.random() - 0.5) * spread)
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// runHDBSCAN
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
describe('runHDBSCAN', () => {
|
||||||
|
it('returns all points as noise when fewer than minClusterSize', () => {
|
||||||
|
const points = [makePoint('a', [1, 0, 0]), makePoint('b', [0, 1, 0])];
|
||||||
|
const result = runHDBSCAN(points, { minClusterSize: 3 });
|
||||||
|
expect(result.clusters).toHaveLength(0);
|
||||||
|
expect(result.noise).toHaveLength(2);
|
||||||
|
expect(result.labels.get('a')).toBe(-1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('finds clusters in well-separated data', () => {
|
||||||
|
const clusterA = makeCluster([1, 0, 0], 0.01, 5, 'a');
|
||||||
|
const clusterB = makeCluster([0, 0, 1], 0.01, 5, 'b');
|
||||||
|
const points = [...clusterA, ...clusterB];
|
||||||
|
|
||||||
|
const result = runHDBSCAN(points, { minClusterSize: 3, minSamples: 2, metric: 'euclidean' });
|
||||||
|
expect(result.clusters.length).toBeGreaterThanOrEqual(1);
|
||||||
|
// Every point should get a label
|
||||||
|
expect(result.labels.size).toBe(points.length);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('assigns centroids to clusters', () => {
|
||||||
|
const points = makeCluster([1, 1, 1], 0.01, 6, 'c');
|
||||||
|
const result = runHDBSCAN(points, { minClusterSize: 3, metric: 'euclidean' });
|
||||||
|
for (const cluster of result.clusters) {
|
||||||
|
expect(cluster.centroid.length).toBe(3);
|
||||||
|
// Centroid should be near [1,1,1]
|
||||||
|
for (const val of cluster.centroid) {
|
||||||
|
expect(val).toBeCloseTo(1, 0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('handles single-dimension embeddings', () => {
|
||||||
|
const points = [
|
||||||
|
makePoint('x1', [0.1]),
|
||||||
|
makePoint('x2', [0.2]),
|
||||||
|
makePoint('x3', [0.15]),
|
||||||
|
makePoint('x4', [0.9]),
|
||||||
|
makePoint('x5', [0.95]),
|
||||||
|
makePoint('x6', [0.88]),
|
||||||
|
];
|
||||||
|
const result = runHDBSCAN(points, { minClusterSize: 2, minSamples: 1, metric: 'euclidean' });
|
||||||
|
expect(result.labels.size).toBe(6);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// runDBSCAN
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
describe('runDBSCAN', () => {
|
||||||
|
it('finds dense clusters and marks outliers as noise', () => {
|
||||||
|
const cluster = makeCluster([0, 0], 0.05, 5, 'dense');
|
||||||
|
const outlier = makePoint('outlier', [10, 10]);
|
||||||
|
const points = [...cluster, outlier];
|
||||||
|
|
||||||
|
const result = runDBSCAN(points, { eps: 0.5, minPts: 2 });
|
||||||
|
expect(result.clusters.length).toBeGreaterThanOrEqual(1);
|
||||||
|
expect(result.labels.get('outlier')).toBe(-1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns no clusters when eps is too small', () => {
|
||||||
|
const points = makeCluster([0, 0], 1.0, 5, 'spread');
|
||||||
|
const result = runDBSCAN(points, { eps: 0.001, minPts: 3 });
|
||||||
|
expect(result.clusters).toHaveLength(0);
|
||||||
|
expect(result.noise).toHaveLength(5);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('puts all points in one cluster when eps is large enough', () => {
|
||||||
|
const points = makeCluster([0, 0], 0.1, 6, 'tight');
|
||||||
|
const result = runDBSCAN(points, { eps: 100, minPts: 2 });
|
||||||
|
expect(result.clusters).toHaveLength(1);
|
||||||
|
expect(result.noise).toHaveLength(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// autoSelectParameters
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
describe('autoSelectParameters', () => {
|
||||||
|
it('selects DBSCAN for very small datasets (<10)', () => {
|
||||||
|
const params = autoSelectParameters(5);
|
||||||
|
expect(params.algorithm).toBe('dbscan');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('selects HDBSCAN with small minClusterSize for small datasets', () => {
|
||||||
|
const params = autoSelectParameters(25);
|
||||||
|
expect(params.algorithm).toBe('hdbscan');
|
||||||
|
expect(params.minClusterSize).toBe(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('selects HDBSCAN with medium settings for medium datasets', () => {
|
||||||
|
const params = autoSelectParameters(100);
|
||||||
|
expect(params.algorithm).toBe('hdbscan');
|
||||||
|
expect(params.minClusterSize).toBe(3);
|
||||||
|
expect(params.metric).toBe('cosine');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('selects HDBSCAN with larger settings for large datasets', () => {
|
||||||
|
const params = autoSelectParameters(1000);
|
||||||
|
expect(params.algorithm).toBe('hdbscan');
|
||||||
|
expect(params.minClusterSize).toBe(5);
|
||||||
|
expect(params.minSamples).toBe(3);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// calculateClusterQuality
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
describe('calculateClusterQuality', () => {
|
||||||
|
it('returns metrics object with silhouetteScore', () => {
|
||||||
|
const points = [...makeCluster([0, 0], 0.01, 3, 'a'), ...makeCluster([5, 5], 0.01, 3, 'b')];
|
||||||
|
const labels = new Map<string, number>();
|
||||||
|
points.forEach((p, i) => labels.set(p.id, i < 3 ? 0 : 1));
|
||||||
|
|
||||||
|
const clusters = [
|
||||||
|
{
|
||||||
|
id: 'c0',
|
||||||
|
points: points.slice(0, 3),
|
||||||
|
centroid: [0, 0],
|
||||||
|
stability: 1,
|
||||||
|
density: 1,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'c1',
|
||||||
|
points: points.slice(3),
|
||||||
|
centroid: [5, 5],
|
||||||
|
stability: 1,
|
||||||
|
density: 1,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const quality = calculateClusterQuality(points, labels, clusters);
|
||||||
|
expect(quality).toHaveProperty('silhouetteScore');
|
||||||
|
expect(quality).toHaveProperty('daviesBouldinIndex');
|
||||||
|
expect(quality).toHaveProperty('calinskiHarabaszIndex');
|
||||||
|
// Well-separated clusters should have positive silhouette
|
||||||
|
expect(quality.silhouetteScore).toBeGreaterThan(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns 0 silhouette when all points are noise', () => {
|
||||||
|
const points = [makePoint('n1', [0, 0]), makePoint('n2', [1, 1])];
|
||||||
|
const labels = new Map<string, number>([
|
||||||
|
['n1', -1],
|
||||||
|
['n2', -1],
|
||||||
|
]);
|
||||||
|
const quality = calculateClusterQuality(points, labels, []);
|
||||||
|
expect(quality.silhouetteScore).toBe(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
@ -0,0 +1,430 @@
|
|||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
import {
|
||||||
|
normalizeErrorMessage,
|
||||||
|
parseStackTrace,
|
||||||
|
normalizeStackFrames,
|
||||||
|
generateFingerprint,
|
||||||
|
levenshteinDistance,
|
||||||
|
calculateFingerprintSimilarity,
|
||||||
|
processErrorEvent,
|
||||||
|
updateClusterWithError,
|
||||||
|
} from './error-normalization.js';
|
||||||
|
import type { ErrorEvent, ErrorFingerprint, ErrorClusterDoc } from './types.js';
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// normalizeErrorMessage
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
describe('normalizeErrorMessage', () => {
|
||||||
|
it('replaces UUIDs with <UUID>', () => {
|
||||||
|
const msg = 'Failed for user 550e8400-e29b-41d4-a716-446655440000';
|
||||||
|
expect(normalizeErrorMessage(msg)).toContain('<UUID>');
|
||||||
|
expect(normalizeErrorMessage(msg)).not.toContain('550e8400');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('replaces MongoDB ObjectIds with <ID>', () => {
|
||||||
|
const msg = 'Document 507f1f77bcf86cd799439011 not found';
|
||||||
|
expect(normalizeErrorMessage(msg)).toContain('<ID>');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('replaces email addresses with <EMAIL>', () => {
|
||||||
|
const msg = 'Login failed for alice@example.com';
|
||||||
|
expect(normalizeErrorMessage(msg)).toContain('<EMAIL>');
|
||||||
|
expect(normalizeErrorMessage(msg)).not.toContain('alice@example.com');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('replaces IPv4 addresses with <IP>', () => {
|
||||||
|
const msg = 'Connection refused from 192.168.1.100';
|
||||||
|
expect(normalizeErrorMessage(msg)).toContain('<IP>');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('replaces ISO timestamps with <DATE>', () => {
|
||||||
|
const msg = 'Error at 2026-03-21T12:30:45.123Z';
|
||||||
|
expect(normalizeErrorMessage(msg)).toContain('<DATE>');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('replaces simple dates with <DATE>', () => {
|
||||||
|
const msg = 'Last seen on 03/21/2026';
|
||||||
|
expect(normalizeErrorMessage(msg)).toContain('<DATE>');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('replaces user IDs with <USER_ID>', () => {
|
||||||
|
const msg = 'Cannot find user_12345';
|
||||||
|
expect(normalizeErrorMessage(msg)).toContain('<USER_ID>');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('replaces long numbers with <NUM>', () => {
|
||||||
|
const msg = 'Record 12345678901 overflow';
|
||||||
|
expect(normalizeErrorMessage(msg)).toContain('<NUM>');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('replaces URLs with <URL>', () => {
|
||||||
|
const msg = 'Fetch failed for https://api.example.com/v1/data';
|
||||||
|
expect(normalizeErrorMessage(msg)).toContain('<URL>');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('normalizes file paths keeping filename', () => {
|
||||||
|
const msg = 'Error in /src/lib/server.ts';
|
||||||
|
const result = normalizeErrorMessage(msg);
|
||||||
|
expect(result).toContain('server.ts');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('handles messages with multiple replaceable parts', () => {
|
||||||
|
const msg =
|
||||||
|
'User user_999 at 192.168.0.1 failed at 2026-01-01T00:00:00Z with id 550e8400-e29b-41d4-a716-446655440000';
|
||||||
|
const result = normalizeErrorMessage(msg);
|
||||||
|
expect(result).toContain('<USER_ID>');
|
||||||
|
expect(result).toContain('<IP>');
|
||||||
|
expect(result).toContain('<DATE>');
|
||||||
|
expect(result).toContain('<UUID>');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns unchanged message if nothing to normalize', () => {
|
||||||
|
expect(normalizeErrorMessage('TypeError: cannot read property')).toBe(
|
||||||
|
'TypeError: cannot read property'
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// parseStackTrace
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
describe('parseStackTrace', () => {
|
||||||
|
it('parses JavaScript stack frames', () => {
|
||||||
|
const stack = `Error: something went wrong
|
||||||
|
at handleRequest (/app/src/server.ts:42:10)
|
||||||
|
at processTicksAndRejections (internal/process/task_queues.js:95:5)`;
|
||||||
|
const frames = parseStackTrace(stack);
|
||||||
|
expect(frames.length).toBeGreaterThanOrEqual(1);
|
||||||
|
expect(frames[0].function).toBe('handleRequest');
|
||||||
|
expect(frames[0].line).toBe(42);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('parses async JavaScript stack frames', () => {
|
||||||
|
const stack = ' at async fetchData (/app/lib/api.ts:10:3)';
|
||||||
|
const frames = parseStackTrace(stack);
|
||||||
|
expect(frames.length).toBe(1);
|
||||||
|
expect(frames[0].function).toBe('fetchData');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('parses Python stack frames', () => {
|
||||||
|
const stack = ' File "/app/main.py", line 25, in handle_request';
|
||||||
|
const frames = parseStackTrace(stack);
|
||||||
|
expect(frames.length).toBe(1);
|
||||||
|
expect(frames[0].function).toBe('handle_request');
|
||||||
|
expect(frames[0].line).toBe(25);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('parses Java/Kotlin stack frames', () => {
|
||||||
|
const stack = ' at com.example.MyClass.doSomething(MyClass.java:55)';
|
||||||
|
const frames = parseStackTrace(stack);
|
||||||
|
expect(frames.length).toBe(1);
|
||||||
|
expect(frames[0].line).toBe(55);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns empty array for empty input', () => {
|
||||||
|
expect(parseStackTrace('')).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('skips unparseable lines', () => {
|
||||||
|
const stack = `Error: boom
|
||||||
|
This is not a stack frame
|
||||||
|
Another line
|
||||||
|
at realFrame (/app/file.ts:1:1)`;
|
||||||
|
const frames = parseStackTrace(stack);
|
||||||
|
expect(frames.length).toBe(1);
|
||||||
|
expect(frames[0].function).toBe('realFrame');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// normalizeStackFrames
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
describe('normalizeStackFrames', () => {
|
||||||
|
it('normalizes and joins frames', () => {
|
||||||
|
const frames = [
|
||||||
|
{ function: 'handleRequest', file: '/app/server.ts', line: 42, column: 10 },
|
||||||
|
{ function: 'processData', file: '/app/lib/data.ts', line: 15 },
|
||||||
|
];
|
||||||
|
const result = normalizeStackFrames(frames);
|
||||||
|
expect(result).toContain('handleRequest@');
|
||||||
|
expect(result).toContain('|');
|
||||||
|
expect(result).toContain('processData@');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('truncates to maxFrames', () => {
|
||||||
|
const frames = Array.from({ length: 20 }, (_, i) => ({
|
||||||
|
function: `fn${i}`,
|
||||||
|
file: `/app/f${i}.ts`,
|
||||||
|
line: i,
|
||||||
|
}));
|
||||||
|
const result = normalizeStackFrames(frames, 3);
|
||||||
|
expect(result.split('|').length).toBe(3);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('handles empty frames', () => {
|
||||||
|
expect(normalizeStackFrames([])).toBe('');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// generateFingerprint
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
describe('generateFingerprint', () => {
|
||||||
|
it('generates a SHA-256 hash', () => {
|
||||||
|
const result = generateFingerprint({
|
||||||
|
errorType: 'TypeError',
|
||||||
|
message: 'Cannot read property of null',
|
||||||
|
});
|
||||||
|
expect(result.hash).toMatch(/^[a-f0-9]{64}$/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('strips Error/Exception suffix from type', () => {
|
||||||
|
const result = generateFingerprint({
|
||||||
|
errorType: 'NullPointerException',
|
||||||
|
message: 'Null pointer',
|
||||||
|
});
|
||||||
|
expect(result.normalizedType).toBe('NullPointer');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('normalizes the message', () => {
|
||||||
|
const result = generateFingerprint({
|
||||||
|
errorType: 'Error',
|
||||||
|
message: 'Failed for user user_123 at 192.168.1.1',
|
||||||
|
});
|
||||||
|
expect(result.normalizedMessage).toContain('<USER_ID>');
|
||||||
|
expect(result.normalizedMessage).toContain('<IP>');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('extracts source location from stack trace', () => {
|
||||||
|
const result = generateFingerprint({
|
||||||
|
errorType: 'Error',
|
||||||
|
message: 'boom',
|
||||||
|
stackTrace: ' at myFunc (/app/src/handler.ts:10:5)',
|
||||||
|
});
|
||||||
|
expect(result.sourceLocation).toBeDefined();
|
||||||
|
expect(result.sourceLocation?.function).toBe('myFunc');
|
||||||
|
expect(result.sourceLocation?.line).toBe(10);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('produces same hash for same normalized input', () => {
|
||||||
|
const a = generateFingerprint({
|
||||||
|
errorType: 'TypeError',
|
||||||
|
message: 'Cannot read property of null',
|
||||||
|
});
|
||||||
|
const b = generateFingerprint({
|
||||||
|
errorType: 'TypeError',
|
||||||
|
message: 'Cannot read property of null',
|
||||||
|
});
|
||||||
|
expect(a.hash).toBe(b.hash);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('produces different hash for different inputs', () => {
|
||||||
|
const a = generateFingerprint({ errorType: 'TypeError', message: 'a' });
|
||||||
|
const b = generateFingerprint({ errorType: 'RangeError', message: 'b' });
|
||||||
|
expect(a.hash).not.toBe(b.hash);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// levenshteinDistance
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
describe('levenshteinDistance', () => {
|
||||||
|
it('returns 0 for identical strings', () => {
|
||||||
|
expect(levenshteinDistance('abc', 'abc')).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns correct distance for single edit', () => {
|
||||||
|
expect(levenshteinDistance('cat', 'bat')).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns length of longer string when one is empty', () => {
|
||||||
|
expect(levenshteinDistance('', 'abc')).toBe(3);
|
||||||
|
expect(levenshteinDistance('abc', '')).toBe(3);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('handles completely different strings', () => {
|
||||||
|
expect(levenshteinDistance('abc', 'xyz')).toBe(3);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// calculateFingerprintSimilarity
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
describe('calculateFingerprintSimilarity', () => {
|
||||||
|
it('returns 1 for identical fingerprints', () => {
|
||||||
|
const fp = {
|
||||||
|
hash: 'abc',
|
||||||
|
normalizedType: 'Type',
|
||||||
|
normalizedMessage: 'msg',
|
||||||
|
stackSignature: 'sig',
|
||||||
|
};
|
||||||
|
expect(calculateFingerprintSimilarity(fp, fp)).toBeCloseTo(1, 1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns high score for same type and similar message', () => {
|
||||||
|
const a = {
|
||||||
|
hash: 'a',
|
||||||
|
normalizedType: 'TypeError',
|
||||||
|
normalizedMessage: 'Cannot read property x',
|
||||||
|
stackSignature: '',
|
||||||
|
};
|
||||||
|
const b = {
|
||||||
|
hash: 'b',
|
||||||
|
normalizedType: 'TypeError',
|
||||||
|
normalizedMessage: 'Cannot read property y',
|
||||||
|
stackSignature: '',
|
||||||
|
};
|
||||||
|
expect(calculateFingerprintSimilarity(a, b)).toBeGreaterThan(0.7);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns low score for different types and messages', () => {
|
||||||
|
const a = {
|
||||||
|
hash: 'a',
|
||||||
|
normalizedType: 'TypeError',
|
||||||
|
normalizedMessage: 'null pointer',
|
||||||
|
stackSignature: '',
|
||||||
|
};
|
||||||
|
const b = {
|
||||||
|
hash: 'b',
|
||||||
|
normalizedType: 'RangeError',
|
||||||
|
normalizedMessage: 'stack overflow in recursive call',
|
||||||
|
stackSignature: '',
|
||||||
|
};
|
||||||
|
expect(calculateFingerprintSimilarity(a, b)).toBeLessThan(0.5);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// processErrorEvent
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
describe('processErrorEvent', () => {
|
||||||
|
const baseEvent: ErrorEvent = {
|
||||||
|
id: 'ev_1',
|
||||||
|
productId: 'lysnrai',
|
||||||
|
errorType: 'TypeError',
|
||||||
|
message: 'Cannot read property of null',
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
platform: 'ios',
|
||||||
|
osVersion: '18.0',
|
||||||
|
appVersion: '1.2.0',
|
||||||
|
};
|
||||||
|
|
||||||
|
it('creates a new cluster when no existing fingerprints', () => {
|
||||||
|
const result = processErrorEvent(baseEvent);
|
||||||
|
expect(result.isNewCluster).toBe(true);
|
||||||
|
expect(result.clusterId).toMatch(/^ec_/);
|
||||||
|
expect(result.fingerprint.hash).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('matches existing fingerprint when similarity >= 0.85', () => {
|
||||||
|
const fp = generateFingerprint({
|
||||||
|
errorType: baseEvent.errorType,
|
||||||
|
message: baseEvent.message,
|
||||||
|
});
|
||||||
|
|
||||||
|
const existing: ErrorFingerprint = {
|
||||||
|
id: 'ef_existing',
|
||||||
|
productId: 'lysnrai',
|
||||||
|
fingerprintHash: fp.hash,
|
||||||
|
errorType: fp.normalizedType,
|
||||||
|
messageTemplate: fp.normalizedMessage,
|
||||||
|
stackSignature: fp.stackSignature,
|
||||||
|
createdAt: new Date().toISOString(),
|
||||||
|
updatedAt: new Date().toISOString(),
|
||||||
|
occurrenceCount: 5,
|
||||||
|
uniqueUsers: 3,
|
||||||
|
ttl: 90 * 86400,
|
||||||
|
};
|
||||||
|
|
||||||
|
const existingMap = new Map<string, ErrorFingerprint>();
|
||||||
|
existingMap.set(existing.id, existing);
|
||||||
|
|
||||||
|
const result = processErrorEvent(baseEvent, existingMap);
|
||||||
|
expect(result.isNewCluster).toBe(false);
|
||||||
|
expect(result.clusterId).toBe('ef_existing');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('extracts platform context from error event', () => {
|
||||||
|
const result = processErrorEvent(baseEvent);
|
||||||
|
expect(result.context.platform).toBe('ios');
|
||||||
|
expect(result.context.osVersion).toBe('18.0');
|
||||||
|
expect(result.context.appVersion).toBe('1.2.0');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// updateClusterWithError
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
describe('updateClusterWithError', () => {
|
||||||
|
const cluster: ErrorClusterDoc = {
|
||||||
|
id: 'ec_1',
|
||||||
|
productId: 'lysnrai',
|
||||||
|
fingerprintHash: 'fp_1',
|
||||||
|
firstSeenAt: '2026-03-20T00:00:00.000Z',
|
||||||
|
lastSeenAt: '2026-03-20T12:00:00.000Z',
|
||||||
|
occurrenceCount: 5,
|
||||||
|
uniqueUsers: 2,
|
||||||
|
errorType: 'TypeError',
|
||||||
|
messageTemplate: 'Cannot read property',
|
||||||
|
stackSignature: 'sig',
|
||||||
|
relatedClusterIds: [],
|
||||||
|
status: 'active',
|
||||||
|
createdAt: '2026-03-20T00:00:00.000Z',
|
||||||
|
updatedAt: '2026-03-20T12:00:00.000Z',
|
||||||
|
ttl: 90 * 86400,
|
||||||
|
};
|
||||||
|
|
||||||
|
const errorEvent: ErrorEvent = {
|
||||||
|
id: 'ev_2',
|
||||||
|
productId: 'lysnrai',
|
||||||
|
errorType: 'TypeError',
|
||||||
|
message: 'Cannot read property of null',
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
platform: 'ios',
|
||||||
|
osVersion: '18.1',
|
||||||
|
appVersion: '1.3.0',
|
||||||
|
userId: 'user_new',
|
||||||
|
};
|
||||||
|
|
||||||
|
const fingerprint = generateFingerprint({
|
||||||
|
errorType: errorEvent.errorType,
|
||||||
|
message: errorEvent.message,
|
||||||
|
});
|
||||||
|
|
||||||
|
it('increments occurrence count', () => {
|
||||||
|
const updated = updateClusterWithError(cluster, errorEvent, fingerprint);
|
||||||
|
expect(updated.occurrenceCount).toBe(6);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('updates lastSeenAt timestamp', () => {
|
||||||
|
const updated = updateClusterWithError(cluster, errorEvent, fingerprint);
|
||||||
|
expect(new Date(updated.lastSeenAt).getTime()).toBeGreaterThan(
|
||||||
|
new Date(cluster.lastSeenAt).getTime()
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('re-activates resolved clusters', () => {
|
||||||
|
const resolvedCluster = { ...cluster, status: 'resolved' as const };
|
||||||
|
const updated = updateClusterWithError(resolvedCluster, errorEvent, fingerprint);
|
||||||
|
expect(updated.status).toBe('active');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('updates commonContext with new os/app version', () => {
|
||||||
|
const updated = updateClusterWithError(cluster, errorEvent, fingerprint);
|
||||||
|
expect(updated.commonContext).toBeDefined();
|
||||||
|
expect(updated.commonContext!.osVersions).toEqual(
|
||||||
|
expect.arrayContaining([expect.objectContaining({ version: '18.1' })])
|
||||||
|
);
|
||||||
|
expect(updated.commonContext!.appVersions).toEqual(
|
||||||
|
expect.arrayContaining([expect.objectContaining({ version: '1.3.0' })])
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
@ -0,0 +1,309 @@
|
|||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
import {
|
||||||
|
parseQuery,
|
||||||
|
matchQueryPattern,
|
||||||
|
generateQuerySuggestions,
|
||||||
|
validateQuery,
|
||||||
|
QUERY_PATTERNS,
|
||||||
|
} from './query-parser.js';
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// parseQuery — intent classification
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
describe('parseQuery — intent classification', () => {
|
||||||
|
it('classifies "why" questions as root_cause', () => {
|
||||||
|
const result = parseQuery('Why did iOS crash yesterday?');
|
||||||
|
expect(result.intent).toBe('root_cause');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('classifies "what caused" as root_cause', () => {
|
||||||
|
const result = parseQuery('What caused the authentication failures?');
|
||||||
|
expect(result.intent).toBe('root_cause');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('classifies "show me" as pattern_search', () => {
|
||||||
|
const result = parseQuery('Show me similar database errors');
|
||||||
|
expect(result.intent).toBe('pattern_search');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('classifies "find" as pattern_search', () => {
|
||||||
|
const result = parseQuery('Find crashes like the login timeout');
|
||||||
|
expect(result.intent).toBe('pattern_search');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('classifies "compare" as comparison', () => {
|
||||||
|
const result = parseQuery('Compare this week to last week');
|
||||||
|
expect(result.intent).toBe('comparison');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('classifies "trend" as trend', () => {
|
||||||
|
const result = parseQuery('Show the trend for timeout errors over time');
|
||||||
|
expect(result.intent).toBe('trend');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('classifies "how many" as impact', () => {
|
||||||
|
const result = parseQuery('How many users were affected by the crash?');
|
||||||
|
expect(result.intent).toBe('impact');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('defaults to root_cause for ambiguous queries', () => {
|
||||||
|
const result = parseQuery('errors happening on iOS');
|
||||||
|
expect(result.intent).toBe('root_cause');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// parseQuery — entity extraction
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
describe('parseQuery — entity extraction', () => {
|
||||||
|
it('extracts iOS platform', () => {
|
||||||
|
const result = parseQuery('Show iOS crashes');
|
||||||
|
expect(result.entities.platforms).toContain('ios');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('extracts Android platform', () => {
|
||||||
|
const result = parseQuery('Android login failures');
|
||||||
|
expect(result.entities.platforms).toContain('android');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('extracts web platform', () => {
|
||||||
|
const result = parseQuery('Web dashboard timeout');
|
||||||
|
expect(result.entities.platforms).toContain('web');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('extracts desktop platform', () => {
|
||||||
|
const result = parseQuery('Desktop app crashes on Mac');
|
||||||
|
expect(result.entities.platforms).toContain('desktop');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('extracts multiple platforms', () => {
|
||||||
|
const result = parseQuery('Compare iOS and Android crashes');
|
||||||
|
expect(result.entities.platforms).toContain('ios');
|
||||||
|
expect(result.entities.platforms).toContain('android');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('extracts known product IDs', () => {
|
||||||
|
const result = parseQuery('Show errors for lysnrai');
|
||||||
|
expect(result.entities.products).toContain('lysnrai');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('extracts error types like crash', () => {
|
||||||
|
const result = parseQuery('Show me recent crash reports');
|
||||||
|
expect(result.entities.errorTypes!.length).toBeGreaterThan(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('extracts error types like timeout', () => {
|
||||||
|
const result = parseQuery('Why are there timeout errors?');
|
||||||
|
expect(result.entities.errorTypes!.length).toBeGreaterThan(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('extracts error types like memory leak', () => {
|
||||||
|
const result = parseQuery('Investigate the memory leak');
|
||||||
|
expect(result.entities.errorTypes!.length).toBeGreaterThan(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// parseQuery — time range extraction
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
describe('parseQuery — time range extraction', () => {
|
||||||
|
it('extracts "yesterday" time range', () => {
|
||||||
|
const result = parseQuery('What crashed yesterday?');
|
||||||
|
expect(result.entities.timeRange).toBeDefined();
|
||||||
|
const start = new Date(result.entities.timeRange!.start);
|
||||||
|
const end = new Date(result.entities.timeRange!.end);
|
||||||
|
// Start is yesterday midnight, end is yesterday 23:59:59 — span is ~24h
|
||||||
|
// But implementation sets start to yesterday-midnight and end to today-23:59, so up to ~48h
|
||||||
|
expect(end.getTime() - start.getTime()).toBeLessThanOrEqual(48 * 60 * 60 * 1000);
|
||||||
|
expect(start.getHours()).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('extracts "last week" time range', () => {
|
||||||
|
const result = parseQuery('Show errors from last week');
|
||||||
|
expect(result.entities.timeRange).toBeDefined();
|
||||||
|
const start = new Date(result.entities.timeRange!.start);
|
||||||
|
const now = Date.now();
|
||||||
|
const diffDays = (now - start.getTime()) / (24 * 60 * 60 * 1000);
|
||||||
|
expect(diffDays).toBeCloseTo(7, 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('extracts "last month" time range', () => {
|
||||||
|
const result = parseQuery('Crashes last month');
|
||||||
|
expect(result.entities.timeRange).toBeDefined();
|
||||||
|
const start = new Date(result.entities.timeRange!.start);
|
||||||
|
const now = Date.now();
|
||||||
|
const diffDays = (now - start.getTime()) / (24 * 60 * 60 * 1000);
|
||||||
|
expect(diffDays).toBeCloseTo(30, 1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('extracts "N days ago" time range', () => {
|
||||||
|
const result = parseQuery('Errors from 3 days ago');
|
||||||
|
expect(result.entities.timeRange).toBeDefined();
|
||||||
|
const start = new Date(result.entities.timeRange!.start);
|
||||||
|
const now = Date.now();
|
||||||
|
const diffDays = (now - start.getTime()) / (24 * 60 * 60 * 1000);
|
||||||
|
expect(diffDays).toBeCloseTo(3, 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('extracts "N hours ago" time range', () => {
|
||||||
|
const result = parseQuery('What happened 6 hours ago');
|
||||||
|
expect(result.entities.timeRange).toBeDefined();
|
||||||
|
const start = new Date(result.entities.timeRange!.start);
|
||||||
|
const now = Date.now();
|
||||||
|
const diffHours = (now - start.getTime()) / (60 * 60 * 1000);
|
||||||
|
expect(diffHours).toBeCloseTo(6, 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('defaults to 7 days when no time specified', () => {
|
||||||
|
const result = parseQuery('Show all TypeError errors');
|
||||||
|
expect(result.entities.timeRange).toBeDefined();
|
||||||
|
const start = new Date(result.entities.timeRange!.start);
|
||||||
|
const now = Date.now();
|
||||||
|
const diffDays = (now - start.getTime()) / (24 * 60 * 60 * 1000);
|
||||||
|
expect(diffDays).toBeCloseTo(7, 0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// parseQuery — constraints
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
describe('parseQuery — constraints', () => {
|
||||||
|
it('extracts exclusion constraints', () => {
|
||||||
|
const result = parseQuery('Show errors excluding android');
|
||||||
|
expect(result.constraints).toContain('exclude:android');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('extracts beta-only constraint', () => {
|
||||||
|
const result = parseQuery('Show only beta crashes');
|
||||||
|
expect(result.constraints).toContain('userSegment:beta');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('extracts production-only constraint', () => {
|
||||||
|
const result = parseQuery('Show only production errors');
|
||||||
|
expect(result.constraints).toContain('environment:production');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns empty constraints for unconstrained queries', () => {
|
||||||
|
const result = parseQuery('Why did the app crash?');
|
||||||
|
expect(result.constraints).toHaveLength(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// matchQueryPattern
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
describe('matchQueryPattern', () => {
|
||||||
|
it('matches root_cause queries to root_cause_investigation pattern', () => {
|
||||||
|
const parsed = parseQuery('Why did the authentication fail?');
|
||||||
|
const pattern = matchQueryPattern(parsed);
|
||||||
|
expect(pattern).toBeDefined();
|
||||||
|
expect(pattern!.name).toBe('root_cause_investigation');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('matches pattern_search queries', () => {
|
||||||
|
const parsed = parseQuery('Find similar timeout errors');
|
||||||
|
const pattern = matchQueryPattern(parsed);
|
||||||
|
expect(pattern).toBeDefined();
|
||||||
|
expect(pattern!.name).toBe('similar_errors_search');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('matches impact queries', () => {
|
||||||
|
const parsed = parseQuery('How many users were affected?');
|
||||||
|
const pattern = matchQueryPattern(parsed);
|
||||||
|
expect(pattern).toBeDefined();
|
||||||
|
expect(pattern!.name).toBe('impact_assessment');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('matches comparison queries', () => {
|
||||||
|
const parsed = parseQuery('Compare these two error clusters');
|
||||||
|
const pattern = matchQueryPattern(parsed);
|
||||||
|
expect(pattern).toBeDefined();
|
||||||
|
expect(pattern!.name).toBe('cluster_comparison');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// generateQuerySuggestions
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
describe('generateQuerySuggestions', () => {
|
||||||
|
it('generates suggestions based on error types', () => {
|
||||||
|
const suggestions = generateQuerySuggestions(['TypeError', 'NetworkError'], ['ios']);
|
||||||
|
expect(suggestions.length).toBeGreaterThan(0);
|
||||||
|
expect(suggestions.some(s => s.query.includes('TypeError'))).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('includes platform-specific suggestions', () => {
|
||||||
|
const suggestions = generateQuerySuggestions(['CrashError'], ['android', 'web']);
|
||||||
|
expect(suggestions.some(s => s.query.includes('android'))).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('always includes impact and trend suggestions', () => {
|
||||||
|
const suggestions = generateQuerySuggestions([], []);
|
||||||
|
expect(suggestions.some(s => s.pattern === 'impact_assessment')).toBe(true);
|
||||||
|
expect(suggestions.some(s => s.pattern === 'trend_analysis')).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('limits suggestions to 6', () => {
|
||||||
|
const suggestions = generateQuerySuggestions(
|
||||||
|
['A', 'B', 'C', 'D', 'E'],
|
||||||
|
['ios', 'android', 'web']
|
||||||
|
);
|
||||||
|
expect(suggestions.length).toBeLessThanOrEqual(6);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// validateQuery
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
describe('validateQuery', () => {
|
||||||
|
it('returns valid for specific queries', () => {
|
||||||
|
const parsed = parseQuery('Why did iOS crash yesterday?');
|
||||||
|
const result = validateQuery(parsed);
|
||||||
|
expect(result.valid).toBe(true);
|
||||||
|
expect(result.errors).toHaveLength(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('warns when query is too broad', () => {
|
||||||
|
const parsed = parseQuery('What happened?');
|
||||||
|
const result = validateQuery(parsed);
|
||||||
|
expect(result.warnings.length).toBeGreaterThan(0);
|
||||||
|
expect(result.warnings[0]).toContain('broad');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('warns when time range exceeds 90 days', () => {
|
||||||
|
const parsed = parseQuery('Show errors');
|
||||||
|
// Override time range to > 90 days
|
||||||
|
parsed.entities.timeRange = {
|
||||||
|
start: new Date(Date.now() - 120 * 86400000).toISOString(),
|
||||||
|
end: new Date().toISOString(),
|
||||||
|
};
|
||||||
|
const result = validateQuery(parsed);
|
||||||
|
expect(result.warnings.some(w => w.includes('90 days'))).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// QUERY_PATTERNS constant
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
describe('QUERY_PATTERNS', () => {
|
||||||
|
it('has at least 5 patterns', () => {
|
||||||
|
expect(QUERY_PATTERNS.length).toBeGreaterThanOrEqual(5);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('each pattern has required fields', () => {
|
||||||
|
for (const pattern of QUERY_PATTERNS) {
|
||||||
|
expect(pattern.name).toBeTruthy();
|
||||||
|
expect(pattern.description).toBeTruthy();
|
||||||
|
expect(pattern.examples.length).toBeGreaterThan(0);
|
||||||
|
expect(pattern.intent).toBeTruthy();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
Loading…
Reference in New Issue
Block a user