import {
BindingError,
ChargeError,
clearErrorHandlers,
EffectError,
EvaluatorError,
getErrorHandlerCount,
HttpError,
LifecycleError,
onError,
PluginError,
report,
UserError,
VoltError,
} from "$core/error";
import type { ErrorContext, ErrorLevel, ErrorSource } from "$types/volt";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
describe("VoltError", () => {
it("creates error with basic context", () => {
const cause = new Error("Test error");
const context: ErrorContext = { source: "binding" };
const voltError = new VoltError(cause, context);
expect(voltError).toBeInstanceOf(Error);
expect(voltError).toBeInstanceOf(VoltError);
expect(voltError.name).toBe("VoltError");
expect(voltError.source).toBe("binding");
expect(voltError.cause).toBe(cause);
expect(voltError.stopped).toBe(false);
});
it("includes directive and expression in context", () => {
const cause = new Error("Evaluation failed");
const context: ErrorContext = { source: "evaluator", directive: "data-volt-text", expression: "count * 2" };
const voltError = new VoltError(cause, context);
expect(voltError.directive).toBe("data-volt-text");
expect(voltError.expression).toBe("count * 2");
expect(voltError.message).toContain("[evaluator]");
expect(voltError.message).toContain("Directive: data-volt-text");
expect(voltError.message).toContain("Expression: count * 2");
});
it("includes element information in message", () => {
const div = document.createElement("div");
div.id = "test";
div.className = "foo bar";
const cause = new Error("DOM error");
const context: ErrorContext = { source: "binding", element: div };
const voltError = new VoltError(cause, context);
expect(voltError.element).toBe(div);
expect(voltError.message).toContain("Element:
");
});
it("includes HTTP context in message", () => {
const cause = new Error("Request failed");
const context: ErrorContext = { source: "http", httpMethod: "POST", httpUrl: "/api/users", httpStatus: 500 };
const voltError = new VoltError(cause, context);
expect(voltError.message).toContain("HTTP: POST /api/users");
expect(voltError.message).toContain("Status: 500");
});
it("includes plugin name in message", () => {
const cause = new Error("Plugin failed");
const context: ErrorContext = { source: "plugin", pluginName: "persist" };
const voltError = new VoltError(cause, context);
expect(voltError.message).toContain("Plugin: persist");
});
it("includes lifecycle hook name in message", () => {
const cause = new Error("Hook failed");
const context: ErrorContext = { source: "lifecycle", hookName: "onMount" };
const voltError = new VoltError(cause, context);
expect(voltError.message).toContain("Hook: onMount");
});
it("stopPropagation prevents handler chain", () => {
const cause = new Error("Test");
const context: ErrorContext = { source: "binding" };
const voltError = new VoltError(cause, context);
expect(voltError.stopped).toBe(false);
voltError.stopPropagation();
expect(voltError.stopped).toBe(true);
});
it("serializes to JSON", () => {
const cause = new Error("Test error");
const context: ErrorContext = { source: "effect", directive: "data-volt-on-click", expression: "count++" };
const voltError = new VoltError(cause, context);
const json = voltError.toJSON();
expect(json.name).toBe("VoltError");
expect(json.source).toBe("effect");
expect(json.directive).toBe("data-volt-on-click");
expect(json.expression).toBe("count++");
expect(json.cause).toEqual({ name: "Error", message: "Test error", stack: cause.stack });
});
it("truncates long expressions in message", () => {
const longExpr = "a".repeat(150);
const cause = new Error("Test");
const context: ErrorContext = { source: "evaluator", expression: longExpr };
const voltError = new VoltError(cause, context);
expect(voltError.message).toContain("Expression: " + "a".repeat(100) + "...");
expect(voltError.message).not.toContain("a".repeat(101));
});
});
describe("Error Handler Registration", () => {
beforeEach(() => {
clearErrorHandlers();
});
afterEach(() => {
clearErrorHandlers();
});
it("registers error handler", () => {
expect(getErrorHandlerCount()).toBe(0);
const handler = vi.fn();
onError(handler);
expect(getErrorHandlerCount()).toBe(1);
});
it("returns cleanup function", () => {
const handler = vi.fn();
const cleanup = onError(handler);
expect(getErrorHandlerCount()).toBe(1);
cleanup();
expect(getErrorHandlerCount()).toBe(0);
});
it("registers multiple handlers", () => {
const handler1 = vi.fn();
const handler2 = vi.fn();
onError(handler1);
onError(handler2);
expect(getErrorHandlerCount()).toBe(2);
});
it("clears all handlers", () => {
onError(vi.fn());
onError(vi.fn());
onError(vi.fn());
expect(getErrorHandlerCount()).toBe(3);
clearErrorHandlers();
expect(getErrorHandlerCount()).toBe(0);
});
});
describe("Error Reporting", () => {
beforeEach(() => {
clearErrorHandlers();
vi.spyOn(console, "error").mockImplementation(() => {});
});
afterEach(() => {
clearErrorHandlers();
vi.restoreAllMocks();
});
it("calls registered handler with VoltError", () => {
const handler = vi.fn();
onError(handler);
const error = new Error("Test");
const context: ErrorContext = { source: "binding" };
report(error, context);
expect(handler).toHaveBeenCalledTimes(1);
expect(handler).toHaveBeenCalledWith(expect.any(VoltError));
const voltError = handler.mock.calls[0][0];
expect(voltError.cause).toBe(error);
expect(voltError.source).toBe("binding");
});
it("calls multiple handlers in order", () => {
const callOrder: number[] = [];
const handler1 = vi.fn(() => callOrder.push(1));
const handler2 = vi.fn(() => callOrder.push(2));
const handler3 = vi.fn(() => callOrder.push(3));
onError(handler1);
onError(handler2);
onError(handler3);
report(new Error("Test"), { source: "effect" });
expect(callOrder).toEqual([1, 2, 3]);
});
it("stops propagation when stopPropagation is called", () => {
const handler1 = vi.fn((error: VoltError) => {
error.stopPropagation();
});
const handler2 = vi.fn();
const handler3 = vi.fn();
onError(handler1);
onError(handler2);
onError(handler3);
report(new Error("Test"), { source: "effect" });
expect(handler1).toHaveBeenCalledTimes(1);
expect(handler2).not.toHaveBeenCalled();
expect(handler3).not.toHaveBeenCalled();
});
it("falls back to console.error when no handlers registered", () => {
const error = new Error("Test error");
const context: ErrorContext = { source: "http", httpMethod: "GET", httpUrl: "/api/data" };
report(error, context);
expect(console.error).toHaveBeenCalledTimes(2);
expect(console.error).toHaveBeenCalledWith(expect.stringContaining("[http]"));
expect(console.error).toHaveBeenCalledWith("Caused by:", error);
});
it("converts non-Error values to Error", () => {
const handler = vi.fn();
onError(handler);
report("string error", { source: "user" });
expect(handler).toHaveBeenCalledTimes(1);
const voltError: VoltError = handler.mock.calls[0][0];
expect(voltError.cause).toBeInstanceOf(Error);
expect(voltError.cause.message).toBe("string error");
});
it("catches errors in error handlers", () => {
const handler1 = vi.fn(() => {
throw new Error("Handler error");
});
const handler2 = vi.fn();
onError(handler1);
onError(handler2);
report(new Error("Test"), { source: "effect" });
expect(handler1).toHaveBeenCalledTimes(1);
expect(handler2).toHaveBeenCalledTimes(1);
expect(console.error).toHaveBeenCalledWith("Error in error handler:", expect.any(Error));
});
it("includes element in console fallback", () => {
const div = document.createElement("div");
div.id = "test-element";
report(new Error("Test"), { source: "binding", element: div });
expect(console.error).toHaveBeenCalledWith("Element:", div);
});
it("handles all error sources", () => {
const handler = vi.fn();
onError(handler);
const sources: Array = [
"evaluator",
"binding",
"effect",
"http",
"plugin",
"lifecycle",
"charge",
"user",
];
for (const source of sources) {
report(new Error(`Test ${source}`), { source });
}
expect(handler).toHaveBeenCalledTimes(sources.length);
for (const [i, source] of sources.entries()) {
const voltError: VoltError = handler.mock.calls[i][0];
expect(voltError.source).toBe(source);
}
});
it("creates correct error types based on source", () => {
const handler = vi.fn();
onError(handler);
const testCases: Array<{ source: ErrorSource; errorType: typeof VoltError; name: string }> = [
{ source: "evaluator", errorType: EvaluatorError, name: "EvaluatorError" },
{ source: "binding", errorType: BindingError, name: "BindingError" },
{ source: "effect", errorType: EffectError, name: "EffectError" },
{ source: "http", errorType: HttpError, name: "HttpError" },
{ source: "plugin", errorType: PluginError, name: "PluginError" },
{ source: "lifecycle", errorType: LifecycleError, name: "LifecycleError" },
{ source: "charge", errorType: ChargeError, name: "ChargeError" },
{ source: "user", errorType: UserError, name: "UserError" },
];
for (const { source } of testCases) {
report(new Error(`Test ${source}`), { source });
}
expect(handler).toHaveBeenCalledTimes(testCases.length);
for (const [i, { errorType, name }] of testCases.entries()) {
const voltError = handler.mock.calls[i][0];
expect(voltError).toBeInstanceOf(errorType);
expect(voltError).toBeInstanceOf(VoltError);
expect(voltError.name).toBe(name);
}
});
});
describe("Error Levels", () => {
beforeEach(() => {
clearErrorHandlers();
vi.spyOn(console, "error").mockImplementation(() => {});
vi.spyOn(console, "warn").mockImplementation(() => {});
});
afterEach(() => {
clearErrorHandlers();
vi.restoreAllMocks();
});
it("defaults to error level when not specified", () => {
const cause = new Error("Test error");
const context: ErrorContext = { source: "binding" };
const voltError = new VoltError(cause, context);
expect(voltError.level).toBe("error");
});
it("includes error level in VoltError", () => {
const levels: Array = ["warn", "error", "fatal"];
for (const level of levels) {
const cause = new Error(`Test ${level}`);
const context: ErrorContext = { source: "binding", level };
const voltError = new VoltError(cause, context);
expect(voltError.level).toBe(level);
}
});
it("includes error level in message", () => {
const levels: Array = ["warn", "error", "fatal"];
for (const level of levels) {
const cause = new Error(`Test ${level}`);
const context: ErrorContext = { source: "binding", level };
const voltError = new VoltError(cause, context);
expect(voltError.message).toContain(`[${level.toUpperCase()}]`);
}
});
it("includes error level in JSON serialization", () => {
const cause = new Error("Test error");
const context: ErrorContext = { source: "binding", level: "warn" };
const voltError = new VoltError(cause, context);
const json = voltError.toJSON();
expect(json.level).toBe("warn");
});
it("uses console.warn for warn level without handlers", () => {
const error = new Error("Warning message");
const context: ErrorContext = { source: "binding", level: "warn" };
report(error, context);
expect(console.warn).toHaveBeenCalledTimes(2);
expect(console.warn).toHaveBeenCalledWith(expect.stringContaining("[WARN]"));
expect(console.warn).toHaveBeenCalledWith("Caused by:", error);
expect(console.error).not.toHaveBeenCalled();
});
it("uses console.error for error level without handlers", () => {
const error = new Error("Error message");
const context: ErrorContext = { source: "binding", level: "error" };
report(error, context);
expect(console.error).toHaveBeenCalledTimes(2);
expect(console.error).toHaveBeenCalledWith(expect.stringContaining("[ERROR]"));
expect(console.error).toHaveBeenCalledWith("Caused by:", error);
expect(console.warn).not.toHaveBeenCalled();
});
it("uses console.error for fatal level without handlers", () => {
const error = new Error("Fatal error");
const context: ErrorContext = { source: "charge", level: "fatal" };
expect(() => report(error, context)).toThrow(VoltError);
expect(console.error).toHaveBeenCalledTimes(2);
expect(console.error).toHaveBeenCalledWith(expect.stringContaining("[FATAL]"));
expect(console.error).toHaveBeenCalledWith("Caused by:", error);
});
it("throws error for fatal level after handlers", () => {
const handler = vi.fn();
onError(handler);
const error = new Error("Fatal error");
const context: ErrorContext = { source: "charge", level: "fatal" };
expect(() => report(error, context)).toThrow(VoltError);
expect(handler).toHaveBeenCalledTimes(1);
const voltError = handler.mock.calls[0][0];
expect(voltError.level).toBe("fatal");
});
it("does not throw for warn level", () => {
const error = new Error("Warning");
const context: ErrorContext = { source: "http", level: "warn" };
expect(() => report(error, context)).not.toThrow();
});
it("does not throw for error level", () => {
const error = new Error("Error");
const context: ErrorContext = { source: "binding", level: "error" };
expect(() => report(error, context)).not.toThrow();
});
it("passes error level to handlers", () => {
const handler = vi.fn();
onError(handler);
const levels: Array = ["warn", "error", "fatal"];
for (const level of levels) {
try {
report(new Error(`Test ${level}`), { source: "binding", level });
} catch { /* No-op */ }
}
expect(handler).toHaveBeenCalledTimes(3);
for (const [i, level] of levels.entries()) {
const voltError: VoltError = handler.mock.calls[i][0];
expect(voltError.level).toBe(level);
}
});
});