import { describe, it, expect } from "vitest"; import { calculateErrorFingerprint, normalizeErrorMessage, normalizeStackTrace, } from "~/utils/errorFingerprinting"; describe("normalizeErrorMessage", () => { it("should normalize UUIDs", () => { const message = "Error processing user 550e8400-e29b-41d4-a716-446655440000"; const normalized = normalizeErrorMessage(message); expect(normalized).toBe("Error processing user "); }); it("should normalize run IDs", () => { const message = "Failed to execute run_abcd1234xyz"; const normalized = normalizeErrorMessage(message); expect(normalized).toBe("Failed to execute "); }); it("should normalize task friendly IDs", () => { const message = "Task task_abc12345678 failed"; const normalized = normalizeErrorMessage(message); expect(normalized).toBe("Task failed"); }); it("should normalize numeric IDs (4+ digits)", () => { const message = "User 12345 not found"; const normalized = normalizeErrorMessage(message); expect(normalized).toBe("User not found"); }); it("should not normalize short numbers", () => { const message = "Retry attempt 3 of 5"; const normalized = normalizeErrorMessage(message); expect(normalized).toBe("Retry attempt 3 of 5"); }); it("should normalize ISO 8601 timestamps", () => { const message = "Event at 2024-03-01T15:30:45Z failed"; const normalized = normalizeErrorMessage(message); expect(normalized).toBe("Event at failed"); }); it("should normalize ISO timestamps with milliseconds", () => { const message = "Timeout at 2024-03-01T15:30:45.123Z"; const normalized = normalizeErrorMessage(message); expect(normalized).toBe("Timeout at "); }); it("should normalize Unix timestamps", () => { const message = "Created at 1234567890"; const normalized = normalizeErrorMessage(message); expect(normalized).toBe("Created at "); }); it("should normalize Unix timestamps (milliseconds)", () => { const message = "Created at 1234567890123"; const normalized = normalizeErrorMessage(message); expect(normalized).toBe("Created at "); }); it("should normalize Unix file paths", () => { const message = "Cannot read /home/user/project/file.ts"; const normalized = normalizeErrorMessage(message); expect(normalized).toBe("Cannot read "); }); it("should normalize Windows file paths", () => { const message = "Cannot read C:\\Users\\John\\project\\file.ts"; const normalized = normalizeErrorMessage(message); expect(normalized).toBe("Cannot read "); }); it("should normalize email addresses", () => { const message = "Email user@example.com already exists"; const normalized = normalizeErrorMessage(message); expect(normalized).toBe("Email already exists"); }); it("should normalize URLs", () => { const message = "Failed to fetch https://api.example.com/users/123"; const normalized = normalizeErrorMessage(message); expect(normalized).toBe("Failed to fetch "); }); it("should normalize HTTP URLs", () => { const message = "Request to http://localhost:3000/api failed"; const normalized = normalizeErrorMessage(message); expect(normalized).toBe("Request to failed"); }); it("should normalize memory addresses", () => { const message = "Segfault at 0x7fff5fbffab0"; const normalized = normalizeErrorMessage(message); expect(normalized).toBe("Segfault at "); }); it("should normalize long quoted strings", () => { const message = 'Error: "this is a very long error message with dynamic content that changes"'; const normalized = normalizeErrorMessage(message); expect(normalized).toBe('Error: ""'); }); it("should handle multiple replacements", () => { const message = "User 12345 at user@example.com failed to access run_abc123 at 2024-03-01T15:30:45Z"; const normalized = normalizeErrorMessage(message); expect(normalized).toBe("User at failed to access at "); }); it("should return empty string for empty input", () => { expect(normalizeErrorMessage("")).toBe(""); }); it("should handle messages with no dynamic content", () => { const message = "Connection timeout"; const normalized = normalizeErrorMessage(message); expect(normalized).toBe("Connection timeout"); }); describe("ordering: specific patterns before generic ones", () => { it("ISO timestamp year should not be consumed by numeric ID regex", () => { const message = "Deadline was 2025-12-31T23:59:59Z"; expect(normalizeErrorMessage(message)).toBe("Deadline was "); }); it("ISO timestamp without trailing Z should normalize correctly", () => { const message = "Started at 2024-01-15T08:00:00"; expect(normalizeErrorMessage(message)).toBe("Started at "); }); it("Unix timestamp (10 digits) should not become ", () => { const message = "Token expires 1700000000"; expect(normalizeErrorMessage(message)).toBe("Token expires "); }); it("Unix timestamp (13 digits) should not become ", () => { const message = "Sent at 1700000000000"; expect(normalizeErrorMessage(message)).toBe("Sent at "); }); it("URL path should not be stripped before URL regex runs", () => { const message = "Webhook failed for https://hooks.example.com/webhook/abc"; expect(normalizeErrorMessage(message)).toBe("Webhook failed for "); }); it("URL with port and path should normalize to ", () => { const message = "Cannot reach http://localhost:8080/health/ready"; expect(normalizeErrorMessage(message)).toBe("Cannot reach "); }); it("URL with query string should normalize to ", () => { const message = "GET https://api.example.com/v2/users?page=1&limit=50 returned 500"; expect(normalizeErrorMessage(message)).toBe("GET returned 500"); }); it("message with both a URL and a timestamp", () => { const message = "Request to https://api.example.com/data failed at 2025-06-15T10:30:00Z"; expect(normalizeErrorMessage(message)).toBe("Request to failed at "); }); it("message with a URL and a unix timestamp", () => { const message = "Callback to https://example.com/hook timed out after 1700000000"; expect(normalizeErrorMessage(message)).toBe("Callback to timed out after "); }); it("path-like string that is NOT a URL should still become ", () => { const message = "Cannot read /var/log/app/error.log"; expect(normalizeErrorMessage(message)).toBe("Cannot read "); }); }); describe("fingerprint stability: same error class groups together despite dynamic values", () => { it("errors differing only in ISO timestamp should share a fingerprint", () => { const e1 = { type: "TimeoutError", message: "Timed out at 2025-01-01T00:00:00Z" }; const e2 = { type: "TimeoutError", message: "Timed out at 2026-06-15T12:30:00Z" }; expect(calculateErrorFingerprint(e1)).toBe(calculateErrorFingerprint(e2)); }); it("errors differing only in URL path should share a fingerprint", () => { const e1 = { type: "FetchError", message: "Failed to fetch https://api.example.com/users/123", }; const e2 = { type: "FetchError", message: "Failed to fetch https://api.example.com/orders/456", }; expect(calculateErrorFingerprint(e1)).toBe(calculateErrorFingerprint(e2)); }); it("errors differing only in unix timestamp should share a fingerprint", () => { const e1 = { type: "ExpiredError", message: "Token expired at 1700000000" }; const e2 = { type: "ExpiredError", message: "Token expired at 1800000000" }; expect(calculateErrorFingerprint(e1)).toBe(calculateErrorFingerprint(e2)); }); }); }); describe("normalizeStackTrace", () => { it("should normalize line and column numbers", () => { const stack = `Error: Test error at functionName (file.ts:123:45) at anotherFunction (other.ts:67:89)`; const normalized = normalizeStackTrace(stack); expect(normalized).toContain(":_:_"); expect(normalized).not.toContain(":123:45"); }); it("should remove standalone numbers", () => { const stack = `Error: Test at Object. (/path/to/file.ts:123:45) at Module._compile (node:internal/modules/cjs/loader:456:78)`; const normalized = normalizeStackTrace(stack); expect(normalized).not.toMatch(/\b\d+\b/); }); it("should keep only first 5 frames", () => { const stack = `Error: Test at frame1 (file1.ts:1:1) at frame2 (file2.ts:2:2) at frame3 (file3.ts:3:3) at frame4 (file4.ts:4:4) at frame5 (file5.ts:5:5) at frame6 (file6.ts:6:6) at frame7 (file7.ts:7:7)`; const normalized = normalizeStackTrace(stack); const frames = normalized.split("|"); expect(frames.length).toBeLessThanOrEqual(5); }); it("should remove file paths but keep filenames", () => { const stack = `Error: Test at functionName (/home/user/project/src/file.ts:123:45)`; const normalized = normalizeStackTrace(stack); expect(normalized).toContain("file.ts"); expect(normalized).not.toContain("/home/user/project/src/"); }); it("should filter out empty lines", () => { const stack = `Error: Test at functionName (file.ts:123:45) at anotherFunction (other.ts:67:89)`; const normalized = normalizeStackTrace(stack); const frames = normalized.split("|").filter((f) => f.length > 0); expect(frames.length).toBeLessThanOrEqual(3); }); it("should return empty string for empty stack", () => { expect(normalizeStackTrace("")).toBe(""); }); it("should join frames with pipe delimiter", () => { const stack = `Error: Test at frame1 (file1.ts:1:1) at frame2 (file2.ts:2:2)`; const normalized = normalizeStackTrace(stack); expect(normalized).toContain("|"); }); }); describe("calculateErrorFingerprint", () => { it("should generate consistent fingerprints for same error", () => { const error = { type: "DatabaseError", message: "Connection timeout", stack: "at db.connect (db.ts:123:45)", }; const fp1 = calculateErrorFingerprint(error); const fp2 = calculateErrorFingerprint(error); expect(fp1).toBe(fp2); expect(fp1.length).toBe(16); }); it("should generate same fingerprint for errors with different IDs", () => { const error1 = { type: "NotFoundError", message: "User 12345 not found", stack: "at findUser (user.ts:50:10)", }; const error2 = { type: "NotFoundError", message: "User 67890 not found", stack: "at findUser (user.ts:50:10)", }; const fp1 = calculateErrorFingerprint(error1); const fp2 = calculateErrorFingerprint(error2); expect(fp1).toBe(fp2); }); it("should generate same fingerprint for errors with different UUIDs", () => { const error1 = { type: "ValidationError", message: "Invalid token 550e8400-e29b-41d4-a716-446655440000", }; const error2 = { type: "ValidationError", message: "Invalid token 123e4567-e89b-12d3-a456-426614174000", }; expect(calculateErrorFingerprint(error1)).toBe(calculateErrorFingerprint(error2)); }); it("should generate same fingerprint for errors with different run IDs", () => { const error1 = { type: "TaskError", message: "Failed to execute run_abc123", }; const error2 = { type: "TaskError", message: "Failed to execute run_xyz789", }; expect(calculateErrorFingerprint(error1)).toBe(calculateErrorFingerprint(error2)); }); it("should generate different fingerprints for different error types", () => { const error1 = { type: "DatabaseError", message: "Connection failed", }; const error2 = { type: "NetworkError", message: "Connection failed", }; expect(calculateErrorFingerprint(error1)).not.toBe(calculateErrorFingerprint(error2)); }); it("should generate different fingerprints for different error messages", () => { const error1 = { type: "Error", message: "Connection timeout", }; const error2 = { type: "Error", message: "Connection refused", }; expect(calculateErrorFingerprint(error1)).not.toBe(calculateErrorFingerprint(error2)); }); it("should handle error with name instead of type", () => { const error = { name: "TypeError", message: "Cannot read property 'foo' of undefined", }; const fp = calculateErrorFingerprint(error); expect(fp).toBeTruthy(); expect(fp.length).toBe(16); }); it("should handle error with stacktrace instead of stack", () => { const error = { type: "Error", message: "Test error", stacktrace: "at test (file.ts:1:1)", }; const fp = calculateErrorFingerprint(error); expect(fp).toBeTruthy(); }); it("should return empty string for non-object error", () => { expect(calculateErrorFingerprint(null)).toBe(""); expect(calculateErrorFingerprint(undefined)).toBe(""); expect(calculateErrorFingerprint("error string")).toBe(""); expect(calculateErrorFingerprint(123)).toBe(""); }); it("should handle errors with no message or stack", () => { const error = { type: "Error", }; const fp = calculateErrorFingerprint(error); expect(fp).toBeTruthy(); expect(fp.length).toBe(16); }); it("should generate fingerprints using stack trace when available", () => { const error1 = { type: "Error", message: "Test", stack: "at funcA (a.ts:1:1)\nat funcB (b.ts:2:2)", }; const error2 = { type: "Error", message: "Test", stack: "at funcX (x.ts:1:1)\nat funcY (y.ts:2:2)", }; expect(calculateErrorFingerprint(error1)).not.toBe(calculateErrorFingerprint(error2)); }); it("should normalize line numbers in stack traces for same code location", () => { const error1 = { type: "Error", message: "Test", stack: "at func (file.ts:123:45)", }; const error2 = { type: "Error", message: "Test", stack: "at func (file.ts:456:78)", }; expect(calculateErrorFingerprint(error1)).toBe(calculateErrorFingerprint(error2)); }); describe("message fallback to name / raw", () => { it("distinguishes messageless built-in errors by their class name", () => { const error1 = { type: "BUILT_IN_ERROR", name: "ListMessagesError", message: "" }; const error2 = { type: "BUILT_IN_ERROR", name: "SendMessageError", message: "" }; expect(calculateErrorFingerprint(error1)).not.toBe(calculateErrorFingerprint(error2)); }); it("groups two messageless errors of the same class together", () => { const error1 = { type: "BUILT_IN_ERROR", name: "ListMessagesError", message: "" }; const error2 = { type: "BUILT_IN_ERROR", name: "ListMessagesError", message: "" }; expect(calculateErrorFingerprint(error1)).toBe(calculateErrorFingerprint(error2)); }); it("does not change the fingerprint of a message-bearing error", () => { // Fingerprint must stay stable for the common path, otherwise existing // error groups would split on deploy. const withMessage = { type: "BUILT_IN_ERROR", name: "SomeError", message: "Connection timeout", }; const withoutName = { type: "BUILT_IN_ERROR", message: "Connection timeout" }; expect(calculateErrorFingerprint(withMessage)).toBe(calculateErrorFingerprint(withoutName)); }); it("distinguishes string errors by their raw value", () => { const error1 = { type: "STRING_ERROR", raw: "rate limited" }; const error2 = { type: "STRING_ERROR", raw: "connection refused" }; expect(calculateErrorFingerprint(error1)).not.toBe(calculateErrorFingerprint(error2)); }); it("distinguishes custom errors by their raw value", () => { const error1 = { type: "CUSTOM_ERROR", raw: '{"code":"E_LIMIT"}' }; const error2 = { type: "CUSTOM_ERROR", raw: '{"code":"E_AUTH"}' }; expect(calculateErrorFingerprint(error1)).not.toBe(calculateErrorFingerprint(error2)); }); it("prefers message over name and raw when all are present", () => { const withMessage = { type: "BUILT_IN_ERROR", name: "Ignored", message: "real message" }; const messageOnly = { type: "BUILT_IN_ERROR", message: "real message" }; expect(calculateErrorFingerprint(withMessage)).toBe(calculateErrorFingerprint(messageOnly)); }); }); });