a reactive (signals based) hypermedia web framework (wip)
stormlightlabs.github.io/volt/
hypermedia
frontend
signals
1import { mount } from "$core/binder";
2import {
3 clearStates,
4 parseHttpConfig,
5 request,
6 serializeForm,
7 serializeFormToJSON,
8 setErrorState,
9 setLoadingState,
10 swap,
11} from "$core/http";
12import { beforeEach, describe, expect, it, vi } from "vitest";
13
14describe("http", () => {
15 describe("swap", () => {
16 let container: HTMLDivElement;
17
18 beforeEach(() => {
19 container = document.createElement("div");
20 container.innerHTML = "<div id=\"target\">Original</div>";
21 document.body.append(container);
22 });
23
24 it("swaps innerHTML by default", () => {
25 const target = container.querySelector("#target")!;
26 swap(target, "<span>New</span>");
27 expect(target.innerHTML).toBe("<span>New</span>");
28 });
29
30 it("swaps innerHTML explicitly", () => {
31 const target = container.querySelector("#target")!;
32 swap(target, "<strong>Bold</strong>", "innerHTML");
33 expect(target.innerHTML).toBe("<strong>Bold</strong>");
34 });
35
36 it("swaps outerHTML", () => {
37 const target = container.querySelector("#target")!;
38 swap(target, "<section id=\"new\">Replaced</section>", "outerHTML");
39 expect(container.querySelector("#target")).toBeNull();
40 expect(container.querySelector("#new")?.textContent).toBe("Replaced");
41 });
42
43 it("inserts beforebegin", () => {
44 const target = container.querySelector("#target")!;
45 swap(target, "<span id=\"before\">Before</span>", "beforebegin");
46 expect(container.querySelector("#before")?.nextElementSibling?.id).toBe("target");
47 });
48
49 it("inserts afterbegin", () => {
50 const target = container.querySelector("#target")!;
51 swap(target, "<span id=\"first\">First</span>", "afterbegin");
52 expect(target.firstElementChild?.id).toBe("first");
53 });
54
55 it("inserts beforeend", () => {
56 const target = container.querySelector("#target")!;
57 swap(target, "<span id=\"last\">Last</span>", "beforeend");
58 expect(target.lastElementChild?.id).toBe("last");
59 });
60
61 it("inserts afterend", () => {
62 const target = container.querySelector("#target")!;
63 swap(target, "<span id=\"after\">After</span>", "afterend");
64 expect(container.querySelector("#target")?.nextElementSibling?.id).toBe("after");
65 });
66
67 it("deletes the target element", () => {
68 const target = container.querySelector("#target")!;
69 swap(target, "", "delete");
70 expect(container.querySelector("#target")).toBeNull();
71 });
72
73 it("does nothing with none strategy", () => {
74 const target = container.querySelector("#target")!;
75 const originalHTML = target.innerHTML;
76 swap(target, "<span>Should not appear</span>", "none");
77 expect(target.innerHTML).toBe(originalHTML);
78 });
79
80 describe("state preservation", () => {
81 it("preserves focus when swapping innerHTML", () => {
82 container.innerHTML = `
83 <div id="target">
84 <input id="input1" type="text" />
85 <input id="input2" type="text" />
86 </div>
87 `;
88 const target = container.querySelector("#target")!;
89 const input2 = container.querySelector("#input2") as HTMLInputElement;
90 input2.focus();
91
92 expect(document.activeElement).toBe(input2);
93
94 swap(
95 target,
96 `
97 <input id="input1" type="text" />
98 <input id="input2" type="text" />
99 `,
100 "innerHTML",
101 );
102
103 const newInput2 = container.querySelector("#input2") as HTMLInputElement;
104 expect(document.activeElement).toBe(newInput2);
105 });
106
107 it("preserves input values when swapping innerHTML", () => {
108 container.innerHTML = `
109 <div id="target">
110 <input id="name" type="text" value="initial" />
111 <textarea id="bio">initial bio</textarea>
112 <input id="agree" type="checkbox" checked />
113 </div>
114 `;
115 const target = container.querySelector("#target")!;
116 const nameInput = container.querySelector("#name") as HTMLInputElement;
117 const bioInput = container.querySelector("#bio") as HTMLTextAreaElement;
118
119 nameInput.value = "John Doe";
120 bioInput.value = "Software developer";
121
122 swap(
123 target,
124 `
125 <input id="name" type="text" value="different" />
126 <textarea id="bio">different bio</textarea>
127 <input id="agree" type="checkbox" />
128 `,
129 "innerHTML",
130 );
131
132 const newNameInput = container.querySelector("#name") as HTMLInputElement;
133 const newBioInput = container.querySelector("#bio") as HTMLTextAreaElement;
134 const newAgreeInput = container.querySelector("#agree") as HTMLInputElement;
135
136 expect(newNameInput.value).toBe("John Doe");
137 expect(newBioInput.value).toBe("Software developer");
138 expect(newAgreeInput.checked).toBe(true);
139 });
140
141 it("preserves scroll position when swapping innerHTML", () => {
142 container.innerHTML = `
143 <div id="target" style="height: 100px; overflow-y: scroll;">
144 <div style="height: 500px;">
145 <p>Scrollable content</p>
146 </div>
147 </div>
148 `;
149 const target = container.querySelector("#target")!;
150 target.scrollTop = 50;
151
152 swap(
153 target,
154 `
155 <div style="height: 500px;">
156 <p>New scrollable content</p>
157 </div>
158 `,
159 "innerHTML",
160 );
161
162 expect(target.scrollTop).toBe(50);
163 });
164
165 it("preserves nested element scroll positions", () => {
166 container.innerHTML = `
167 <div id="target">
168 <div id="nested" style="height: 100px; overflow-y: scroll;">
169 <div style="height: 300px;">Content</div>
170 </div>
171 </div>
172 `;
173 const target = container.querySelector("#target")!;
174 const nested = container.querySelector("#nested")!;
175 nested.scrollTop = 75;
176
177 swap(
178 target,
179 `
180 <div id="nested" style="height: 100px; overflow-y: scroll;">
181 <div style="height: 300px;">New content</div>
182 </div>
183 `,
184 "innerHTML",
185 );
186
187 const newNested = container.querySelector("#nested")!;
188 expect(newNested.scrollTop).toBe(75);
189 });
190
191 it("preserves state when swapping outerHTML", () => {
192 container.innerHTML = `
193 <div id="target">
194 <input id="field" type="text" value="initial" />
195 </div>
196 `;
197 const target = container.querySelector("#target")!;
198 const field = container.querySelector("#field") as HTMLInputElement;
199 field.value = "updated";
200 field.focus();
201
202 swap(
203 target,
204 `
205 <div id="target">
206 <input id="field" type="text" value="different" />
207 </div>
208 `,
209 "outerHTML",
210 );
211
212 const newField = container.querySelector("#field") as HTMLInputElement;
213 expect(newField.value).toBe("updated");
214 expect(document.activeElement).toBe(newField);
215 });
216
217 it("does not attempt state preservation for insert strategies", () => {
218 container.innerHTML = `
219 <div id="target">
220 <input id="existing" type="text" />
221 </div>
222 `;
223 const target = container.querySelector("#target")!;
224 const existing = container.querySelector("#existing") as HTMLInputElement;
225 existing.value = "test";
226 existing.focus();
227
228 swap(target, "<input id=\"new\" type=\"text\" />", "beforeend");
229
230 expect(existing.value).toBe("test");
231 expect(document.activeElement).toBe(existing);
232 });
233 });
234 });
235
236 describe("serializeForm", () => {
237 it("serializes form to FormData", () => {
238 const form = document.createElement("form");
239 form.innerHTML = `
240 <input name="username" value="john" />
241 <input name="email" value="john@example.com" />
242 <input type="checkbox" name="subscribe" checked />
243 `;
244
245 const formData = serializeForm(form);
246
247 expect(formData.get("username")).toBe("john");
248 expect(formData.get("email")).toBe("john@example.com");
249 expect(formData.get("subscribe")).toBe("on");
250 });
251
252 it("handles multiple values with same name", () => {
253 const form = document.createElement("form");
254 form.innerHTML = `
255 <input type="checkbox" name="tags" value="tag1" checked />
256 <input type="checkbox" name="tags" value="tag2" checked />
257 `;
258
259 const formData = serializeForm(form);
260 expect(formData.getAll("tags")).toEqual(["tag1", "tag2"]);
261 });
262 });
263
264 describe("serializeFormToJSON", () => {
265 it("serializes form to JSON object", () => {
266 const form = document.createElement("form");
267 form.innerHTML = `
268 <input name="username" value="jane" />
269 <input name="age" value="25" />
270 `;
271
272 const json = serializeFormToJSON(form);
273
274 expect(json).toEqual({ username: "jane", age: "25" });
275 });
276
277 it("handles multiple values as array", () => {
278 const form = document.createElement("form");
279 form.innerHTML = `
280 <input name="color" value="red" />
281 <input name="color" value="blue" />
282 `;
283
284 const json = serializeFormToJSON(form);
285
286 expect(json.color).toEqual(["red", "blue"]);
287 });
288 });
289
290 describe("parseHttpConfig", () => {
291 it("parses default configuration", () => {
292 const element = document.createElement("button");
293 const config = parseHttpConfig(element, {});
294
295 expect(config.trigger).toBe("click");
296 expect(config.target).toBe(element);
297 expect(config.swap).toBe("innerHTML");
298 expect(config.headers).toEqual({});
299 });
300
301 it("parses trigger from dataset", () => {
302 const element = document.createElement("div");
303 element.dataset.voltTrigger = "mouseover";
304 const config = parseHttpConfig(element, {});
305
306 expect(config.trigger).toBe("mouseover");
307 });
308
309 it("parses target selector from dataset", () => {
310 const element = document.createElement("div");
311 element.dataset.voltTarget = "'#result'";
312 const config = parseHttpConfig(element, {});
313
314 expect(config.target).toBe("#result");
315 });
316
317 it("parses swap strategy from dataset", () => {
318 const element = document.createElement("div");
319 element.dataset.voltSwap = "outerHTML";
320 const config = parseHttpConfig(element, {});
321
322 expect(config.swap).toBe("outerHTML");
323 });
324
325 it("parses headers from dataset", () => {
326 const element = document.createElement("div");
327 element.dataset.voltHeaders = "headers";
328 const config = parseHttpConfig(element, { headers: { Authorization: "Bearer token" } });
329
330 expect(config.headers).toEqual({ Authorization: "Bearer token" });
331 });
332
333 it("uses submit trigger for forms", () => {
334 const element = document.createElement("form");
335 const config = parseHttpConfig(element, {});
336
337 expect(config.trigger).toBe("submit");
338 });
339 });
340
341 describe("state management", () => {
342 let element: HTMLDivElement;
343
344 beforeEach(() => {
345 element = document.createElement("div");
346 });
347
348 it("sets loading state", () => {
349 setLoadingState(element);
350 expect(element.dataset.voltLoading).toBe("true");
351 });
352
353 it("sets error state", () => {
354 setErrorState(element, "Network error");
355 expect(element.dataset.voltError).toBe("Network error");
356 });
357
358 it("clears states", () => {
359 element.dataset.voltLoading = "true";
360 element.dataset.voltError = "Some error";
361
362 clearStates(element);
363
364 expect(Object.hasOwn(element.dataset, "voltLoading")).toBe(false);
365 expect(Object.hasOwn(element.dataset, "voltError")).toBe(false);
366 });
367
368 it("dispatches volt:loading event", () => {
369 const handler = vi.fn();
370 element.addEventListener("volt:loading", handler);
371
372 setLoadingState(element);
373
374 expect(handler).toHaveBeenCalledOnce();
375 expect(handler.mock.calls[0][0]).toBeInstanceOf(CustomEvent);
376 expect(handler.mock.calls[0][0].detail).toEqual({ element });
377 });
378
379 it("dispatches volt:error event", () => {
380 const handler = vi.fn();
381 element.addEventListener("volt:error", handler);
382
383 setErrorState(element, "Test error");
384
385 expect(handler).toHaveBeenCalledOnce();
386 expect(handler.mock.calls[0][0]).toBeInstanceOf(CustomEvent);
387 expect(handler.mock.calls[0][0].detail).toEqual({ element, message: "Test error" });
388 });
389
390 it("dispatches volt:success event", () => {
391 const handler = vi.fn();
392 element.addEventListener("volt:success", handler);
393
394 clearStates(element);
395
396 expect(handler).toHaveBeenCalledOnce();
397 expect(handler.mock.calls[0][0]).toBeInstanceOf(CustomEvent);
398 expect(handler.mock.calls[0][0].detail).toEqual({ element });
399 });
400
401 it("events bubble up the DOM", () => {
402 const parent = document.createElement("div");
403 parent.append(element);
404
405 const loadingHandler = vi.fn();
406 const errorHandler = vi.fn();
407 const successHandler = vi.fn();
408
409 parent.addEventListener("volt:loading", loadingHandler);
410 parent.addEventListener("volt:error", errorHandler);
411 parent.addEventListener("volt:success", successHandler);
412
413 setLoadingState(element);
414 setErrorState(element, "Bubbled error");
415 clearStates(element);
416
417 expect(loadingHandler).toHaveBeenCalledOnce();
418 expect(errorHandler).toHaveBeenCalledOnce();
419 expect(successHandler).toHaveBeenCalledOnce();
420 });
421 });
422
423 describe("request", () => {
424 beforeEach(() => {
425 vi.restoreAllMocks();
426 });
427
428 it("makes a GET request", async () => {
429 const mockFetch = vi.fn(() =>
430 Promise.resolve(
431 {
432 ok: true,
433 status: 200,
434 statusText: "OK",
435 headers: new Headers({ "content-type": "text/html" }),
436 text: () => Promise.resolve("<div>Response</div>"),
437 } as Response,
438 )
439 );
440 vi.stubGlobal("fetch", mockFetch);
441
442 const response = await request({ method: "GET", url: "/api/data" });
443
444 expect(mockFetch).toHaveBeenCalledWith("/api/data", { method: "GET", headers: {}, body: undefined });
445 expect(response.ok).toBe(true);
446 expect(response.html).toBe("<div>Response</div>");
447 });
448
449 it("makes a POST request with body", async () => {
450 const mockFetch = vi.fn(() =>
451 Promise.resolve(
452 {
453 ok: true,
454 status: 201,
455 statusText: "Created",
456 headers: new Headers({ "content-type": "application/json" }),
457 json: () => Promise.resolve({ id: 123 }),
458 } as Response,
459 )
460 );
461 vi.stubGlobal("fetch", mockFetch);
462
463 const formData = new FormData();
464 formData.append("name", "Test");
465
466 const response = await request({ method: "POST", url: "/api/create", body: formData });
467
468 expect(mockFetch).toHaveBeenCalledWith("/api/create", { method: "POST", headers: {}, body: formData });
469 expect(response.ok).toBe(true);
470 expect(response.json).toEqual({ id: 123 });
471 });
472
473 it("parses HTML response", async () => {
474 const mockFetch = vi.fn(() =>
475 Promise.resolve(
476 {
477 ok: true,
478 status: 200,
479 statusText: "OK",
480 headers: new Headers({ "content-type": "text/html; charset=utf-8" }),
481 text: () => Promise.resolve("<p>HTML content</p>"),
482 } as Response,
483 )
484 );
485 vi.stubGlobal("fetch", mockFetch);
486
487 const response = await request({ method: "GET", url: "/page" });
488
489 expect(response.html).toBe("<p>HTML content</p>");
490 expect(response.json).toBeUndefined();
491 });
492
493 it("parses JSON response", async () => {
494 const mockFetch = vi.fn(() =>
495 Promise.resolve(
496 {
497 ok: true,
498 status: 200,
499 statusText: "OK",
500 headers: new Headers({ "content-type": "application/json" }),
501 json: () => Promise.resolve({ success: true }),
502 } as Response,
503 )
504 );
505 vi.stubGlobal("fetch", mockFetch);
506
507 const response = await request({ method: "GET", url: "/api/status" });
508
509 expect(response.json).toEqual({ success: true });
510 expect(response.html).toBeUndefined();
511 });
512
513 it("throws error for network failure", async () => {
514 const mockFetch = vi.fn(() => Promise.reject(new Error("Network error")));
515 vi.stubGlobal("fetch", mockFetch);
516
517 await expect(request({ method: "GET", url: "/api/fail" })).rejects.toThrow("HTTP request failed: Network error");
518 });
519 });
520
521 describe("HTTP method bindings", () => {
522 beforeEach(() => {
523 vi.restoreAllMocks();
524 });
525
526 it("binds data-volt-get and makes GET request on click", async () => {
527 const mockFetch = vi.fn(() =>
528 Promise.resolve(
529 {
530 ok: true,
531 status: 200,
532 statusText: "OK",
533 headers: new Headers({ "content-type": "text/html" }),
534 text: () => Promise.resolve("<div>Loaded</div>"),
535 } as Response,
536 )
537 );
538 vi.stubGlobal("fetch", mockFetch);
539
540 const button = document.createElement("button");
541 button.dataset.voltGet = "'/api/data'";
542 document.body.append(button);
543
544 mount(button, {});
545
546 button.click();
547
548 await vi.waitFor(() => {
549 expect(mockFetch).toHaveBeenCalledWith("/api/data", expect.objectContaining({ method: "GET" }));
550 });
551 });
552
553 it("binds data-volt-post and serializes form on submit", async () => {
554 const mockFetch = vi.fn(() =>
555 Promise.resolve(
556 {
557 ok: true,
558 status: 201,
559 statusText: "Created",
560 headers: new Headers({ "content-type": "text/html" }),
561 text: () => Promise.resolve("<div>Created</div>"),
562 } as Response,
563 )
564 );
565 vi.stubGlobal("fetch", mockFetch);
566
567 const form = document.createElement("form");
568 form.dataset.voltPost = "'/api/submit'";
569 form.innerHTML = "<input name=\"test\" value=\"value\" />";
570 document.body.append(form);
571
572 mount(form, {});
573
574 form.dispatchEvent(new Event("submit", { bubbles: true, cancelable: true }));
575
576 await vi.waitFor(() => {
577 expect(mockFetch).toHaveBeenCalledWith(
578 "/api/submit",
579 expect.objectContaining({ method: "POST", body: expect.any(FormData) }),
580 );
581 });
582 });
583
584 it("updates target element with response", async () => {
585 const mockFetch = vi.fn(() =>
586 Promise.resolve(
587 {
588 ok: true,
589 status: 200,
590 statusText: "OK",
591 headers: new Headers({ "content-type": "text/html" }),
592 text: () => Promise.resolve("<span>New content</span>"),
593 } as Response,
594 )
595 );
596 vi.stubGlobal("fetch", mockFetch);
597
598 const container = document.createElement("div");
599 const button = document.createElement("button");
600 button.dataset.voltGet = "'/api/data'";
601 container.append(button);
602 document.body.append(container);
603
604 mount(button, {});
605
606 button.click();
607
608 await vi.waitFor(() => {
609 expect(button.innerHTML).toBe("<span>New content</span>");
610 });
611 });
612
613 it("sets loading state during request", async () => {
614 let resolveRequest: ((value: Response) => void) | undefined;
615 const mockFetch = vi.fn(() =>
616 new Promise<Response>((resolve) => {
617 resolveRequest = resolve;
618 })
619 );
620 vi.stubGlobal("fetch", mockFetch);
621
622 const button = document.createElement("button");
623 button.dataset.voltGet = "'/api/slow'";
624 document.body.append(button);
625
626 mount(button, {});
627 button.click();
628
629 await vi.waitFor(() => {
630 expect(button.dataset.voltLoading).toBe("true");
631 });
632
633 resolveRequest?.(
634 {
635 ok: true,
636 status: 200,
637 statusText: "OK",
638 headers: new Headers({ "content-type": "text/html" }),
639 text: () => Promise.resolve("<div>Done</div>"),
640 } as Response,
641 );
642
643 await vi.waitFor(() => {
644 expect(Object.hasOwn(button.dataset, "voltLoading")).toBe(false);
645 });
646 });
647
648 it("sets error state on request failure", async () => {
649 const mockFetch = vi.fn(() => Promise.reject(new Error("Server error")));
650 vi.stubGlobal("fetch", mockFetch);
651
652 const button = document.createElement("button");
653 button.dataset.voltGet = "'/api/fail'";
654 document.body.append(button);
655
656 mount(button, {});
657 button.click();
658
659 await vi.waitFor(() => {
660 expect(button.dataset.voltError).toContain("Server error");
661 });
662 });
663 });
664
665 describe("retry logic", () => {
666 it("retries network errors immediately", async () => {
667 let callCount = 0;
668 const mockFetch = vi.fn(() => {
669 callCount++;
670 if (callCount < 3) {
671 return Promise.reject(new Error("HTTP request failed: fetch failed"));
672 }
673 return Promise.resolve(
674 {
675 ok: true,
676 status: 200,
677 statusText: "OK",
678 headers: new Headers({ "content-type": "text/html" }),
679 text: () => Promise.resolve("<div>Success</div>"),
680 } as Response,
681 );
682 });
683 vi.stubGlobal("fetch", mockFetch);
684
685 const button = document.createElement("button");
686 button.dataset.voltGet = "'/api/data'";
687 button.dataset.voltRetry = "3";
688 button.dataset.voltTarget = "'#result'";
689
690 const result = document.createElement("div");
691 result.id = "result";
692 document.body.append(button, result);
693
694 mount(button, {});
695 button.click();
696
697 await vi.waitFor(() => {
698 expect(result.innerHTML).toBe("<div>Success</div>");
699 }, { timeout: 2000 });
700
701 expect(callCount).toBe(3);
702 });
703
704 it("retries 5xx errors with exponential backoff", async () => {
705 let callCount = 0;
706 const mockFetch = vi.fn(() => {
707 callCount++;
708 if (callCount < 3) {
709 return Promise.resolve(
710 {
711 ok: false,
712 status: 500,
713 statusText: "Internal Server Error",
714 headers: new Headers({ "content-type": "text/html" }),
715 text: () => Promise.resolve(""),
716 } as Response,
717 );
718 }
719 return Promise.resolve(
720 {
721 ok: true,
722 status: 200,
723 statusText: "OK",
724 headers: new Headers({ "content-type": "text/html" }),
725 text: () => Promise.resolve("<div>Success</div>"),
726 } as Response,
727 );
728 });
729 vi.stubGlobal("fetch", mockFetch);
730
731 const button = document.createElement("button");
732 button.dataset.voltGet = "'/api/data'";
733 button.dataset.voltRetry = "3";
734 button.dataset.voltRetryDelay = "100";
735 button.dataset.voltTarget = "'#result'";
736
737 const result = document.createElement("div");
738 result.id = "result";
739 document.body.append(button, result);
740
741 mount(button, {});
742 button.click();
743
744 await vi.waitFor(() => {
745 expect(result.innerHTML).toBe("<div>Success</div>");
746 }, { timeout: 5000 });
747
748 expect(callCount).toBe(3);
749 });
750
751 it("does not retry 4xx errors", async () => {
752 const mockFetch = vi.fn(() =>
753 Promise.resolve(
754 {
755 ok: false,
756 status: 404,
757 statusText: "Not Found",
758 headers: new Headers({ "content-type": "text/html" }),
759 text: () => Promise.resolve(""),
760 } as Response,
761 )
762 );
763 vi.stubGlobal("fetch", mockFetch);
764
765 const button = document.createElement("button");
766 button.dataset.voltGet = "'/api/missing'";
767 button.dataset.voltRetry = "3";
768 button.dataset.voltTarget = "'#result'";
769
770 const result = document.createElement("div");
771 result.id = "result";
772 document.body.append(button, result);
773
774 mount(button, {});
775 button.click();
776
777 await vi.waitFor(() => {
778 const errorAttr = result.dataset.voltError;
779 expect(errorAttr).toBeTruthy();
780 expect(errorAttr).toContain("404");
781 });
782
783 expect(mockFetch).toHaveBeenCalledTimes(1);
784 });
785
786 it("respects max retry attempts", async () => {
787 const mockFetch = vi.fn(() => Promise.reject(new Error("HTTP request failed: network error")));
788 vi.stubGlobal("fetch", mockFetch);
789
790 const button = document.createElement("button");
791 button.dataset.voltGet = "'/api/data'";
792 button.dataset.voltRetry = "2";
793 button.dataset.voltTarget = "'#result'";
794
795 const result = document.createElement("div");
796 result.id = "result";
797 document.body.append(button, result);
798
799 mount(button, {});
800 button.click();
801
802 await vi.waitFor(() => {
803 expect(result.dataset.voltError).toBeTruthy();
804 });
805
806 expect(mockFetch).toHaveBeenCalledTimes(3);
807 });
808
809 it("sets retry attempt attribute and dispatches retry event", async () => {
810 let callCount = 0;
811 const mockFetch = vi.fn(() => {
812 callCount++;
813 if (callCount < 2) {
814 return Promise.reject(new Error("HTTP request failed: network error"));
815 }
816 return Promise.resolve(
817 {
818 ok: true,
819 status: 200,
820 statusText: "OK",
821 headers: new Headers({ "content-type": "text/html" }),
822 text: () => Promise.resolve("<div>Success</div>"),
823 } as Response,
824 );
825 });
826 vi.stubGlobal("fetch", mockFetch);
827
828 const button = document.createElement("button");
829 button.dataset.voltGet = "'/api/data'";
830 button.dataset.voltRetry = "3";
831 button.dataset.voltTarget = "'#result'";
832
833 const result = document.createElement("div");
834 result.id = "result";
835
836 let retryEventFired = false;
837 let retryAttempt = 0;
838
839 result.addEventListener(
840 "volt:retry",
841 ((event: CustomEvent) => {
842 retryEventFired = true;
843 retryAttempt = event.detail.attempt;
844 }) as EventListener,
845 );
846
847 document.body.append(button, result);
848
849 mount(button, {});
850 button.click();
851
852 await vi.waitFor(() => {
853 expect(result.innerHTML).toBe("<div>Success</div>");
854 }, { timeout: 2000 });
855
856 expect(retryEventFired).toBe(true);
857 expect(retryAttempt).toBe(1);
858 });
859 });
860
861 describe("loading indicators", () => {
862 it("shows and hides indicator with display style", async () => {
863 const mockFetch = vi.fn(() =>
864 Promise.resolve(
865 {
866 ok: true,
867 status: 200,
868 statusText: "OK",
869 headers: new Headers({ "content-type": "text/html" }),
870 text: () => Promise.resolve("<div>Success</div>"),
871 } as Response,
872 )
873 );
874 vi.stubGlobal("fetch", mockFetch);
875
876 const button = document.createElement("button");
877 button.dataset.voltGet = "'/api/data'";
878 button.dataset.voltIndicator = "#spinner";
879 button.dataset.voltTarget = "'#result'";
880
881 const spinner = document.createElement("div");
882 spinner.id = "spinner";
883 spinner.style.display = "none";
884
885 const result = document.createElement("div");
886 result.id = "result";
887
888 document.body.append(button, spinner, result);
889
890 expect(spinner.style.display).toBe("none");
891
892 mount(button, {});
893 button.click();
894
895 await vi.waitFor(() => {
896 expect(spinner.style.display).toBe("");
897 });
898
899 await vi.waitFor(() => {
900 expect(result.innerHTML).toBe("<div>Success</div>");
901 expect(spinner.style.display).toBe("none");
902 }, { timeout: 1000 });
903 });
904
905 it("shows and hides indicator with CSS class", async () => {
906 const mockFetch = vi.fn(() =>
907 Promise.resolve(
908 {
909 ok: true,
910 status: 200,
911 statusText: "OK",
912 headers: new Headers({ "content-type": "text/html" }),
913 text: () => Promise.resolve("<div>Success</div>"),
914 } as Response,
915 )
916 );
917 vi.stubGlobal("fetch", mockFetch);
918
919 const button = document.createElement("button");
920 button.dataset.voltGet = "'/api/data'";
921 button.dataset.voltIndicator = "#spinner";
922 button.dataset.voltTarget = "'#result'";
923
924 const spinner = document.createElement("div");
925 spinner.id = "spinner";
926 spinner.classList.add("hidden");
927
928 const result = document.createElement("div");
929 result.id = "result";
930
931 document.body.append(button, spinner, result);
932
933 expect(spinner.classList.contains("hidden")).toBe(true);
934
935 mount(button, {});
936 button.click();
937
938 await vi.waitFor(() => {
939 expect(spinner.classList.contains("hidden")).toBe(false);
940 });
941
942 await vi.waitFor(() => {
943 expect(result.innerHTML).toBe("<div>Success</div>");
944 expect(spinner.classList.contains("hidden")).toBe(true);
945 }, { timeout: 1000 });
946 });
947
948 it("hides indicator on error", async () => {
949 const mockFetch = vi.fn(() => Promise.reject(new Error("Server error")));
950 vi.stubGlobal("fetch", mockFetch);
951
952 const button = document.createElement("button");
953 button.dataset.voltGet = "'/api/fail'";
954 button.dataset.voltIndicator = "#spinner";
955
956 const spinner = document.createElement("div");
957 spinner.id = "spinner";
958 spinner.style.display = "none";
959
960 document.body.append(button, spinner);
961
962 mount(button, {});
963 button.click();
964
965 await vi.waitFor(() => {
966 expect(spinner.style.display).toBe("");
967 });
968
969 await vi.waitFor(() => {
970 expect(button.dataset.voltError).toBeTruthy();
971 expect(spinner.style.display).toBe("none");
972 }, { timeout: 1000 });
973 });
974
975 it("handles multiple indicators", async () => {
976 const mockFetch = vi.fn(() =>
977 Promise.resolve(
978 {
979 ok: true,
980 status: 200,
981 statusText: "OK",
982 headers: new Headers({ "content-type": "text/html" }),
983 text: () => Promise.resolve("<div>Success</div>"),
984 } as Response,
985 )
986 );
987 vi.stubGlobal("fetch", mockFetch);
988
989 const button = document.createElement("button");
990 button.dataset.voltGet = "'/api/data'";
991 button.dataset.voltIndicator = ".spinner";
992 button.dataset.voltTarget = "'#result'";
993
994 const spinner1 = document.createElement("div");
995 spinner1.classList.add("spinner", "hidden");
996
997 const spinner2 = document.createElement("div");
998 spinner2.classList.add("spinner", "hidden");
999
1000 const result = document.createElement("div");
1001 result.id = "result";
1002
1003 document.body.append(button, spinner1, spinner2, result);
1004
1005 mount(button, {});
1006 button.click();
1007
1008 await vi.waitFor(() => {
1009 expect(spinner1.classList.contains("hidden")).toBe(false);
1010 expect(spinner2.classList.contains("hidden")).toBe(false);
1011 });
1012
1013 await vi.waitFor(() => {
1014 expect(result.innerHTML).toBe("<div>Success</div>");
1015 expect(spinner1.classList.contains("hidden")).toBe(true);
1016 expect(spinner2.classList.contains("hidden")).toBe(true);
1017 }, { timeout: 1000 });
1018 });
1019 });
1020});