a reactive (signals based) hypermedia web framework (wip) stormlightlabs.github.io/volt/
hypermedia frontend signals
at main 15 kB view raw
1import { 2 BindingError, 3 ChargeError, 4 clearErrorHandlers, 5 EffectError, 6 EvaluatorError, 7 getErrorHandlerCount, 8 HttpError, 9 LifecycleError, 10 onError, 11 PluginError, 12 report, 13 UserError, 14 VoltError, 15} from "$core/error"; 16import type { ErrorContext, ErrorLevel, ErrorSource } from "$types/volt"; 17import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; 18 19describe("VoltError", () => { 20 it("creates error with basic context", () => { 21 const cause = new Error("Test error"); 22 const context: ErrorContext = { source: "binding" }; 23 24 const voltError = new VoltError(cause, context); 25 26 expect(voltError).toBeInstanceOf(Error); 27 expect(voltError).toBeInstanceOf(VoltError); 28 expect(voltError.name).toBe("VoltError"); 29 expect(voltError.source).toBe("binding"); 30 expect(voltError.cause).toBe(cause); 31 expect(voltError.stopped).toBe(false); 32 }); 33 34 it("includes directive and expression in context", () => { 35 const cause = new Error("Evaluation failed"); 36 const context: ErrorContext = { source: "evaluator", directive: "data-volt-text", expression: "count * 2" }; 37 38 const voltError = new VoltError(cause, context); 39 40 expect(voltError.directive).toBe("data-volt-text"); 41 expect(voltError.expression).toBe("count * 2"); 42 expect(voltError.message).toContain("[evaluator]"); 43 expect(voltError.message).toContain("Directive: data-volt-text"); 44 expect(voltError.message).toContain("Expression: count * 2"); 45 }); 46 47 it("includes element information in message", () => { 48 const div = document.createElement("div"); 49 div.id = "test"; 50 div.className = "foo bar"; 51 52 const cause = new Error("DOM error"); 53 const context: ErrorContext = { source: "binding", element: div }; 54 55 const voltError = new VoltError(cause, context); 56 57 expect(voltError.element).toBe(div); 58 expect(voltError.message).toContain("Element: <div#test.foo.bar>"); 59 }); 60 61 it("includes HTTP context in message", () => { 62 const cause = new Error("Request failed"); 63 const context: ErrorContext = { source: "http", httpMethod: "POST", httpUrl: "/api/users", httpStatus: 500 }; 64 65 const voltError = new VoltError(cause, context); 66 67 expect(voltError.message).toContain("HTTP: POST /api/users"); 68 expect(voltError.message).toContain("Status: 500"); 69 }); 70 71 it("includes plugin name in message", () => { 72 const cause = new Error("Plugin failed"); 73 const context: ErrorContext = { source: "plugin", pluginName: "persist" }; 74 75 const voltError = new VoltError(cause, context); 76 77 expect(voltError.message).toContain("Plugin: persist"); 78 }); 79 80 it("includes lifecycle hook name in message", () => { 81 const cause = new Error("Hook failed"); 82 const context: ErrorContext = { source: "lifecycle", hookName: "onMount" }; 83 84 const voltError = new VoltError(cause, context); 85 86 expect(voltError.message).toContain("Hook: onMount"); 87 }); 88 89 it("stopPropagation prevents handler chain", () => { 90 const cause = new Error("Test"); 91 const context: ErrorContext = { source: "binding" }; 92 93 const voltError = new VoltError(cause, context); 94 95 expect(voltError.stopped).toBe(false); 96 voltError.stopPropagation(); 97 expect(voltError.stopped).toBe(true); 98 }); 99 100 it("serializes to JSON", () => { 101 const cause = new Error("Test error"); 102 const context: ErrorContext = { source: "effect", directive: "data-volt-on-click", expression: "count++" }; 103 104 const voltError = new VoltError(cause, context); 105 const json = voltError.toJSON(); 106 107 expect(json.name).toBe("VoltError"); 108 expect(json.source).toBe("effect"); 109 expect(json.directive).toBe("data-volt-on-click"); 110 expect(json.expression).toBe("count++"); 111 expect(json.cause).toEqual({ name: "Error", message: "Test error", stack: cause.stack }); 112 }); 113 114 it("truncates long expressions in message", () => { 115 const longExpr = "a".repeat(150); 116 const cause = new Error("Test"); 117 const context: ErrorContext = { source: "evaluator", expression: longExpr }; 118 119 const voltError = new VoltError(cause, context); 120 121 expect(voltError.message).toContain("Expression: " + "a".repeat(100) + "..."); 122 expect(voltError.message).not.toContain("a".repeat(101)); 123 }); 124}); 125 126describe("Error Handler Registration", () => { 127 beforeEach(() => { 128 clearErrorHandlers(); 129 }); 130 131 afterEach(() => { 132 clearErrorHandlers(); 133 }); 134 135 it("registers error handler", () => { 136 expect(getErrorHandlerCount()).toBe(0); 137 138 const handler = vi.fn(); 139 onError(handler); 140 141 expect(getErrorHandlerCount()).toBe(1); 142 }); 143 144 it("returns cleanup function", () => { 145 const handler = vi.fn(); 146 const cleanup = onError(handler); 147 148 expect(getErrorHandlerCount()).toBe(1); 149 150 cleanup(); 151 152 expect(getErrorHandlerCount()).toBe(0); 153 }); 154 155 it("registers multiple handlers", () => { 156 const handler1 = vi.fn(); 157 const handler2 = vi.fn(); 158 159 onError(handler1); 160 onError(handler2); 161 162 expect(getErrorHandlerCount()).toBe(2); 163 }); 164 165 it("clears all handlers", () => { 166 onError(vi.fn()); 167 onError(vi.fn()); 168 onError(vi.fn()); 169 170 expect(getErrorHandlerCount()).toBe(3); 171 172 clearErrorHandlers(); 173 174 expect(getErrorHandlerCount()).toBe(0); 175 }); 176}); 177 178describe("Error Reporting", () => { 179 beforeEach(() => { 180 clearErrorHandlers(); 181 vi.spyOn(console, "error").mockImplementation(() => {}); 182 }); 183 184 afterEach(() => { 185 clearErrorHandlers(); 186 vi.restoreAllMocks(); 187 }); 188 189 it("calls registered handler with VoltError", () => { 190 const handler = vi.fn(); 191 onError(handler); 192 193 const error = new Error("Test"); 194 const context: ErrorContext = { source: "binding" }; 195 196 report(error, context); 197 198 expect(handler).toHaveBeenCalledTimes(1); 199 expect(handler).toHaveBeenCalledWith(expect.any(VoltError)); 200 201 const voltError = handler.mock.calls[0][0]; 202 expect(voltError.cause).toBe(error); 203 expect(voltError.source).toBe("binding"); 204 }); 205 206 it("calls multiple handlers in order", () => { 207 const callOrder: number[] = []; 208 209 const handler1 = vi.fn(() => callOrder.push(1)); 210 const handler2 = vi.fn(() => callOrder.push(2)); 211 const handler3 = vi.fn(() => callOrder.push(3)); 212 213 onError(handler1); 214 onError(handler2); 215 onError(handler3); 216 217 report(new Error("Test"), { source: "effect" }); 218 219 expect(callOrder).toEqual([1, 2, 3]); 220 }); 221 222 it("stops propagation when stopPropagation is called", () => { 223 const handler1 = vi.fn((error: VoltError) => { 224 error.stopPropagation(); 225 }); 226 const handler2 = vi.fn(); 227 const handler3 = vi.fn(); 228 229 onError(handler1); 230 onError(handler2); 231 onError(handler3); 232 233 report(new Error("Test"), { source: "effect" }); 234 235 expect(handler1).toHaveBeenCalledTimes(1); 236 expect(handler2).not.toHaveBeenCalled(); 237 expect(handler3).not.toHaveBeenCalled(); 238 }); 239 240 it("falls back to console.error when no handlers registered", () => { 241 const error = new Error("Test error"); 242 const context: ErrorContext = { source: "http", httpMethod: "GET", httpUrl: "/api/data" }; 243 244 report(error, context); 245 246 expect(console.error).toHaveBeenCalledTimes(2); 247 expect(console.error).toHaveBeenCalledWith(expect.stringContaining("[http]")); 248 expect(console.error).toHaveBeenCalledWith("Caused by:", error); 249 }); 250 251 it("converts non-Error values to Error", () => { 252 const handler = vi.fn(); 253 onError(handler); 254 255 report("string error", { source: "user" }); 256 257 expect(handler).toHaveBeenCalledTimes(1); 258 const voltError: VoltError = handler.mock.calls[0][0]; 259 expect(voltError.cause).toBeInstanceOf(Error); 260 expect(voltError.cause.message).toBe("string error"); 261 }); 262 263 it("catches errors in error handlers", () => { 264 const handler1 = vi.fn(() => { 265 throw new Error("Handler error"); 266 }); 267 const handler2 = vi.fn(); 268 269 onError(handler1); 270 onError(handler2); 271 272 report(new Error("Test"), { source: "effect" }); 273 274 expect(handler1).toHaveBeenCalledTimes(1); 275 expect(handler2).toHaveBeenCalledTimes(1); 276 expect(console.error).toHaveBeenCalledWith("Error in error handler:", expect.any(Error)); 277 }); 278 279 it("includes element in console fallback", () => { 280 const div = document.createElement("div"); 281 div.id = "test-element"; 282 283 report(new Error("Test"), { source: "binding", element: div }); 284 285 expect(console.error).toHaveBeenCalledWith("Element:", div); 286 }); 287 288 it("handles all error sources", () => { 289 const handler = vi.fn(); 290 onError(handler); 291 292 const sources: Array<ErrorSource> = [ 293 "evaluator", 294 "binding", 295 "effect", 296 "http", 297 "plugin", 298 "lifecycle", 299 "charge", 300 "user", 301 ]; 302 303 for (const source of sources) { 304 report(new Error(`Test ${source}`), { source }); 305 } 306 307 expect(handler).toHaveBeenCalledTimes(sources.length); 308 309 for (const [i, source] of sources.entries()) { 310 const voltError: VoltError = handler.mock.calls[i][0]; 311 expect(voltError.source).toBe(source); 312 } 313 }); 314 315 it("creates correct error types based on source", () => { 316 const handler = vi.fn(); 317 onError(handler); 318 319 const testCases: Array<{ source: ErrorSource; errorType: typeof VoltError; name: string }> = [ 320 { source: "evaluator", errorType: EvaluatorError, name: "EvaluatorError" }, 321 { source: "binding", errorType: BindingError, name: "BindingError" }, 322 { source: "effect", errorType: EffectError, name: "EffectError" }, 323 { source: "http", errorType: HttpError, name: "HttpError" }, 324 { source: "plugin", errorType: PluginError, name: "PluginError" }, 325 { source: "lifecycle", errorType: LifecycleError, name: "LifecycleError" }, 326 { source: "charge", errorType: ChargeError, name: "ChargeError" }, 327 { source: "user", errorType: UserError, name: "UserError" }, 328 ]; 329 330 for (const { source } of testCases) { 331 report(new Error(`Test ${source}`), { source }); 332 } 333 334 expect(handler).toHaveBeenCalledTimes(testCases.length); 335 336 for (const [i, { errorType, name }] of testCases.entries()) { 337 const voltError = handler.mock.calls[i][0]; 338 expect(voltError).toBeInstanceOf(errorType); 339 expect(voltError).toBeInstanceOf(VoltError); 340 expect(voltError.name).toBe(name); 341 } 342 }); 343}); 344 345describe("Error Levels", () => { 346 beforeEach(() => { 347 clearErrorHandlers(); 348 vi.spyOn(console, "error").mockImplementation(() => {}); 349 vi.spyOn(console, "warn").mockImplementation(() => {}); 350 }); 351 352 afterEach(() => { 353 clearErrorHandlers(); 354 vi.restoreAllMocks(); 355 }); 356 357 it("defaults to error level when not specified", () => { 358 const cause = new Error("Test error"); 359 const context: ErrorContext = { source: "binding" }; 360 361 const voltError = new VoltError(cause, context); 362 363 expect(voltError.level).toBe("error"); 364 }); 365 366 it("includes error level in VoltError", () => { 367 const levels: Array<ErrorLevel> = ["warn", "error", "fatal"]; 368 369 for (const level of levels) { 370 const cause = new Error(`Test ${level}`); 371 const context: ErrorContext = { source: "binding", level }; 372 373 const voltError = new VoltError(cause, context); 374 375 expect(voltError.level).toBe(level); 376 } 377 }); 378 379 it("includes error level in message", () => { 380 const levels: Array<ErrorLevel> = ["warn", "error", "fatal"]; 381 382 for (const level of levels) { 383 const cause = new Error(`Test ${level}`); 384 const context: ErrorContext = { source: "binding", level }; 385 386 const voltError = new VoltError(cause, context); 387 388 expect(voltError.message).toContain(`[${level.toUpperCase()}]`); 389 } 390 }); 391 392 it("includes error level in JSON serialization", () => { 393 const cause = new Error("Test error"); 394 const context: ErrorContext = { source: "binding", level: "warn" }; 395 396 const voltError = new VoltError(cause, context); 397 const json = voltError.toJSON(); 398 399 expect(json.level).toBe("warn"); 400 }); 401 402 it("uses console.warn for warn level without handlers", () => { 403 const error = new Error("Warning message"); 404 const context: ErrorContext = { source: "binding", level: "warn" }; 405 406 report(error, context); 407 408 expect(console.warn).toHaveBeenCalledTimes(2); 409 expect(console.warn).toHaveBeenCalledWith(expect.stringContaining("[WARN]")); 410 expect(console.warn).toHaveBeenCalledWith("Caused by:", error); 411 expect(console.error).not.toHaveBeenCalled(); 412 }); 413 414 it("uses console.error for error level without handlers", () => { 415 const error = new Error("Error message"); 416 const context: ErrorContext = { source: "binding", level: "error" }; 417 418 report(error, context); 419 420 expect(console.error).toHaveBeenCalledTimes(2); 421 expect(console.error).toHaveBeenCalledWith(expect.stringContaining("[ERROR]")); 422 expect(console.error).toHaveBeenCalledWith("Caused by:", error); 423 expect(console.warn).not.toHaveBeenCalled(); 424 }); 425 426 it("uses console.error for fatal level without handlers", () => { 427 const error = new Error("Fatal error"); 428 const context: ErrorContext = { source: "charge", level: "fatal" }; 429 430 expect(() => report(error, context)).toThrow(VoltError); 431 432 expect(console.error).toHaveBeenCalledTimes(2); 433 expect(console.error).toHaveBeenCalledWith(expect.stringContaining("[FATAL]")); 434 expect(console.error).toHaveBeenCalledWith("Caused by:", error); 435 }); 436 437 it("throws error for fatal level after handlers", () => { 438 const handler = vi.fn(); 439 onError(handler); 440 441 const error = new Error("Fatal error"); 442 const context: ErrorContext = { source: "charge", level: "fatal" }; 443 444 expect(() => report(error, context)).toThrow(VoltError); 445 446 expect(handler).toHaveBeenCalledTimes(1); 447 const voltError = handler.mock.calls[0][0]; 448 expect(voltError.level).toBe("fatal"); 449 }); 450 451 it("does not throw for warn level", () => { 452 const error = new Error("Warning"); 453 const context: ErrorContext = { source: "http", level: "warn" }; 454 455 expect(() => report(error, context)).not.toThrow(); 456 }); 457 458 it("does not throw for error level", () => { 459 const error = new Error("Error"); 460 const context: ErrorContext = { source: "binding", level: "error" }; 461 462 expect(() => report(error, context)).not.toThrow(); 463 }); 464 465 it("passes error level to handlers", () => { 466 const handler = vi.fn(); 467 onError(handler); 468 469 const levels: Array<ErrorLevel> = ["warn", "error", "fatal"]; 470 471 for (const level of levels) { 472 try { 473 report(new Error(`Test ${level}`), { source: "binding", level }); 474 } catch { /* No-op */ } 475 } 476 477 expect(handler).toHaveBeenCalledTimes(3); 478 479 for (const [i, level] of levels.entries()) { 480 const voltError: VoltError = handler.mock.calls[i][0]; 481 expect(voltError.level).toBe(level); 482 } 483 }); 484});