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.shdocfx 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);