···103103 let mut affected_routes = FxHashSet::default();
104104105105 for changed_file in changed_files {
106106- // Try exact match first
107107- if let Some(routes) = self.asset_to_routes.get(changed_file) {
108108- affected_routes.extend(routes.iter().cloned());
109109- }
106106+ // Canonicalize the changed file path for consistent comparison
107107+ // All asset paths in asset_to_routes are stored as canonical paths
108108+ let canonical_changed = changed_file.canonicalize().ok();
110109111111- // Try canonicalized path match
112112- if let Ok(canonical) = changed_file.canonicalize() {
113113- if let Some(routes) = self.asset_to_routes.get(&canonical) {
110110+ // Try exact match with canonical path
111111+ if let Some(canonical) = &canonical_changed {
112112+ if let Some(routes) = self.asset_to_routes.get(canonical) {
114113 affected_routes.extend(routes.iter().cloned());
114114+ continue; // Found exact match, no need for directory check
115115 }
116116 }
117117118118- // Also check if any tracked asset has this file as a prefix (for directories)
119119- for (asset_path, routes) in &self.asset_to_routes {
120120- if asset_path.starts_with(changed_file) {
121121- affected_routes.extend(routes.iter().cloned());
118118+ // Fallback: try exact match with original path (shouldn't normally match)
119119+ if let Some(routes) = self.asset_to_routes.get(changed_file) {
120120+ affected_routes.extend(routes.iter().cloned());
121121+ continue;
122122+ }
123123+124124+ // Only do directory prefix check if the changed path is actually a directory
125125+ // This handles cases where a directory is modified and we want to rebuild
126126+ // all routes that use assets within that directory
127127+ if changed_file.is_dir() {
128128+ let canonical_dir = canonical_changed.as_ref().unwrap_or(changed_file);
129129+ for (asset_path, routes) in &self.asset_to_routes {
130130+ if asset_path.starts_with(canonical_dir) {
131131+ affected_routes.extend(routes.iter().cloned());
132132+ }
122133 }
123134 }
124135 }
+8-1
e2e/fixtures/incremental-build/src/pages/about.rs
···11use maud::{html, Markup};
22use maudit::route::prelude::*;
33+use std::time::{SystemTime, UNIX_EPOCH};
3445#[route("/about")]
56pub struct About;
···910 let _image = ctx.assets.add_image("src/assets/team.png");
1011 let _script = ctx.assets.add_script("src/assets/about.js");
11121313+ // Generate a unique build ID - uses nanoseconds for uniqueness
1414+ let build_id = SystemTime::now()
1515+ .duration_since(UNIX_EPOCH)
1616+ .map(|d| d.as_nanos().to_string())
1717+ .unwrap_or_else(|_| "0".to_string());
1818+1219 html! {
1320 html {
1421 head {
1522 title { "About Page" }
1623 }
1717- body {
2424+ body data-build-id=(build_id) {
1825 h1 id="title" { "About Us" }
1926 p id="content" { "Learn more about us" }
2027 }
+8-1
e2e/fixtures/incremental-build/src/pages/blog.rs
···11use maud::{html, Markup};
22use maudit::route::prelude::*;
33+use std::time::{SystemTime, UNIX_EPOCH};
3445#[route("/blog")]
56pub struct Blog;
···89 fn render(&self, ctx: &mut PageContext) -> impl Into<RenderResult> {
910 let _style = ctx.assets.add_style("src/assets/blog.css");
10111212+ // Generate a unique build ID - uses nanoseconds for uniqueness
1313+ let build_id = SystemTime::now()
1414+ .duration_since(UNIX_EPOCH)
1515+ .map(|d| d.as_nanos().to_string())
1616+ .unwrap_or_else(|_| "0".to_string());
1717+1118 html! {
1219 html {
1320 head {
1421 title { "Blog Page" }
1522 }
1616- body {
2323+ body data-build-id=(build_id) {
1724 h1 id="title" { "Blog" }
1825 p id="content" { "Read our latest posts" }
1926 }
+8-1
e2e/fixtures/incremental-build/src/pages/index.rs
···11use maud::{html, Markup};
22use maudit::route::prelude::*;
33+use std::time::{SystemTime, UNIX_EPOCH};
3445#[route("/")]
56pub struct Index;
···1011 let _script = ctx.assets.add_script("src/assets/main.js");
1112 let _style = ctx.assets.add_style("src/assets/styles.css");
12131414+ // Generate a unique build ID - uses nanoseconds for uniqueness
1515+ let build_id = SystemTime::now()
1616+ .duration_since(UNIX_EPOCH)
1717+ .map(|d| d.as_nanos().to_string())
1818+ .unwrap_or_else(|_| "0".to_string());
1919+1320 html! {
1421 html {
1522 head {
1623 title { "Home Page" }
1724 }
1818- body {
2525+ body data-build-id=(build_id) {
1926 h1 id="title" { "Home Page" }
2027 p id="content" { "Welcome to the home page" }
2128 }
+112-236
e2e/tests/incremental-build.spec.ts
···11import { expect } from "@playwright/test";
22import { createTestWithFixture } from "./test-utils";
33-import { readFileSync, writeFileSync, statSync } from "node:fs";
33+import { readFileSync, writeFileSync } from "node:fs";
44import { resolve, dirname } from "node:path";
55import { fileURLToPath } from "node:url";
66···1010// Create test instance with incremental-build fixture
1111const test = createTestWithFixture("incremental-build");
12121313-test.describe.configure({ mode: "serial" });
1313+// Allow retries for timing-sensitive tests
1414+test.describe.configure({ mode: "serial", retries: 2 });
14151516/**
1616- * Wait for dev server to complete a build/rerun by polling logs
1717+ * Wait for dev server to complete a build by looking for specific patterns.
1818+ * Waits for the build to START, then waits for it to FINISH.
1719 */
1818-async function waitForBuildComplete(devServer: any, timeoutMs = 20000): Promise<string[]> {
2020+async function waitForBuildComplete(devServer: any, timeoutMs = 30000): Promise<string[]> {
1921 const startTime = Date.now();
20222323+ // Phase 1: Wait for build to start
2124 while (Date.now() - startTime < timeoutMs) {
2222- const logs = devServer.getLogs(100);
2525+ const logs = devServer.getLogs(200);
2326 const logsText = logs.join("\n").toLowerCase();
24272525- // Look for completion messages
2828+ if (logsText.includes("rerunning") ||
2929+ logsText.includes("rebuilding") ||
3030+ logsText.includes("files changed")) {
3131+ break;
3232+ }
3333+3434+ await new Promise(resolve => setTimeout(resolve, 50));
3535+ }
3636+3737+ // Phase 2: Wait for build to finish
3838+ while (Date.now() - startTime < timeoutMs) {
3939+ const logs = devServer.getLogs(200);
4040+ const logsText = logs.join("\n").toLowerCase();
4141+2642 if (logsText.includes("finished") ||
2743 logsText.includes("rerun finished") ||
2844 logsText.includes("build finished")) {
2929- return logs;
4545+ // Wait for filesystem to fully sync
4646+ await new Promise(resolve => setTimeout(resolve, 500));
4747+ return devServer.getLogs(200);
3048 }
31493232- // Wait 100ms before checking again
3350 await new Promise(resolve => setTimeout(resolve, 100));
3451 }
35523653 throw new Error(`Build did not complete within ${timeoutMs}ms`);
3754}
38555656+/**
5757+ * Extract the build ID from an HTML file.
5858+ */
5959+function getBuildId(htmlPath: string): string | null {
6060+ try {
6161+ const content = readFileSync(htmlPath, "utf-8");
6262+ const match = content.match(/data-build-id="(\d+)"/);
6363+ return match ? match[1] : null;
6464+ } catch {
6565+ return null;
6666+ }
6767+}
6868+6969+/**
7070+ * Check if logs indicate incremental build was used
7171+ */
7272+function isIncrementalBuild(logs: string[]): boolean {
7373+ return logs.join("\n").toLowerCase().includes("incremental build");
7474+}
7575+7676+/**
7777+ * Get the number of affected routes from logs
7878+ */
7979+function getAffectedRouteCount(logs: string[]): number {
8080+ const logsText = logs.join("\n");
8181+ const match = logsText.match(/Rebuilding (\d+) affected routes/i);
8282+ return match ? parseInt(match[1], 10) : -1;
8383+}
8484+3985test.describe("Incremental Build", () => {
4040- // Increase timeout for these tests since they involve compilation
4141- test.setTimeout(60000);
8686+ test.setTimeout(180000);
42874388 const fixturePath = resolve(__dirname, "..", "fixtures", "incremental-build");
4444- const stylesPath = resolve(fixturePath, "src", "assets", "styles.css");
8989+9090+ // Asset paths
4591 const blogStylesPath = resolve(fixturePath, "src", "assets", "blog.css");
4646- const mainScriptPath = resolve(fixturePath, "src", "assets", "main.js");
4747- const aboutScriptPath = resolve(fixturePath, "src", "assets", "about.js");
4848- const logoPath = resolve(fixturePath, "src", "assets", "logo.png");
4949- const teamPath = resolve(fixturePath, "src", "assets", "team.png");
50925151- const indexHtmlPath = resolve(fixturePath, "dist", "index.html");
5252- const aboutHtmlPath = resolve(fixturePath, "dist", "about", "index.html");
5353- const blogHtmlPath = resolve(fixturePath, "dist", "blog", "index.html");
9393+ // Output HTML paths
9494+ const htmlPaths = {
9595+ index: resolve(fixturePath, "dist", "index.html"),
9696+ about: resolve(fixturePath, "dist", "about", "index.html"),
9797+ blog: resolve(fixturePath, "dist", "blog", "index.html"),
9898+ };
54995555- let originalStylesContent: string;
5656- let originalBlogStylesContent: string;
5757- let originalMainScriptContent: string;
5858- let originalAboutScriptContent: string;
5959- let originalLogoContent: Buffer;
6060- let originalTeamContent: Buffer;
100100+ // Original content storage
101101+ let originalBlogStyles: string;
6110262103 test.beforeAll(async () => {
6363- // Save original content
6464- originalStylesContent = readFileSync(stylesPath, "utf-8");
6565- originalBlogStylesContent = readFileSync(blogStylesPath, "utf-8");
6666- originalMainScriptContent = readFileSync(mainScriptPath, "utf-8");
6767- originalAboutScriptContent = readFileSync(aboutScriptPath, "utf-8");
6868- originalLogoContent = readFileSync(logoPath);
6969- originalTeamContent = readFileSync(teamPath);
7070-7171- // Ensure files are in original state
7272- writeFileSync(stylesPath, originalStylesContent, "utf-8");
7373- writeFileSync(blogStylesPath, originalBlogStylesContent, "utf-8");
7474- writeFileSync(mainScriptPath, originalMainScriptContent, "utf-8");
7575- writeFileSync(aboutScriptPath, originalAboutScriptContent, "utf-8");
7676- writeFileSync(logoPath, originalLogoContent);
7777- writeFileSync(teamPath, originalTeamContent);
7878- });
7979-8080- test.afterEach(async ({ devServer }) => {
8181- // Restore original content after each test
8282- writeFileSync(stylesPath, originalStylesContent, "utf-8");
8383- writeFileSync(blogStylesPath, originalBlogStylesContent, "utf-8");
8484- writeFileSync(mainScriptPath, originalMainScriptContent, "utf-8");
8585- writeFileSync(aboutScriptPath, originalAboutScriptContent, "utf-8");
8686- writeFileSync(logoPath, originalLogoContent);
8787- writeFileSync(teamPath, originalTeamContent);
8888-8989- // Wait for build if devServer is available
9090- if (devServer) {
9191- try {
9292- devServer.clearLogs();
9393- await waitForBuildComplete(devServer);
9494- } catch (error) {
9595- console.warn("Failed to wait for build completion in afterEach:", error);
9696- }
9797- }
104104+ originalBlogStyles = readFileSync(blogStylesPath, "utf-8");
105105+ // Ensure file is in original state
106106+ writeFileSync(blogStylesPath, originalBlogStyles, "utf-8");
98107 });
99108100109 test.afterAll(async () => {
101110 // Restore original content
102102- writeFileSync(stylesPath, originalStylesContent, "utf-8");
103103- writeFileSync(blogStylesPath, originalBlogStylesContent, "utf-8");
104104- writeFileSync(mainScriptPath, originalMainScriptContent, "utf-8");
105105- writeFileSync(aboutScriptPath, originalAboutScriptContent, "utf-8");
106106- writeFileSync(logoPath, originalLogoContent);
107107- writeFileSync(teamPath, originalTeamContent);
108108- });
109109-110110- test("should perform full build on first run after recompilation", async ({ devServer }) => {
111111- // Clear logs to track what happens after initial startup
112112- devServer.clearLogs();
113113-114114- // Modify a file to trigger a rebuild
115115- writeFileSync(stylesPath, originalStylesContent + "\n/* comment */", "utf-8");
116116-117117- // Wait for rebuild
118118- const logs = await waitForBuildComplete(devServer, 20000);
119119- const logsText = logs.join("\n").toLowerCase();
120120-121121- // After the first change post-startup, we should see an incremental build message
122122- expect(logsText).toContain("incremental build");
123123- });
124124-125125- test("should only rebuild affected route when CSS changes", async ({ devServer }) => {
126126- // First, do a change to ensure we have build state
127127- writeFileSync(stylesPath, originalStylesContent + "\n/* setup */", "utf-8");
128128- await waitForBuildComplete(devServer);
129129-130130- // Get modification times before change
131131- const indexMtimeBefore = statSync(indexHtmlPath).mtimeMs;
132132- const aboutMtimeBefore = statSync(aboutHtmlPath).mtimeMs;
133133- const blogMtimeBefore = statSync(blogHtmlPath).mtimeMs;
134134-135135- // Wait longer to ensure timestamps differ and debouncer completes
136136- await new Promise(resolve => setTimeout(resolve, 500));
137137-138138- // Clear logs
139139- devServer.clearLogs();
140140-141141- // Change blog.css (only used by /blog route)
142142- writeFileSync(blogStylesPath, originalBlogStylesContent + "\n/* modified */", "utf-8");
143143-144144- // Wait for rebuild
145145- const logs = await waitForBuildComplete(devServer, 20000);
146146- const logsText = logs.join("\n").toLowerCase();
147147-148148- // Should be incremental build
149149- expect(logsText).toContain("incremental build");
150150-151151- // Get modification times after change
152152- const indexMtimeAfter = statSync(indexHtmlPath).mtimeMs;
153153- const aboutMtimeAfter = statSync(aboutHtmlPath).mtimeMs;
154154- const blogMtimeAfter = statSync(blogHtmlPath).mtimeMs;
155155-156156- // Index and About should NOT be rebuilt (same mtime)
157157- expect(indexMtimeAfter).toBe(indexMtimeBefore);
158158- expect(aboutMtimeAfter).toBe(aboutMtimeBefore);
159159-160160- // Blog should be rebuilt (different mtime)
161161- expect(blogMtimeAfter).toBeGreaterThan(blogMtimeBefore);
111111+ writeFileSync(blogStylesPath, originalBlogStyles, "utf-8");
162112 });
163113164164- test("should rebuild multiple routes when shared asset changes", async ({ devServer }) => {
165165- // First, do a change to ensure we have build state
166166- writeFileSync(stylesPath, originalStylesContent + "\n/* setup */", "utf-8");
167167- await waitForBuildComplete(devServer);
114114+ test("incremental builds only rebuild affected routes", async ({ devServer }) => {
115115+ let testCounter = 0;
168116169169- // Get modification times before change
170170- const indexMtimeBefore = statSync(indexHtmlPath).mtimeMs;
171171- const aboutMtimeBefore = statSync(aboutHtmlPath).mtimeMs;
172172- const blogMtimeBefore = statSync(blogHtmlPath).mtimeMs;
117117+ async function triggerChange(suffix: string) {
118118+ testCounter++;
119119+ devServer.clearLogs();
120120+ writeFileSync(blogStylesPath, originalBlogStyles + `\n/* test-${testCounter}-${suffix} */`, "utf-8");
121121+ return await waitForBuildComplete(devServer, 30000);
122122+ }
173123174174- // Wait longer to ensure timestamps differ and debouncer completes
124124+ // ========================================
125125+ // SETUP: Establish incremental build state
126126+ // ========================================
127127+ // First change triggers a full build (no previous state)
128128+ await triggerChange("init");
175129 await new Promise(resolve => setTimeout(resolve, 500));
176130177177- // Clear logs
178178- devServer.clearLogs();
179179-180180- // Change styles.css (used by /index route)
181181- writeFileSync(stylesPath, originalStylesContent + "\n/* modified */", "utf-8");
182182-183183- // Wait for rebuild
184184- const logs = await waitForBuildComplete(devServer, 20000);
185185- const logsText = logs.join("\n").toLowerCase();
186186-187187- // Should be incremental build
188188- expect(logsText).toContain("incremental build");
189189-190190- // Get modification times after change
191191- const indexMtimeAfter = statSync(indexHtmlPath).mtimeMs;
192192- const aboutMtimeAfter = statSync(aboutHtmlPath).mtimeMs;
193193- const blogMtimeAfter = statSync(blogHtmlPath).mtimeMs;
194194-195195- // Index should be rebuilt (uses styles.css)
196196- expect(indexMtimeAfter).toBeGreaterThan(indexMtimeBefore);
197197-198198- // About and Blog should NOT be rebuilt
199199- expect(aboutMtimeAfter).toBe(aboutMtimeBefore);
200200- expect(blogMtimeAfter).toBe(blogMtimeBefore);
201201- });
202202-203203- test("should rebuild affected route when script changes", async ({ devServer }) => {
204204- // First, do a change to ensure we have build state
205205- writeFileSync(mainScriptPath, originalMainScriptContent + "\n// setup", "utf-8");
206206- await waitForBuildComplete(devServer);
207207-208208- // Get modification times before change
209209- const indexMtimeBefore = statSync(indexHtmlPath).mtimeMs;
210210- const aboutMtimeBefore = statSync(aboutHtmlPath).mtimeMs;
211211-212212- // Wait longer to ensure timestamps differ and debouncer completes
131131+ // Second change should be incremental (state now exists)
132132+ let logs = await triggerChange("setup");
133133+ expect(isIncrementalBuild(logs)).toBe(true);
213134 await new Promise(resolve => setTimeout(resolve, 500));
214214-215215- // Clear logs
216216- devServer.clearLogs();
217217-218218- // Change about.js (only used by /about route)
219219- writeFileSync(aboutScriptPath, originalAboutScriptContent + "\n// modified", "utf-8");
220220-221221- // Wait for rebuild
222222- const logs = await waitForBuildComplete(devServer, 20000);
223223- const logsText = logs.join("\n").toLowerCase();
224224-225225- // Should be incremental build
226226- expect(logsText).toContain("incremental build");
227227-228228- // Get modification times after change
229229- const indexMtimeAfter = statSync(indexHtmlPath).mtimeMs;
230230- const aboutMtimeAfter = statSync(aboutHtmlPath).mtimeMs;
231231-232232- // Index should NOT be rebuilt
233233- expect(indexMtimeAfter).toBe(indexMtimeBefore);
234234-235235- // About should be rebuilt
236236- expect(aboutMtimeAfter).toBeGreaterThan(aboutMtimeBefore);
237237- });
238135239239- test("should rebuild affected route when image changes", async ({ devServer }) => {
240240- // First, do a change to ensure we have build state
241241- writeFileSync(stylesPath, originalStylesContent + "\n/* setup */", "utf-8");
242242- await waitForBuildComplete(devServer);
136136+ // ========================================
137137+ // TEST: CSS file change (blog.css → only /blog)
138138+ // ========================================
139139+ // Record build IDs before
140140+ const beforeIndex = getBuildId(htmlPaths.index);
141141+ const beforeAbout = getBuildId(htmlPaths.about);
142142+ const beforeBlog = getBuildId(htmlPaths.blog);
243143244244- // Get modification times before change
245245- const indexMtimeBefore = statSync(indexHtmlPath).mtimeMs;
246246- const aboutMtimeBefore = statSync(aboutHtmlPath).mtimeMs;
144144+ expect(beforeIndex).not.toBeNull();
145145+ expect(beforeAbout).not.toBeNull();
146146+ expect(beforeBlog).not.toBeNull();
247147248248- // Wait longer to ensure timestamps differ and debouncer completes
148148+ // Wait a bit more to ensure clean slate
249149 await new Promise(resolve => setTimeout(resolve, 500));
250150251251- // Clear logs
252252- devServer.clearLogs();
151151+ // Trigger the change
152152+ logs = await triggerChange("final");
253153254254- // "Change" team.png (used by /about route)
255255- // We'll just write it again with same content but new mtime
256256- writeFileSync(teamPath, originalTeamContent);
154154+ // Verify it was an incremental build
155155+ expect(isIncrementalBuild(logs)).toBe(true);
257156258258- // Wait for rebuild
259259- const logs = await waitForBuildComplete(devServer, 20000);
260260- const logsText = logs.join("\n").toLowerCase();
157157+ // Verify exactly 1 route was rebuilt (from logs)
158158+ const routeCount = getAffectedRouteCount(logs);
159159+ expect(routeCount).toBe(1);
261160262262- // Should be incremental build
263263- expect(logsText).toContain("incremental build");
264264-265265- // Get modification times after change
266266- const indexMtimeAfter = statSync(indexHtmlPath).mtimeMs;
267267- const aboutMtimeAfter = statSync(aboutHtmlPath).mtimeMs;
161161+ // Verify build IDs: only blog should have changed
162162+ const afterIndex = getBuildId(htmlPaths.index);
163163+ const afterAbout = getBuildId(htmlPaths.about);
164164+ const afterBlog = getBuildId(htmlPaths.blog);
268165269269- // Index should NOT be rebuilt
270270- expect(indexMtimeAfter).toBe(indexMtimeBefore);
166166+ // Index and about should NOT have been rebuilt
167167+ expect(afterIndex).toBe(beforeIndex);
168168+ expect(afterAbout).toBe(beforeAbout);
271169272272- // About should be rebuilt
273273- expect(aboutMtimeAfter).toBeGreaterThan(aboutMtimeBefore);
274274- });
275275-276276- test("should preserve bundler inputs across incremental builds", async ({ devServer }) => {
277277- // First, do a change to ensure we have build state
278278- writeFileSync(stylesPath, originalStylesContent + "\n/* setup */", "utf-8");
279279- await waitForBuildComplete(devServer);
280280-281281- // Clear logs
282282- devServer.clearLogs();
283283-284284- // Change only blog.css (blog route only)
285285- writeFileSync(blogStylesPath, originalBlogStylesContent + "\n/* modified */", "utf-8");
286286-287287- // Wait for rebuild
288288- const logs = await waitForBuildComplete(devServer, 20000);
289289- const logsText = logs.join("\n");
290290-291291- // Check that logs mention merging with previous bundler inputs
292292- // This ensures that even though only blog route was rebuilt,
293293- // all assets from the previous build are still bundled
294294- expect(logsText).toContain("Merging with");
295295- expect(logsText).toContain("previous bundler inputs");
170170+ // Blog SHOULD have been rebuilt
171171+ expect(afterBlog).not.toBe(beforeBlog);
296172 });
297173});