@@ -75,6 +75,8 @@ public class RouteCheckTests
7575 private readonly System . Text . StringBuilder _previewLog = new ( ) ;
7676 private Task ? _previewStdoutDrain ;
7777 private Task ? _previewStderrDrain ;
78+ private static DateTime _fixtureStartTime ;
79+ private static string _distDir = string . Empty ;
7880
7981 // Walk-up bound is intentionally large (32) rather than fitting the
8082 // current `bin/Debug/netN.N/` suffix exactly. A deeper container path,
@@ -98,6 +100,8 @@ public class RouteCheckTests
98100 [ OneTimeSetUp ]
99101 public async Task OneTimeSetUp ( )
100102 {
103+ _fixtureStartTime = DateTime . UtcNow ;
104+
101105 // Wrap the bootstrap so a partial failure (npm bootstrap throw,
102106 // chromium-install non-zero exit, preview-server bind timeout)
103107 // rethrows with whatever state was captured up to that point —
@@ -109,23 +113,27 @@ public async Task OneTimeSetUp()
109113 {
110114 _docsRoot = Path . Combine ( RepoRoot , "docs" ) ;
111115 var distDir = Path . Combine ( _docsRoot , ".vitepress" , "dist" ) ;
116+ _distDir = distDir ;
112117
113- // The test owns its build prerequisite. If the dist/ tree
114- // doesn't already exist, run `npm ci && npm run build` from
115- // docs/ to produce it. `npm run build` invokes the `prebuild`
116- // hook (regenerate api + reference), so the rendered site
117- // matches what CI builds on every push. The presence check
118- // looks for index.html specifically because a partial /
119- // stale dist tree (e.g. just an assets/ subdirectory left
120- // from a prior aborted run) is not a usable preview target.
121- if ( ! File . Exists ( Path . Combine ( distDir , "index.html" ) ) )
118+ // Install node_modules if the cache is missing (first run on a
119+ // clean checkout or after `rm -rf node_modules`). Separated from
120+ // the build step so a repeated local run skips the network-bound
121+ // install while still always rebuilding the dist artefact.
122+ if ( ! Directory . Exists ( Path . Combine ( _docsRoot , "node_modules" ) ) )
122123 {
123124 stage = "npm ci" ;
124125 RunNpm ( "ci" , _docsRoot ) ;
125- stage = "npm run build" ;
126- RunNpm ( "run build" , _docsRoot ) ;
127126 }
128127
128+ // Always rebuild dist so the test asserts against a tree generated
129+ // from the current source, not a stale artefact from a prior run.
130+ // CI starts every run with a clean checkout (dist never exists),
131+ // so this only matters for repeated local invocations on a persistent
132+ // clone — the 5-min cost is amortised across the whole fixture's
133+ // OneTimeSetUp, not paid per test.
134+ stage = "npm run build" ;
135+ RunNpm ( "run build" , _docsRoot ) ;
136+
129137 // Install the chromium binary the Playwright .NET binding drives.
130138 // Idempotent: re-installs cleanly if the cache is already warm.
131139 stage = "playwright install chromium" ;
@@ -428,6 +436,29 @@ public async Task Landing_Hero_Image_Asset_Resolves()
428436 }
429437 }
430438
439+ /// <summary>
440+ /// Negative regression for the always-rebuild invariant per §10a:
441+ /// asserts that <c>dist/index.html</c> was written during this fixture
442+ /// invocation rather than reused from a prior run. A regression back
443+ /// to "rebuild only if missing" would leave the mtime unchanged on a
444+ /// persistent clone where dist already exists, causing the assertion
445+ /// to fail and making the stale-dist defect observable in CI.
446+ /// </summary>
447+ [ Test ]
448+ [ Category ( "E2E" ) ]
449+ public void OneTimeSetUp_Rebuilds_Dist_Even_When_Index_Exists ( )
450+ {
451+ // Pin the contract: regardless of pre-existing dist artefacts, the
452+ // fixture's OneTimeSetUp produces a fresh dist whose mtime is no
453+ // older than the test fixture invocation start time. A regression
454+ // back to "rebuild-if-missing" would let mtime fall behind.
455+ var distIndex = Path . Combine ( _distDir , "index.html" ) ;
456+ var distMtime = File . GetLastWriteTimeUtc ( distIndex ) ;
457+ Assert . That ( distMtime , Is . GreaterThanOrEqualTo ( _fixtureStartTime ) ,
458+ $ "dist/index.html mtime { distMtime : O} is older than fixture start " +
459+ $ "{ _fixtureStartTime : O} — OneTimeSetUp skipped the rebuild") ;
460+ }
461+
431462 /// <summary>
432463 /// Walks every markdown-backed route the docs/ tree implies and
433464 /// asserts none rendered a 404. Failures are collected across all
0 commit comments