a reactive (signals based) hypermedia web framework (wip) stormlightlabs.github.io/volt/
hypermedia frontend signals
at main 1020 lines 32 kB view raw
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});