diff --git a/.github/workflows/dotnet.yml b/.github/workflows/dotnet.yml
index 8793ee637..ebbf9b3f0 100644
--- a/.github/workflows/dotnet.yml
+++ b/.github/workflows/dotnet.yml
@@ -328,10 +328,17 @@ jobs:
# ref.sh` → `docfx metadata`). The built dist tree is uploaded as a
# workflow artifact so the sharded route-check job below can skip
# the npm + docfx + build wall-clock (~5 min) entirely. The test
- # fixture's [OneTimeSetUp] checks for docs/.vitepress/dist/index.html
- # and skips the `npm ci && npm run build` bootstrap when the file is
- # present, so a shard that downloads the artifact into the right
- # path bypasses the bootstrap altogether.
+ # fixture's [OneTimeSetUp] contract has two modes:
+ # - Consumer mode (ROUTE_SHARD_TOTAL > 1, i.e. the sharded matrix
+ # leg below): checks for docs/.vitepress/dist/index.html and
+ # skips the vitepress build bootstrap when the sentinel is
+ # present. A shard that downloads this job's artifact into the
+ # right path bypasses the bootstrap altogether.
+ # - Producer mode (ROUTE_SHARD_TOTAL <= 1, i.e. local + unsharded
+ # CI): always rebuilds by invoking `vitepress build` directly
+ # (bypassing the `prebuild` docfx hook so the shard runners'
+ # missing docfx is a non-issue) so a warm-cache developer run
+ # still walks a dist tree generated from the CURRENT source.
# ------------------------------------------------------------------
docs-prepare:
name: docs-prepare
diff --git a/docs/development/docs-site.md b/docs/development/docs-site.md
index bc88dc565..61801b7af 100644
--- a/docs/development/docs-site.md
+++ b/docs/development/docs-site.md
@@ -61,7 +61,7 @@ The classic symptom of a base mismatch is a deployed page that renders as raw HT
## End-to-end route check
-`tests/MTConnect.NET-Docs-Tests/RouteCheckTests.cs` is a Playwright e2e fixture that builds the docs site, spawns `vitepress preview` against the built `dist/` tree, walks every route the markdown source tree implies in a headless Chromium browser, and asserts no client-side 404s. CI runs it on the `ubuntu-latest` matrix leg of `.github/workflows/dotnet.yml` (the `windows-latest` leg filters `Category=E2E` out — hosted Windows runners do not carry Linux-image Docker, and the test fixture's `npm ci && npm run build` bootstrap is the easier target to keep Linux-only).
+`tests/MTConnect.NET-Docs-Tests/RouteCheckTests.cs` is a Playwright e2e fixture that builds the docs site, spawns `vitepress preview` against the built `dist/` tree, walks every route the markdown source tree implies in a headless Chromium browser, and asserts no client-side 404s. CI runs it on the `ubuntu-latest` matrix leg of `.github/workflows/dotnet.yml` (the `windows-latest` leg filters `Category=E2E` out — hosted Windows runners do not carry Linux-image Docker, and the test fixture's `npm ci` + direct `vitepress build` bootstrap is the easier target to keep Linux-only).
Run locally from the repo root:
@@ -69,7 +69,7 @@ Run locally from the repo root:
dotnet test tests/MTConnect.NET-Docs-Tests --filter Category=E2E
```
-On the first run the fixture installs the chromium binary the Playwright .NET binding drives (~150 MB; cached on subsequent runs) and — if `docs/.vitepress/dist/` is missing — invokes `npm ci && npm run build` from `docs/` to produce a preview-able site. Subsequent runs reuse both, so a warm working tree completes in a couple of minutes; a cold checkout takes longer because the build artefact is rebuilt from scratch.
+On the first run the fixture installs the chromium binary the Playwright .NET binding drives (~150 MB; cached on subsequent runs) and, in producer mode (local + unsharded CI, i.e. `ROUTE_SHARD_TOTAL <= 1`), invokes `npm ci` when `docs/node_modules/` is missing and then always invokes `vitepress build` directly from `docs/` — bypassing the `package.json` `prebuild` hook (`docs/scripts/generate-api-ref.sh` → `docfx metadata`) that would otherwise clobber every touched project's `obj/project.assets.json` back to a Debug-only `net8.0` view and race any in-flight multi-TFM Release build. In consumer mode (sharded CI matrix with `ROUTE_SHARD_TOTAL > 1`), the shard downloads a `dist/` tree from the `docs-prepare` workflow artifact and honours the `docs/.vitepress/dist/index.html` sentinel, skipping the rebuild. Subsequent local runs reuse the cached `node_modules/` and Playwright chromium, so a warm producer-mode run completes in a couple of minutes; a cold checkout takes longer because the build artefact is rebuilt from scratch.
Failure output names every route that surfaced as a 404 along with which of the two signals fired—the `.NotFound` element rendered by the VitePress default theme's NotFound component, or `document.title` starting with `404` (the static `404.html` emits `
404 | MTConnect.NET`, so a prefix match catches it regardless of the trailing site-title suffix). Typical fixes:
diff --git a/tests/MTConnect.NET-Docs-Tests/RouteCheckTests.cs b/tests/MTConnect.NET-Docs-Tests/RouteCheckTests.cs
index daad0c643..3f95ad36e 100644
--- a/tests/MTConnect.NET-Docs-Tests/RouteCheckTests.cs
+++ b/tests/MTConnect.NET-Docs-Tests/RouteCheckTests.cs
@@ -31,8 +31,13 @@ namespace MTConnect.NET_Docs_Tests;
/// dotnet test tests/MTConnect.NET-Docs-Tests --filter Category=E2E
///
/// Prerequisites:
-/// - Node.js (the setup invokes `npm ci` + `npm run build` if the
-/// docs/.vitepress/dist/ artifact is missing).
+/// - Node.js. In producer mode (local + unsharded CI) the setup
+/// runs `npm ci` when `node_modules/` is absent and then always
+/// invokes `vitepress build` directly (bypassing the docfx
+/// `prebuild` hook that would clobber `obj/project.assets.json`).
+/// In consumer mode (`ROUTE_SHARD_TOTAL > 1`) the shard consumes
+/// a `dist/` produced by the `docs-prepare` CI job and skips the
+/// rebuild when `docs/.vitepress/dist/index.html` is present.
/// - The Microsoft.Playwright package's chromium browser binary
/// (installed automatically by the fixture's one-time setup).
///
@@ -54,13 +59,23 @@ public class RouteCheckTests
private const int ServerReadyPollMs = 200;
/// Hard deadline for the preview-server bind. 60 s
- /// accommodates a cold CI runner where `npm ci` + `npm run build`
+ /// accommodates a cold CI runner where `npm ci` + `vitepress build`
/// + vitepress startup land before the first port probe — anything
/// past that is a real failure (dist/ missing, port collision,
/// vitepress CLI usage error) worth surfacing as a TimeoutException
/// with the drained startup log.
private const int ServerReadyTimeoutMs = 60_000;
+ /// Hard deadline for the vitepress build spawned by
+ /// . 20 minutes bounds the worst
+ /// documented cold path (cold node_modules cache + full SSR pass
+ /// on a slow runner completes in ~5 min); anything past that
+ /// implies a hang (deadlocked worker, HMR loop, wedged fetch)
+ /// worth surfacing as an InvalidOperationException with the
+ /// drained output rather than a wall-clock CI timeout that
+ /// discards the diagnostic.
+ private const int VitepressBuildTimeoutMs = 20 * 60 * 1000;
+
/// Per-page navigation timeout. 30 s covers a slow runner
/// with a cold network cache; anything past that is a real failure
/// (vitepress hang, JS exception that prevents Load) worth failing
@@ -125,23 +140,55 @@ public async Task OneTimeSetUp()
RunNpm("ci", _docsRoot);
}
- // Rebuild dist only when it is missing so the test asserts against
- // a tree generated from the current source. CI's sharded
- // route-check jobs download the dist/ tree from the `docs-prepare`
- // workflow artefact and do NOT install docfx, so re-running
- // `npm run build` here would invoke the `prebuild` hook
- // (`scripts/generate-api-ref.sh` → `docfx metadata`) and fail
- // with "docfx not found on PATH". Honouring the pre-existing
- // dist/index.html sentinel matches the workflow's documented
- // contract: docs-prepare is the single docfx-owning producer
- // and each shard consumes its artefact. Locally, deleting
- // docs/.vitepress/dist/ (or running on a clean clone) still
- // triggers a full build.
+ // Producer vs. consumer mode.
+ //
+ // Producer mode (non-shard local + non-shard CI leg): the
+ // fixture is the sole authority on dist/, so it always
+ // rebuilds — a warm-cache local run must still walk a tree
+ // generated from the CURRENT source markdown, current
+ // config.ts, current sidebar, and so on. Honouring a
+ // pre-existing dist/index.html sentinel was the earlier
+ // policy and it silently walked a stale tree whenever a
+ // developer re-ran `dotnet test` after editing source: the
+ // walked routes, meta tags, and rendered HTML lagged the
+ // source by an arbitrary distance (a stale dist from Jun 2
+ // failed the landing-page og:image assertion on Aug 17 for
+ // exactly this reason, because the config-side fix that
+ // added the https:// og:image URL had landed since the last
+ // build).
+ //
+ // Consumer mode (sharded CI matrix, ROUTE_SHARD_TOTAL > 1):
+ // CI's sharded route-check jobs download the dist/ tree
+ // from the `docs-prepare` workflow artifact and skip the
+ // rebuild. The docs-prepare job is the single docfx-owning
+ // producer.
+ //
+ // Why call vitepress directly instead of `npm run build`:
+ // the `prebuild` hook wired into `package.json` runs
+ // `docs/scripts/generate-api-ref.sh`, which does a
+ // `dotnet build -c Debug --no-incremental` sweep of every
+ // library, agent, adapter and module project. Under a full
+ // `dotnet test MTConnect.NET.sln -c Release` invocation the
+ // solution build is still in flight (multi-TFM Release
+ // outputs for net47, net461, net472, net9.0, net10.0, …
+ // build in parallel with the net8.0 test hosts), so
+ // clobbering each project's `obj/project.assets.json` back
+ // to a Debug-only net8.0 view races the Release build and
+ // trips NETSDK1005 on every non-net8.0 target that MSBuild
+ // has not yet linked. Invoking vitepress directly walks the
+ // same source markdown, produces the same dist/, keeps the
+ // producer-mode rebuild guarantee, and leaves the obj/
+ // tree untouched. The api reference sub-tree under
+ // docs/api/ stays as whatever the last regen produced —
+ // this fixture does not own that regen (the docs-prepare
+ // workflow and `docs/scripts/generate-api-ref.sh` do).
var distIndex = Path.Combine(distDir, "index.html");
- if (!File.Exists(distIndex))
+ var (_, shardTotal) = RouteCheckHelpers.ReadShardEnv();
+ var isConsumerShard = shardTotal > 1;
+ if (!isConsumerShard || !File.Exists(distIndex))
{
- stage = "npm run build";
- RunNpm("run build", _docsRoot);
+ stage = "vitepress build";
+ RunVitepressBuild(_docsRoot);
}
// Install the chromium binary the Playwright .NET binding drives.
@@ -468,15 +515,15 @@ public async Task Landing_Hero_Image_Asset_Resolves()
///
///
/// Sharded CI runs (matrix env var ROUTE_SHARD_TOTAL > 1)
- /// download the dist artefact from the upstream docs-prepare
- /// job and intentionally bypass the in-fixture build — the shard
- /// runners do not install docfx, so re-running npm run build
- /// would fail on the prebuild hook
- /// (scripts/generate-api-ref.sh → docfx metadata). In
- /// that mode the upstream job is the producer and this fixture is a
- /// pure consumer, so the mtime invariant does not apply and the test
- /// is inconclusive. Local invocations and the unsharded leg still
- /// enforce it.
+ /// download the dist artifact from the upstream docs-prepare
+ /// job and intentionally bypass the in-fixture build — the
+ /// docs-prepare job is the single docfx-owning producer
+ /// and each shard is a pure consumer, so the mtime invariant does
+ /// not apply and the test is inconclusive. Local invocations and
+ /// the unsharded CI leg still enforce it (the producer path
+ /// invokes vitepress build directly, bypassing the
+ /// package.json prebuild hook so the shard runners'
+ /// missing docfx binary is a non-issue for the fixture itself).
///
[Test]
[Category("E2E")]
@@ -790,6 +837,113 @@ private static void StopPreviewServer(Process? proc)
// ─── npm bootstrap ───────────────────────────────────────────────────────
+ ///
+ /// Invoke the local vitepress binary directly against the docs root,
+ /// bypassing the package.json prebuild hook that
+ /// npm run build would trigger. The prebuild step runs
+ /// docs/scripts/generate-api-ref.sh, which does a
+ /// dotnet build -c Debug --no-incremental sweep across the
+ /// entire library, agent, adapter and module surface; that sweep
+ /// rewrites every touched project's obj/project.assets.json
+ /// to a Debug-only net8.0 view and races any in-flight
+ /// multi-TFM Release build (NETSDK1005 on net47,
+ /// net9.0, net10.0, …). This helper resolves
+ /// node_modules/vitepress/bin/vitepress.js relative to the
+ /// docs root, drains stdout+stderr concurrently to avoid the
+ /// classic pipe-deadlock pattern, bounds the child by
+ /// so a wedged worker
+ /// surfaces as an actionable exception rather than a job-level
+ /// timeout that discards the diagnostic, and rethrows with the
+ /// captured output when the child exits non-zero. The
+ /// handle is disposed on every path so a
+ /// warm test-runner does not leak file descriptors across reruns.
+ ///
+ ///
+ /// Absolute path to the docs site (docs/ under the repo
+ /// root); becomes the child process's working directory and the
+ /// anchor for the node_modules lookup.
+ ///
+ ///
+ /// Thrown when the vitepress binary cannot be located,
+ /// returns
+ /// , the child fails to exit within
+ /// milliseconds (the child
+ /// tree is killed before the exception is thrown), or the child
+ /// process exits with a non-zero code. The captured stdout and
+ /// stderr are appended to the exception message in every failure
+ /// mode for diagnosis.
+ ///
+ private static void RunVitepressBuild(string docsRoot)
+ {
+ var vitepressEntry = Path.Combine(docsRoot, "node_modules", "vitepress", "bin", "vitepress.js");
+ if (!File.Exists(vitepressEntry))
+ {
+ throw new InvalidOperationException(
+ $"Cannot invoke vitepress build directly — expected entry point at '{vitepressEntry}' does not exist. Run `npm ci` under {docsRoot} first (the OneTimeSetUp does this when node_modules is missing).");
+ }
+
+ var psi = new ProcessStartInfo
+ {
+ FileName = "node",
+ WorkingDirectory = docsRoot,
+ UseShellExecute = false,
+ RedirectStandardOutput = true,
+ RedirectStandardError = true,
+ CreateNoWindow = true,
+ };
+ // Mirror package.json's `build` script memory budget — vitepress
+ // build's Vue SSR pass can exceed V8's default 2 GB old-space
+ // when the source tree is thousands of pages.
+ psi.ArgumentList.Add("--max-old-space-size=8192");
+ psi.ArgumentList.Add(vitepressEntry);
+ psi.ArgumentList.Add("build");
+
+ // `using` on Process guarantees the OS handle + redirected
+ // pipes are released even when the drain/wait/exit-code path
+ // throws — a warm test-runner otherwise accumulates handles
+ // and can starve pipes across reruns.
+ using var proc = Process.Start(psi)
+ ?? throw new InvalidOperationException("Failed to start `node … vitepress build` process");
+
+ var stdoutTask = proc.StandardOutput.ReadToEndAsync();
+ var stderrTask = proc.StandardError.ReadToEndAsync();
+
+ // Bound the child wait so a wedged vitepress (deadlocked
+ // Vue-SSR worker, hung fetch, infinite HMR loop) surfaces as
+ // an actionable exception with the drained partial output
+ // rather than a wall-clock CI timeout that discards it.
+ if (!proc.WaitForExit(VitepressBuildTimeoutMs))
+ {
+ try
+ {
+ proc.Kill(entireProcessTree: true);
+ }
+ catch
+ {
+ // Best-effort — the child may already be exiting; a
+ // failure to signal is not itself the diagnostic.
+ }
+ // Give the drains one last chance to complete after the
+ // kill; ignore any fault so the timeout message is what
+ // the caller sees.
+ try { Task.WaitAll(new[] { stdoutTask, stderrTask }, millisecondsTimeout: 2_000); } catch { }
+ var partialStdout = stdoutTask.IsCompletedSuccessfully ? stdoutTask.Result : "";
+ var partialStderr = stderrTask.IsCompletedSuccessfully ? stderrTask.Result : "";
+ throw new InvalidOperationException(
+ $"`node … vitepress.js build` did not exit within {VitepressBuildTimeoutMs} ms — killed the child tree and captured what stdout/stderr had been drained.{Environment.NewLine}stdout:{Environment.NewLine}{partialStdout}{Environment.NewLine}stderr:{Environment.NewLine}{partialStderr}");
+ }
+
+ Task.WaitAll(stdoutTask, stderrTask);
+ var stdout = stdoutTask.Result;
+ var stderr = stderrTask.Result;
+
+ if (proc.ExitCode != 0)
+ {
+ throw new InvalidOperationException(
+ $"`node … vitepress.js build` exited {proc.ExitCode}{Environment.NewLine}stdout:{Environment.NewLine}{stdout}{Environment.NewLine}stderr:{Environment.NewLine}{stderr}");
+ }
+ }
+
private static void RunNpm(string arguments, string workingDirectory)
{
var isWindows = RuntimeInformation.IsOSPlatform(OSPlatform.Windows);