Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 11 additions & 4 deletions .github/workflows/dotnet.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
4 changes: 2 additions & 2 deletions docs/development/docs-site.md
Original file line number Diff line number Diff line change
Expand Up @@ -61,15 +61,15 @@ 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:

```
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 `<title>404 | MTConnect.NET</title>`, so a prefix match catches it regardless of the trailing site-title suffix). Typical fixes:

Expand Down
208 changes: 181 additions & 27 deletions tests/MTConnect.NET-Docs-Tests/RouteCheckTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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).
/// </summary>
Expand All @@ -54,13 +59,23 @@ public class RouteCheckTests
private const int ServerReadyPollMs = 200;

/// <summary>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.</summary>
private const int ServerReadyTimeoutMs = 60_000;

/// <summary>Hard deadline for the vitepress build spawned by
/// <see cref="RunVitepressBuild"/>. 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.</summary>
private const int VitepressBuildTimeoutMs = 20 * 60 * 1000;

/// <summary>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
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -468,15 +515,15 @@ public async Task Landing_Hero_Image_Asset_Resolves()
/// </summary>
/// <remarks>
/// Sharded CI runs (matrix env var <c>ROUTE_SHARD_TOTAL</c> &gt; 1)
/// download the dist artefact from the upstream <c>docs-prepare</c>
/// job and intentionally bypass the in-fixture build — the shard
/// runners do not install docfx, so re-running <c>npm run build</c>
/// would fail on the <c>prebuild</c> hook
/// (<c>scripts/generate-api-ref.sh</c> → <c>docfx metadata</c>). 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 <c>docs-prepare</c>
/// job and intentionally bypass the in-fixture build — the
/// <c>docs-prepare</c> 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 <c>vitepress build</c> directly, bypassing the
/// <c>package.json</c> <c>prebuild</c> hook so the shard runners'
/// missing docfx binary is a non-issue for the fixture itself).
/// </remarks>
[Test]
[Category("E2E")]
Expand Down Expand Up @@ -790,6 +837,113 @@ private static void StopPreviewServer(Process? proc)

// ─── npm bootstrap ───────────────────────────────────────────────────────

/// <summary>
/// Invoke the local vitepress binary directly against the docs root,
/// bypassing the <c>package.json</c> <c>prebuild</c> hook that
/// <c>npm run build</c> would trigger. The prebuild step runs
/// <c>docs/scripts/generate-api-ref.sh</c>, which does a
/// <c>dotnet build -c Debug --no-incremental</c> sweep across the
/// entire library, agent, adapter and module surface; that sweep
/// rewrites every touched project's <c>obj/project.assets.json</c>
/// to a Debug-only <c>net8.0</c> view and races any in-flight
/// multi-TFM Release build (NETSDK1005 on <c>net47</c>,
/// <c>net9.0</c>, <c>net10.0</c>, …). This helper resolves
/// <c>node_modules/vitepress/bin/vitepress.js</c> relative to the
/// docs root, drains stdout+stderr concurrently to avoid the
/// classic pipe-deadlock pattern, bounds the child by
/// <see cref="VitepressBuildTimeoutMs"/> 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
/// <see cref="Process"/> handle is disposed on every path so a
/// warm test-runner does not leak file descriptors across reruns.
/// </summary>
/// <param name="docsRoot">
/// Absolute path to the docs site (<c>docs/</c> under the repo
/// root); becomes the child process's working directory and the
/// anchor for the <c>node_modules</c> lookup.
/// </param>
/// <exception cref="InvalidOperationException">
/// Thrown when the vitepress binary cannot be located,
/// <see cref="Process.Start(ProcessStartInfo)"/> returns
/// <see langword="null"/>, the child fails to exit within
/// <see cref="VitepressBuildTimeoutMs"/> 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.
/// </exception>
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 : "<drain incomplete>";
var partialStderr = stderrTask.IsCompletedSuccessfully ? stderrTask.Result : "<drain incomplete>";
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);
Expand Down