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/build/MTConnect.NET-DocsGen/EnvVarInventory.cs b/build/MTConnect.NET-DocsGen/EnvVarInventory.cs index 9feda8adc..cd32b93d6 100644 --- a/build/MTConnect.NET-DocsGen/EnvVarInventory.cs +++ b/build/MTConnect.NET-DocsGen/EnvVarInventory.cs @@ -45,6 +45,15 @@ public static class EnvVarInventory private static readonly string[] ExcludedDirectoryNames = { "bin", "obj", "node_modules", ".git", ".vitepress", + // ".claude" holds sibling git-worktree checkouts (see repo + // convention: .claude/worktrees//) used by agent sessions + // for isolated branch work. Left unexcluded, a repo-root-relative + // walk descends into every nested worktree and mixes in + // env-var reads from whatever branch each one happens to have + // checked out — producing a false "out of sync" verdict against + // docs/reference/environment-variables.md that has nothing to + // do with the actual source tree on the current branch. + ".claude", }; // Match `MTCONNECT_FOO`, `DOTNET_BAR`, plus any other ALL-CAPS env-var diff --git a/docs/.docfx/docfx.json b/docs/.docfx/docfx.json index c196606e9..362abff3f 100644 --- a/docs/.docfx/docfx.json +++ b/docs/.docfx/docfx.json @@ -4,50 +4,50 @@ "src": [ { "files": [ - "libraries/MTConnect.NET-Common/bin/Debug/net8.0/MTConnect.NET-Common.dll", - "libraries/MTConnect.NET-DeviceFinder/bin/Debug/net8.0/MTConnect.NET-DeviceFinder.dll", - "libraries/MTConnect.NET-HTTP/bin/Debug/net8.0/MTConnect.NET-HTTP.dll", - "libraries/MTConnect.NET-JSON/bin/Debug/net8.0/MTConnect.NET-JSON.dll", - "libraries/MTConnect.NET-JSON-cppagent/bin/Debug/net8.0/MTConnect.NET-JSON-cppagent.dll", - "libraries/MTConnect.NET-MQTT/bin/Debug/net8.0/MTConnect.NET-MQTT.dll", - "libraries/MTConnect.NET-Protobuf/bin/Debug/net8.0/MTConnect.NET-Protobuf.dll", - "libraries/MTConnect.NET-Services/bin/Debug/net8.0/MTConnect.NET-Services.dll", - "libraries/MTConnect.NET-SHDR/bin/Debug/net8.0/MTConnect.NET-SHDR.dll", - "libraries/MTConnect.NET-SysML/bin/Debug/net8.0/MTConnect.NET-SysML.dll", - "libraries/MTConnect.NET-TLS/bin/Debug/net8.0/MTConnect.NET-TLS.dll", - "libraries/MTConnect.NET-XML/bin/Debug/net8.0/MTConnect.NET-XML.dll", - "libraries/MTConnect.NET/bin/Debug/net8.0/MTConnect.NET.dll", - "agent/MTConnect.NET-Agent/bin/Debug/net8.0/MTConnect.NET-Agent.dll", - "agent/MTConnect.NET-Applications-Agents/bin/Debug/net8.0/MTConnect.NET-Applications-Agents.dll", - "agent/Modules/MTConnect.NET-AgentModule-HttpServer/bin/Debug/net8.0/MTConnect.NET-AgentModule-HttpServer.dll", - "agent/Modules/MTConnect.NET-AgentModule-HttpAdapter/bin/Debug/net8.0/MTConnect.NET-AgentModule-HttpAdapter.dll", - "agent/Modules/MTConnect.NET-AgentModule-MqttAdapter/bin/Debug/net8.0/MTConnect.NET-AgentModule-MqttAdapter.dll", - "agent/Modules/MTConnect.NET-AgentModule-MqttBroker/bin/Debug/net8.0/MTConnect.NET-AgentModule-MqttBroker.dll", - "agent/Modules/MTConnect.NET-AgentModule-MqttRelay/bin/Debug/net8.0/MTConnect.NET-AgentModule-MqttRelay.dll", - "agent/Modules/MTConnect.NET-AgentModule-ShdrAdapter/bin/Debug/net8.0/MTConnect.NET-AgentModule-ShdrAdapter.dll", - "agent/Processors/MTConnect.NET-AgentProcessor-Python/bin/Debug/net8.0/MTConnect.NET-AgentProcessor-Python.dll", - "adapter/MTConnect.NET-Adapter/bin/Debug/net8.0/MTConnect.NET-Adapter.dll", - "adapter/MTConnect.NET-Applications-Adapter/bin/Debug/net8.0/MTConnect.NET-Applications-Adapter.dll", - "adapter/Modules/MTConnect.NET-AdapterModule-MQTT/bin/Debug/net8.0/MTConnect.NET-AdapterModule-MQTT.dll", - "adapter/Modules/MTConnect.NET-AdapterModule-SHDR/bin/Debug/net8.0/MTConnect.NET-AdapterModule-SHDR.dll", - "build/MTConnect.NET-SysML-Import/bin/Debug/net8.0/MTConnect.NET-SysML-Import.dll", - "build/MTConnect.NET.Builder/bin/Debug/net8.0/MTConnect.NET.Builder.dll", - "examples/MTConnect.NET-Agent-Embedded/bin/Debug/net8.0/MTConnect.NET-Agent-Embedded.dll", - "examples/MTConnect.NET-Client-HTTP/bin/Debug/net8.0/MTConnect.NET-Client-HTTP.dll", - "examples/MTConnect.NET-Client-MQTT/bin/Debug/net8.0/MTConnect.NET-Client-MQTT.dll", - "examples/MTConnect.NET-Client-SHDR/bin/Debug/net8.0/MTConnect.NET-Client-SHDR.dll", - "templates/mtconnect.net-agent/content/MTConnect.NET-Embedded-Agent/bin/Debug/net8.0/agent.dll", - "tests/Compliance/MTConnect-Compliance-Tests/bin/Debug/net8.0/MTConnect-Compliance-Tests.dll", - "tests/MTConnect.NET-AgentModule-MqttRelay-Tests/bin/Debug/net8.0/MTConnect.NET-AgentModule-MqttRelay-Tests.dll", - "tests/MTConnect.NET-Common-Tests/bin/Debug/net8.0/MTConnect.NET-Common-Tests.dll", - "tests/MTConnect.NET-Docs-Tests/bin/Debug/net8.0/MTConnect.NET-Docs-Tests.dll", - "tests/MTConnect.NET-HTTP-Tests/bin/Debug/net8.0/MTConnect.NET-HTTP-Tests.dll", - "tests/MTConnect.NET-Integration-Tests/bin/Debug/net8.0/MTConnect.NET-Integration-Tests.dll", - "tests/MTConnect.NET-JSON-cppagent-Tests/bin/Debug/net8.0/MTConnect.NET-JSON-cppagent-Tests.dll", - "tests/MTConnect.NET-JSON-Tests/bin/Debug/net8.0/MTConnect.NET-JSON-Tests.dll", - "tests/MTConnect.NET-SHDR-Tests/bin/Debug/net8.0/MTConnect.NET-SHDR-Tests.dll", - "tests/MTConnect.NET-Tests-Agents/bin/Debug/net8.0/MTConnect.NET-Tests-Agents.dll", - "tests/MTConnect.NET-XML-Tests/bin/Debug/net8.0/MTConnect.NET-XML-Tests.dll" + "libraries/MTConnect.NET-Common/MTConnect.NET-Common.csproj", + "libraries/MTConnect.NET-DeviceFinder/MTConnect.NET-DeviceFinder.csproj", + "libraries/MTConnect.NET-HTTP/MTConnect.NET-HTTP.csproj", + "libraries/MTConnect.NET-JSON/MTConnect.NET-JSON.csproj", + "libraries/MTConnect.NET-JSON-cppagent/MTConnect.NET-JSON-cppagent.csproj", + "libraries/MTConnect.NET-MQTT/MTConnect.NET-MQTT.csproj", + "libraries/MTConnect.NET-Protobuf/MTConnect.NET-Protobuf.csproj", + "libraries/MTConnect.NET-Services/MTConnect.NET-Services.csproj", + "libraries/MTConnect.NET-SHDR/MTConnect.NET-SHDR.csproj", + "libraries/MTConnect.NET-SysML/MTConnect.NET-SysML.csproj", + "libraries/MTConnect.NET-TLS/MTConnect.NET-TLS.csproj", + "libraries/MTConnect.NET-XML/MTConnect.NET-XML.csproj", + "libraries/MTConnect.NET/MTConnect.NET.csproj", + "agent/MTConnect.NET-Agent/MTConnect.NET-Agent.csproj", + "agent/MTConnect.NET-Applications-Agents/MTConnect.NET-Applications-Agents.csproj", + "agent/Modules/MTConnect.NET-AgentModule-HttpServer/MTConnect.NET-AgentModule-HttpServer.csproj", + "agent/Modules/MTConnect.NET-AgentModule-HttpAdapter/MTConnect.NET-AgentModule-HttpAdapter.csproj", + "agent/Modules/MTConnect.NET-AgentModule-MqttAdapter/MTConnect.NET-AgentModule-MqttAdapter.csproj", + "agent/Modules/MTConnect.NET-AgentModule-MqttBroker/MTConnect.NET-AgentModule-MqttBroker.csproj", + "agent/Modules/MTConnect.NET-AgentModule-MqttRelay/MTConnect.NET-AgentModule-MqttRelay.csproj", + "agent/Modules/MTConnect.NET-AgentModule-ShdrAdapter/MTConnect.NET-AgentModule-ShdrAdapter.csproj", + "agent/Processors/MTConnect.NET-AgentProcessor-Python/MTConnect.NET-AgentProcessor-Python.csproj", + "adapter/MTConnect.NET-Adapter/MTConnect.NET-Adapter.csproj", + "adapter/MTConnect.NET-Applications-Adapter/MTConnect.NET-Applications-Adapter.csproj", + "adapter/Modules/MTConnect.NET-AdapterModule-MQTT/MTConnect.NET-AdapterModule-MQTT.csproj", + "adapter/Modules/MTConnect.NET-AdapterModule-SHDR/MTConnect.NET-AdapterModule-SHDR.csproj", + "build/MTConnect.NET-SysML-Import/MTConnect.NET-SysML-Import.csproj", + "build/MTConnect.NET.Builder/MTConnect.NET.Builder.csproj", + "examples/MTConnect.NET-Agent-Embedded/MTConnect.NET-Agent-Embedded.csproj", + "examples/MTConnect.NET-Client-HTTP/MTConnect.NET-Client-HTTP.csproj", + "examples/MTConnect.NET-Client-MQTT/MTConnect.NET-Client-MQTT.csproj", + "examples/MTConnect.NET-Client-SHDR/MTConnect.NET-Client-SHDR.csproj", + "templates/mtconnect.net-agent/content/MTConnect.NET-Embedded-Agent/Agent.csproj", + "tests/Compliance/MTConnect-Compliance-Tests/MTConnect-Compliance-Tests.csproj", + "tests/MTConnect.NET-AgentModule-MqttRelay-Tests/MTConnect.NET-AgentModule-MqttRelay-Tests.csproj", + "tests/MTConnect.NET-Common-Tests/MTConnect.NET-Common-Tests.csproj", + "tests/MTConnect.NET-Docs-Tests/MTConnect.NET-Docs-Tests.csproj", + "tests/MTConnect.NET-HTTP-Tests/MTConnect.NET-HTTP-Tests.csproj", + "tests/MTConnect.NET-Integration-Tests/MTConnect.NET-Integration-Tests.csproj", + "tests/MTConnect.NET-JSON-cppagent-Tests/MTConnect.NET-JSON-cppagent-Tests.csproj", + "tests/MTConnect.NET-JSON-Tests/MTConnect.NET-JSON-Tests.csproj", + "tests/MTConnect.NET-SHDR-Tests/MTConnect.NET-SHDR-Tests.csproj", + "tests/MTConnect.NET-Tests-Agents/MTConnect.NET-Tests-Agents.csproj", + "tests/MTConnect.NET-XML-Tests/MTConnect.NET-XML-Tests.csproj" ], "src": "../.." } @@ -57,7 +57,11 @@ "namespaceLayout": "flattened", "memberLayout": "samePage", "includePrivateMembers": true, - "filter": "filter.yml" + "filter": "filter.yml", + "properties": { + "TargetFramework": "net8.0", + "Configuration": "Debug" + } } ] } diff --git a/docs/development/docs-site.md b/docs/development/docs-site.md index bc88dc565..5ab3f3d47 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 honors 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/DocsReferenceGenerationTests.cs b/tests/MTConnect.NET-Docs-Tests/DocsReferenceGenerationTests.cs index 922a42391..ccb9273d9 100644 --- a/tests/MTConnect.NET-Docs-Tests/DocsReferenceGenerationTests.cs +++ b/tests/MTConnect.NET-Docs-Tests/DocsReferenceGenerationTests.cs @@ -64,7 +64,10 @@ public void HttpApi_Page_Is_In_Sync_With_Source() } } - /// Pins the behaviour expressed by the test name: environment variables page is in sync with source. + /// Pins the behavior expressed by the test name: environment variables page is in sync with source. + /// Historically flaky as collateral damage from a prebuild-hook obj/ race triggered elsewhere in the + /// same dotnet test invocation — see 's remarks on why + /// producer-mode OneTimeSetUp no longer shells out through `npm run build`'s `prebuild` hook. [Test] public void EnvironmentVariables_Page_Is_In_Sync_With_Source() { diff --git a/tests/MTConnect.NET-Docs-Tests/RouteCheckTests.cs b/tests/MTConnect.NET-Docs-Tests/RouteCheckTests.cs index daad0c643..b0cd01b7c 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). /// @@ -53,14 +58,35 @@ public class RouteCheckTests /// cost trivial while still ringing the door bell ~5x per second. 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` - /// + 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. + /// Hard deadline for the preview-server bind. `npm ci` and + /// `vitepress build` are earlier, sequential OneTimeSetUp + /// stages that already ran to completion before this countdown + /// starts (see ), so 60 s covers only + /// vitepress preview's own startup — 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; + + /// Hard deadline for `npm ci`, spawned by + /// when docs/node_modules is absent. 10 + /// minutes bounds a cold registry fetch on a slow runner; anything + /// past that implies a hang (registry outage, interactive prompt, + /// corrupt lockfile) worth surfacing as an InvalidOperationException + /// with the drained output rather than hanging OneTimeSetUp + /// indefinitely with no diagnostic. + private const int NpmTimeoutMs = 10 * 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 +151,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. Honoring 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 +526,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 +848,70 @@ 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"); + + RunProcess(psi, VitepressBuildTimeoutMs, "`node … vitepress.js build`"); + } + private static void RunNpm(string arguments, string workingDirectory) { var isWindows = RuntimeInformation.IsOSPlatform(OSPlatform.Windows); @@ -809,25 +931,95 @@ private static void RunNpm(string arguments, string workingDirectory) psi.ArgumentList.Add(token); } - var proc = Process.Start(psi) - ?? throw new InvalidOperationException($"Failed to start `npm {arguments}`"); + RunProcess(psi, NpmTimeoutMs, $"`npm {arguments}`"); + } + + /// + /// Spawn , drain stdout/stderr concurrently to + /// avoid the classic pipe-deadlock pattern, bound the wait by + /// so a wedged child surfaces as an + /// actionable exception rather than a wall-clock CI timeout that + /// discards the diagnostic, and rethrow with the captured output on + /// either a timeout (after killing the child tree) or a non-zero + /// exit code. Shared by and + /// so both child-process launches get the same + /// dispose/timeout/kill hardening rather than diverging over time. + /// + /// Fully configured start info; stdout/stderr must + /// already be redirected. + /// Hard wait deadline in milliseconds. + /// Human-readable command label used in + /// exception messages (e.g. `npm ci`). + /// + /// Thrown when 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 RunProcess(ProcessStartInfo psi, int timeoutMs, string label) + { + // `using` 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 {label}"); - // Drain both pipes concurrently so the child doesn't block on a - // full stderr buffer while the parent waits on stdout — the - // classic pipe-deadlock pattern. `npm ci` emits stderr volume in - // the form of deprecation warnings and peer-dep notices that on a - // verbose run can exceed the OS pipe buffer (typically 64 KB). var stdoutTask = proc.StandardOutput.ReadToEndAsync(); var stderrTask = proc.StandardError.ReadToEndAsync(); + + // Bound the child wait so a wedged process (deadlocked worker, + // hung fetch, interactive prompt, infinite loop) surfaces as an + // actionable exception with the drained partial output rather + // than a wall-clock CI timeout that discards it. + if (!proc.WaitForExit(timeoutMs)) + { + 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 ? SanitizeForException(stdoutTask.Result) : ""; + var partialStderr = stderrTask.IsCompletedSuccessfully ? SanitizeForException(stderrTask.Result) : ""; + throw new InvalidOperationException( + $"{label} did not exit within {timeoutMs} 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; - proc.WaitForExit(); + var stdout = SanitizeForException(stdoutTask.Result); + var stderr = SanitizeForException(stderrTask.Result); if (proc.ExitCode != 0) { throw new InvalidOperationException( - $"`npm {arguments}` exited {proc.ExitCode}{Environment.NewLine}stdout:{Environment.NewLine}{stdout}{Environment.NewLine}stderr:{Environment.NewLine}{stderr}"); + $"{label} exited {proc.ExitCode}{Environment.NewLine}stdout:{Environment.NewLine}{stdout}{Environment.NewLine}stderr:{Environment.NewLine}{stderr}"); } } + + /// + /// Strip ASCII control characters (other than newline/carriage + /// return/tab) from captured child-process output before it is + /// interpolated into an exception message. A compromised or + /// misbehaving npm dependency could otherwise emit control + /// sequences that spoof adjacent lines in aggregated CI log + /// viewers; this keeps the diagnostic text inert. + /// + /// Raw captured stdout or stderr. + /// with disallowed control + /// characters removed. + private static string SanitizeForException(string text) + { + return new string(text.Where(c => !char.IsControl(c) || c is '\n' or '\r' or '\t').ToArray()); + } }