a reactive (signals based) hypermedia web framework (wip) stormlightlabs.github.io/volt/
hypermedia frontend signals
0
fork

Configure Feed

Select the types of activity you want to include in your feed.

at main 655 lines 22 kB view raw
1import { mount } from "$core/binder"; 2import { goBack, goForward, initNavigationListener, navigate, redirect } from "$plugins/navigate"; 3import { beforeEach, describe, expect, it, vi } from "vitest"; 4 5describe("navigate plugin", () => { 6 beforeEach(() => { 7 globalThis.history.replaceState({}, "", "/"); 8 vi.clearAllMocks(); 9 }); 10 11 describe("link navigation", () => { 12 it("intercepts link clicks and prevents default navigation", () => { 13 const link = document.createElement("a"); 14 link.href = "/about"; 15 link.dataset.voltNavigate = ""; 16 17 const preventDefault = vi.fn(); 18 const event = new MouseEvent("click", { bubbles: true, cancelable: true }); 19 Object.defineProperty(event, "preventDefault", { value: preventDefault }); 20 21 mount(link, {}); 22 link.dispatchEvent(event); 23 24 expect(preventDefault).toHaveBeenCalled(); 25 }); 26 27 it("navigates to href when no explicit URL provided", () => { 28 const link = document.createElement("a"); 29 link.href = "/products"; 30 link.dataset.voltNavigate = ""; 31 32 const event = new MouseEvent("click", { bubbles: true, cancelable: true }); 33 mount(link, {}); 34 link.dispatchEvent(event); 35 36 expect(globalThis.location.pathname).toBe("/products"); 37 }); 38 39 it("navigates to explicit URL when provided", () => { 40 const link = document.createElement("a"); 41 link.href = "/default"; 42 link.dataset.voltNavigate = "/custom"; 43 44 const event = new MouseEvent("click", { bubbles: true, cancelable: true }); 45 mount(link, {}); 46 link.dispatchEvent(event); 47 48 expect(globalThis.location.pathname).toBe("/custom"); 49 }); 50 51 it("allows ctrl+click to open in new tab (does not prevent default)", () => { 52 const link = document.createElement("a"); 53 link.href = "/external"; 54 link.dataset.voltNavigate = ""; 55 56 const preventDefault = vi.fn(); 57 const event = new MouseEvent("click", { bubbles: true, cancelable: true, ctrlKey: true }); 58 Object.defineProperty(event, "preventDefault", { value: preventDefault }); 59 60 mount(link, {}); 61 link.dispatchEvent(event); 62 63 expect(preventDefault).not.toHaveBeenCalled(); 64 }); 65 66 it("allows meta+click to open in new tab (does not prevent default)", () => { 67 const link = document.createElement("a"); 68 link.href = "/external"; 69 link.dataset.voltNavigate = ""; 70 71 const preventDefault = vi.fn(); 72 const event = new MouseEvent("click", { bubbles: true, cancelable: true, metaKey: true }); 73 Object.defineProperty(event, "preventDefault", { value: preventDefault }); 74 75 mount(link, {}); 76 link.dispatchEvent(event); 77 78 expect(preventDefault).not.toHaveBeenCalled(); 79 }); 80 81 it("allows shift+click to open in new window (does not prevent default)", () => { 82 const link = document.createElement("a"); 83 link.href = "/external"; 84 link.dataset.voltNavigate = ""; 85 86 const preventDefault = vi.fn(); 87 const event = new MouseEvent("click", { bubbles: true, cancelable: true, shiftKey: true }); 88 Object.defineProperty(event, "preventDefault", { value: preventDefault }); 89 90 mount(link, {}); 91 link.dispatchEvent(event); 92 93 expect(preventDefault).not.toHaveBeenCalled(); 94 }); 95 96 it("allows middle mouse button to open in new tab (does not prevent default)", () => { 97 const link = document.createElement("a"); 98 link.href = "/external"; 99 link.dataset.voltNavigate = ""; 100 101 const preventDefault = vi.fn(); 102 const event = new MouseEvent("click", { bubbles: true, cancelable: true, button: 1 }); 103 Object.defineProperty(event, "preventDefault", { value: preventDefault }); 104 105 mount(link, {}); 106 link.dispatchEvent(event); 107 108 expect(preventDefault).not.toHaveBeenCalled(); 109 }); 110 111 it("does not intercept external links", () => { 112 const link = document.createElement("a"); 113 link.href = "https://external.com/page"; 114 link.dataset.voltNavigate = ""; 115 116 const preventDefault = vi.fn(); 117 const event = new MouseEvent("click", { bubbles: true, cancelable: true }); 118 Object.defineProperty(event, "preventDefault", { value: preventDefault }); 119 120 mount(link, {}); 121 link.dispatchEvent(event); 122 123 expect(preventDefault).not.toHaveBeenCalled(); 124 }); 125 126 it("uses replaceState when .replace modifier is used", () => { 127 globalThis.history.replaceState({}, "", "/initial"); 128 129 const link = document.createElement("a"); 130 link.href = "/about"; 131 link.dataset.voltNavigateReplace = ""; 132 133 const event = new MouseEvent("click", { bubbles: true, cancelable: true }); 134 mount(link, {}); 135 link.dispatchEvent(event); 136 137 expect(globalThis.location.pathname).toBe("/about"); 138 }); 139 140 it("scrolls to top on navigation", async () => { 141 const scrollToSpy = vi.spyOn(globalThis, "scrollTo"); 142 143 const link = document.createElement("a"); 144 link.href = "/page"; 145 link.dataset.voltNavigate = ""; 146 147 const event = new MouseEvent("click", { bubbles: true, cancelable: true }); 148 mount(link, {}); 149 link.dispatchEvent(event); 150 151 await vi.waitFor(() => { 152 expect(scrollToSpy).toHaveBeenCalledWith(0, 0); 153 }); 154 }); 155 156 it("dispatches volt:navigate event on navigation", async () => { 157 const navigateHandler = vi.fn(); 158 globalThis.addEventListener("volt:navigate", navigateHandler); 159 160 const link = document.createElement("a"); 161 link.href = "/dashboard"; 162 link.dataset.voltNavigate = ""; 163 164 const event = new MouseEvent("click", { bubbles: true, cancelable: true }); 165 mount(link, {}); 166 link.dispatchEvent(event); 167 168 await vi.waitFor(() => { 169 expect(navigateHandler).toHaveBeenCalled(); 170 const customEvent = navigateHandler.mock.calls[0][0] as CustomEvent; 171 expect(customEvent.detail.url).toBe("/dashboard"); 172 }); 173 174 globalThis.removeEventListener("volt:navigate", navigateHandler); 175 }); 176 177 it("adds prefetch link on hover when .prefetch modifier is used", async () => { 178 const link = document.createElement("a"); 179 link.href = "/prefetch-page"; 180 link.dataset.voltNavigatePrefetch = ""; 181 182 mount(link, {}); 183 184 const mouseenterEvent = new MouseEvent("mouseenter", { bubbles: true }); 185 link.dispatchEvent(mouseenterEvent); 186 187 await vi.waitFor(() => { 188 const prefetchLink = document.querySelector("link[rel=\"prefetch\"][href=\"/prefetch-page\"]"); 189 expect(prefetchLink).toBeTruthy(); 190 }); 191 }); 192 193 it("adds prefetch link on focus when .prefetch modifier is used", async () => { 194 const link = document.createElement("a"); 195 link.href = "/prefetch-focus"; 196 link.dataset.voltNavigatePrefetch = ""; 197 198 mount(link, {}); 199 200 const focusEvent = new FocusEvent("focus", { bubbles: true }); 201 link.dispatchEvent(focusEvent); 202 203 await vi.waitFor(() => { 204 const prefetchLink = document.querySelector("link[rel=\"prefetch\"][href=\"/prefetch-focus\"]"); 205 expect(prefetchLink).toBeTruthy(); 206 }); 207 }); 208 209 it("only prefetches once even with multiple hover events", async () => { 210 const link = document.createElement("a"); 211 link.href = "/prefetch-once"; 212 link.dataset.voltNavigatePrefetch = ""; 213 214 mount(link, {}); 215 216 link.dispatchEvent(new MouseEvent("mouseenter", { bubbles: true })); 217 link.dispatchEvent(new MouseEvent("mouseenter", { bubbles: true })); 218 link.dispatchEvent(new MouseEvent("mouseenter", { bubbles: true })); 219 220 await vi.waitFor(() => { 221 const prefetchLinks = document.querySelectorAll("link[rel=\"prefetch\"][href=\"/prefetch-once\"]"); 222 expect(prefetchLinks.length).toBe(1); 223 }); 224 }); 225 }); 226 227 describe("form navigation", () => { 228 it("intercepts form GET submissions", () => { 229 const form = document.createElement("form"); 230 form.method = "GET"; 231 form.action = "/search"; 232 form.dataset.voltNavigate = ""; 233 234 const input = document.createElement("input"); 235 input.name = "q"; 236 input.value = "test"; 237 form.append(input); 238 239 const preventDefault = vi.fn(); 240 const event = new Event("submit", { bubbles: true, cancelable: true }); 241 Object.defineProperty(event, "preventDefault", { value: preventDefault }); 242 243 mount(form, {}); 244 form.dispatchEvent(event); 245 246 expect(preventDefault).toHaveBeenCalled(); 247 expect(globalThis.location.pathname).toBe("/search"); 248 expect(globalThis.location.search).toContain("q=test"); 249 }); 250 251 it("warns on POST form submissions", () => { 252 const consoleWarnSpy = vi.spyOn(console, "warn").mockImplementation(() => {}); 253 254 const form = document.createElement("form"); 255 form.method = "POST"; 256 form.action = "/submit"; 257 form.dataset.voltNavigate = ""; 258 259 const event = new Event("submit", { bubbles: true, cancelable: true }); 260 mount(form, {}); 261 form.dispatchEvent(event); 262 263 expect(consoleWarnSpy).toHaveBeenCalledWith( 264 expect.stringContaining("POST/PUT/PATCH forms should use data-volt-post/put/patch"), 265 ); 266 267 consoleWarnSpy.mockRestore(); 268 }); 269 270 it("uses current pathname as default action", () => { 271 globalThis.history.replaceState({}, "", "/current"); 272 273 const form = document.createElement("form"); 274 form.method = "GET"; 275 form.dataset.voltNavigate = ""; 276 277 const input = document.createElement("input"); 278 input.name = "filter"; 279 input.value = "active"; 280 form.append(input); 281 282 const event = new Event("submit", { bubbles: true, cancelable: true }); 283 mount(form, {}); 284 form.dispatchEvent(event); 285 286 expect(globalThis.location.pathname).toBe("/current"); 287 expect(globalThis.location.search).toContain("filter=active"); 288 }); 289 }); 290 291 describe("programmatic navigation", () => { 292 it("navigate() changes the URL", async () => { 293 await navigate("/dashboard"); 294 expect(globalThis.location.pathname).toBe("/dashboard"); 295 }); 296 297 it("navigate() with replace option uses replaceState", async () => { 298 globalThis.history.replaceState({}, "", "/initial"); 299 await navigate("/replaced", { replace: true }); 300 expect(globalThis.location.pathname).toBe("/replaced"); 301 }); 302 303 it("redirect() uses replaceState", async () => { 304 globalThis.history.replaceState({}, "", "/old"); 305 await redirect("/new"); 306 expect(globalThis.location.pathname).toBe("/new"); 307 }); 308 309 it("navigate() dispatches volt:navigate event", async () => { 310 const handler = vi.fn(); 311 globalThis.addEventListener("volt:navigate", handler); 312 313 await navigate("/profile"); 314 315 expect(handler).toHaveBeenCalled(); 316 const event = handler.mock.calls[0][0] as CustomEvent; 317 expect(event.detail.url).toBe("/profile"); 318 319 globalThis.removeEventListener("volt:navigate", handler); 320 }); 321 322 it("goBack() navigates backward in history", () => { 323 const backSpy = vi.spyOn(globalThis.history, "back"); 324 goBack(); 325 expect(backSpy).toHaveBeenCalled(); 326 }); 327 328 it("goForward() navigates forward in history", () => { 329 const forwardSpy = vi.spyOn(globalThis.history, "forward"); 330 goForward(); 331 expect(forwardSpy).toHaveBeenCalled(); 332 }); 333 }); 334 335 describe("scroll position restoration", () => { 336 it("saves scroll position before navigation", async () => { 337 Object.defineProperty(globalThis, "scrollX", { value: 100, writable: true, configurable: true }); 338 Object.defineProperty(globalThis, "scrollY", { value: 200, writable: true, configurable: true }); 339 340 await navigate("/page1"); 341 await navigate("/page2"); 342 343 expect(globalThis.history.state).toBeDefined(); 344 }); 345 346 it("restores scroll position on popstate", async () => { 347 const cleanup = initNavigationListener(); 348 349 Object.defineProperty(globalThis, "scrollX", { value: 0, writable: true, configurable: true }); 350 Object.defineProperty(globalThis, "scrollY", { value: 0, writable: true, configurable: true }); 351 await navigate("/page1"); 352 353 Object.defineProperty(globalThis, "scrollX", { value: 0, writable: true, configurable: true }); 354 Object.defineProperty(globalThis, "scrollY", { value: 500, writable: true, configurable: true }); 355 356 await navigate("/page2"); 357 358 const scrollToSpy = vi.spyOn(globalThis, "scrollTo"); 359 globalThis.history.back(); 360 globalThis.dispatchEvent(new PopStateEvent("popstate", { state: { scrollPosition: { x: 0, y: 500 } } })); 361 362 await vi.waitFor(() => { 363 expect(scrollToSpy).toHaveBeenCalledWith(0, 500); 364 }); 365 366 cleanup(); 367 }); 368 369 it("dispatches volt:popstate event on back/forward navigation", async () => { 370 const cleanup = initNavigationListener(); 371 const popstateHandler = vi.fn(); 372 globalThis.addEventListener("volt:popstate", popstateHandler); 373 374 globalThis.dispatchEvent(new PopStateEvent("popstate", { state: { timestamp: Date.now() } })); 375 376 await vi.waitFor(() => { 377 expect(popstateHandler).toHaveBeenCalled(); 378 }); 379 380 globalThis.removeEventListener("volt:popstate", popstateHandler); 381 cleanup(); 382 }); 383 }); 384 385 describe("navigation state", () => { 386 it("stores navigation state in history", async () => { 387 await navigate("/stateful"); 388 389 expect(globalThis.history.state).toBeDefined(); 390 expect(globalThis.history.state.timestamp).toBeDefined(); 391 expect(typeof globalThis.history.state.timestamp).toBe("number"); 392 }); 393 394 it("includes scroll position in navigation state", async () => { 395 Object.defineProperty(globalThis, "scrollX", { value: 150, writable: true, configurable: true }); 396 Object.defineProperty(globalThis, "scrollY", { value: 300, writable: true, configurable: true }); 397 398 await navigate("/with-scroll"); 399 400 expect(globalThis.history.state.scrollPosition).toBeDefined(); 401 expect(globalThis.history.state.scrollPosition.x).toBe(150); 402 expect(globalThis.history.state.scrollPosition.y).toBe(300); 403 }); 404 }); 405 406 describe("view transitions", () => { 407 it("uses view transitions by default", async () => { 408 const mockTransition = { 409 finished: Promise.resolve(), 410 ready: Promise.resolve(), 411 updateCbDone: Promise.resolve(), 412 skipTransition: vi.fn(), 413 }; 414 415 const startViewTransitionSpy = vi.fn(() => mockTransition); 416 Object.defineProperty(document, "startViewTransition", { 417 value: startViewTransitionSpy, 418 writable: true, 419 configurable: true, 420 }); 421 422 await navigate("/with-transition"); 423 424 expect(startViewTransitionSpy).toHaveBeenCalled(); 425 }); 426 427 it("skips view transitions when notransition modifier is used", async () => { 428 const link = document.createElement("a"); 429 link.href = "/no-transition"; 430 link.dataset.voltNavigateNotransition = ""; 431 432 const mockTransition = { 433 finished: Promise.resolve(), 434 ready: Promise.resolve(), 435 updateCbDone: Promise.resolve(), 436 skipTransition: vi.fn(), 437 }; 438 439 const startViewTransitionSpy = vi.fn(() => mockTransition); 440 Object.defineProperty(document, "startViewTransition", { 441 value: startViewTransitionSpy, 442 writable: true, 443 configurable: true, 444 }); 445 446 const event = new MouseEvent("click", { bubbles: true, cancelable: true }); 447 mount(link, {}); 448 link.dispatchEvent(event); 449 450 await vi.waitFor(() => { 451 expect(globalThis.location.pathname).toBe("/no-transition"); 452 }); 453 }); 454 455 it("can disable view transitions programmatically", async () => { 456 await navigate("/no-vt", { transition: false }); 457 expect(globalThis.location.pathname).toBe("/no-vt"); 458 }); 459 }); 460 461 describe("focus management", () => { 462 it("includes focus restoration functions in navigate module", () => { 463 expect(navigate).toBeDefined(); 464 expect(initNavigationListener).toBeDefined(); 465 }); 466 467 it.skip("saves focus state in navigation state when element has ID", async () => { 468 const input = document.createElement("input"); 469 input.id = "test-input"; 470 input.type = "text"; 471 document.body.append(input); 472 473 const activeElementGetter = vi.spyOn(document, "activeElement", "get"); 474 activeElementGetter.mockReturnValue(input); 475 476 await navigate("/page-with-focus"); 477 478 expect(globalThis.history.state.focusSelector).toBe("#test-input"); 479 480 activeElementGetter.mockRestore(); 481 input.remove(); 482 }); 483 484 it.skip("attempts to restore focus on popstate", () => { 485 const cleanup = initNavigationListener(); 486 487 const button = document.createElement("button"); 488 button.id = "focus-button"; 489 button.textContent = "Click me"; 490 document.body.append(button); 491 492 const focusSpy = vi.spyOn(button, "focus"); 493 494 globalThis.dispatchEvent(new PopStateEvent("popstate", { state: { focusSelector: "#focus-button" } })); 495 496 expect(focusSpy).toHaveBeenCalledWith({ preventScroll: true }); 497 498 cleanup(); 499 button.remove(); 500 }); 501 }); 502 503 describe("viewport-based prefetching", () => { 504 it.skip("prefetches when link enters viewport with .viewport modifier", async () => { 505 const link = document.createElement("a"); 506 link.href = "/viewport-prefetch"; 507 link.dataset.voltNavigatePrefetchViewport = ""; 508 document.body.append(link); 509 510 let observerCallback!: IntersectionObserverCallback; 511 const mockObserver = { 512 observe: vi.fn(), 513 disconnect: vi.fn(), 514 unobserve: vi.fn(), 515 takeRecords: vi.fn(), 516 root: null, 517 rootMargin: "", 518 thresholds: [], 519 }; 520 521 (globalThis as typeof globalThis).IntersectionObserver = vi.fn((callback) => { 522 observerCallback = callback; 523 return mockObserver; 524 }) as unknown as typeof IntersectionObserver; 525 526 const fetchSpy = vi.spyOn(globalThis, "fetch").mockResolvedValue(new Response()); 527 528 mount(link, {}); 529 530 expect(mockObserver.observe).toHaveBeenCalledWith(link); 531 532 observerCallback( 533 [{ isIntersecting: true, target: link } as unknown as IntersectionObserverEntry], 534 mockObserver as IntersectionObserver, 535 ); 536 537 await vi.waitFor(() => { 538 expect(fetchSpy).toHaveBeenCalledWith("/viewport-prefetch", expect.objectContaining({ method: "GET" })); 539 }); 540 541 fetchSpy.mockRestore(); 542 link.remove(); 543 }); 544 545 it.skip("only prefetches once when element enters viewport multiple times", async () => { 546 const link = document.createElement("a"); 547 link.href = "/viewport-once"; 548 link.dataset.voltNavigatePrefetchViewport = ""; 549 document.body.append(link); 550 551 let observerCallback!: IntersectionObserverCallback; 552 const mockObserver = { 553 observe: vi.fn(), 554 disconnect: vi.fn(), 555 unobserve: vi.fn(), 556 takeRecords: vi.fn(), 557 root: null, 558 rootMargin: "", 559 thresholds: [], 560 }; 561 562 (globalThis as typeof globalThis).IntersectionObserver = vi.fn((callback) => { 563 observerCallback = callback; 564 return mockObserver; 565 }) as unknown as typeof IntersectionObserver; 566 567 const fetchSpy = vi.spyOn(globalThis, "fetch").mockResolvedValue(new Response()); 568 569 mount(link, {}); 570 571 observerCallback( 572 [{ isIntersecting: true, target: link } as unknown as IntersectionObserverEntry], 573 mockObserver as IntersectionObserver, 574 ); 575 576 observerCallback( 577 [{ isIntersecting: false, target: link } as unknown as IntersectionObserverEntry], 578 mockObserver as IntersectionObserver, 579 ); 580 581 observerCallback( 582 [{ isIntersecting: true, target: link } as unknown as IntersectionObserverEntry], 583 mockObserver as IntersectionObserver, 584 ); 585 586 await vi.waitFor(() => { 587 expect(fetchSpy).toHaveBeenCalledTimes(1); 588 expect(mockObserver.disconnect).toHaveBeenCalled(); 589 }); 590 591 fetchSpy.mockRestore(); 592 link.remove(); 593 }); 594 595 it("falls back to link prefetch when fetch fails", async () => { 596 const link = document.createElement("a"); 597 link.href = "/fetch-fail"; 598 link.dataset.voltNavigatePrefetch = ""; 599 document.body.append(link); 600 601 const fetchSpy = vi.spyOn(globalThis, "fetch").mockRejectedValue(new Error("Network error")); 602 603 mount(link, {}); 604 605 link.dispatchEvent(new MouseEvent("mouseenter", { bubbles: true })); 606 607 await vi.waitFor(() => { 608 const prefetchLink = document.querySelector("link[rel=\"prefetch\"][href=\"/fetch-fail\"]"); 609 expect(prefetchLink).toBeTruthy(); 610 }); 611 612 fetchSpy.mockRestore(); 613 link.remove(); 614 }); 615 }); 616 617 describe("cleanup", () => { 618 it("removes event listeners on cleanup", () => { 619 const link = document.createElement("a"); 620 link.href = "/cleanup-test"; 621 link.dataset.voltNavigate = ""; 622 623 const cleanup = mount(link, {}); 624 const preventDefault = vi.fn(); 625 const event = new MouseEvent("click", { bubbles: true, cancelable: true }); 626 Object.defineProperty(event, "preventDefault", { value: preventDefault }); 627 link.dispatchEvent(event); 628 expect(preventDefault).toHaveBeenCalled(); 629 cleanup(); 630 631 preventDefault.mockClear(); 632 const event2 = new MouseEvent("click", { bubbles: true, cancelable: true }); 633 Object.defineProperty(event2, "preventDefault", { value: preventDefault }); 634 link.dispatchEvent(event2); 635 expect(preventDefault).not.toHaveBeenCalled(); 636 }); 637 638 it("initNavigationListener returns cleanup function", () => { 639 const cleanup = initNavigationListener(); 640 expect(typeof cleanup).toBe("function"); 641 642 const popstateHandler = vi.fn(); 643 globalThis.addEventListener("volt:popstate", popstateHandler); 644 645 globalThis.dispatchEvent(new PopStateEvent("popstate")); 646 expect(popstateHandler).toHaveBeenCalled(); 647 648 cleanup(); 649 popstateHandler.mockClear(); 650 651 globalThis.dispatchEvent(new PopStateEvent("popstate")); 652 globalThis.removeEventListener("volt:popstate", popstateHandler); 653 }); 654 }); 655});