Files
2026-07-13 13:32:57 +08:00

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