···432}
433434fn 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());
0437438 let mut filename = PathBuf::new();
439 filename.push(format!("{}.{}", sanitized_stem, hash));
···533#[cfg(test)]
534mod tests {
535 use super::*;
536- use std::env;
537538- 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();
542543- 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();
00000000546 temp_dir
547 }
548···550 fn test_add_style() {
551 let temp_dir = setup_temp_dir();
552 let mut page_assets = RouteAssets::default();
553- page_assets.add_style(temp_dir.join("style.css")).unwrap();
00554555 assert!(page_assets.styles.len() == 1);
556 }
···561 let mut page_assets = RouteAssets::default();
562563 page_assets
564- .include_style(temp_dir.join("style.css"))
565 .unwrap();
566567 assert!(page_assets.styles.len() == 1);
···573 let temp_dir = setup_temp_dir();
574 let mut page_assets = RouteAssets::default();
575576- page_assets.add_script(temp_dir.join("script.js")).unwrap();
00577 assert!(page_assets.scripts.len() == 1);
578 }
579···583 let mut page_assets = RouteAssets::default();
584585 page_assets
586- .include_script(temp_dir.join("script.js"))
587 .unwrap();
588589 assert!(page_assets.scripts.len() == 1);
···595 let temp_dir = setup_temp_dir();
596 let mut page_assets = RouteAssets::default();
597598- page_assets.add_image(temp_dir.join("image.png")).unwrap();
00599 assert!(page_assets.images.len() == 1);
600 }
601···604 let temp_dir = setup_temp_dir();
605 let mut page_assets = RouteAssets::default();
606607- let image = page_assets.add_image(temp_dir.join("image.png")).unwrap();
00608 assert_eq!(image.url().chars().next(), Some('/'));
609610- let script = page_assets.add_script(temp_dir.join("script.js")).unwrap();
00611 assert_eq!(script.url().chars().next(), Some('/'));
612613- let style = page_assets.add_style(temp_dir.join("style.css")).unwrap();
00614 assert_eq!(style.url().chars().next(), Some('/'));
615 }
616···619 let temp_dir = setup_temp_dir();
620 let mut page_assets = RouteAssets::default();
621622- let image = page_assets.add_image(temp_dir.join("image.png")).unwrap();
00623 assert!(image.url().contains(&image.hash));
624625- let script = page_assets.add_script(temp_dir.join("script.js")).unwrap();
00626 assert!(script.url().contains(&script.hash));
627628- let style = page_assets.add_style(temp_dir.join("style.css")).unwrap();
00629 assert!(style.url().contains(&style.hash));
630 }
631···634 let temp_dir = setup_temp_dir();
635 let mut page_assets = RouteAssets::default();
636637- let image = page_assets.add_image(temp_dir.join("image.png")).unwrap();
00638 assert!(image.build_path().to_string_lossy().contains(&image.hash));
639640- let script = page_assets.add_script(temp_dir.join("script.js")).unwrap();
00641 assert!(script.build_path().to_string_lossy().contains(&script.hash));
642643- let style = page_assets.add_style(temp_dir.join("style.css")).unwrap();
00644 assert!(style.build_path().to_string_lossy().contains(&style.hash));
645 }
646647 #[test]
648 fn test_image_hash_different_options() {
649 let temp_dir = setup_temp_dir();
650- let image_path = temp_dir.join("image.png");
651652 // Create a simple test PNG (1x1 transparent pixel)
653 let png_data = [
···659 ];
660 std::fs::write(&image_path, png_data).unwrap();
661662- let mut page_assets = RouteAssets::default();
000000663664 // Test that different options produce different hashes
665 let image_default = page_assets.add_image(&image_path).unwrap();
···716 #[test]
717 fn test_image_hash_same_options() {
718 let temp_dir = setup_temp_dir();
719- let image_path = temp_dir.join("image.png");
720721 // Create a simple test PNG (1x1 transparent pixel)
722 let png_data = [
···728 ];
729 std::fs::write(&image_path, png_data).unwrap();
730731- let mut page_assets = RouteAssets::default();
000000732733 // Same options should produce same hash
734 let image1 = page_assets
···762 #[test]
763 fn test_style_hash_different_options() {
764 let temp_dir = setup_temp_dir();
765- let style_path = temp_dir.join("style.css");
766767- let mut page_assets = RouteAssets::new(&RouteAssetsOptions::default(), None);
000000768769 // Test that different tailwind options produce different hashes
770 let style_default = page_assets.add_style(&style_path).unwrap();
···784785 // Create two identical files with different paths
786 let content = "body { background: blue; }";
787- let style1_path = temp_dir.join("style1.css");
788- let style2_path = temp_dir.join("style2.css");
789790 std::fs::write(&style1_path, content).unwrap();
791 std::fs::write(&style2_path, content).unwrap();
792793- let mut page_assets = RouteAssets::new(&RouteAssetsOptions::default(), None);
000000794795 let style1 = page_assets.add_style(&style1_path).unwrap();
796 let style2 = page_assets.add_style(&style2_path).unwrap();
···804 #[test]
805 fn test_hash_includes_content() {
806 let temp_dir = setup_temp_dir();
807- let style_path = temp_dir.join("dynamic_style.css");
808809- let assets_options = RouteAssetsOptions::default();
810- let mut page_assets = RouteAssets::new(&assets_options, None);
00000811812 // Write first content and get hash
813 std::fs::write(&style_path, "body { background: red; }").unwrap();
···823 hash1, hash2,
824 "Different content should produce different hashes"
825 );
00000000000000000000000000000000000000000000000000000000000826 }
827}
···432}
433434fn make_filename(path: &Path, hash: &String, extension: Option<&str>) -> PathBuf {
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);
438439 let mut filename = PathBuf::new();
440 filename.push(format!("{}.{}", sanitized_stem, hash));
···534#[cfg(test)]
535mod tests {
536 use super::*;
0537538+ fn setup_temp_dir() -> tempfile::TempDir {
539+ let temp_dir = tempfile::tempdir().unwrap();
00540541+ 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();
552 temp_dir
553 }
554···556 fn test_add_style() {
557 let temp_dir = setup_temp_dir();
558 let mut page_assets = RouteAssets::default();
559+ page_assets
560+ .add_style(temp_dir.path().join("style.css"))
561+ .unwrap();
562563 assert!(page_assets.styles.len() == 1);
564 }
···569 let mut page_assets = RouteAssets::default();
570571 page_assets
572+ .include_style(temp_dir.path().join("style.css"))
573 .unwrap();
574575 assert!(page_assets.styles.len() == 1);
···581 let temp_dir = setup_temp_dir();
582 let mut page_assets = RouteAssets::default();
583584+ page_assets
585+ .add_script(temp_dir.path().join("script.js"))
586+ .unwrap();
587 assert!(page_assets.scripts.len() == 1);
588 }
589···593 let mut page_assets = RouteAssets::default();
594595 page_assets
596+ .include_script(temp_dir.path().join("script.js"))
597 .unwrap();
598599 assert!(page_assets.scripts.len() == 1);
···605 let temp_dir = setup_temp_dir();
606 let mut page_assets = RouteAssets::default();
607608+ page_assets
609+ .add_image(temp_dir.path().join("image.png"))
610+ .unwrap();
611 assert!(page_assets.images.len() == 1);
612 }
613···616 let temp_dir = setup_temp_dir();
617 let mut page_assets = RouteAssets::default();
618619+ let image = page_assets
620+ .add_image(temp_dir.path().join("image.png"))
621+ .unwrap();
622 assert_eq!(image.url().chars().next(), Some('/'));
623624+ let script = page_assets
625+ .add_script(temp_dir.path().join("script.js"))
626+ .unwrap();
627 assert_eq!(script.url().chars().next(), Some('/'));
628629+ let style = page_assets
630+ .add_style(temp_dir.path().join("style.css"))
631+ .unwrap();
632 assert_eq!(style.url().chars().next(), Some('/'));
633 }
634···637 let temp_dir = setup_temp_dir();
638 let mut page_assets = RouteAssets::default();
639640+ let image = page_assets
641+ .add_image(temp_dir.path().join("image.png"))
642+ .unwrap();
643 assert!(image.url().contains(&image.hash));
644645+ let script = page_assets
646+ .add_script(temp_dir.path().join("script.js"))
647+ .unwrap();
648 assert!(script.url().contains(&script.hash));
649650+ let style = page_assets
651+ .add_style(temp_dir.path().join("style.css"))
652+ .unwrap();
653 assert!(style.url().contains(&style.hash));
654 }
655···658 let temp_dir = setup_temp_dir();
659 let mut page_assets = RouteAssets::default();
660661+ let image = page_assets
662+ .add_image(temp_dir.path().join("image.png"))
663+ .unwrap();
664 assert!(image.build_path().to_string_lossy().contains(&image.hash));
665666+ let script = page_assets
667+ .add_script(temp_dir.path().join("script.js"))
668+ .unwrap();
669 assert!(script.build_path().to_string_lossy().contains(&script.hash));
670671+ let style = page_assets
672+ .add_style(temp_dir.path().join("style.css"))
673+ .unwrap();
674 assert!(style.build_path().to_string_lossy().contains(&style.hash));
675 }
676677 #[test]
678 fn test_image_hash_different_options() {
679 let temp_dir = setup_temp_dir();
680+ let image_path = temp_dir.path().join("image.png");
681682 // Create a simple test PNG (1x1 transparent pixel)
683 let png_data = [
···689 ];
690 std::fs::write(&image_path, png_data).unwrap();
691692+ let mut page_assets = RouteAssets::new(
693+ &RouteAssetsOptions {
694+ hashing_strategy: AssetHashingStrategy::Precise,
695+ ..Default::default()
696+ },
697+ None,
698+ );
699700 // Test that different options produce different hashes
701 let image_default = page_assets.add_image(&image_path).unwrap();
···752 #[test]
753 fn test_image_hash_same_options() {
754 let temp_dir = setup_temp_dir();
755+ let image_path = temp_dir.path().join("image.png");
756757 // Create a simple test PNG (1x1 transparent pixel)
758 let png_data = [
···764 ];
765 std::fs::write(&image_path, png_data).unwrap();
766767+ let mut page_assets = RouteAssets::new(
768+ &RouteAssetsOptions {
769+ hashing_strategy: AssetHashingStrategy::Precise,
770+ ..Default::default()
771+ },
772+ None,
773+ );
774775 // Same options should produce same hash
776 let image1 = page_assets
···804 #[test]
805 fn test_style_hash_different_options() {
806 let temp_dir = setup_temp_dir();
807+ let style_path = temp_dir.path().join("style.css");
808809+ let mut page_assets = RouteAssets::new(
810+ &RouteAssetsOptions {
811+ hashing_strategy: AssetHashingStrategy::Precise,
812+ ..Default::default()
813+ },
814+ None,
815+ );
816817 // Test that different tailwind options produce different hashes
818 let style_default = page_assets.add_style(&style_path).unwrap();
···832833 // Create two identical files with different paths
834 let content = "body { background: blue; }";
835+ let style1_path = temp_dir.path().join("style1.css");
836+ let style2_path = temp_dir.path().join("style2.css");
837838 std::fs::write(&style1_path, content).unwrap();
839 std::fs::write(&style2_path, content).unwrap();
840841+ let mut page_assets = RouteAssets::new(
842+ &RouteAssetsOptions {
843+ hashing_strategy: AssetHashingStrategy::Precise,
844+ ..Default::default()
845+ },
846+ None,
847+ );
848849 let style1 = page_assets.add_style(&style1_path).unwrap();
850 let style2 = page_assets.add_style(&style2_path).unwrap();
···858 #[test]
859 fn test_hash_includes_content() {
860 let temp_dir = setup_temp_dir();
861+ let style_path = temp_dir.path().join("dynamic_style.css");
862863+ let mut page_assets = RouteAssets::new(
864+ &RouteAssetsOptions {
865+ hashing_strategy: AssetHashingStrategy::Precise,
866+ ..Default::default()
867+ },
868+ None,
869+ );
870871 // Write first content and get hash
872 std::fs::write(&style_path, "body { background: red; }").unwrap();
···882 hash1, hash2,
883 "Different content should produce different hashes"
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"));
944 }
945}
+99-5
crates/maudit/src/assets/image.rs
···154 /// Get a placeholder for the image, which can be used for low-quality image placeholders (LQIP) or similar techniques.
155 ///
156 /// This uses the [ThumbHash](https://evanw.github.io/thumbhash/) algorithm to generate a very small placeholder image.
157- pub fn placeholder(&self) -> ImagePlaceholder {
00158 get_placeholder(&self.path, self.cache.as_ref())
159 }
160···258 }
259}
260261-fn get_placeholder(path: &PathBuf, cache: Option<&ImageCache>) -> ImagePlaceholder {
262 // Check cache first if provided
263 if let Some(cache) = cache
264 && let Some(cached) = cache.get_placeholder(path)
265 {
266 debug!("Using cached placeholder for {}", path.display());
267 let thumbhash_base64 = base64::engine::general_purpose::STANDARD.encode(&cached.thumbhash);
268- return ImagePlaceholder::new(cached.thumbhash, thumbhash_base64);
269 }
270271 let total_start = Instant::now();
272273 let load_start = Instant::now();
274- let image = image::open(path).ok().unwrap();
000275 let (width, height) = image.dimensions();
276 let (width, height) = (width as usize, height as usize);
277 debug!(
···329 cache.cache_placeholder(path, thumb_hash.clone());
330 }
331332- ImagePlaceholder::new(thumb_hash, thumbhash_base64)
333}
334335/// Port of https://github.com/evanw/thumbhash/blob/a652ce6ed691242f459f468f0a8756cda3b90a82/js/thumbhash.js#L234
···516 ).into()
517 }
518}
00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000
···154 /// Get a placeholder for the image, which can be used for low-quality image placeholders (LQIP) or similar techniques.
155 ///
156 /// This uses the [ThumbHash](https://evanw.github.io/thumbhash/) algorithm to generate a very small placeholder image.
157+ ///
158+ /// Returns an error if the image cannot be loaded.
159+ pub fn placeholder(&self) -> Result<ImagePlaceholder, crate::errors::AssetError> {
160 get_placeholder(&self.path, self.cache.as_ref())
161 }
162···260 }
261}
262263+fn get_placeholder(path: &PathBuf, cache: Option<&ImageCache>) -> Result<ImagePlaceholder, crate::errors::AssetError> {
264 // Check cache first if provided
265 if let Some(cache) = cache
266 && let Some(cached) = cache.get_placeholder(path)
267 {
268 debug!("Using cached placeholder for {}", path.display());
269 let thumbhash_base64 = base64::engine::general_purpose::STANDARD.encode(&cached.thumbhash);
270+ return Ok(ImagePlaceholder::new(cached.thumbhash, thumbhash_base64));
271 }
272273 let total_start = Instant::now();
274275 let load_start = Instant::now();
276+ let image = image::open(path).map_err(|e| crate::errors::AssetError::ImageLoadFailed {
277+ path: path.clone(),
278+ source: e,
279+ })?;
280 let (width, height) = image.dimensions();
281 let (width, height) = (width as usize, height as usize);
282 debug!(
···334 cache.cache_placeholder(path, thumb_hash.clone());
335 }
336337+ Ok(ImagePlaceholder::new(thumb_hash, thumbhash_base64))
338}
339340/// Port of https://github.com/evanw/thumbhash/blob/a652ce6ed691242f459f468f0a8756cda3b90a82/js/thumbhash.js#L234
···521 ).into()
522 }
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+}
···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";
2import { prefetchScript } from "./utils";
0034test.describe("Prefetch", () => {
5 test("should create prefetch via speculation rules on Chromium or link element elsewhere", async ({
···1+import { createTestWithFixture, expect } from "./test-utils";
2import { prefetchScript } from "./utils";
3+4+const test = createTestWithFixture("prefetch-prerender");
56test.describe("Prefetch", () => {
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";
2import { prefetchScript } from "./utils";
0034test.describe("Prefetch - Speculation Rules (Prerender)", () => {
5 test("should create speculation rules on Chromium or link prefetch elsewhere when prerender is enabled", async ({
···1+import { createTestWithFixture, expect } from "./test-utils";
2import { prefetchScript } from "./utils";
3+4+const test = createTestWithFixture("prefetch-prerender");
56test.describe("Prefetch - Speculation Rules (Prerender)", () => {
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}
137138// Worker-scoped server pool - one server per worker, shared across all tests in that worker
139-const workerServers = new Map<number, DevServer>();
0140141-// 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;
0000000000000000000146147- let server = workerServers.get(workerIndex);
148149- if (!server) {
150- // Assign unique port based on worker index
151- const port = 1864 + workerIndex;
152153- server = await startDevServer({
154- fixture: "prefetch-prerender",
155- port,
156- });
157158- workerServers.set(workerIndex, server);
159- }
160161- await use(server);
162163- // 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-});
0167168export { expect } from "@playwright/test";
···136}
137138// Worker-scoped server pool - one server per worker, shared across all tests in that worker
139+// Key format: "workerIndex-fixtureName"
140+const workerServers = new Map<string, DevServer>();
141142+/**
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}`;
166167+ let server = workerServers.get(serverKey);
168169+ if (!server) {
170+ // Assign unique port based on worker index
171+ const port = basePort + workerIndex;
172173+ server = await startDevServer({
174+ fixture: fixtureName,
175+ port,
176+ });
177178+ workerServers.set(serverKey, server);
179+ }
180181+ await use(server);
182183+ // 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+}
188189export { expect } from "@playwright/test";