a reactive (signals based) hypermedia web framework (wip)
stormlightlabs.github.io/volt/
hypermedia
frontend
signals
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});