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