Rust library to generate static websites

fix: some assets error handling and tests

+453 -74
+16 -8
Cargo.lock
··· 1662 1662 checksum = "1d674e81391d1e1ab681a28d99df07927c6d4aa5b027d7da16ba32d1d21ecd99" 1663 1663 1664 1664 [[package]] 1665 + name = "fixtures-hot-reload" 1666 + version = "0.1.0" 1667 + dependencies = [ 1668 + "maud", 1669 + "maudit", 1670 + ] 1671 + 1672 + [[package]] 1673 + name = "fixtures-prefetch-prerender" 1674 + version = "0.1.0" 1675 + dependencies = [ 1676 + "maud", 1677 + "maudit", 1678 + ] 1679 + 1680 + [[package]] 1665 1681 name = "flate2" 1666 1682 version = "1.1.8" 1667 1683 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 3845 3861 version = "0.1.1" 3846 3862 source = "registry+https://github.com/rust-lang/crates.io-index" 3847 3863 checksum = "925383efa346730478fb4838dbe9137d2a47675ad789c546d150a6e1dd4ab31c" 3848 - 3849 - [[package]] 3850 - name = "prefetch-prerender" 3851 - version = "0.1.0" 3852 - dependencies = [ 3853 - "maud", 3854 - "maudit", 3855 - ] 3856 3864 3857 3865 [[package]] 3858 3866 name = "proc-macro-crate"
+154 -36
crates/maudit/src/assets.rs
··· 432 432 } 433 433 434 434 fn make_filename(path: &Path, hash: &String, extension: Option<&str>) -> PathBuf { 435 - let file_stem = path.file_stem().unwrap(); 436 - let sanitized_stem = sanitize_filename::default_sanitize_file_name(file_stem.to_str().unwrap()); 435 + let file_stem = path.file_stem().and_then(|s| s.to_str()).unwrap_or("asset"); 436 + 437 + let sanitized_stem = sanitize_filename::default_sanitize_file_name(file_stem); 437 438 438 439 let mut filename = PathBuf::new(); 439 440 filename.push(format!("{}.{}", sanitized_stem, hash)); ··· 533 534 #[cfg(test)] 534 535 mod tests { 535 536 use super::*; 536 - use std::env; 537 537 538 - fn setup_temp_dir() -> PathBuf { 539 - // Create a temporary directory and test files 540 - let temp_dir = env::temp_dir().join("maudit_test"); 541 - std::fs::create_dir_all(&temp_dir).unwrap(); 538 + fn setup_temp_dir() -> tempfile::TempDir { 539 + let temp_dir = tempfile::tempdir().unwrap(); 542 540 543 - std::fs::write(temp_dir.join("style.css"), "body { background: red; }").unwrap(); 544 - std::fs::write(temp_dir.join("script.js"), "console.log('Hello, world!');").unwrap(); 545 - std::fs::write(temp_dir.join("image.png"), b"").unwrap(); 541 + std::fs::write( 542 + temp_dir.path().join("style.css"), 543 + "body { background: red; }", 544 + ) 545 + .unwrap(); 546 + std::fs::write( 547 + temp_dir.path().join("script.js"), 548 + "console.log('Hello, world!');", 549 + ) 550 + .unwrap(); 551 + std::fs::write(temp_dir.path().join("image.png"), b"").unwrap(); 546 552 temp_dir 547 553 } 548 554 ··· 550 556 fn test_add_style() { 551 557 let temp_dir = setup_temp_dir(); 552 558 let mut page_assets = RouteAssets::default(); 553 - page_assets.add_style(temp_dir.join("style.css")).unwrap(); 559 + page_assets 560 + .add_style(temp_dir.path().join("style.css")) 561 + .unwrap(); 554 562 555 563 assert!(page_assets.styles.len() == 1); 556 564 } ··· 561 569 let mut page_assets = RouteAssets::default(); 562 570 563 571 page_assets 564 - .include_style(temp_dir.join("style.css")) 572 + .include_style(temp_dir.path().join("style.css")) 565 573 .unwrap(); 566 574 567 575 assert!(page_assets.styles.len() == 1); ··· 573 581 let temp_dir = setup_temp_dir(); 574 582 let mut page_assets = RouteAssets::default(); 575 583 576 - page_assets.add_script(temp_dir.join("script.js")).unwrap(); 584 + page_assets 585 + .add_script(temp_dir.path().join("script.js")) 586 + .unwrap(); 577 587 assert!(page_assets.scripts.len() == 1); 578 588 } 579 589 ··· 583 593 let mut page_assets = RouteAssets::default(); 584 594 585 595 page_assets 586 - .include_script(temp_dir.join("script.js")) 596 + .include_script(temp_dir.path().join("script.js")) 587 597 .unwrap(); 588 598 589 599 assert!(page_assets.scripts.len() == 1); ··· 595 605 let temp_dir = setup_temp_dir(); 596 606 let mut page_assets = RouteAssets::default(); 597 607 598 - page_assets.add_image(temp_dir.join("image.png")).unwrap(); 608 + page_assets 609 + .add_image(temp_dir.path().join("image.png")) 610 + .unwrap(); 599 611 assert!(page_assets.images.len() == 1); 600 612 } 601 613 ··· 604 616 let temp_dir = setup_temp_dir(); 605 617 let mut page_assets = RouteAssets::default(); 606 618 607 - let image = page_assets.add_image(temp_dir.join("image.png")).unwrap(); 619 + let image = page_assets 620 + .add_image(temp_dir.path().join("image.png")) 621 + .unwrap(); 608 622 assert_eq!(image.url().chars().next(), Some('/')); 609 623 610 - let script = page_assets.add_script(temp_dir.join("script.js")).unwrap(); 624 + let script = page_assets 625 + .add_script(temp_dir.path().join("script.js")) 626 + .unwrap(); 611 627 assert_eq!(script.url().chars().next(), Some('/')); 612 628 613 - let style = page_assets.add_style(temp_dir.join("style.css")).unwrap(); 629 + let style = page_assets 630 + .add_style(temp_dir.path().join("style.css")) 631 + .unwrap(); 614 632 assert_eq!(style.url().chars().next(), Some('/')); 615 633 } 616 634 ··· 619 637 let temp_dir = setup_temp_dir(); 620 638 let mut page_assets = RouteAssets::default(); 621 639 622 - let image = page_assets.add_image(temp_dir.join("image.png")).unwrap(); 640 + let image = page_assets 641 + .add_image(temp_dir.path().join("image.png")) 642 + .unwrap(); 623 643 assert!(image.url().contains(&image.hash)); 624 644 625 - let script = page_assets.add_script(temp_dir.join("script.js")).unwrap(); 645 + let script = page_assets 646 + .add_script(temp_dir.path().join("script.js")) 647 + .unwrap(); 626 648 assert!(script.url().contains(&script.hash)); 627 649 628 - let style = page_assets.add_style(temp_dir.join("style.css")).unwrap(); 650 + let style = page_assets 651 + .add_style(temp_dir.path().join("style.css")) 652 + .unwrap(); 629 653 assert!(style.url().contains(&style.hash)); 630 654 } 631 655 ··· 634 658 let temp_dir = setup_temp_dir(); 635 659 let mut page_assets = RouteAssets::default(); 636 660 637 - let image = page_assets.add_image(temp_dir.join("image.png")).unwrap(); 661 + let image = page_assets 662 + .add_image(temp_dir.path().join("image.png")) 663 + .unwrap(); 638 664 assert!(image.build_path().to_string_lossy().contains(&image.hash)); 639 665 640 - let script = page_assets.add_script(temp_dir.join("script.js")).unwrap(); 666 + let script = page_assets 667 + .add_script(temp_dir.path().join("script.js")) 668 + .unwrap(); 641 669 assert!(script.build_path().to_string_lossy().contains(&script.hash)); 642 670 643 - let style = page_assets.add_style(temp_dir.join("style.css")).unwrap(); 671 + let style = page_assets 672 + .add_style(temp_dir.path().join("style.css")) 673 + .unwrap(); 644 674 assert!(style.build_path().to_string_lossy().contains(&style.hash)); 645 675 } 646 676 647 677 #[test] 648 678 fn test_image_hash_different_options() { 649 679 let temp_dir = setup_temp_dir(); 650 - let image_path = temp_dir.join("image.png"); 680 + let image_path = temp_dir.path().join("image.png"); 651 681 652 682 // Create a simple test PNG (1x1 transparent pixel) 653 683 let png_data = [ ··· 659 689 ]; 660 690 std::fs::write(&image_path, png_data).unwrap(); 661 691 662 - let mut page_assets = RouteAssets::default(); 692 + let mut page_assets = RouteAssets::new( 693 + &RouteAssetsOptions { 694 + hashing_strategy: AssetHashingStrategy::Precise, 695 + ..Default::default() 696 + }, 697 + None, 698 + ); 663 699 664 700 // Test that different options produce different hashes 665 701 let image_default = page_assets.add_image(&image_path).unwrap(); ··· 716 752 #[test] 717 753 fn test_image_hash_same_options() { 718 754 let temp_dir = setup_temp_dir(); 719 - let image_path = temp_dir.join("image.png"); 755 + let image_path = temp_dir.path().join("image.png"); 720 756 721 757 // Create a simple test PNG (1x1 transparent pixel) 722 758 let png_data = [ ··· 728 764 ]; 729 765 std::fs::write(&image_path, png_data).unwrap(); 730 766 731 - let mut page_assets = RouteAssets::default(); 767 + let mut page_assets = RouteAssets::new( 768 + &RouteAssetsOptions { 769 + hashing_strategy: AssetHashingStrategy::Precise, 770 + ..Default::default() 771 + }, 772 + None, 773 + ); 732 774 733 775 // Same options should produce same hash 734 776 let image1 = page_assets ··· 762 804 #[test] 763 805 fn test_style_hash_different_options() { 764 806 let temp_dir = setup_temp_dir(); 765 - let style_path = temp_dir.join("style.css"); 807 + let style_path = temp_dir.path().join("style.css"); 766 808 767 - let mut page_assets = RouteAssets::new(&RouteAssetsOptions::default(), None); 809 + let mut page_assets = RouteAssets::new( 810 + &RouteAssetsOptions { 811 + hashing_strategy: AssetHashingStrategy::Precise, 812 + ..Default::default() 813 + }, 814 + None, 815 + ); 768 816 769 817 // Test that different tailwind options produce different hashes 770 818 let style_default = page_assets.add_style(&style_path).unwrap(); ··· 784 832 785 833 // Create two identical files with different paths 786 834 let content = "body { background: blue; }"; 787 - let style1_path = temp_dir.join("style1.css"); 788 - let style2_path = temp_dir.join("style2.css"); 835 + let style1_path = temp_dir.path().join("style1.css"); 836 + let style2_path = temp_dir.path().join("style2.css"); 789 837 790 838 std::fs::write(&style1_path, content).unwrap(); 791 839 std::fs::write(&style2_path, content).unwrap(); 792 840 793 - let mut page_assets = RouteAssets::new(&RouteAssetsOptions::default(), None); 841 + let mut page_assets = RouteAssets::new( 842 + &RouteAssetsOptions { 843 + hashing_strategy: AssetHashingStrategy::Precise, 844 + ..Default::default() 845 + }, 846 + None, 847 + ); 794 848 795 849 let style1 = page_assets.add_style(&style1_path).unwrap(); 796 850 let style2 = page_assets.add_style(&style2_path).unwrap(); ··· 804 858 #[test] 805 859 fn test_hash_includes_content() { 806 860 let temp_dir = setup_temp_dir(); 807 - let style_path = temp_dir.join("dynamic_style.css"); 861 + let style_path = temp_dir.path().join("dynamic_style.css"); 808 862 809 - let assets_options = RouteAssetsOptions::default(); 810 - let mut page_assets = RouteAssets::new(&assets_options, None); 863 + let mut page_assets = RouteAssets::new( 864 + &RouteAssetsOptions { 865 + hashing_strategy: AssetHashingStrategy::Precise, 866 + ..Default::default() 867 + }, 868 + None, 869 + ); 811 870 812 871 // Write first content and get hash 813 872 std::fs::write(&style_path, "body { background: red; }").unwrap(); ··· 823 882 hash1, hash2, 824 883 "Different content should produce different hashes" 825 884 ); 885 + } 886 + 887 + #[test] 888 + fn test_make_filename_normal_path() { 889 + let path = PathBuf::from("/foo/bar/test.png"); 890 + let hash = "abc12".to_string(); 891 + 892 + let filename = make_filename(&path, &hash, Some("png")); 893 + 894 + // Format is: stem.hash with extension hash.ext 895 + assert_eq!(filename.to_string_lossy(), "test.abc12.png"); 896 + } 897 + 898 + #[test] 899 + fn test_make_filename_no_extension() { 900 + let path = PathBuf::from("/foo/bar/test"); 901 + let hash = "abc12".to_string(); 902 + 903 + let filename = make_filename(&path, &hash, None); 904 + 905 + assert_eq!(filename.to_string_lossy(), "test.abc12"); 906 + } 907 + 908 + #[test] 909 + fn test_make_filename_fallback_for_root_path() { 910 + // Root path has no file stem 911 + let path = PathBuf::from("/"); 912 + let hash = "abc12".to_string(); 913 + 914 + let filename = make_filename(&path, &hash, Some("css")); 915 + 916 + // Should fallback to "asset" 917 + assert_eq!(filename.to_string_lossy(), "asset.abc12.css"); 918 + } 919 + 920 + #[test] 921 + fn test_make_filename_fallback_for_dotdot_path() { 922 + // Path ending with ".." has no file stem 923 + let path = PathBuf::from("/foo/.."); 924 + let hash = "xyz99".to_string(); 925 + 926 + let filename = make_filename(&path, &hash, Some("js")); 927 + 928 + // Should fallback to "asset" 929 + assert_eq!(filename.to_string_lossy(), "asset.xyz99.js"); 930 + } 931 + 932 + #[test] 933 + fn test_make_filename_with_special_characters() { 934 + // Test that special characters get sanitized 935 + let path = PathBuf::from("/foo/test:file*.txt"); 936 + let hash = "def45".to_string(); 937 + 938 + let filename = make_filename(&path, &hash, Some("txt")); 939 + 940 + // Special characters should be replaced with underscores 941 + let result = filename.to_string_lossy(); 942 + assert!(result.contains("test_file_")); 943 + assert!(result.ends_with(".def45.txt")); 826 944 } 827 945 }
+99 -5
crates/maudit/src/assets/image.rs
··· 154 154 /// Get a placeholder for the image, which can be used for low-quality image placeholders (LQIP) or similar techniques. 155 155 /// 156 156 /// This uses the [ThumbHash](https://evanw.github.io/thumbhash/) algorithm to generate a very small placeholder image. 157 - pub fn placeholder(&self) -> ImagePlaceholder { 157 + /// 158 + /// Returns an error if the image cannot be loaded. 159 + pub fn placeholder(&self) -> Result<ImagePlaceholder, crate::errors::AssetError> { 158 160 get_placeholder(&self.path, self.cache.as_ref()) 159 161 } 160 162 ··· 258 260 } 259 261 } 260 262 261 - fn get_placeholder(path: &PathBuf, cache: Option<&ImageCache>) -> ImagePlaceholder { 263 + fn get_placeholder(path: &PathBuf, cache: Option<&ImageCache>) -> Result<ImagePlaceholder, crate::errors::AssetError> { 262 264 // Check cache first if provided 263 265 if let Some(cache) = cache 264 266 && let Some(cached) = cache.get_placeholder(path) 265 267 { 266 268 debug!("Using cached placeholder for {}", path.display()); 267 269 let thumbhash_base64 = base64::engine::general_purpose::STANDARD.encode(&cached.thumbhash); 268 - return ImagePlaceholder::new(cached.thumbhash, thumbhash_base64); 270 + return Ok(ImagePlaceholder::new(cached.thumbhash, thumbhash_base64)); 269 271 } 270 272 271 273 let total_start = Instant::now(); 272 274 273 275 let load_start = Instant::now(); 274 - let image = image::open(path).ok().unwrap(); 276 + let image = image::open(path).map_err(|e| crate::errors::AssetError::ImageLoadFailed { 277 + path: path.clone(), 278 + source: e, 279 + })?; 275 280 let (width, height) = image.dimensions(); 276 281 let (width, height) = (width as usize, height as usize); 277 282 debug!( ··· 329 334 cache.cache_placeholder(path, thumb_hash.clone()); 330 335 } 331 336 332 - ImagePlaceholder::new(thumb_hash, thumbhash_base64) 337 + Ok(ImagePlaceholder::new(thumb_hash, thumbhash_base64)) 333 338 } 334 339 335 340 /// Port of https://github.com/evanw/thumbhash/blob/a652ce6ed691242f459f468f0a8756cda3b90a82/js/thumbhash.js#L234 ··· 516 521 ).into() 517 522 } 518 523 } 524 + 525 + #[cfg(test)] 526 + mod tests { 527 + use super::*; 528 + use std::path::PathBuf; 529 + 530 + fn setup_unique_temp_dir() -> tempfile::TempDir { 531 + // Create a unique temporary directory that's automatically cleaned up 532 + tempfile::tempdir().unwrap() 533 + } 534 + 535 + #[test] 536 + fn test_placeholder_with_missing_file() { 537 + let nonexistent_path = PathBuf::from("/this/file/does/not/exist.png"); 538 + 539 + let result = get_placeholder(&nonexistent_path, None); 540 + 541 + // Should return an error, not panic 542 + assert!(result.is_err()); 543 + 544 + if let Err(crate::errors::AssetError::ImageLoadFailed { path, .. }) = result { 545 + assert_eq!(path, nonexistent_path); 546 + } else { 547 + panic!("Expected ImageLoadFailed error"); 548 + } 549 + } 550 + 551 + #[test] 552 + fn test_placeholder_with_invalid_image_data() { 553 + let temp_dir = setup_unique_temp_dir(); 554 + 555 + // Create a file with invalid image data 556 + let invalid_image_path = temp_dir.path().join("invalid.png"); 557 + std::fs::write(&invalid_image_path, b"This is not a valid PNG file").unwrap(); 558 + 559 + let result = get_placeholder(&invalid_image_path, None); 560 + 561 + // Should return an error, not panic 562 + assert!(result.is_err()); 563 + 564 + if let Err(crate::errors::AssetError::ImageLoadFailed { path, .. }) = result { 565 + assert_eq!(path, invalid_image_path); 566 + } else { 567 + panic!("Expected ImageLoadFailed error"); 568 + } 569 + 570 + // Cleanup 571 + std::fs::remove_file(&invalid_image_path).ok(); 572 + } 573 + 574 + #[test] 575 + fn test_placeholder_with_valid_image() { 576 + use std::path::Path; 577 + 578 + // Try to find an existing image in the examples directory 579 + let project_root = Path::new(env!("CARGO_MANIFEST_DIR")).parent().unwrap().parent().unwrap(); 580 + let test_image = project_root.join("examples/image-processing/images/walrus.jpg"); 581 + 582 + // Skip test if the image doesn't exist (e.g., in CI without examples) 583 + if !test_image.exists() { 584 + eprintln!("Skipping test: test image not found at {:?}", test_image); 585 + return; 586 + } 587 + 588 + let result = get_placeholder(&test_image, None); 589 + 590 + // Should succeed 591 + assert!(result.is_ok()); 592 + 593 + let placeholder = result.unwrap(); 594 + // Verify the placeholder has a thumbhash 595 + assert!(!placeholder.thumbhash.is_empty()); 596 + assert!(!placeholder.thumbhash_base64.is_empty()); 597 + } 598 + 599 + #[test] 600 + fn test_placeholder_with_empty_file() { 601 + let temp_dir = setup_unique_temp_dir(); 602 + 603 + // Create an empty file 604 + let empty_file_path = temp_dir.path().join("empty.png"); 605 + std::fs::write(&empty_file_path, b"").unwrap(); 606 + 607 + let result = get_placeholder(&empty_file_path, None); 608 + 609 + // Should return an error for empty/invalid image 610 + assert!(result.is_err()); 611 + } 612 + }
+6
crates/maudit/src/errors.rs
··· 53 53 #[source] 54 54 source: std::io::Error, 55 55 }, 56 + #[error("Failed to load image for placeholder generation: {path}")] 57 + ImageLoadFailed { 58 + path: PathBuf, 59 + #[source] 60 + source: image::ImageError, 61 + }, 56 62 } 57 63 58 64 #[derive(Error, Debug)]
+17 -1
crates/maudit/src/routing.rs
··· 56 56 57 57 #[cfg(test)] 58 58 mod tests { 59 - use crate::routing::{ParameterDef, extract_params_from_raw_route}; 59 + use crate::routing::{ParameterDef, extract_params_from_raw_route, guess_if_route_is_endpoint}; 60 60 61 61 #[test] 62 62 fn test_extract_params() { ··· 123 123 }]; 124 124 125 125 assert_eq!(extract_params_from_raw_route(input), expected); 126 + } 127 + 128 + #[test] 129 + fn test_guess_if_route_is_endpoint() { 130 + // Routes with file extensions should be detected as endpoints 131 + assert!(guess_if_route_is_endpoint("/api/data.json")); 132 + assert!(guess_if_route_is_endpoint("/feed.xml")); 133 + assert!(guess_if_route_is_endpoint("/sitemap.xml")); 134 + assert!(guess_if_route_is_endpoint("/robots.txt")); 135 + assert!(guess_if_route_is_endpoint("/path/to/file.tar.gz")); 136 + assert!(guess_if_route_is_endpoint("/api/users/[id].json")); 137 + 138 + assert!(!guess_if_route_is_endpoint("/")); 139 + assert!(!guess_if_route_is_endpoint("/articles")); 140 + assert!(!guess_if_route_is_endpoint("/articles/[slug]")); 141 + assert!(!guess_if_route_is_endpoint("/blog/posts/[year]/[month]")); 126 142 } 127 143 }
+9
e2e/fixtures/hot-reload/Cargo.toml
··· 1 + [package] 2 + name = "fixtures-hot-reload" 3 + version = "0.1.0" 4 + edition = "2024" 5 + publish = false 6 + 7 + [dependencies] 8 + maudit.workspace = true 9 + maud.workspace = true
+14
e2e/fixtures/hot-reload/src/main.rs
··· 1 + use maudit::{content_sources, coronate, routes, BuildOptions, BuildOutput}; 2 + 3 + mod pages { 4 + mod index; 5 + pub use index::Index; 6 + } 7 + 8 + fn main() -> Result<BuildOutput, Box<dyn std::error::Error>> { 9 + coronate( 10 + routes![pages::Index], 11 + content_sources![], 12 + BuildOptions::default(), 13 + ) 14 + }
+30
e2e/fixtures/hot-reload/src/pages/index.rs
··· 1 + use maud::html; 2 + use maudit::route::prelude::*; 3 + 4 + #[route("/")] 5 + pub struct Index; 6 + 7 + impl Route for Index { 8 + fn render(&self, _ctx: &mut PageContext) -> impl Into<RenderResult> { 9 + Ok(html! { 10 + html { 11 + head { 12 + title { "Hot Reload Test" } 13 + } 14 + body { 15 + h1 id="title" { "Original Title" } 16 + div id="content" { 17 + p id="message" { "Original message" } 18 + ul id="list" { 19 + li { "Item 1" } 20 + li { "Item 2" } 21 + } 22 + } 23 + footer { 24 + p { "Footer content" } 25 + } 26 + } 27 + } 28 + }) 29 + } 30 + }
+58
e2e/tests/hot-reload.spec.ts
··· 1 + import { expect } from "@playwright/test"; 2 + import { createTestWithFixture } from "./test-utils"; 3 + import { readFileSync, writeFileSync } from "node:fs"; 4 + import { resolve, dirname } from "node:path"; 5 + import { fileURLToPath } from "node:url"; 6 + 7 + const __filename = fileURLToPath(import.meta.url); 8 + const __dirname = dirname(__filename); 9 + 10 + // Create test instance with hot-reload fixture 11 + const test = createTestWithFixture("hot-reload"); 12 + 13 + test.describe.configure({ mode: "serial" }); 14 + 15 + test.describe("Hot Reload", () => { 16 + const fixturePath = resolve(__dirname, "..", "fixtures", "hot-reload"); 17 + const indexPath = resolve(fixturePath, "src", "pages", "index.rs"); 18 + let originalContent: string; 19 + 20 + test.beforeAll(async () => { 21 + // Save original content 22 + originalContent = readFileSync(indexPath, "utf-8"); 23 + }); 24 + 25 + test.afterEach(async () => { 26 + // Restore original content after each test 27 + writeFileSync(indexPath, originalContent, "utf-8"); 28 + // Wait a bit for the rebuild 29 + await new Promise((resolve) => setTimeout(resolve, 2000)); 30 + }); 31 + 32 + test.afterAll(async () => { 33 + // Restore original content 34 + writeFileSync(indexPath, originalContent, "utf-8"); 35 + }); 36 + 37 + test("should show updated content after file changes", async ({ page, devServer }) => { 38 + await page.goto(devServer.url); 39 + 40 + // Verify initial content 41 + await expect(page.locator("#title")).toHaveText("Original Title"); 42 + 43 + // Prepare to wait for actual reload by waiting for the same URL to reload 44 + const currentUrl = page.url(); 45 + 46 + // Modify the file 47 + const modifiedContent = originalContent.replace( 48 + 'h1 id="title" { "Original Title" }', 49 + 'h1 id="title" { "Another Update" }', 50 + ); 51 + writeFileSync(indexPath, modifiedContent, "utf-8"); 52 + 53 + // Wait for the page to actually reload on the same URL 54 + await page.waitForURL(currentUrl, { timeout: 15000 }); 55 + // Verify the updated content 56 + await expect(page.locator("#title")).toHaveText("Another Update", { timeout: 15000 }); 57 + }); 58 + });
+3 -1
e2e/tests/prefetch.spec.ts
··· 1 - import { test, expect } from "./test-utils"; 1 + import { createTestWithFixture, expect } from "./test-utils"; 2 2 import { prefetchScript } from "./utils"; 3 + 4 + const test = createTestWithFixture("prefetch-prerender"); 3 5 4 6 test.describe("Prefetch", () => { 5 7 test("should create prefetch via speculation rules on Chromium or link element elsewhere", async ({
+3 -1
e2e/tests/prerender.spec.ts
··· 1 - import { test, expect } from "./test-utils"; 1 + import { createTestWithFixture, expect } from "./test-utils"; 2 2 import { prefetchScript } from "./utils"; 3 + 4 + const test = createTestWithFixture("prefetch-prerender"); 3 5 4 6 test.describe("Prefetch - Speculation Rules (Prerender)", () => { 5 7 test("should create speculation rules on Chromium or link prefetch elsewhere when prerender is enabled", async ({
+42 -21
e2e/tests/test-utils.ts
··· 136 136 } 137 137 138 138 // Worker-scoped server pool - one server per worker, shared across all tests in that worker 139 - const workerServers = new Map<number, DevServer>(); 139 + // Key format: "workerIndex-fixtureName" 140 + const workerServers = new Map<string, DevServer>(); 140 141 141 - // Extend Playwright's test with a devServer fixture 142 - export const test = base.extend<{ devServer: DevServer }>({ 143 - devServer: async ({}, use, testInfo) => { 144 - // Use worker index to get or create a server for this worker 145 - const workerIndex = testInfo.workerIndex; 142 + /** 143 + * Create a test instance with a devServer fixture for a specific fixture. 144 + * This allows each test file to use a different fixture while sharing the same pattern. 145 + * 146 + * @param fixtureName - Name of the fixture directory under e2e/fixtures/ 147 + * @param basePort - Starting port number (default: 1864). Each worker gets basePort + workerIndex 148 + * 149 + * @example 150 + * ```ts 151 + * import { createTestWithFixture } from "./test-utils"; 152 + * const test = createTestWithFixture("my-fixture"); 153 + * 154 + * test("my test", async ({ devServer }) => { 155 + * // devServer is automatically started for "my-fixture" 156 + * }); 157 + * ``` 158 + */ 159 + export function createTestWithFixture(fixtureName: string, basePort = 1864) { 160 + return base.extend<{ devServer: DevServer }>({ 161 + // oxlint-disable-next-line no-empty-pattern 162 + devServer: async ({}, use, testInfo) => { 163 + // Use worker index to get or create a server for this worker 164 + const workerIndex = testInfo.workerIndex; 165 + const serverKey = `${workerIndex}-${fixtureName}`; 146 166 147 - let server = workerServers.get(workerIndex); 167 + let server = workerServers.get(serverKey); 148 168 149 - if (!server) { 150 - // Assign unique port based on worker index 151 - const port = 1864 + workerIndex; 169 + if (!server) { 170 + // Assign unique port based on worker index 171 + const port = basePort + workerIndex; 152 172 153 - server = await startDevServer({ 154 - fixture: "prefetch-prerender", 155 - port, 156 - }); 173 + server = await startDevServer({ 174 + fixture: fixtureName, 175 + port, 176 + }); 157 177 158 - workerServers.set(workerIndex, server); 159 - } 178 + workerServers.set(serverKey, server); 179 + } 160 180 161 - await use(server); 181 + await use(server); 162 182 163 - // Don't stop the server here - it stays alive for all tests in this worker 164 - // Playwright will clean up when the worker exits 165 - }, 166 - }); 183 + // Don't stop the server here - it stays alive for all tests in this worker 184 + // Playwright will clean up when the worker exits 185 + }, 186 + }); 187 + } 167 188 168 189 export { expect } from "@playwright/test";
+2 -1
package.json
··· 12 12 "lint:fix": "oxlint --fix --type-aware && cargo clippy --fix --allow-dirty --allow-staged", 13 13 "format": "pnpm run format:ts && pnpm run format:rs", 14 14 "format:ts": "oxfmt", 15 - "format:rs": "cargo fmt" 15 + "format:rs": "cargo fmt", 16 + "test:e2e": "cd e2e && pnpm run test" 16 17 }, 17 18 "dependencies": { 18 19 "@tailwindcss/cli": "^4.1.18",