450 lines
16 KiB
TypeScript
450 lines
16 KiB
TypeScript
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 <uuid>");
|
|
});
|
|
|
|
it("should normalize run IDs", () => {
|
|
const message = "Failed to execute run_abcd1234xyz";
|
|
const normalized = normalizeErrorMessage(message);
|
|
expect(normalized).toBe("Failed to execute <run-id>");
|
|
});
|
|
|
|
it("should normalize task friendly IDs", () => {
|
|
const message = "Task task_abc12345678 failed";
|
|
const normalized = normalizeErrorMessage(message);
|
|
expect(normalized).toBe("Task <id> failed");
|
|
});
|
|
|
|
it("should normalize numeric IDs (4+ digits)", () => {
|
|
const message = "User 12345 not found";
|
|
const normalized = normalizeErrorMessage(message);
|
|
expect(normalized).toBe("User <id> 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 <timestamp> 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 <timestamp>");
|
|
});
|
|
|
|
it("should normalize Unix timestamps", () => {
|
|
const message = "Created at 1234567890";
|
|
const normalized = normalizeErrorMessage(message);
|
|
expect(normalized).toBe("Created at <timestamp>");
|
|
});
|
|
|
|
it("should normalize Unix timestamps (milliseconds)", () => {
|
|
const message = "Created at 1234567890123";
|
|
const normalized = normalizeErrorMessage(message);
|
|
expect(normalized).toBe("Created at <timestamp>");
|
|
});
|
|
|
|
it("should normalize Unix file paths", () => {
|
|
const message = "Cannot read /home/user/project/file.ts";
|
|
const normalized = normalizeErrorMessage(message);
|
|
expect(normalized).toBe("Cannot read <path>");
|
|
});
|
|
|
|
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 <path>");
|
|
});
|
|
|
|
it("should normalize email addresses", () => {
|
|
const message = "Email user@example.com already exists";
|
|
const normalized = normalizeErrorMessage(message);
|
|
expect(normalized).toBe("Email <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 <url>");
|
|
});
|
|
|
|
it("should normalize HTTP URLs", () => {
|
|
const message = "Request to http://localhost:3000/api failed";
|
|
const normalized = normalizeErrorMessage(message);
|
|
expect(normalized).toBe("Request to <url> failed");
|
|
});
|
|
|
|
it("should normalize memory addresses", () => {
|
|
const message = "Segfault at 0x7fff5fbffab0";
|
|
const normalized = normalizeErrorMessage(message);
|
|
expect(normalized).toBe("Segfault at <addr>");
|
|
});
|
|
|
|
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: "<string>"');
|
|
});
|
|
|
|
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 <id> at <email> failed to access <run-id> at <timestamp>");
|
|
});
|
|
|
|
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 <timestamp>");
|
|
});
|
|
|
|
it("ISO timestamp without trailing Z should normalize correctly", () => {
|
|
const message = "Started at 2024-01-15T08:00:00";
|
|
expect(normalizeErrorMessage(message)).toBe("Started at <timestamp>");
|
|
});
|
|
|
|
it("Unix timestamp (10 digits) should not become <id>", () => {
|
|
const message = "Token expires 1700000000";
|
|
expect(normalizeErrorMessage(message)).toBe("Token expires <timestamp>");
|
|
});
|
|
|
|
it("Unix timestamp (13 digits) should not become <id>", () => {
|
|
const message = "Sent at 1700000000000";
|
|
expect(normalizeErrorMessage(message)).toBe("Sent at <timestamp>");
|
|
});
|
|
|
|
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 <url>");
|
|
});
|
|
|
|
it("URL with port and path should normalize to <url>", () => {
|
|
const message = "Cannot reach http://localhost:8080/health/ready";
|
|
expect(normalizeErrorMessage(message)).toBe("Cannot reach <url>");
|
|
});
|
|
|
|
it("URL with query string should normalize to <url>", () => {
|
|
const message = "GET https://api.example.com/v2/users?page=1&limit=50 returned 500";
|
|
expect(normalizeErrorMessage(message)).toBe("GET <url> 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 <url> failed at <timestamp>");
|
|
});
|
|
|
|
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 <url> timed out after <timestamp>");
|
|
});
|
|
|
|
it("path-like string that is NOT a URL should still become <path>", () => {
|
|
const message = "Cannot read /var/log/app/error.log";
|
|
expect(normalizeErrorMessage(message)).toBe("Cannot read <path>");
|
|
});
|
|
});
|
|
|
|
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.<anonymous> (/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));
|
|
});
|
|
});
|
|
});
|