From 32357342e27910c9453ad40a9fd610f3c1e13ca5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Otto=20Boly=C3=B3s?= Date: Mon, 1 Jun 2026 07:30:30 +0200 Subject: [PATCH 01/27] chore(tools): add Playwright docs route-check script Adds docs/scripts/check-routes.mjs, which spawns `vitepress preview` against the pre-built dist/ tree, walks every route derived from the markdown source tree in a headless Chromium browser (via Playwright), detects VitePress's .NotFound 404 marker per page, and exits non-zero on any 404. Concurrency-capped at 8 via p-limit; progress line every 25 routes keeps CI logs live during the walk. Updates docs/package.json with a `check-routes` npm script and three new devDependencies: playwright, p-limit, tree-kill. --- docs/package-lock.json | 174 +++++++++++++++++++++++- docs/package.json | 6 +- docs/scripts/check-routes.mjs | 240 ++++++++++++++++++++++++++++++++++ 3 files changed, 416 insertions(+), 4 deletions(-) create mode 100644 docs/scripts/check-routes.mjs diff --git a/docs/package-lock.json b/docs/package-lock.json index 7a16bb208..5cbcd2d94 100644 --- a/docs/package-lock.json +++ b/docs/package-lock.json @@ -9,11 +9,14 @@ "version": "0.1.0", "devDependencies": { "mermaid": "^11.0.0", + "p-limit": "^7.3.0", + "playwright": "^1.60.0", "rehype-slug": "^6.0.0", "rehype-stringify": "^10.0.0", "remark-gfm": "^4.0.0", "remark-parse": "^11.0.0", "remark-rehype": "^11.0.0", + "tree-kill": "^1.2.2", "unified": "^11.0.0", "unist-util-visit": "^5.0.0", "vitepress": "^1.5.0", @@ -418,6 +421,7 @@ "ppc64" ], "dev": true, + "license": "MIT", "optional": true, "os": [ "aix" @@ -434,6 +438,7 @@ "arm" ], "dev": true, + "license": "MIT", "optional": true, "os": [ "android" @@ -450,6 +455,7 @@ "arm64" ], "dev": true, + "license": "MIT", "optional": true, "os": [ "android" @@ -466,6 +472,7 @@ "x64" ], "dev": true, + "license": "MIT", "optional": true, "os": [ "android" @@ -482,6 +489,7 @@ "arm64" ], "dev": true, + "license": "MIT", "optional": true, "os": [ "darwin" @@ -498,6 +506,7 @@ "x64" ], "dev": true, + "license": "MIT", "optional": true, "os": [ "darwin" @@ -514,6 +523,7 @@ "arm64" ], "dev": true, + "license": "MIT", "optional": true, "os": [ "freebsd" @@ -530,6 +540,7 @@ "x64" ], "dev": true, + "license": "MIT", "optional": true, "os": [ "freebsd" @@ -546,6 +557,7 @@ "arm" ], "dev": true, + "license": "MIT", "optional": true, "os": [ "linux" @@ -562,6 +574,7 @@ "arm64" ], "dev": true, + "license": "MIT", "optional": true, "os": [ "linux" @@ -578,6 +591,7 @@ "ia32" ], "dev": true, + "license": "MIT", "optional": true, "os": [ "linux" @@ -594,6 +608,7 @@ "loong64" ], "dev": true, + "license": "MIT", "optional": true, "os": [ "linux" @@ -610,6 +625,7 @@ "mips64el" ], "dev": true, + "license": "MIT", "optional": true, "os": [ "linux" @@ -626,6 +642,7 @@ "ppc64" ], "dev": true, + "license": "MIT", "optional": true, "os": [ "linux" @@ -642,6 +659,7 @@ "riscv64" ], "dev": true, + "license": "MIT", "optional": true, "os": [ "linux" @@ -658,6 +676,7 @@ "s390x" ], "dev": true, + "license": "MIT", "optional": true, "os": [ "linux" @@ -690,6 +709,7 @@ "x64" ], "dev": true, + "license": "MIT", "optional": true, "os": [ "netbsd" @@ -706,6 +726,7 @@ "x64" ], "dev": true, + "license": "MIT", "optional": true, "os": [ "openbsd" @@ -722,6 +743,7 @@ "x64" ], "dev": true, + "license": "MIT", "optional": true, "os": [ "sunos" @@ -738,6 +760,7 @@ "arm64" ], "dev": true, + "license": "MIT", "optional": true, "os": [ "win32" @@ -754,6 +777,7 @@ "ia32" ], "dev": true, + "license": "MIT", "optional": true, "os": [ "win32" @@ -770,6 +794,7 @@ "x64" ], "dev": true, + "license": "MIT", "optional": true, "os": [ "win32" @@ -850,6 +875,7 @@ "arm" ], "dev": true, + "license": "MIT", "optional": true, "os": [ "android" @@ -863,6 +889,7 @@ "arm64" ], "dev": true, + "license": "MIT", "optional": true, "os": [ "android" @@ -876,6 +903,7 @@ "arm64" ], "dev": true, + "license": "MIT", "optional": true, "os": [ "darwin" @@ -889,6 +917,7 @@ "x64" ], "dev": true, + "license": "MIT", "optional": true, "os": [ "darwin" @@ -902,6 +931,7 @@ "arm64" ], "dev": true, + "license": "MIT", "optional": true, "os": [ "freebsd" @@ -915,6 +945,7 @@ "x64" ], "dev": true, + "license": "MIT", "optional": true, "os": [ "freebsd" @@ -928,6 +959,10 @@ "arm" ], "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", "optional": true, "os": [ "linux" @@ -941,6 +976,10 @@ "arm" ], "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", "optional": true, "os": [ "linux" @@ -954,6 +993,10 @@ "arm64" ], "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", "optional": true, "os": [ "linux" @@ -967,6 +1010,10 @@ "arm64" ], "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", "optional": true, "os": [ "linux" @@ -980,6 +1027,10 @@ "loong64" ], "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", "optional": true, "os": [ "linux" @@ -993,6 +1044,10 @@ "loong64" ], "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", "optional": true, "os": [ "linux" @@ -1006,6 +1061,10 @@ "ppc64" ], "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", "optional": true, "os": [ "linux" @@ -1019,6 +1078,10 @@ "ppc64" ], "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", "optional": true, "os": [ "linux" @@ -1032,6 +1095,10 @@ "riscv64" ], "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", "optional": true, "os": [ "linux" @@ -1045,6 +1112,10 @@ "riscv64" ], "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", "optional": true, "os": [ "linux" @@ -1058,6 +1129,10 @@ "s390x" ], "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", "optional": true, "os": [ "linux" @@ -1097,6 +1172,7 @@ "x64" ], "dev": true, + "license": "MIT", "optional": true, "os": [ "openbsd" @@ -1110,6 +1186,7 @@ "arm64" ], "dev": true, + "license": "MIT", "optional": true, "os": [ "openharmony" @@ -1123,6 +1200,7 @@ "arm64" ], "dev": true, + "license": "MIT", "optional": true, "os": [ "win32" @@ -1136,6 +1214,7 @@ "ia32" ], "dev": true, + "license": "MIT", "optional": true, "os": [ "win32" @@ -1149,6 +1228,7 @@ "x64" ], "dev": true, + "license": "MIT", "optional": true, "os": [ "win32" @@ -1162,6 +1242,7 @@ "x64" ], "dev": true, + "license": "MIT", "optional": true, "os": [ "win32" @@ -2701,11 +2782,12 @@ } }, "node_modules/fsevents": { - "version": "2.3.3", - "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", - "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz", + "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==", "dev": true, "hasInstallScript": true, + "license": "MIT", "optional": true, "os": [ "darwin" @@ -3866,6 +3948,22 @@ "regex-recursion": "^6.0.2" } }, + "node_modules/p-limit": { + "version": "7.3.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-7.3.0.tgz", + "integrity": "sha512-7cIXg/Z0M5WZRblrsOla88S4wAK+zOQQWeBYfV3qJuJXMr+LnbYjaadrFaS0JILfEDPVqHyKnZ1Z/1d6J9VVUw==", + "dev": true, + "license": "MIT", + "dependencies": { + "yocto-queue": "^1.2.1" + }, + "engines": { + "node": ">=20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/package-manager-detector": { "version": "1.6.0", "resolved": "https://registry.npmjs.org/package-manager-detector/-/package-manager-detector-1.6.0.tgz", @@ -3907,6 +4005,38 @@ "pathe": "^2.0.1" } }, + "node_modules/playwright": { + "version": "1.60.0", + "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.60.0.tgz", + "integrity": "sha512-hheHdokM8cdqCb0lcE3s+zT4t4W+vvjpGxsZlDnikarzx8tSzMebh3UiFtgqwFwnTnjYQcsyMF8ei2mCO/tpeA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright-core": "1.60.0" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "fsevents": "2.3.2" + } + }, + "node_modules/playwright-core": { + "version": "1.60.0", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.60.0.tgz", + "integrity": "sha512-9bW6zvX/m0lEbgTKJ6YppOKx8H3VOPBMOCFh2irXFOT4BbHgrx5hPjwJYLT40Lu+4qtD36qKc/Hn56StUW57IA==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "playwright-core": "cli.js" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/points-on-curve": { "version": "0.2.0", "resolved": "https://registry.npmjs.org/points-on-curve/-/points-on-curve-0.2.0.tgz", @@ -4277,6 +4407,16 @@ "node": ">=18" } }, + "node_modules/tree-kill": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/tree-kill/-/tree-kill-1.2.2.tgz", + "integrity": "sha512-L0Orpi8qGpRG//Nd+H90vFB+3iHnue1zSSGmNOOCh1GLJ7rUKVwV2HvijphGQS2UmhUZewS9VgvxYIdgr+fG1A==", + "dev": true, + "license": "MIT", + "bin": { + "tree-kill": "cli.js" + } + }, "node_modules/trim-lines": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/trim-lines/-/trim-lines-3.0.1.tgz", @@ -4501,6 +4641,21 @@ } } }, + "node_modules/vite/node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, "node_modules/vitepress": { "version": "1.6.4", "resolved": "https://registry.npmjs.org/vitepress/-/vitepress-1.6.4.tgz", @@ -4625,6 +4780,19 @@ } } }, + "node_modules/yocto-queue": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-1.2.2.tgz", + "integrity": "sha512-4LCcse/U2MHZ63HAJVE+v71o7yOdIe4cZ70Wpf8D/IyjDKYQLV5GD46B+hSTjJsvV5PztjvHoU580EftxjDZFQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/zwitch": { "version": "2.0.4", "resolved": "https://registry.npmjs.org/zwitch/-/zwitch-2.0.4.tgz", diff --git a/docs/package.json b/docs/package.json index 041af2cd8..a774b0457 100644 --- a/docs/package.json +++ b/docs/package.json @@ -8,15 +8,19 @@ "dev": "vitepress dev", "build": "vitepress build", "preview": "vitepress preview", - "check-links": "node scripts/check-broken-links.mjs ." + "check-links": "node scripts/check-broken-links.mjs .", + "check-routes": "node scripts/check-routes.mjs" }, "devDependencies": { "mermaid": "^11.0.0", + "p-limit": "^7.3.0", + "playwright": "^1.60.0", "rehype-slug": "^6.0.0", "rehype-stringify": "^10.0.0", "remark-gfm": "^4.0.0", "remark-parse": "^11.0.0", "remark-rehype": "^11.0.0", + "tree-kill": "^1.2.2", "unified": "^11.0.0", "unist-util-visit": "^5.0.0", "vitepress": "^1.5.0", diff --git a/docs/scripts/check-routes.mjs b/docs/scripts/check-routes.mjs new file mode 100644 index 000000000..c18395462 --- /dev/null +++ b/docs/scripts/check-routes.mjs @@ -0,0 +1,240 @@ +#!/usr/bin/env node + +// Walk every Markdown file under (default: '.'), derive the +// VitePress route for each, then navigate to it in a headless Chromium +// browser and fail on any client-side 404. +// +// Complements check-broken-links.mjs (which validates internal link targets +// exist on disk). This script validates that VitePress actually registers and +// renders each route at runtime—a gap the filesystem check cannot catch. +// +// The script spawns `vitepress preview` against docs/.vitepress/dist/ (the +// already-built site artifact), waits for the server to be ready, then +// crawls every route with Playwright's chromium browser. VitePress's +// client-side 404 page is detected via the `.NotFound` CSS class, a +// document.title of '404', or body text containing 'PAGE NOT FOUND'. +// +// External (http/https) URLs are not validated by design: third-party state +// is not the CI gate. +// +// Usage (run from the docs/ directory): +// node scripts/check-routes.mjs +// +// Environment: +// DOCS_BASE_PATH URL path prefix (default: ''). Set to '/MTConnect.NET' +// when checking the GitHub Pages deploy base. + +import { readdir } from 'node:fs/promises'; +import { createServer as createTcpServer, Socket } from 'node:net'; +import { resolve, sep } from 'node:path'; +import { spawn } from 'node:child_process'; +import { chromium } from 'playwright'; +import pLimit from 'p-limit'; +import treeKill from 'tree-kill'; + +// ─── Configuration ─────────────────────────────────────────────────────────── + +const DOCS_ROOT = resolve('.'); +const DIST_DIR = resolve(DOCS_ROOT, '.vitepress/dist'); +const CONCURRENCY = 8; +const PROGRESS_EVERY = 25; +const SERVER_READY_POLL_MS = 200; +const SERVER_READY_TIMEOUT_MS = 30_000; + +const BASE_PATH = (process.env.DOCS_BASE_PATH ?? '').replace(/\/$/, ''); + +// ─── Route derivation ──────────────────────────────────────────────────────── + +// Directories skipped during the Markdown walk. Skip dot-prefixed dirs +// wholesale (.vitepress includes the build cache and the dist tree). +const isSkippedDir = (name) => name === 'node_modules' || name.startsWith('.'); + +const collectMarkdownFiles = async (dir) => { + const entries = await readdir(dir, { withFileTypes: true }); + const results = []; + for (const entry of entries) { + const fullPath = resolve(dir, entry.name); + if (entry.isSymbolicLink()) continue; + if (entry.isDirectory()) { + if (isSkippedDir(entry.name)) continue; + results.push(...(await collectMarkdownFiles(fullPath))); + } else if (entry.isFile() && entry.name.endsWith('.md')) { + results.push(fullPath); + } + } + return results; +}; + +// Convert an absolute .md file path to the VitePress route it maps to. +// +// docs/index.md -> / +// docs/getting-started.md -> /getting-started +// docs/reference/index.md -> /reference/ +// docs/reference/cli.md -> /reference/cli +// +// VitePress cleanUrls strips the .html extension, so both /foo and /foo/ +// work for regular pages; directory-level index pages reliably use the +// trailing-slash form which avoids the slug-vs-cleanUrls ambiguity that +// motivated this checker in the first place. +const mdFileToRoute = (absPath) => { + const rel = absPath.slice(DOCS_ROOT.length).replaceAll(sep, '/'); + if (rel === '/index.md') return BASE_PATH + '/'; + if (rel.endsWith('/index.md')) return BASE_PATH + rel.slice(0, -'index.md'.length); + return BASE_PATH + rel.slice(0, -'.md'.length); +}; + +// ─── Free-port finder ──────────────────────────────────────────────────────── + +const findFreePort = () => + new Promise((resolve, reject) => { + const srv = createTcpServer(); + srv.listen(0, '127.0.0.1', () => { + const { port } = srv.address(); + srv.close(() => resolve(port)); + }); + srv.on('error', reject); + }); + +// ─── Preview server lifecycle ──────────────────────────────────────────────── + +// Poll TCP until the port accepts connections or the deadline passes. +const waitForServer = (port) => + new Promise((resolve, reject) => { + const deadline = Date.now() + SERVER_READY_TIMEOUT_MS; + + const probe = () => { + const sock = new Socket(); + sock + .connect(port, '127.0.0.1', () => { + sock.destroy(); + resolve(); + }) + .on('error', () => { + sock.destroy(); + if (Date.now() >= deadline) { + reject(new Error(`Preview server did not start within ${SERVER_READY_TIMEOUT_MS / 1000}s`)); + } else { + setTimeout(probe, SERVER_READY_POLL_MS); + } + }); + }; + + probe(); + }); + +const startPreviewServer = async (port) => { + const proc = spawn( + 'npx', + ['vitepress', 'preview', '--port', String(port), '--outDir', DIST_DIR], + { + cwd: DOCS_ROOT, + stdio: ['ignore', 'pipe', 'pipe'], + detached: false, + }, + ); + // Drain stdout/stderr so the child process doesn't block on a full pipe. + proc.stdout.resume(); + proc.stderr.resume(); + proc.on('error', (err) => { + throw new Error(`Failed to spawn vitepress preview: ${err.message}`); + }); + await waitForServer(port); + return proc; +}; + +const stopPreviewServer = (proc) => + new Promise((done) => { + if (!proc || proc.exitCode !== null) { + done(); + return; + } + treeKill(proc.pid, 'SIGTERM', () => done()); + }); + +// ─── Route crawl ───────────────────────────────────────────────────────────── + +// Detect the VitePress 404 page. VitePress renders a
+// wrapper, sets document.title to '404', and includes an h1 whose text +// contains 'PAGE NOT FOUND'. Any one signal is enough to declare a miss. +const is404Page = (page) => + page.evaluate(() => { + const hasClass = !!document.querySelector('.NotFound'); + const title404 = document.title === '404'; + const body = (document.body?.innerText ?? '').toUpperCase(); + return hasClass || title404 || body.includes('PAGE NOT FOUND'); + }); + +const checkRoute = async (browser, baseUrl, route) => { + const url = `${baseUrl}${route}`; + const page = await browser.newPage(); + try { + const response = await page.goto(url, { waitUntil: 'networkidle', timeout: 15_000 }); + const httpStatus = response?.status() ?? 0; + if (await is404Page(page)) { + const h1 = await page.evaluate(() => document.querySelector('h1')?.textContent ?? ''); + return { route, url, httpStatus, h1: h1.trim() }; + } + return null; + } finally { + await page.close(); + } +}; + +// ─── Main ──────────────────────────────────────────────────────────────────── + +const main = async () => { + const files = await collectMarkdownFiles(DOCS_ROOT); + const routes = [...new Set(files.map(mdFileToRoute))].sort(); + + console.info(`Found ${routes.length} routes to check.`); + + const port = await findFreePort(); + const baseUrl = `http://127.0.0.1:${port}`; + + console.info(`Starting vitepress preview on port ${port}…`); + const server = await startPreviewServer(port); + + let browser; + const failures = []; + + try { + browser = await chromium.launch({ headless: true }); + const limit = pLimit(CONCURRENCY); + let checked = 0; + + const tasks = routes.map((route) => + limit(async () => { + const result = await checkRoute(browser, baseUrl, route); + if (result) failures.push(result); + checked++; + if (checked % PROGRESS_EVERY === 0 || checked === routes.length) { + console.info(` ${checked}/${routes.length} routes checked…`); + } + }), + ); + + await Promise.all(tasks); + } finally { + if (browser) await browser.close(); + await stopPreviewServer(server); + } + + if (failures.length === 0) { + console.info('All routes resolved successfully.'); + process.exit(0); + } + + console.error(`\n${failures.length} route(s) returned a 404:\n`); + for (const f of failures) { + const statusNote = f.httpStatus > 0 ? ` (HTTP ${f.httpStatus})` : ''; + const h1Note = f.h1 ? ` — "${f.h1}"` : ''; + console.error(` ${f.route}${statusNote}${h1Note}`); + } + process.exit(1); +}; + +main().catch((err) => { + console.error('check-routes failed:', err instanceof Error ? err.message : String(err)); + if (err instanceof Error && err.stack) console.error(err.stack); + process.exit(2); +}); From 086ea63adbf3cf1f9111729845fed1821be03316 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Otto=20Boly=C3=B3s?= Date: Mon, 1 Jun 2026 07:31:26 +0200 Subject: [PATCH 02/27] ci(repo): run Playwright route check after VitePress build MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds two sequential steps to the `build` job in docs.yml, immediately after `Build site`: 1. `Install Playwright chromium` — runs `npx playwright install --with-deps chromium` so the OS-level shared libraries are present on ubuntu-latest without a separate apt step. 2. `Check VitePress routes` — runs `npm run check-routes`, which spawns `vitepress preview` against the built dist/ tree, walks every route in a headless Chromium browser, and exits non-zero on any client-side 404. Both steps run on every push and pull_request trigger (not gated on the deploy path) so broken routes surface in PR review, not after a deploy. --- .github/workflows/docs.yml | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/.github/workflows/docs.yml b/.github/workflows/docs.yml index 845e5b8c5..c17af3c1d 100644 --- a/.github/workflows/docs.yml +++ b/.github/workflows/docs.yml @@ -124,6 +124,20 @@ jobs: env: DOCS_BASE: /MTConnect.NET/ + # Install the Playwright chromium binary. The `--with-deps` flag also + # installs the OS-level shared libraries chromium needs on ubuntu-latest. + - name: Install Playwright chromium + working-directory: docs + run: npx playwright install --with-deps chromium + + # Walk every route derived from the markdown source tree in a headless + # Chromium browser and fail on any client-side 404. Runs against the + # already-built dist/ artifact so CI catches router misregistrations + # that the filesystem link checker cannot see. + - name: Check VitePress routes + working-directory: docs + run: npm run check-routes + # Upload the built artifact only on the deploy path. PR builds stop # after the build step (success / failure surfaces in the check). - name: Upload Pages artifact From 8951d188563210e2417ea6fe69ef9ce5a9d79010 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Otto=20Boly=C3=B3s?= Date: Mon, 1 Jun 2026 07:57:38 +0200 Subject: [PATCH 03/27] chore(tools): auto-regenerate references on dev and build Wire `generate-api-ref.sh` + `generate-reference.sh` into npm `predev` and `prebuild` hooks via a `regen` umbrella script. A contributor who clones the repo and runs `npm run dev` (or `npm run build`) now gets a fully populated reference tree without remembering to run the two generators by hand. Adds a `check` umbrella script for local convenience (currently fans out to `check-routes`; future link checkers can be added without touching the CI workflow). Trims the now-redundant explicit `generate-api-ref.sh` and `generate-reference.sh` workflow steps; `npm run build`'s `prebuild` hook handles both. The `--check` drift gate moves ahead of the build so it runs against the committed reference pages, not the freshly regenerated ones the prebuild hook would overwrite them with. --- .github/workflows/docs.yml | 14 ++++++++------ docs/package.json | 6 +++++- 2 files changed, 13 insertions(+), 7 deletions(-) diff --git a/.github/workflows/docs.yml b/.github/workflows/docs.yml index c17af3c1d..1e74f69c9 100644 --- a/.github/workflows/docs.yml +++ b/.github/workflows/docs.yml @@ -59,12 +59,14 @@ jobs: working-directory: docs run: npm ci - - name: Generate API reference - run: bash docs/scripts/generate-api-ref.sh - - - name: Regenerate auto-generated reference pages - run: bash docs/scripts/generate-reference.sh - + # Drift gate first, before any regeneration: verifies the committed + # docs/reference/ pages already match what DocsGen would emit from + # the current source tree. The subsequent `npm run build` step + # invokes the `prebuild` hook (in docs/package.json), which runs + # generate-api-ref.sh + generate-reference.sh and overwrites the + # tree on disk — so checking drift after the build would always + # pass. The api/ tree is gitignored and regenerated unconditionally; + # only the reference/ tree carries committed content to drift-check. - name: Verify reference pages match source (drift gate) run: bash docs/scripts/generate-reference.sh --check diff --git a/docs/package.json b/docs/package.json index a774b0457..40cabbe2a 100644 --- a/docs/package.json +++ b/docs/package.json @@ -5,11 +5,15 @@ "private": true, "type": "module", "scripts": { + "regen": "bash scripts/generate-api-ref.sh && bash scripts/generate-reference.sh", + "predev": "npm run regen", "dev": "vitepress dev", + "prebuild": "npm run regen", "build": "vitepress build", "preview": "vitepress preview", "check-links": "node scripts/check-broken-links.mjs .", - "check-routes": "node scripts/check-routes.mjs" + "check-routes": "node scripts/check-routes.mjs", + "check": "npm run check-routes" }, "devDependencies": { "mermaid": "^11.0.0", From 01a91627e028c5a64979f1dfbc4052411edc4c20 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Otto=20Boly=C3=B3s?= Date: Mon, 1 Jun 2026 08:14:16 +0200 Subject: [PATCH 04/27] feat(docs): auto-generate the API and reference sidebars MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replaces the hand-curated `/api/` and `/reference/` sidebar blocks in `docs/.vitepress/config.ts` with auto-derivation from the generated trees on disk. Two functions in the new `docs/.vitepress/sidebar.ts`: - apiSidebar() parses docfx's `toc.yml` and projects the namespace hierarchy into nested collapsible groups. `MTConnect.Devices.DataItems.SampleDataItem` lands at `MTConnect > Devices > DataItems > SampleDataItem`. All ~1800 types are one click from a section landing; the tree is collapsed by default so the viewport stays usable. - referenceSidebar() lists every `.md` file under `docs/reference/` except `index.md` (wired in as Overview). Flat alphabetical list — a future page emitted by `MTConnect.NET-DocsGen` surfaces automatically once the regen scripts have produced it. Hand-curated narrative sections (`/compliance/`, `/configure/`, `/concepts/`, `/cookbook/`, `/modules/`, `/wire-formats/`, `/troubleshooting/`, `/examples/`, `/migration/`, `/development/`, `/cli/`) remain hand-edited; only the two generated trees swap to auto-derivation. Gitignores the VitePress `.temp/` cache directory that the build emits alongside `dist/`. --- .gitignore | 1 + docs/.vitepress/config.ts | 40 +++--- docs/.vitepress/sidebar.ts | 263 +++++++++++++++++++++++++++++++++++++ 3 files changed, 282 insertions(+), 22 deletions(-) create mode 100644 docs/.vitepress/sidebar.ts diff --git a/.gitignore b/.gitignore index 899782835..dfbbed279 100644 --- a/.gitignore +++ b/.gitignore @@ -320,6 +320,7 @@ $RECYCLE.BIN/ node_modules/ docs/.vitepress/cache/ docs/.vitepress/dist/ +docs/.vitepress/.temp/ # docfx-generated API reference (regenerated by docs/scripts/generate-api-ref.sh # on every CI run; keep the section index but ignore the per-type pages). diff --git a/docs/.vitepress/config.ts b/docs/.vitepress/config.ts index 58f9cde66..558e3c406 100644 --- a/docs/.vitepress/config.ts +++ b/docs/.vitepress/config.ts @@ -1,5 +1,6 @@ import { defineConfig } from 'vitepress'; import { withMermaid } from 'vitepress-plugin-mermaid'; +import { apiSidebar, referenceSidebar } from './sidebar'; /** * VitePress config for the MTConnect.NET documentation site. @@ -13,10 +14,15 @@ import { withMermaid } from 'vitepress-plugin-mermaid'; * * @remarks * The auto-generated reference section (`/reference/`) is produced by - * `build/MTConnect.NET-DocsGen` at docs-build time; do not edit those - * pages by hand. Adding a new section here that should also feed the - * sidebar — extend both the top `nav:` list and the matching entry in - * `sidebar:`. + * `build/MTConnect.NET-DocsGen` at docs-build time; the docfx-driven + * API reference (`/api/`) is produced by `docs/scripts/generate-api-ref.sh`. + * Do not edit those pages by hand. Both sidebars are derived from + * the generated output by `./sidebar.ts` — adding a new generated + * page surfaces automatically once the regen scripts have produced + * it (the npm `predev` / `prebuild` hooks run them on every dev + * server boot and build). Hand-curated narrative sections still + * extend the top `nav:` list plus the matching `sidebar:` entry + * below. * * @see {@link https://vitepress.dev/reference/site-config} */ @@ -124,24 +130,14 @@ export default withMermaid( ], }, ], - '/api/': [ - { - text: 'API reference', - items: [{ text: 'Overview', link: '/api/' }], - }, - ], - '/reference/': [ - { - text: 'Auto-generated reference', - items: [ - { text: 'Overview', link: '/reference/' }, - { text: 'HTTP API', link: '/reference/http-api' }, - { text: 'Environment variables', link: '/reference/environment-variables' }, - { text: 'Configuration schema', link: '/reference/configuration' }, - { text: 'CLI reference', link: '/reference/cli' }, - ], - }, - ], + // Auto-derived from docfx's toc.yml under docs/api/. Hierarchical + // by namespace dots, collapsed by default so the 1800-odd type + // pages do not flood the viewport. + '/api/': apiSidebar(), + // Auto-derived from the .md files under docs/reference/. Flat + // alphabetical list so a future page emitted by DocsGen surfaces + // without a config-side edit. + '/reference/': referenceSidebar(), '/wire-formats/': [ { text: 'Wire formats', diff --git a/docs/.vitepress/sidebar.ts b/docs/.vitepress/sidebar.ts new file mode 100644 index 000000000..87f0e8bc5 --- /dev/null +++ b/docs/.vitepress/sidebar.ts @@ -0,0 +1,263 @@ +import { readdirSync, readFileSync, existsSync } from 'node:fs'; +import { resolve, dirname } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +/** + * Auto-derived sidebars for the generated docs sections. + * + * Two trees feed this module: + * + * - `docs/api/` — docfx-generated reference for the public surface of + * every shipped MTConnect.NET library. ~1800 type pages keyed by + * `MTConnect...md`, plus a docfx-produced `toc.yml` + * that lists each namespace and the types under it. We parse `toc.yml` + * and build a hierarchical sidebar where namespace dots fold into + * nested collapsible groups — so `MTConnect.Devices.DataItems.SampleDataItem` + * lives at `MTConnect > Devices > DataItems > SampleDataItem`. + * + * - `docs/reference/` — Roslyn-generated narrative reference (CLI flags, + * environment variables, configuration schema, HTTP API). A flat + * alphabetical list keyed off whatever `.md` files the generator + * emits, so a future page added by `MTConnect.NET-DocsGen` surfaces + * in the sidebar without a config-side edit. + * + * Both functions are pure-fs reads; they run at VitePress config-load + * time (which is before the dev server boots and before the build + * traverses pages). The npm `predev` / `prebuild` hooks regenerate + * both trees before VitePress reads them, so a fresh clone produces + * a fully-populated sidebar on the first `npm run dev`. + */ + +// VitePress sidebar shapes (loosely typed — VitePress accepts plain +// objects, and pulling the official types would require an import that +// VitePress' own config doesn't enforce). +type SidebarItem = { + text: string; + link?: string; + collapsed?: boolean; + items?: SidebarItem[]; +}; + +// Resolve paths relative to this file so the module works whether +// VitePress is invoked from `docs/` or from the repo root. +const here = dirname(fileURLToPath(import.meta.url)); +const docsRoot = resolve(here, '..'); + +// ─── /api/ — docfx hierarchy ──────────────────────────────────────────────── + +// Internal tree node used while building the namespace hierarchy. +// Each node represents one dot-segment in a namespace path. `types` +// holds the leaf entries (sidebar items pointing at type pages); +// `overview` is the namespace landing page link if one exists. +type ApiNode = { + children: Map; + types: SidebarItem[]; + overview?: string; +}; + +const makeNode = (): ApiNode => ({ children: new Map(), types: [] }); + +// Strip the `.md` extension and any leading `/` so the result is a +// clean VitePress route, e.g. `MTConnect.Adapters.AgentClient.md` -> +// `/api/MTConnect.Adapters.AgentClient`. +const hrefToRoute = (href: string): string => + `/api/${href.replace(/\.md$/, '')}`; + +// Minimal `toc.yml` parser. The docfx-emitted file is regular enough +// that a line-oriented parse is robust and avoids pulling in a YAML +// dependency. Schema: +// +// - name: +// href: .md +// items: +// - name: Classes # section divider (no href) +// - name: +// href: ..md +// - name: Structs # next divider +// ... +// - name: +// ... +const parseToc = (content: string) => { + const namespaces: Array<{ + name: string; + href: string; + types: Array<{ name: string; href: string }>; + }> = []; + + let current: (typeof namespaces)[number] | null = null; + let pending: { name: string; href?: string } | null = null; + let inItems = false; + + const flushPending = () => { + if (!pending || !current || !pending.href) return; + current.types.push({ name: pending.name, href: pending.href }); + pending = null; + }; + + for (const raw of content.split('\n')) { + const line = raw.replace(/\r$/, ''); + // Top-level namespace entry. + let m = /^- name: (.+)$/.exec(line); + if (m) { + flushPending(); + if (current) namespaces.push(current); + current = { name: m[1], href: '', types: [] }; + pending = null; + inItems = false; + continue; + } + // Top-level href for the namespace block currently being parsed. + m = /^ {2}href: (.+)$/.exec(line); + if (m && current && !inItems) { + current.href = m[1]; + continue; + } + if (/^ {2}items:$/.test(line)) { + inItems = true; + continue; + } + // Item-level entry inside the current namespace block. + m = /^ {2}- name: (.+)$/.exec(line); + if (m && inItems) { + flushPending(); + pending = { name: m[1] }; + continue; + } + m = /^ {4}href: (.+)$/.exec(line); + if (m && pending) { + pending.href = m[1]; + continue; + } + } + flushPending(); + if (current) namespaces.push(current); + return namespaces; +}; + +// Build the nested namespace tree by walking each namespace's +// dot-separated path. Each segment becomes a child node; the final +// segment receives the namespace's overview href and the type list. +const buildApiTree = ( + namespaces: ReturnType, +): ApiNode => { + const root = makeNode(); + for (const ns of namespaces) { + const segments = ns.name.split('.'); + let node = root; + for (const segment of segments) { + let child = node.children.get(segment); + if (!child) { + child = makeNode(); + node.children.set(segment, child); + } + node = child; + } + if (ns.href) node.overview = hrefToRoute(ns.href); + for (const t of ns.types) { + node.types.push({ text: t.name, link: hrefToRoute(t.href) }); + } + } + return root; +}; + +// Case-insensitive locale comparator so groups and types sort +// predictably regardless of underlying string ordering quirks +// (e.g. uppercase ASCII grouping ahead of lowercase). +const byTextCI = (a: SidebarItem, b: SidebarItem) => + a.text.localeCompare(b.text, 'en', { sensitivity: 'base' }); + +// Recursively project the tree into VitePress sidebar items. A node +// with children becomes a collapsible group; types are sorted into +// the group alongside any nested child groups. The namespace overview +// (if present) leads the group as an "Overview" entry. +const projectNode = (segment: string, node: ApiNode): SidebarItem => { + const items: SidebarItem[] = []; + if (node.overview) { + items.push({ text: 'Overview', link: node.overview }); + } + const typeItems = [...node.types].sort(byTextCI); + const childItems = [...node.children.entries()] + .map(([s, n]) => projectNode(s, n)) + .sort(byTextCI); + // Children (sub-namespaces) listed before types so the hierarchy + // reads top-down: nested namespaces first, then the types declared + // directly in this namespace. + items.push(...childItems, ...typeItems); + return { + text: segment, + collapsed: true, + items, + }; +}; + +/** + * Build the `/api/` sidebar from docfx's `toc.yml`. Returns a single + * top-level "API reference" group whose items are the nested namespace + * tree. Falls back to a one-entry "Overview" sidebar when the docfx + * output is missing (e.g. a tree without `npm run regen` first). + */ +export const apiSidebar = (): SidebarItem[] => { + const tocPath = resolve(docsRoot, 'api', 'toc.yml'); + const overview: SidebarItem = { text: 'Overview', link: '/api/' }; + if (!existsSync(tocPath)) { + return [{ text: 'API reference', items: [overview] }]; + } + const namespaces = parseToc(readFileSync(tocPath, 'utf8')); + const tree = buildApiTree(namespaces); + const topLevel = [...tree.children.entries()] + .map(([s, n]) => projectNode(s, n)) + .sort(byTextCI); + return [ + { + text: 'API reference', + items: [overview, ...topLevel], + }, + ]; +}; + +// ─── /reference/ — Roslyn-generated narrative ───────────────────────────── + +// Convert a file name like `environment-variables.md` to a sidebar +// label like `Environment variables` (lower-case-with-hyphens to +// sentence case, keeping mid-word capitals as-is for HTTP/CLI/etc.). +const labelFor = (slug: string): string => { + // Hand-tuned overrides for common acronyms / multi-cap labels. + const overrides: Record = { + cli: 'CLI reference', + 'http-api': 'HTTP API', + 'environment-variables': 'Environment variables', + configuration: 'Configuration schema', + }; + if (overrides[slug]) return overrides[slug]; + const spaced = slug.replace(/-/g, ' '); + return spaced.charAt(0).toUpperCase() + spaced.slice(1); +}; + +/** + * Build the `/reference/` sidebar by listing every `.md` file under + * `docs/reference/` except `index.md` (which is wired in as the + * "Overview" entry). Pages sort alphabetically by displayed label, + * so a new generator output appears without a config-side edit. + */ +export const referenceSidebar = (): SidebarItem[] => { + const refRoot = resolve(docsRoot, 'reference'); + const items: SidebarItem[] = [ + { text: 'Overview', link: '/reference/' }, + ]; + if (existsSync(refRoot)) { + const pages = readdirSync(refRoot) + .filter((f) => f.endsWith('.md') && f !== 'index.md') + .map((f) => { + const slug = f.replace(/\.md$/, ''); + return { text: labelFor(slug), link: `/reference/${slug}` }; + }) + .sort(byTextCI); + items.push(...pages); + } + return [ + { + text: 'Auto-generated reference', + items, + }, + ]; +}; From a7957bfa7a1d653abbb0b5ae2cc9b68b23736236 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Otto=20Boly=C3=B3s?= Date: Mon, 1 Jun 2026 08:24:08 +0200 Subject: [PATCH 05/27] revert(tools): remove npm-script Playwright crawler The route check belongs in the dotnet test matrix, not as a docs-workflow Node script. Drop the .mjs crawler, its three devDependencies (playwright, p-limit, tree-kill), the check-routes / check npm scripts, and the two docs-workflow steps that installed chromium and ran the crawler. The .NET e2e form that supersedes this lands in the next commit. --- .github/workflows/docs.yml | 14 -- docs/package-lock.json | 95 +------------- docs/package.json | 7 +- docs/scripts/check-routes.mjs | 240 ---------------------------------- 4 files changed, 4 insertions(+), 352 deletions(-) delete mode 100644 docs/scripts/check-routes.mjs diff --git a/.github/workflows/docs.yml b/.github/workflows/docs.yml index 1e74f69c9..177b1c02a 100644 --- a/.github/workflows/docs.yml +++ b/.github/workflows/docs.yml @@ -126,20 +126,6 @@ jobs: env: DOCS_BASE: /MTConnect.NET/ - # Install the Playwright chromium binary. The `--with-deps` flag also - # installs the OS-level shared libraries chromium needs on ubuntu-latest. - - name: Install Playwright chromium - working-directory: docs - run: npx playwright install --with-deps chromium - - # Walk every route derived from the markdown source tree in a headless - # Chromium browser and fail on any client-side 404. Runs against the - # already-built dist/ artifact so CI catches router misregistrations - # that the filesystem link checker cannot see. - - name: Check VitePress routes - working-directory: docs - run: npm run check-routes - # Upload the built artifact only on the deploy path. PR builds stop # after the build step (success / failure surfaces in the check). - name: Upload Pages artifact diff --git a/docs/package-lock.json b/docs/package-lock.json index 5cbcd2d94..bf725dea3 100644 --- a/docs/package-lock.json +++ b/docs/package-lock.json @@ -9,14 +9,11 @@ "version": "0.1.0", "devDependencies": { "mermaid": "^11.0.0", - "p-limit": "^7.3.0", - "playwright": "^1.60.0", "rehype-slug": "^6.0.0", "rehype-stringify": "^10.0.0", "remark-gfm": "^4.0.0", "remark-parse": "^11.0.0", "remark-rehype": "^11.0.0", - "tree-kill": "^1.2.2", "unified": "^11.0.0", "unist-util-visit": "^5.0.0", "vitepress": "^1.5.0", @@ -2782,9 +2779,9 @@ } }, "node_modules/fsevents": { - "version": "2.3.2", - "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz", - "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==", + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", "dev": true, "hasInstallScript": true, "license": "MIT", @@ -3948,22 +3945,6 @@ "regex-recursion": "^6.0.2" } }, - "node_modules/p-limit": { - "version": "7.3.0", - "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-7.3.0.tgz", - "integrity": "sha512-7cIXg/Z0M5WZRblrsOla88S4wAK+zOQQWeBYfV3qJuJXMr+LnbYjaadrFaS0JILfEDPVqHyKnZ1Z/1d6J9VVUw==", - "dev": true, - "license": "MIT", - "dependencies": { - "yocto-queue": "^1.2.1" - }, - "engines": { - "node": ">=20" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/package-manager-detector": { "version": "1.6.0", "resolved": "https://registry.npmjs.org/package-manager-detector/-/package-manager-detector-1.6.0.tgz", @@ -4005,38 +3986,6 @@ "pathe": "^2.0.1" } }, - "node_modules/playwright": { - "version": "1.60.0", - "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.60.0.tgz", - "integrity": "sha512-hheHdokM8cdqCb0lcE3s+zT4t4W+vvjpGxsZlDnikarzx8tSzMebh3UiFtgqwFwnTnjYQcsyMF8ei2mCO/tpeA==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "playwright-core": "1.60.0" - }, - "bin": { - "playwright": "cli.js" - }, - "engines": { - "node": ">=18" - }, - "optionalDependencies": { - "fsevents": "2.3.2" - } - }, - "node_modules/playwright-core": { - "version": "1.60.0", - "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.60.0.tgz", - "integrity": "sha512-9bW6zvX/m0lEbgTKJ6YppOKx8H3VOPBMOCFh2irXFOT4BbHgrx5hPjwJYLT40Lu+4qtD36qKc/Hn56StUW57IA==", - "dev": true, - "license": "Apache-2.0", - "bin": { - "playwright-core": "cli.js" - }, - "engines": { - "node": ">=18" - } - }, "node_modules/points-on-curve": { "version": "0.2.0", "resolved": "https://registry.npmjs.org/points-on-curve/-/points-on-curve-0.2.0.tgz", @@ -4407,16 +4356,6 @@ "node": ">=18" } }, - "node_modules/tree-kill": { - "version": "1.2.2", - "resolved": "https://registry.npmjs.org/tree-kill/-/tree-kill-1.2.2.tgz", - "integrity": "sha512-L0Orpi8qGpRG//Nd+H90vFB+3iHnue1zSSGmNOOCh1GLJ7rUKVwV2HvijphGQS2UmhUZewS9VgvxYIdgr+fG1A==", - "dev": true, - "license": "MIT", - "bin": { - "tree-kill": "cli.js" - } - }, "node_modules/trim-lines": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/trim-lines/-/trim-lines-3.0.1.tgz", @@ -4641,21 +4580,6 @@ } } }, - "node_modules/vite/node_modules/fsevents": { - "version": "2.3.3", - "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", - "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", - "dev": true, - "hasInstallScript": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": "^8.16.0 || ^10.6.0 || >=11.0.0" - } - }, "node_modules/vitepress": { "version": "1.6.4", "resolved": "https://registry.npmjs.org/vitepress/-/vitepress-1.6.4.tgz", @@ -4780,19 +4704,6 @@ } } }, - "node_modules/yocto-queue": { - "version": "1.2.2", - "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-1.2.2.tgz", - "integrity": "sha512-4LCcse/U2MHZ63HAJVE+v71o7yOdIe4cZ70Wpf8D/IyjDKYQLV5GD46B+hSTjJsvV5PztjvHoU580EftxjDZFQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=12.20" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/zwitch": { "version": "2.0.4", "resolved": "https://registry.npmjs.org/zwitch/-/zwitch-2.0.4.tgz", diff --git a/docs/package.json b/docs/package.json index 40cabbe2a..815605038 100644 --- a/docs/package.json +++ b/docs/package.json @@ -11,20 +11,15 @@ "prebuild": "npm run regen", "build": "vitepress build", "preview": "vitepress preview", - "check-links": "node scripts/check-broken-links.mjs .", - "check-routes": "node scripts/check-routes.mjs", - "check": "npm run check-routes" + "check-links": "node scripts/check-broken-links.mjs ." }, "devDependencies": { "mermaid": "^11.0.0", - "p-limit": "^7.3.0", - "playwright": "^1.60.0", "rehype-slug": "^6.0.0", "rehype-stringify": "^10.0.0", "remark-gfm": "^4.0.0", "remark-parse": "^11.0.0", "remark-rehype": "^11.0.0", - "tree-kill": "^1.2.2", "unified": "^11.0.0", "unist-util-visit": "^5.0.0", "vitepress": "^1.5.0", diff --git a/docs/scripts/check-routes.mjs b/docs/scripts/check-routes.mjs deleted file mode 100644 index c18395462..000000000 --- a/docs/scripts/check-routes.mjs +++ /dev/null @@ -1,240 +0,0 @@ -#!/usr/bin/env node - -// Walk every Markdown file under (default: '.'), derive the -// VitePress route for each, then navigate to it in a headless Chromium -// browser and fail on any client-side 404. -// -// Complements check-broken-links.mjs (which validates internal link targets -// exist on disk). This script validates that VitePress actually registers and -// renders each route at runtime—a gap the filesystem check cannot catch. -// -// The script spawns `vitepress preview` against docs/.vitepress/dist/ (the -// already-built site artifact), waits for the server to be ready, then -// crawls every route with Playwright's chromium browser. VitePress's -// client-side 404 page is detected via the `.NotFound` CSS class, a -// document.title of '404', or body text containing 'PAGE NOT FOUND'. -// -// External (http/https) URLs are not validated by design: third-party state -// is not the CI gate. -// -// Usage (run from the docs/ directory): -// node scripts/check-routes.mjs -// -// Environment: -// DOCS_BASE_PATH URL path prefix (default: ''). Set to '/MTConnect.NET' -// when checking the GitHub Pages deploy base. - -import { readdir } from 'node:fs/promises'; -import { createServer as createTcpServer, Socket } from 'node:net'; -import { resolve, sep } from 'node:path'; -import { spawn } from 'node:child_process'; -import { chromium } from 'playwright'; -import pLimit from 'p-limit'; -import treeKill from 'tree-kill'; - -// ─── Configuration ─────────────────────────────────────────────────────────── - -const DOCS_ROOT = resolve('.'); -const DIST_DIR = resolve(DOCS_ROOT, '.vitepress/dist'); -const CONCURRENCY = 8; -const PROGRESS_EVERY = 25; -const SERVER_READY_POLL_MS = 200; -const SERVER_READY_TIMEOUT_MS = 30_000; - -const BASE_PATH = (process.env.DOCS_BASE_PATH ?? '').replace(/\/$/, ''); - -// ─── Route derivation ──────────────────────────────────────────────────────── - -// Directories skipped during the Markdown walk. Skip dot-prefixed dirs -// wholesale (.vitepress includes the build cache and the dist tree). -const isSkippedDir = (name) => name === 'node_modules' || name.startsWith('.'); - -const collectMarkdownFiles = async (dir) => { - const entries = await readdir(dir, { withFileTypes: true }); - const results = []; - for (const entry of entries) { - const fullPath = resolve(dir, entry.name); - if (entry.isSymbolicLink()) continue; - if (entry.isDirectory()) { - if (isSkippedDir(entry.name)) continue; - results.push(...(await collectMarkdownFiles(fullPath))); - } else if (entry.isFile() && entry.name.endsWith('.md')) { - results.push(fullPath); - } - } - return results; -}; - -// Convert an absolute .md file path to the VitePress route it maps to. -// -// docs/index.md -> / -// docs/getting-started.md -> /getting-started -// docs/reference/index.md -> /reference/ -// docs/reference/cli.md -> /reference/cli -// -// VitePress cleanUrls strips the .html extension, so both /foo and /foo/ -// work for regular pages; directory-level index pages reliably use the -// trailing-slash form which avoids the slug-vs-cleanUrls ambiguity that -// motivated this checker in the first place. -const mdFileToRoute = (absPath) => { - const rel = absPath.slice(DOCS_ROOT.length).replaceAll(sep, '/'); - if (rel === '/index.md') return BASE_PATH + '/'; - if (rel.endsWith('/index.md')) return BASE_PATH + rel.slice(0, -'index.md'.length); - return BASE_PATH + rel.slice(0, -'.md'.length); -}; - -// ─── Free-port finder ──────────────────────────────────────────────────────── - -const findFreePort = () => - new Promise((resolve, reject) => { - const srv = createTcpServer(); - srv.listen(0, '127.0.0.1', () => { - const { port } = srv.address(); - srv.close(() => resolve(port)); - }); - srv.on('error', reject); - }); - -// ─── Preview server lifecycle ──────────────────────────────────────────────── - -// Poll TCP until the port accepts connections or the deadline passes. -const waitForServer = (port) => - new Promise((resolve, reject) => { - const deadline = Date.now() + SERVER_READY_TIMEOUT_MS; - - const probe = () => { - const sock = new Socket(); - sock - .connect(port, '127.0.0.1', () => { - sock.destroy(); - resolve(); - }) - .on('error', () => { - sock.destroy(); - if (Date.now() >= deadline) { - reject(new Error(`Preview server did not start within ${SERVER_READY_TIMEOUT_MS / 1000}s`)); - } else { - setTimeout(probe, SERVER_READY_POLL_MS); - } - }); - }; - - probe(); - }); - -const startPreviewServer = async (port) => { - const proc = spawn( - 'npx', - ['vitepress', 'preview', '--port', String(port), '--outDir', DIST_DIR], - { - cwd: DOCS_ROOT, - stdio: ['ignore', 'pipe', 'pipe'], - detached: false, - }, - ); - // Drain stdout/stderr so the child process doesn't block on a full pipe. - proc.stdout.resume(); - proc.stderr.resume(); - proc.on('error', (err) => { - throw new Error(`Failed to spawn vitepress preview: ${err.message}`); - }); - await waitForServer(port); - return proc; -}; - -const stopPreviewServer = (proc) => - new Promise((done) => { - if (!proc || proc.exitCode !== null) { - done(); - return; - } - treeKill(proc.pid, 'SIGTERM', () => done()); - }); - -// ─── Route crawl ───────────────────────────────────────────────────────────── - -// Detect the VitePress 404 page. VitePress renders a
-// wrapper, sets document.title to '404', and includes an h1 whose text -// contains 'PAGE NOT FOUND'. Any one signal is enough to declare a miss. -const is404Page = (page) => - page.evaluate(() => { - const hasClass = !!document.querySelector('.NotFound'); - const title404 = document.title === '404'; - const body = (document.body?.innerText ?? '').toUpperCase(); - return hasClass || title404 || body.includes('PAGE NOT FOUND'); - }); - -const checkRoute = async (browser, baseUrl, route) => { - const url = `${baseUrl}${route}`; - const page = await browser.newPage(); - try { - const response = await page.goto(url, { waitUntil: 'networkidle', timeout: 15_000 }); - const httpStatus = response?.status() ?? 0; - if (await is404Page(page)) { - const h1 = await page.evaluate(() => document.querySelector('h1')?.textContent ?? ''); - return { route, url, httpStatus, h1: h1.trim() }; - } - return null; - } finally { - await page.close(); - } -}; - -// ─── Main ──────────────────────────────────────────────────────────────────── - -const main = async () => { - const files = await collectMarkdownFiles(DOCS_ROOT); - const routes = [...new Set(files.map(mdFileToRoute))].sort(); - - console.info(`Found ${routes.length} routes to check.`); - - const port = await findFreePort(); - const baseUrl = `http://127.0.0.1:${port}`; - - console.info(`Starting vitepress preview on port ${port}…`); - const server = await startPreviewServer(port); - - let browser; - const failures = []; - - try { - browser = await chromium.launch({ headless: true }); - const limit = pLimit(CONCURRENCY); - let checked = 0; - - const tasks = routes.map((route) => - limit(async () => { - const result = await checkRoute(browser, baseUrl, route); - if (result) failures.push(result); - checked++; - if (checked % PROGRESS_EVERY === 0 || checked === routes.length) { - console.info(` ${checked}/${routes.length} routes checked…`); - } - }), - ); - - await Promise.all(tasks); - } finally { - if (browser) await browser.close(); - await stopPreviewServer(server); - } - - if (failures.length === 0) { - console.info('All routes resolved successfully.'); - process.exit(0); - } - - console.error(`\n${failures.length} route(s) returned a 404:\n`); - for (const f of failures) { - const statusNote = f.httpStatus > 0 ? ` (HTTP ${f.httpStatus})` : ''; - const h1Note = f.h1 ? ` — "${f.h1}"` : ''; - console.error(` ${f.route}${statusNote}${h1Note}`); - } - process.exit(1); -}; - -main().catch((err) => { - console.error('check-routes failed:', err instanceof Error ? err.message : String(err)); - if (err instanceof Error && err.stack) console.error(err.stack); - process.exit(2); -}); From 0fca0350c3b5b01fe9802a5ff6bad5853436792b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Otto=20Boly=C3=B3s?= Date: Mon, 1 Jun 2026 08:28:41 +0200 Subject: [PATCH 06/27] test(docs-tests): add Playwright e2e test for VitePress routes Walks every markdown-backed VitePress route against the built dist/ tree in a headless Chromium browser and fails on any client-side 404. Categorised E2E so the existing dotnet matrix runs it on the ubuntu-latest leg (the windows-latest leg already excludes Category=E2E). The fixture's one-time setup builds the docs site via `npm ci && npm run build` if dist/ is missing, installs the Playwright chromium binary, spawns `vitepress preview` on a free port, then waits for the listener to bind. The test class lives alongside DocsReferenceGenerationTests; both belong to the same docs-coverage surface but cover different failure modes (drift vs router gaps). --- .../MTConnect.NET-Docs-Tests.csproj | 1 + .../RouteCheckTests.cs | 371 ++++++++++++++++++ 2 files changed, 372 insertions(+) create mode 100644 tests/MTConnect.NET-Docs-Tests/RouteCheckTests.cs diff --git a/tests/MTConnect.NET-Docs-Tests/MTConnect.NET-Docs-Tests.csproj b/tests/MTConnect.NET-Docs-Tests/MTConnect.NET-Docs-Tests.csproj index 7da64b04e..4e036964f 100644 --- a/tests/MTConnect.NET-Docs-Tests/MTConnect.NET-Docs-Tests.csproj +++ b/tests/MTConnect.NET-Docs-Tests/MTConnect.NET-Docs-Tests.csproj @@ -10,6 +10,7 @@ + diff --git a/tests/MTConnect.NET-Docs-Tests/RouteCheckTests.cs b/tests/MTConnect.NET-Docs-Tests/RouteCheckTests.cs new file mode 100644 index 000000000..179c545ff --- /dev/null +++ b/tests/MTConnect.NET-Docs-Tests/RouteCheckTests.cs @@ -0,0 +1,371 @@ +// Copyright (c) 2026 TrakHound Inc., All Rights Reserved. +// TrakHound Inc. licenses this file to you under the MIT license. + +using System; +using System.Collections.Generic; +using System.Diagnostics; +using System.IO; +using System.Linq; +using System.Net.Sockets; +using System.Runtime.InteropServices; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Playwright; +using NUnit.Framework; + +namespace MTConnect.NET_Docs_Tests; + +/// +/// End-to-end route walk against the built VitePress site. Spawns +/// `vitepress preview` over the docs/.vitepress/dist/ artifact, then +/// navigates every route the markdown source tree implies in a headless +/// Chromium browser and fails on any client-side 404. Catches the failure +/// modes a filesystem link checker cannot see: VitePress router +/// misregistration (e.g. the trailing-slash-vs-cleanUrls bug that hid in +/// the original sidebar config), missing static assets, JS errors in +/// custom theme components. +/// +/// Run locally: +/// +/// 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). +/// - The Microsoft.Playwright package's chromium browser binary +/// (installed automatically by the fixture's one-time setup). +/// +[TestFixture] +[Category("E2E")] +public class RouteCheckTests +{ + private const int Concurrency = 8; + private const int ServerReadyPollMs = 200; + private const int ServerReadyTimeoutMs = 60_000; + private const int PageNavigationTimeoutMs = 15_000; + + private Process? _previewServer; + private IPlaywright? _playwright; + private IBrowser? _browser; + private string _baseUrl = string.Empty; + private string _docsRoot = string.Empty; + + private static string RepoRoot + { + get + { + var dir = AppContext.BaseDirectory.TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar); + for (int i = 0; i < 10 && !string.IsNullOrEmpty(dir); i++) + { + if (File.Exists(Path.Combine(dir, "MTConnect.NET.sln"))) return dir; + dir = Path.GetDirectoryName(dir)!; + } + throw new InvalidOperationException("Could not locate repository root from " + AppContext.BaseDirectory); + } + } + + [OneTimeSetUp] + public async Task OneTimeSetUp() + { + _docsRoot = Path.Combine(RepoRoot, "docs"); + var distDir = Path.Combine(_docsRoot, ".vitepress", "dist"); + + // The test owns its build prerequisite. If the dist/ tree + // doesn't already exist, run `npm ci && npm run build` from + // docs/ to produce it. `npm run build` invokes the `prebuild` + // hook (regenerate api + reference), so the rendered site + // matches what CI builds on every push. + if (!Directory.Exists(distDir) || !Directory.EnumerateFileSystemEntries(distDir).Any()) + { + RunNpm("ci", _docsRoot); + RunNpm("run build", _docsRoot); + } + + // Install the chromium binary the Playwright .NET binding drives. + // Idempotent: re-installs cleanly if the cache is already warm. + var installExit = Microsoft.Playwright.Program.Main(new[] { "install", "chromium" }); + if (installExit != 0) + { + throw new InvalidOperationException($"`playwright install chromium` exited {installExit}"); + } + + var port = FindFreePort(); + _baseUrl = $"http://127.0.0.1:{port}"; + _previewServer = StartPreviewServer(port, distDir, _docsRoot); + await WaitForServerAsync(port); + + _playwright = await Playwright.CreateAsync(); + _browser = await _playwright.Chromium.LaunchAsync(new BrowserTypeLaunchOptions { Headless = true }); + } + + [OneTimeTearDown] + public async Task OneTimeTearDown() + { + if (_browser is not null) await _browser.CloseAsync(); + _playwright?.Dispose(); + StopPreviewServer(_previewServer); + } + + [Test] + public async Task Every_Markdown_Backed_Route_Resolves_Without_A_404() + { + Assert.That(_browser, Is.Not.Null, "browser was not initialised"); + + var routes = CollectRoutes(_docsRoot); + Assert.That(routes.Count, Is.GreaterThan(0), "expected at least one markdown-backed route"); + + var failures = new List<(string Route, string Indicator)>(); + var failuresLock = new object(); + using var semaphore = new SemaphoreSlim(Concurrency); + + var tasks = routes.Select(async route => + { + await semaphore.WaitAsync(); + try + { + var failure = await CheckRouteAsync(_browser!, _baseUrl, route); + if (failure is not null) + { + lock (failuresLock) failures.Add(failure.Value); + } + } + finally + { + semaphore.Release(); + } + }); + + await Task.WhenAll(tasks); + + if (failures.Count > 0) + { + var ordered = failures.OrderBy(f => f.Route, StringComparer.Ordinal).ToList(); + var lines = string.Join(Environment.NewLine, ordered.Select(f => $" {f.Route} — {f.Indicator}")); + Assert.Fail($"{ordered.Count} route(s) returned a 404:{Environment.NewLine}{lines}"); + } + } + + // ─── Route derivation ──────────────────────────────────────────────────── + + // Convert an absolute .md file path to the VitePress route it maps to. + // Mirrors the original Node crawler's mdFileToRoute: + // docs/index.md -> / + // docs/getting-started.md -> /getting-started + // docs/reference/index.md -> /reference/ + // docs/reference/cli.md -> /reference/cli + private static string MdFileToRoute(string docsRoot, string absPath) + { + var rel = absPath.Substring(docsRoot.Length).Replace(Path.DirectorySeparatorChar, '/'); + if (!rel.StartsWith('/')) rel = "/" + rel; + if (rel == "/index.md") return "/"; + if (rel.EndsWith("/index.md", StringComparison.Ordinal)) + return rel.Substring(0, rel.Length - "index.md".Length); + return rel.Substring(0, rel.Length - ".md".Length); + } + + private static List CollectRoutes(string docsRoot) + { + var files = new List(); + CollectMarkdownFiles(docsRoot, files); + return files + .Select(f => MdFileToRoute(docsRoot, f)) + .Distinct(StringComparer.Ordinal) + .OrderBy(r => r, StringComparer.Ordinal) + .ToList(); + } + + private static void CollectMarkdownFiles(string dir, List results) + { + foreach (var entry in Directory.EnumerateFileSystemEntries(dir)) + { + var name = Path.GetFileName(entry); + if (Directory.Exists(entry)) + { + // Skip node_modules and any dot-prefixed directory + // (.vitepress carries the build cache + dist). + if (name == "node_modules" || name.StartsWith('.')) continue; + CollectMarkdownFiles(entry, results); + } + else if (File.Exists(entry) && entry.EndsWith(".md", StringComparison.OrdinalIgnoreCase)) + { + results.Add(entry); + } + } + } + + // ─── Route check ───────────────────────────────────────────────────────── + + // Detects VitePress's client-side 404 page. The original Node + // crawler used three signals; mirror them here exactly so the + // test's failure surface matches what the script caught. + private static async Task<(string Route, string Indicator)?> CheckRouteAsync(IBrowser browser, string baseUrl, string route) + { + var url = baseUrl + route; + var page = await browser.NewPageAsync(); + try + { + await page.GotoAsync(url, new PageGotoOptions + { + WaitUntil = WaitUntilState.NetworkIdle, + Timeout = PageNavigationTimeoutMs, + }); + + var detection = await page.EvaluateAsync(@"() => ({ + hasClass: !!document.querySelector('.NotFound'), + title404: document.title === '404', + bodyMatches: (document.body?.innerText ?? '').toUpperCase().includes('PAGE NOT FOUND') + })"); + + if (detection.HasClass) return (route, ".NotFound element present"); + if (detection.Title404) return (route, "document.title == '404'"); + if (detection.BodyMatches) return (route, "body text contains 'PAGE NOT FOUND'"); + return null; + } + finally + { + await page.CloseAsync(); + } + } + + private sealed class NotFoundDetection + { + public bool HasClass { get; set; } + public bool Title404 { get; set; } + public bool BodyMatches { get; set; } + } + + // ─── Preview server lifecycle ──────────────────────────────────────────── + + private static int FindFreePort() + { + var listener = new System.Net.Sockets.TcpListener(System.Net.IPAddress.Loopback, 0); + listener.Start(); + var port = ((System.Net.IPEndPoint)listener.LocalEndpoint).Port; + listener.Stop(); + return port; + } + + private static Process StartPreviewServer(int port, string distDir, string docsRoot) + { + // Use `npx vitepress preview` so the local node_modules copy + // is invoked without needing a global install. On Windows the + // `npx` shim is npx.cmd; ProcessStartInfo doesn't auto-resolve + // the extension, so name it explicitly. + var isWindows = RuntimeInformation.IsOSPlatform(OSPlatform.Windows); + var fileName = isWindows ? "npx.cmd" : "npx"; + + var psi = new ProcessStartInfo + { + FileName = fileName, + WorkingDirectory = docsRoot, + UseShellExecute = false, + RedirectStandardOutput = true, + RedirectStandardError = true, + CreateNoWindow = true, + }; + psi.ArgumentList.Add("vitepress"); + psi.ArgumentList.Add("preview"); + psi.ArgumentList.Add("--port"); + psi.ArgumentList.Add(port.ToString(System.Globalization.CultureInfo.InvariantCulture)); + psi.ArgumentList.Add("--outDir"); + psi.ArgumentList.Add(distDir); + + var proc = Process.Start(psi) + ?? throw new InvalidOperationException("Failed to start `vitepress preview` process"); + + // Drain stdout/stderr so the child doesn't block on a full + // pipe; discard the output (preview's banner is noise). + _ = Task.Run(async () => + { + try { while (await proc.StandardOutput.ReadLineAsync() is not null) { } } + catch { /* process exited */ } + }); + _ = Task.Run(async () => + { + try { while (await proc.StandardError.ReadLineAsync() is not null) { } } + catch { /* process exited */ } + }); + + return proc; + } + + private static async Task WaitForServerAsync(int port) + { + var deadline = DateTime.UtcNow.AddMilliseconds(ServerReadyTimeoutMs); + while (DateTime.UtcNow < deadline) + { + try + { + using var client = new TcpClient(); + await client.ConnectAsync(System.Net.IPAddress.Loopback, port); + return; + } + catch (SocketException) + { + await Task.Delay(ServerReadyPollMs); + } + } + throw new TimeoutException($"vitepress preview did not bind to 127.0.0.1:{port} within {ServerReadyTimeoutMs / 1000}s"); + } + + private static void StopPreviewServer(Process? proc) + { + if (proc is null) return; + try + { + if (!proc.HasExited) + { + // Kill the whole process tree — `npx` spawns + // `vitepress`, which spawns the node preview server; + // killing only npx leaves the actual server orphaned. + proc.Kill(entireProcessTree: true); + proc.WaitForExit(5_000); + } + } + catch + { + /* best-effort cleanup */ + } + finally + { + proc.Dispose(); + } + } + + // ─── npm bootstrap ─────────────────────────────────────────────────────── + + private static void RunNpm(string arguments, string workingDirectory) + { + var isWindows = RuntimeInformation.IsOSPlatform(OSPlatform.Windows); + var fileName = isWindows ? "npm.cmd" : "npm"; + + var psi = new ProcessStartInfo + { + FileName = fileName, + WorkingDirectory = workingDirectory, + UseShellExecute = false, + RedirectStandardOutput = true, + RedirectStandardError = true, + CreateNoWindow = true, + }; + foreach (var token in arguments.Split(' ', StringSplitOptions.RemoveEmptyEntries)) + { + psi.ArgumentList.Add(token); + } + + var proc = Process.Start(psi) + ?? throw new InvalidOperationException($"Failed to start `npm {arguments}`"); + + // Drain both pipes so the child doesn't block; surface output + // on failure for diagnostics. + var stdout = proc.StandardOutput.ReadToEnd(); + var stderr = proc.StandardError.ReadToEnd(); + proc.WaitForExit(); + + if (proc.ExitCode != 0) + { + throw new InvalidOperationException( + $"`npm {arguments}` exited {proc.ExitCode}{Environment.NewLine}stdout:{Environment.NewLine}{stdout}{Environment.NewLine}stderr:{Environment.NewLine}{stderr}"); + } + } +} From 16346f27183cf1e35cc4b9d8990b1193d638585e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Otto=20Boly=C3=B3s?= Date: Mon, 1 Jun 2026 08:29:35 +0200 Subject: [PATCH 07/27] ci(repo): provide Node on the dotnet matrix for the docs e2e test MTConnect.NET-Docs-Tests now ships a Category=E2E route walk whose [OneTimeSetUp] bootstraps the docs site via `npm ci && npm run build` when dist/ is missing, then drives `vitepress preview` through Microsoft.Playwright. Only the ubuntu-latest leg runs Category=E2E (windows-latest filters it out), so pin Node 20 there with the npm cache keyed on docs/package-lock.json. The existing `dotnet test MTConnect.NET.sln` invocation already discovers the test project; no separate test step is needed. --- .github/workflows/dotnet.yml | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/.github/workflows/dotnet.yml b/.github/workflows/dotnet.yml index 8484837cf..9dd7da60b 100644 --- a/.github/workflows/dotnet.yml +++ b/.github/workflows/dotnet.yml @@ -65,6 +65,20 @@ jobs: 8.0.x 9.0.x + # MTConnect.NET-Docs-Tests carries a Category=E2E route walk + # whose [OneTimeSetUp] runs `npm ci && npm run build` from + # docs/ when the dist/ artifact is missing, then drives + # `vitepress preview` through Microsoft.Playwright. Only the + # ubuntu-latest leg runs Category=E2E (the windows-latest leg + # excludes it via testFilter), so Node is only required there. + - name: Setup Node.js (docs e2e prerequisite) + if: matrix.os == 'ubuntu-latest' + uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0 + with: + node-version: '20' + cache: npm + cache-dependency-path: docs/package-lock.json + - name: Restore dotnet tools (ReportGenerator) run: dotnet tool restore From 67116decea2c83aa2ec7034a07a7b59f6ab6a2ab Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Otto=20Boly=C3=B3s?= Date: Mon, 1 Jun 2026 10:04:21 +0200 Subject: [PATCH 08/27] test(docs-tests): surface vitepress preview output in route-check failures --- .../RouteCheckTests.cs | 54 +++++++++++++++---- 1 file changed, 43 insertions(+), 11 deletions(-) diff --git a/tests/MTConnect.NET-Docs-Tests/RouteCheckTests.cs b/tests/MTConnect.NET-Docs-Tests/RouteCheckTests.cs index 179c545ff..e0450cb34 100644 --- a/tests/MTConnect.NET-Docs-Tests/RouteCheckTests.cs +++ b/tests/MTConnect.NET-Docs-Tests/RouteCheckTests.cs @@ -49,6 +49,7 @@ public class RouteCheckTests private IBrowser? _browser; private string _baseUrl = string.Empty; private string _docsRoot = string.Empty; + private readonly System.Text.StringBuilder _previewLog = new(); private static string RepoRoot { @@ -74,8 +75,11 @@ public async Task OneTimeSetUp() // doesn't already exist, run `npm ci && npm run build` from // docs/ to produce it. `npm run build` invokes the `prebuild` // hook (regenerate api + reference), so the rendered site - // matches what CI builds on every push. - if (!Directory.Exists(distDir) || !Directory.EnumerateFileSystemEntries(distDir).Any()) + // matches what CI builds on every push. The presence check + // looks for index.html specifically because a partial / + // stale dist tree (e.g. just an assets/ subdirectory left + // from a prior aborted run) is not a usable preview target. + if (!File.Exists(Path.Combine(distDir, "index.html"))) { RunNpm("ci", _docsRoot); RunNpm("run build", _docsRoot); @@ -91,8 +95,8 @@ public async Task OneTimeSetUp() var port = FindFreePort(); _baseUrl = $"http://127.0.0.1:{port}"; - _previewServer = StartPreviewServer(port, distDir, _docsRoot); - await WaitForServerAsync(port); + _previewServer = StartPreviewServer(port, distDir, _docsRoot, _previewLog); + await WaitForServerAsync(port, _previewServer, _previewLog); _playwright = await Playwright.CreateAsync(); _browser = await _playwright.Chromium.LaunchAsync(new BrowserTypeLaunchOptions { Headless = true }); @@ -245,7 +249,7 @@ private static int FindFreePort() return port; } - private static Process StartPreviewServer(int port, string distDir, string docsRoot) + private static Process StartPreviewServer(int port, string distDir, string docsRoot, System.Text.StringBuilder log) { // Use `npx vitepress preview` so the local node_modules copy // is invoked without needing a global install. On Windows the @@ -273,27 +277,52 @@ private static Process StartPreviewServer(int port, string distDir, string docsR var proc = Process.Start(psi) ?? throw new InvalidOperationException("Failed to start `vitepress preview` process"); - // Drain stdout/stderr so the child doesn't block on a full - // pipe; discard the output (preview's banner is noise). + // Drain stdout/stderr into the shared log buffer so the + // child doesn't block on a full pipe and so the readiness + // wait can surface the actual error if the preview fails + // before binding (e.g. dist/ missing, port collision the + // free-port finder lost the race to, vitepress CLI usage + // error). _ = Task.Run(async () => { - try { while (await proc.StandardOutput.ReadLineAsync() is not null) { } } + try + { + string? line; + while ((line = await proc.StandardOutput.ReadLineAsync()) is not null) + { + lock (log) log.AppendLine("[stdout] " + line); + } + } catch { /* process exited */ } }); _ = Task.Run(async () => { - try { while (await proc.StandardError.ReadLineAsync() is not null) { } } + try + { + string? line; + while ((line = await proc.StandardError.ReadLineAsync()) is not null) + { + lock (log) log.AppendLine("[stderr] " + line); + } + } catch { /* process exited */ } }); return proc; } - private static async Task WaitForServerAsync(int port) + private static async Task WaitForServerAsync(int port, Process proc, System.Text.StringBuilder log) { var deadline = DateTime.UtcNow.AddMilliseconds(ServerReadyTimeoutMs); while (DateTime.UtcNow < deadline) { + if (proc.HasExited) + { + string snapshot; + lock (log) snapshot = log.ToString(); + throw new InvalidOperationException( + $"vitepress preview exited prematurely with code {proc.ExitCode} before binding to 127.0.0.1:{port}.{Environment.NewLine}{snapshot}"); + } try { using var client = new TcpClient(); @@ -305,7 +334,10 @@ private static async Task WaitForServerAsync(int port) await Task.Delay(ServerReadyPollMs); } } - throw new TimeoutException($"vitepress preview did not bind to 127.0.0.1:{port} within {ServerReadyTimeoutMs / 1000}s"); + string finalSnapshot; + lock (log) finalSnapshot = log.ToString(); + throw new TimeoutException( + $"vitepress preview did not bind to 127.0.0.1:{port} within {ServerReadyTimeoutMs / 1000}s.{Environment.NewLine}{finalSnapshot}"); } private static void StopPreviewServer(Process? proc) From ec6e5c3434ed940455c5b0f038cf6ac78686b893 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Otto=20Boly=C3=B3s?= Date: Mon, 1 Jun 2026 12:35:40 +0200 Subject: [PATCH 09/27] ci(repo): cache Playwright browsers between dotnet matrix runs --- .github/workflows/dotnet.yml | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/.github/workflows/dotnet.yml b/.github/workflows/dotnet.yml index 9dd7da60b..f940677e1 100644 --- a/.github/workflows/dotnet.yml +++ b/.github/workflows/dotnet.yml @@ -88,6 +88,24 @@ jobs: - name: Build (Debug) run: dotnet build MTConnect.NET.sln --configuration Debug --no-restore + # Cache Playwright browser binaries so the Category=E2E + # [OneTimeSetUp] `playwright install chromium` call is a no-op on + # cache hit. The key includes the test project's csproj hash so + # a Microsoft.Playwright NuGet version bump auto-invalidates. + # Both Linux (~/.cache/ms-playwright) and Windows + # (%USERPROFILE%\AppData\Local\ms-playwright) paths are listed; + # actions/cache restores only the path that exists on the running + # OS, so the multi-path entry is safe for both matrix legs. + - name: Cache Playwright browsers + uses: actions/cache@v4 + with: + path: | + ~/.cache/ms-playwright + ~\AppData\Local\ms-playwright + key: ${{ runner.os }}-playwright-${{ hashFiles('**/tests/MTConnect.NET-Docs-Tests/MTConnect.NET-Docs-Tests.csproj') }} + restore-keys: | + ${{ runner.os }}-playwright- + # MTConnect.NET-Integration-Tests is skipped here (its # IsTestProject is false unless IntegrationCoverage=true) and run # in the dedicated step below so it can use its own From d8721ce72552fb4b39cf8a0250736cfe966da2be Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Otto=20Boly=C3=B3s?= Date: Mon, 1 Jun 2026 12:47:54 +0200 Subject: [PATCH 10/27] fix(docs-tests): wait for Load instead of NetworkIdle in route check MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit VitePress's SPA keeps background work alive indefinitely (analytics, web-vitals, hot-reload polling), so NetworkIdle never settles within the 15 s window. Switch to WaitUntilState.Load, which fires once all sub-resources finish — well before background JS quiets — and is sufficient for the .NotFound / title / body-text 404 detection. Raise PageNavigationTimeoutMs to 30 000 ms to give complex pages (e.g. multiple Mermaid diagrams) enough headroom under Load. --- tests/MTConnect.NET-Docs-Tests/RouteCheckTests.cs | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/tests/MTConnect.NET-Docs-Tests/RouteCheckTests.cs b/tests/MTConnect.NET-Docs-Tests/RouteCheckTests.cs index e0450cb34..abc4d7d3d 100644 --- a/tests/MTConnect.NET-Docs-Tests/RouteCheckTests.cs +++ b/tests/MTConnect.NET-Docs-Tests/RouteCheckTests.cs @@ -42,7 +42,7 @@ public class RouteCheckTests private const int Concurrency = 8; private const int ServerReadyPollMs = 200; private const int ServerReadyTimeoutMs = 60_000; - private const int PageNavigationTimeoutMs = 15_000; + private const int PageNavigationTimeoutMs = 30_000; private Process? _previewServer; private IPlaywright? _playwright; @@ -202,6 +202,14 @@ private static void CollectMarkdownFiles(string dir, List results) // Detects VitePress's client-side 404 page. The original Node // crawler used three signals; mirror them here exactly so the // test's failure surface matches what the script caught. + // + // WaitUntilState.Load (not NetworkIdle) is used here deliberately. + // VitePress's SPA keeps background work running indefinitely — + // analytics pings, web-vitals beacons, hot-reload polling — so + // NetworkIdle never settles within any reasonable timeout. All + // three 404 signals (.NotFound selector, document.title, body text) + // are available as soon as the DOM is fully parsed and sub-resources + // have finished loading, which is exactly what Load guarantees. private static async Task<(string Route, string Indicator)?> CheckRouteAsync(IBrowser browser, string baseUrl, string route) { var url = baseUrl + route; @@ -210,7 +218,7 @@ private static void CollectMarkdownFiles(string dir, List results) { await page.GotoAsync(url, new PageGotoOptions { - WaitUntil = WaitUntilState.NetworkIdle, + WaitUntil = WaitUntilState.Load, Timeout = PageNavigationTimeoutMs, }); From 70faba36c9ba7fc12ad118b187022e117791bfdc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Otto=20Boly=C3=B3s?= Date: Tue, 2 Jun 2026 15:09:27 +0200 Subject: [PATCH 11/27] fix(docs-tests): resolve port TOCTOU by parsing vitepress banner --- .../RouteCheckTests.cs | 50 ++++++++++++++++--- 1 file changed, 43 insertions(+), 7 deletions(-) diff --git a/tests/MTConnect.NET-Docs-Tests/RouteCheckTests.cs b/tests/MTConnect.NET-Docs-Tests/RouteCheckTests.cs index abc4d7d3d..5a1a46244 100644 --- a/tests/MTConnect.NET-Docs-Tests/RouteCheckTests.cs +++ b/tests/MTConnect.NET-Docs-Tests/RouteCheckTests.cs @@ -8,6 +8,7 @@ using System.Linq; using System.Net.Sockets; using System.Runtime.InteropServices; +using System.Text.RegularExpressions; using System.Threading; using System.Threading.Tasks; using Microsoft.Playwright; @@ -93,10 +94,15 @@ public async Task OneTimeSetUp() throw new InvalidOperationException($"`playwright install chromium` exited {installExit}"); } + // Hand the port allocation off to vitepress so there is no TOCTOU + // window between picking a free port and binding it. `--port 0` is + // a hint, not a guarantee — vitepress can ignore it and pick its + // own default (5173) — so the actual bound port is parsed off the + // drained startup banner (`Local: http://localhost:NNNN/`). var port = FindFreePort(); - _baseUrl = $"http://127.0.0.1:{port}"; _previewServer = StartPreviewServer(port, distDir, _docsRoot, _previewLog); - await WaitForServerAsync(port, _previewServer, _previewLog); + var boundPort = await WaitForServerAsync(port, _previewServer, _previewLog); + _baseUrl = $"http://127.0.0.1:{boundPort}"; _playwright = await Playwright.CreateAsync(); _browser = await _playwright.Chromium.LaunchAsync(new BrowserTypeLaunchOptions { Headless = true }); @@ -319,7 +325,15 @@ private static Process StartPreviewServer(int port, string distDir, string docsR return proc; } - private static async Task WaitForServerAsync(int port, Process proc, System.Text.StringBuilder log) + // Match the vitepress startup banner so we can confirm which port the + // child actually bound to. If the requested port was claimed by another + // process between FindFreePort returning and vitepress binding, the + // server's own log is the source of truth — not the port we asked for. + private static readonly Regex PreviewBannerPortRegex = new( + @"https?://(?:localhost|127\.0\.0\.1)(?::(?\d+))?/?", + RegexOptions.Compiled | RegexOptions.IgnoreCase); + + private static async Task WaitForServerAsync(int requestedPort, Process proc, System.Text.StringBuilder log) { var deadline = DateTime.UtcNow.AddMilliseconds(ServerReadyTimeoutMs); while (DateTime.UtcNow < deadline) @@ -329,13 +343,19 @@ private static async Task WaitForServerAsync(int port, Process proc, System.Text string snapshot; lock (log) snapshot = log.ToString(); throw new InvalidOperationException( - $"vitepress preview exited prematurely with code {proc.ExitCode} before binding to 127.0.0.1:{port}.{Environment.NewLine}{snapshot}"); + $"vitepress preview exited prematurely with code {proc.ExitCode} before binding to 127.0.0.1:{requestedPort}.{Environment.NewLine}{snapshot}"); } + + // Prefer the port reported in the startup banner — it is the + // authoritative answer to "which port did the child actually + // bind?". Falls back to the requested port if the banner has + // not yet been drained. + var observedPort = ExtractBannerPort(log) ?? requestedPort; try { using var client = new TcpClient(); - await client.ConnectAsync(System.Net.IPAddress.Loopback, port); - return; + await client.ConnectAsync(System.Net.IPAddress.Loopback, observedPort); + return observedPort; } catch (SocketException) { @@ -345,7 +365,23 @@ private static async Task WaitForServerAsync(int port, Process proc, System.Text string finalSnapshot; lock (log) finalSnapshot = log.ToString(); throw new TimeoutException( - $"vitepress preview did not bind to 127.0.0.1:{port} within {ServerReadyTimeoutMs / 1000}s.{Environment.NewLine}{finalSnapshot}"); + $"vitepress preview did not bind to 127.0.0.1:{requestedPort} within {ServerReadyTimeoutMs / 1000}s.{Environment.NewLine}{finalSnapshot}"); + } + + private static int? ExtractBannerPort(System.Text.StringBuilder log) + { + string snapshot; + lock (log) snapshot = log.ToString(); + // vitepress prints something like: + // ➜ Local: http://localhost:4173/ + // Scan every match and prefer the highest-port hit (the banner + // can also include the Network: line, both share the same port). + foreach (Match m in PreviewBannerPortRegex.Matches(snapshot)) + { + var g = m.Groups["port"]; + if (g.Success && int.TryParse(g.Value, out var p) && p > 0) return p; + } + return null; } private static void StopPreviewServer(Process? proc) From 019411ed14c41a73116ebaa98368b1334890df65 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Otto=20Boly=C3=B3s?= Date: Tue, 2 Jun 2026 15:10:56 +0200 Subject: [PATCH 12/27] fix(docs-tests): track drain tasks and narrow exception handling --- .../RouteCheckTests.cs | 73 ++++++++++++------- 1 file changed, 48 insertions(+), 25 deletions(-) diff --git a/tests/MTConnect.NET-Docs-Tests/RouteCheckTests.cs b/tests/MTConnect.NET-Docs-Tests/RouteCheckTests.cs index 5a1a46244..d2e5d2a57 100644 --- a/tests/MTConnect.NET-Docs-Tests/RouteCheckTests.cs +++ b/tests/MTConnect.NET-Docs-Tests/RouteCheckTests.cs @@ -51,6 +51,8 @@ public class RouteCheckTests private string _baseUrl = string.Empty; private string _docsRoot = string.Empty; private readonly System.Text.StringBuilder _previewLog = new(); + private Task? _previewStdoutDrain; + private Task? _previewStderrDrain; private static string RepoRoot { @@ -100,7 +102,8 @@ public async Task OneTimeSetUp() // own default (5173) — so the actual bound port is parsed off the // drained startup banner (`Local: http://localhost:NNNN/`). var port = FindFreePort(); - _previewServer = StartPreviewServer(port, distDir, _docsRoot, _previewLog); + (_previewServer, _previewStdoutDrain, _previewStderrDrain) = + StartPreviewServer(port, distDir, _docsRoot, _previewLog); var boundPort = await WaitForServerAsync(port, _previewServer, _previewLog); _baseUrl = $"http://127.0.0.1:{boundPort}"; @@ -114,6 +117,23 @@ public async Task OneTimeTearDown() if (_browser is not null) await _browser.CloseAsync(); _playwright?.Dispose(); StopPreviewServer(_previewServer); + // Drain tasks complete when ReadLineAsync returns null (the pipes + // close once Kill takes the child down). A small bounded wait stops + // a stuck reader from holding teardown indefinitely. + await AwaitDrainAsync(_previewStdoutDrain); + await AwaitDrainAsync(_previewStderrDrain); + } + + private static async Task AwaitDrainAsync(Task? drain) + { + if (drain is null) return; + var completed = await Task.WhenAny(drain, Task.Delay(2_000)); + if (completed == drain) + { + try { await drain; } + catch (IOException) { /* pipe closed mid-read on Kill */ } + catch (ObjectDisposedException) { /* process disposed */ } + } } [Test] @@ -263,7 +283,7 @@ private static int FindFreePort() return port; } - private static Process StartPreviewServer(int port, string distDir, string docsRoot, System.Text.StringBuilder log) + private static (Process Process, Task StdoutDrain, Task StderrDrain) StartPreviewServer(int port, string distDir, string docsRoot, System.Text.StringBuilder log) { // Use `npx vitepress preview` so the local node_modules copy // is invoked without needing a global install. On Windows the @@ -296,33 +316,30 @@ private static Process StartPreviewServer(int port, string distDir, string docsR // wait can surface the actual error if the preview fails // before binding (e.g. dist/ missing, port collision the // free-port finder lost the race to, vitepress CLI usage - // error). - _ = Task.Run(async () => - { - try - { - string? line; - while ((line = await proc.StandardOutput.ReadLineAsync()) is not null) - { - lock (log) log.AppendLine("[stdout] " + line); - } - } - catch { /* process exited */ } - }); - _ = Task.Run(async () => + // error). The drain tasks are tracked so OneTimeTearDown can + // await them after Kill; an exception narrower than `catch` + // keeps real failures visible. + var stdoutDrain = DrainPipeAsync(proc.StandardOutput, log, "[stdout] "); + var stderrDrain = DrainPipeAsync(proc.StandardError, log, "[stderr] "); + + return (proc, stdoutDrain, stderrDrain); + } + + private static Task DrainPipeAsync(System.IO.StreamReader reader, System.Text.StringBuilder log, string prefix) + { + return Task.Run(async () => { try { string? line; - while ((line = await proc.StandardError.ReadLineAsync()) is not null) + while ((line = await reader.ReadLineAsync()) is not null) { - lock (log) log.AppendLine("[stderr] " + line); + lock (log) log.AppendLine(prefix + line); } } - catch { /* process exited */ } + catch (IOException) { /* pipe closed mid-read on Kill */ } + catch (ObjectDisposedException) { /* process disposed */ } }); - - return proc; } // Match the vitepress startup banner so we can confirm which port the @@ -432,10 +449,16 @@ private static void RunNpm(string arguments, string workingDirectory) var proc = Process.Start(psi) ?? throw new InvalidOperationException($"Failed to start `npm {arguments}`"); - // Drain both pipes so the child doesn't block; surface output - // on failure for diagnostics. - var stdout = proc.StandardOutput.ReadToEnd(); - var stderr = proc.StandardError.ReadToEnd(); + // 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(); + Task.WaitAll(stdoutTask, stderrTask); + var stdout = stdoutTask.Result; + var stderr = stderrTask.Result; proc.WaitForExit(); if (proc.ExitCode != 0) From 6342659113c4011dad3fa9868caff6d8eb693185 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Otto=20Boly=C3=B3s?= Date: Tue, 2 Jun 2026 15:11:47 +0200 Subject: [PATCH 13/27] perf(docs-tests): reuse one BrowserContext per worker in route check --- .../RouteCheckTests.cs | 68 ++++++++++++------- 1 file changed, 44 insertions(+), 24 deletions(-) diff --git a/tests/MTConnect.NET-Docs-Tests/RouteCheckTests.cs b/tests/MTConnect.NET-Docs-Tests/RouteCheckTests.cs index d2e5d2a57..92ef4aed5 100644 --- a/tests/MTConnect.NET-Docs-Tests/RouteCheckTests.cs +++ b/tests/MTConnect.NET-Docs-Tests/RouteCheckTests.cs @@ -144,28 +144,7 @@ public async Task Every_Markdown_Backed_Route_Resolves_Without_A_404() var routes = CollectRoutes(_docsRoot); Assert.That(routes.Count, Is.GreaterThan(0), "expected at least one markdown-backed route"); - var failures = new List<(string Route, string Indicator)>(); - var failuresLock = new object(); - using var semaphore = new SemaphoreSlim(Concurrency); - - var tasks = routes.Select(async route => - { - await semaphore.WaitAsync(); - try - { - var failure = await CheckRouteAsync(_browser!, _baseUrl, route); - if (failure is not null) - { - lock (failuresLock) failures.Add(failure.Value); - } - } - finally - { - semaphore.Release(); - } - }); - - await Task.WhenAll(tasks); + var failures = await WalkRoutesAsync(_browser!, _baseUrl, routes, Concurrency); if (failures.Count > 0) { @@ -175,6 +154,47 @@ public async Task Every_Markdown_Backed_Route_Resolves_Without_A_404() } } + // Walk every route with one BrowserContext per worker. Allocating a + // context once per worker (vs once per route via Browser.NewPageAsync) + // amortises the ~0.5–1 s context-creation cost over the full share + // and exercises VitePress's warm-router path on the second visit + // onwards. Expected 5–10× wall-time reduction over per-route contexts. + private static async Task> WalkRoutesAsync( + IBrowser browser, string baseUrl, List routes, int workerCount) + { + var failures = new List<(string Route, string Indicator)>(); + var failuresLock = new object(); + + // FIFO queue of routes; workers pull from the same queue so + // a slow page on one worker doesn't strand its pre-allocated + // share — work-stealing falls out for free. + var queue = new System.Collections.Concurrent.ConcurrentQueue(routes); + + var workers = Enumerable.Range(0, Math.Min(workerCount, routes.Count)) + .Select(async _ => + { + var context = await browser.NewContextAsync(); + try + { + while (queue.TryDequeue(out var route)) + { + var failure = await CheckRouteAsync(context, baseUrl, route); + if (failure is not null) + { + lock (failuresLock) failures.Add(failure.Value); + } + } + } + finally + { + await context.CloseAsync(); + } + }); + + await Task.WhenAll(workers); + return failures; + } + // ─── Route derivation ──────────────────────────────────────────────────── // Convert an absolute .md file path to the VitePress route it maps to. @@ -236,10 +256,10 @@ private static void CollectMarkdownFiles(string dir, List results) // three 404 signals (.NotFound selector, document.title, body text) // are available as soon as the DOM is fully parsed and sub-resources // have finished loading, which is exactly what Load guarantees. - private static async Task<(string Route, string Indicator)?> CheckRouteAsync(IBrowser browser, string baseUrl, string route) + private static async Task<(string Route, string Indicator)?> CheckRouteAsync(IBrowserContext context, string baseUrl, string route) { var url = baseUrl + route; - var page = await browser.NewPageAsync(); + var page = await context.NewPageAsync(); try { await page.GotoAsync(url, new PageGotoOptions From 0e5bdba0a8492175a421ace432ceae0fd74c30d3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Otto=20Boly=C3=B3s?= Date: Tue, 2 Jun 2026 15:12:51 +0200 Subject: [PATCH 14/27] ci(repo): key Playwright cache on installed chromium version --- .github/workflows/dotnet.yml | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/.github/workflows/dotnet.yml b/.github/workflows/dotnet.yml index f940677e1..b23bd4bf3 100644 --- a/.github/workflows/dotnet.yml +++ b/.github/workflows/dotnet.yml @@ -90,8 +90,14 @@ jobs: # Cache Playwright browser binaries so the Category=E2E # [OneTimeSetUp] `playwright install chromium` call is a no-op on - # cache hit. The key includes the test project's csproj hash so - # a Microsoft.Playwright NuGet version bump auto-invalidates. + # cache hit. The key hashes the `playwright-core` package.json that + # the Microsoft.Playwright NuGet copies into the build output — + # that file's `version` field directly pins the chromium revision + # the .NET binding drives. Hashing the csproj instead would only + # invalidate by coincidence (a future PR adding an unrelated + # PackageReference would invalidate; an SDK-side change to the + # chromium revision would not). The step therefore runs AFTER + # `dotnet build` so the file exists. # Both Linux (~/.cache/ms-playwright) and Windows # (%USERPROFILE%\AppData\Local\ms-playwright) paths are listed; # actions/cache restores only the path that exists on the running @@ -102,7 +108,7 @@ jobs: path: | ~/.cache/ms-playwright ~\AppData\Local\ms-playwright - key: ${{ runner.os }}-playwright-${{ hashFiles('**/tests/MTConnect.NET-Docs-Tests/MTConnect.NET-Docs-Tests.csproj') }} + key: ${{ runner.os }}-playwright-${{ hashFiles('**/tests/MTConnect.NET-Docs-Tests/bin/Debug/net8.0/.playwright/package/package.json') }} restore-keys: | ${{ runner.os }}-playwright- From 646ccc421a7fdd8eb37d34ed82c50604abc7ed70 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Otto=20Boly=C3=B3s?= Date: Tue, 2 Jun 2026 15:14:43 +0200 Subject: [PATCH 15/27] test(docs-tests): add negative route assertion and harden setup error path --- .../RouteCheckTests.cs | 133 +++++++++++++----- 1 file changed, 96 insertions(+), 37 deletions(-) diff --git a/tests/MTConnect.NET-Docs-Tests/RouteCheckTests.cs b/tests/MTConnect.NET-Docs-Tests/RouteCheckTests.cs index 92ef4aed5..62c8cd5dc 100644 --- a/tests/MTConnect.NET-Docs-Tests/RouteCheckTests.cs +++ b/tests/MTConnect.NET-Docs-Tests/RouteCheckTests.cs @@ -54,61 +54,96 @@ public class RouteCheckTests private Task? _previewStdoutDrain; private Task? _previewStderrDrain; + // Walk-up bound is intentionally large (32) rather than fitting the + // current `bin/Debug/netN.N/` suffix exactly. A deeper container path, + // a vendored / submoduled checkout, or a future test-output relayout + // can extend the chain without re-tripping this guard. The exception + // message names both the starting BaseDirectory and the depth walked + // so a future failure is diagnosable from the assertion alone. + private const int RepoRootMaxAncestorDepth = 32; + private static string RepoRoot { get { - var dir = AppContext.BaseDirectory.TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar); - for (int i = 0; i < 10 && !string.IsNullOrEmpty(dir); i++) + var start = AppContext.BaseDirectory; + var dir = start.TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar); + int depth; + for (depth = 0; depth < RepoRootMaxAncestorDepth && !string.IsNullOrEmpty(dir); depth++) { if (File.Exists(Path.Combine(dir, "MTConnect.NET.sln"))) return dir; dir = Path.GetDirectoryName(dir)!; } - throw new InvalidOperationException("Could not locate repository root from " + AppContext.BaseDirectory); + throw new InvalidOperationException( + $"Could not locate repository root: walked {depth} ancestor(s) up from BaseDirectory '{start}' without finding MTConnect.NET.sln."); } } [OneTimeSetUp] public async Task OneTimeSetUp() { - _docsRoot = Path.Combine(RepoRoot, "docs"); - var distDir = Path.Combine(_docsRoot, ".vitepress", "dist"); - - // The test owns its build prerequisite. If the dist/ tree - // doesn't already exist, run `npm ci && npm run build` from - // docs/ to produce it. `npm run build` invokes the `prebuild` - // hook (regenerate api + reference), so the rendered site - // matches what CI builds on every push. The presence check - // looks for index.html specifically because a partial / - // stale dist tree (e.g. just an assets/ subdirectory left - // from a prior aborted run) is not a usable preview target. - if (!File.Exists(Path.Combine(distDir, "index.html"))) + // Wrap the bootstrap so a partial failure (npm bootstrap throw, + // chromium-install non-zero exit, preview-server bind timeout) + // rethrows with whatever state was captured up to that point — + // the partial `_previewLog`, the bootstrap stage that failed — + // rather than a bare exception with no context. OneTimeTearDown + // runs unconditionally and cleans up whatever was allocated. + var stage = "init"; + try { - RunNpm("ci", _docsRoot); - RunNpm("run build", _docsRoot); - } + _docsRoot = Path.Combine(RepoRoot, "docs"); + var distDir = Path.Combine(_docsRoot, ".vitepress", "dist"); + + // The test owns its build prerequisite. If the dist/ tree + // doesn't already exist, run `npm ci && npm run build` from + // docs/ to produce it. `npm run build` invokes the `prebuild` + // hook (regenerate api + reference), so the rendered site + // matches what CI builds on every push. The presence check + // looks for index.html specifically because a partial / + // stale dist tree (e.g. just an assets/ subdirectory left + // from a prior aborted run) is not a usable preview target. + if (!File.Exists(Path.Combine(distDir, "index.html"))) + { + stage = "npm ci"; + RunNpm("ci", _docsRoot); + stage = "npm run build"; + RunNpm("run build", _docsRoot); + } - // Install the chromium binary the Playwright .NET binding drives. - // Idempotent: re-installs cleanly if the cache is already warm. - var installExit = Microsoft.Playwright.Program.Main(new[] { "install", "chromium" }); - if (installExit != 0) + // Install the chromium binary the Playwright .NET binding drives. + // Idempotent: re-installs cleanly if the cache is already warm. + stage = "playwright install chromium"; + var installExit = Microsoft.Playwright.Program.Main(new[] { "install", "chromium" }); + if (installExit != 0) + { + throw new InvalidOperationException($"`playwright install chromium` exited {installExit}"); + } + + // Hand the port allocation off to vitepress so there is no TOCTOU + // window between picking a free port and binding it. `--port 0` is + // a hint, not a guarantee — vitepress can ignore it and pick its + // own default (5173) — so the actual bound port is parsed off the + // drained startup banner (`Local: http://localhost:NNNN/`). + stage = "start preview server"; + var port = FindFreePort(); + (_previewServer, _previewStdoutDrain, _previewStderrDrain) = + StartPreviewServer(port, distDir, _docsRoot, _previewLog); + var boundPort = await WaitForServerAsync(port, _previewServer, _previewLog); + _baseUrl = $"http://127.0.0.1:{boundPort}"; + + stage = "launch chromium"; + _playwright = await Playwright.CreateAsync(); + _browser = await _playwright.Chromium.LaunchAsync(new BrowserTypeLaunchOptions { Headless = true }); + } + catch (Exception ex) { - throw new InvalidOperationException($"`playwright install chromium` exited {installExit}"); + string snapshot; + lock (_previewLog) snapshot = _previewLog.ToString(); + var context = string.IsNullOrEmpty(snapshot) + ? $"OneTimeSetUp failed at stage '{stage}' with no preview output captured." + : $"OneTimeSetUp failed at stage '{stage}'. Preview server output captured so far:{Environment.NewLine}{snapshot}"; + throw new InvalidOperationException(context, ex); } - - // Hand the port allocation off to vitepress so there is no TOCTOU - // window between picking a free port and binding it. `--port 0` is - // a hint, not a guarantee — vitepress can ignore it and pick its - // own default (5173) — so the actual bound port is parsed off the - // drained startup banner (`Local: http://localhost:NNNN/`). - var port = FindFreePort(); - (_previewServer, _previewStdoutDrain, _previewStderrDrain) = - StartPreviewServer(port, distDir, _docsRoot, _previewLog); - var boundPort = await WaitForServerAsync(port, _previewServer, _previewLog); - _baseUrl = $"http://127.0.0.1:{boundPort}"; - - _playwright = await Playwright.CreateAsync(); - _browser = await _playwright.Chromium.LaunchAsync(new BrowserTypeLaunchOptions { Headless = true }); } [OneTimeTearDown] @@ -136,6 +171,30 @@ private static async Task AwaitDrainAsync(Task? drain) } } + [Test] + public async Task A_Synthetic_Unmapped_Route_Surfaces_As_A_404() + { + // Pins the detector itself per §10a. If a future Playwright / VitePress + // upgrade silently breaks one of the three signals (.NotFound element, + // document.title === '404', body-text 'PAGE NOT FOUND'), the positive + // test would still pass — every real markdown-backed route would + // continue to render fine — but a real 404 would go undetected. This + // test makes sure the detector fires on a URL that has no markdown + // source behind it. + Assert.That(_browser, Is.Not.Null, "browser was not initialised"); + var context = await _browser!.NewContextAsync(); + try + { + var failure = await CheckRouteAsync(context, _baseUrl, "/this-route-does-not-exist"); + Assert.That(failure, Is.Not.Null, + "expected /this-route-does-not-exist to surface as a 404, but the detector returned no indicator"); + } + finally + { + await context.CloseAsync(); + } + } + [Test] public async Task Every_Markdown_Backed_Route_Resolves_Without_A_404() { From 74a644f965ca69e16ec4d0c3320672ad738648c7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Otto=20Boly=C3=B3s?= Date: Tue, 2 Jun 2026 15:15:13 +0200 Subject: [PATCH 16/27] fix(docs-tests): pin JSON property names on NotFoundDetection record --- tests/MTConnect.NET-Docs-Tests/RouteCheckTests.cs | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/tests/MTConnect.NET-Docs-Tests/RouteCheckTests.cs b/tests/MTConnect.NET-Docs-Tests/RouteCheckTests.cs index 62c8cd5dc..6e2c6d1db 100644 --- a/tests/MTConnect.NET-Docs-Tests/RouteCheckTests.cs +++ b/tests/MTConnect.NET-Docs-Tests/RouteCheckTests.cs @@ -8,6 +8,7 @@ using System.Linq; using System.Net.Sockets; using System.Runtime.InteropServices; +using System.Text.Json.Serialization; using System.Text.RegularExpressions; using System.Threading; using System.Threading.Tasks; @@ -344,10 +345,20 @@ private static void CollectMarkdownFiles(string dir, List results) } } + // The JS payload returned by EvaluateAsync uses camelCase keys + // (`hasClass`, `title404`, `bodyMatches`); pin them explicitly so + // a future Playwright upgrade that tightens case-insensitive + // deserialisation cannot silently turn every route into a + // no-detection pass-through (which would hide a real 404). private sealed class NotFoundDetection { + [JsonPropertyName("hasClass")] public bool HasClass { get; set; } + + [JsonPropertyName("title404")] public bool Title404 { get; set; } + + [JsonPropertyName("bodyMatches")] public bool BodyMatches { get; set; } } From e1dab7e0de0d5c6260d02c91c4aaeb4d0cc1b7d1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Otto=20Boly=C3=B3s?= Date: Tue, 2 Jun 2026 15:16:01 +0200 Subject: [PATCH 17/27] fix(docs-tests): cap concurrency to ProcessorCount and skip reparse points --- .../RouteCheckTests.cs | 20 +++++++++++++++++-- 1 file changed, 18 insertions(+), 2 deletions(-) diff --git a/tests/MTConnect.NET-Docs-Tests/RouteCheckTests.cs b/tests/MTConnect.NET-Docs-Tests/RouteCheckTests.cs index 6e2c6d1db..7b9fb785d 100644 --- a/tests/MTConnect.NET-Docs-Tests/RouteCheckTests.cs +++ b/tests/MTConnect.NET-Docs-Tests/RouteCheckTests.cs @@ -41,7 +41,12 @@ namespace MTConnect.NET_Docs_Tests; [Category("E2E")] public class RouteCheckTests { - private const int Concurrency = 8; + // 8 is the empirical sweet spot on a high-core developer laptop — + // beyond that, vitepress preview's single Node event loop becomes + // the bottleneck and per-route wall time degrades. Capping at the + // visible CPU count keeps a 2-vCPU GitHub-hosted runner from + // over-subscribing (the cap collapses to ProcessorCount there). + private static readonly int Concurrency = Math.Min(8, Environment.ProcessorCount); private const int ServerReadyPollMs = 200; private const int ServerReadyTimeoutMs = 60_000; private const int PageNavigationTimeoutMs = 30_000; @@ -284,9 +289,20 @@ private static List CollectRoutes(string docsRoot) .ToList(); } + // EnumerationOptions skips reparse points (symlinks, junctions) so a + // loop-back symlink in docs/ — `docs/loop -> docs/` — cannot drive + // the recursion into a stack overflow. The recursion stays explicit + // (RecurseSubdirectories = false) so the node_modules + dotfile + // skip rules apply at every level. + private static readonly EnumerationOptions MarkdownEnumeration = new() + { + RecurseSubdirectories = false, + AttributesToSkip = FileAttributes.ReparsePoint, + }; + private static void CollectMarkdownFiles(string dir, List results) { - foreach (var entry in Directory.EnumerateFileSystemEntries(dir)) + foreach (var entry in Directory.EnumerateFileSystemEntries(dir, "*", MarkdownEnumeration)) { var name = Path.GetFileName(entry); if (Directory.Exists(entry)) From c22c763de2c34b99b262bec3406fc6dc5c0dc791 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Otto=20Boly=C3=B3s?= Date: Tue, 2 Jun 2026 15:18:18 +0200 Subject: [PATCH 18/27] refactor(docs): cache toc.yml parse and tighten label override --- docs/.vitepress/sidebar.ts | 44 ++++++++++++++++++++++++++++++++------ 1 file changed, 38 insertions(+), 6 deletions(-) diff --git a/docs/.vitepress/sidebar.ts b/docs/.vitepress/sidebar.ts index 87f0e8bc5..69f28208a 100644 --- a/docs/.vitepress/sidebar.ts +++ b/docs/.vitepress/sidebar.ts @@ -1,4 +1,4 @@ -import { readdirSync, readFileSync, existsSync } from 'node:fs'; +import { readdirSync, readFileSync, statSync, existsSync } from 'node:fs'; import { resolve, dirname } from 'node:path'; import { fileURLToPath } from 'node:url'; @@ -190,6 +190,13 @@ const projectNode = (segment: string, node: ApiNode): SidebarItem => { }; }; +// Module-level cache keyed on toc.yml mtime. VitePress hot-reloads the +// config on any edit to config.ts; without the cache, each hot-reload +// re-reads + re-parses the ~1800-namespace toc.yml. The mtime check +// keeps the cache correct when `npm run regen` rewrites toc.yml in the +// same Node process. +let apiSidebarCache: { mtimeMs: number; sidebar: SidebarItem[] } | undefined; + /** * Build the `/api/` sidebar from docfx's `toc.yml`. Returns a single * top-level "API reference" group whose items are the nested namespace @@ -202,17 +209,38 @@ export const apiSidebar = (): SidebarItem[] => { if (!existsSync(tocPath)) { return [{ text: 'API reference', items: [overview] }]; } - const namespaces = parseToc(readFileSync(tocPath, 'utf8')); + const mtimeMs = statSync(tocPath).mtimeMs; + if (apiSidebarCache && apiSidebarCache.mtimeMs === mtimeMs) { + return apiSidebarCache.sidebar; + } + const tocBody = readFileSync(tocPath, 'utf8'); + const namespaces = parseToc(tocBody); + + // Self-check: every top-level `- name:` line should produce exactly one + // parsed namespace. A mismatch means the hand-rolled YAML parser silently + // dropped entries — most likely a docfx output reformat (extra indent, + // BOM, quoted strings). Fail loudly rather than letting the sidebar + // surface a partial tree. + const nameLineCount = tocBody.split('\n').filter((l) => /^- name: /.test(l)).length; + if (nameLineCount !== namespaces.length) { + throw new Error( + `sidebar.ts: parsed ${namespaces.length} namespace(s) from api/toc.yml but counted ${nameLineCount} top-level entries. ` + + `The hand-rolled parser likely needs updating — check for indentation or quoting changes from docfx.`, + ); + } + const tree = buildApiTree(namespaces); const topLevel = [...tree.children.entries()] .map(([s, n]) => projectNode(s, n)) .sort(byTextCI); - return [ + const sidebar: SidebarItem[] = [ { text: 'API reference', items: [overview, ...topLevel], }, ]; + apiSidebarCache = { mtimeMs, sidebar }; + return sidebar; }; // ─── /reference/ — Roslyn-generated narrative ───────────────────────────── @@ -220,13 +248,17 @@ export const apiSidebar = (): SidebarItem[] => { // Convert a file name like `environment-variables.md` to a sidebar // label like `Environment variables` (lower-case-with-hyphens to // sentence case, keeping mid-word capitals as-is for HTTP/CLI/etc.). +// Overrides cover acronyms only; no trailing-word suffix is added — +// the parent group ("Auto-generated reference") already supplies the +// "reference" context, so adding it per-leaf is redundant and the +// half-applied policy (only on `cli` and `configuration`) was the +// worst of both worlds. const labelFor = (slug: string): string => { - // Hand-tuned overrides for common acronyms / multi-cap labels. const overrides: Record = { - cli: 'CLI reference', + cli: 'CLI', 'http-api': 'HTTP API', 'environment-variables': 'Environment variables', - configuration: 'Configuration schema', + configuration: 'Configuration', }; if (overrides[slug]) return overrides[slug]; const spaced = slug.replace(/-/g, ' '); From 2a227ac87465ccb0992bad8f7e8251e662fff392 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Otto=20Boly=C3=B3s?= Date: Tue, 2 Jun 2026 15:19:15 +0200 Subject: [PATCH 19/27] ci(repo): hoist drift-gate rationale into generate-reference.sh --- .github/workflows/docs.yml | 9 +-------- docs/scripts/generate-reference.sh | 16 ++++++++++++++++ 2 files changed, 17 insertions(+), 8 deletions(-) diff --git a/.github/workflows/docs.yml b/.github/workflows/docs.yml index 177b1c02a..80da041e8 100644 --- a/.github/workflows/docs.yml +++ b/.github/workflows/docs.yml @@ -59,14 +59,7 @@ jobs: working-directory: docs run: npm ci - # Drift gate first, before any regeneration: verifies the committed - # docs/reference/ pages already match what DocsGen would emit from - # the current source tree. The subsequent `npm run build` step - # invokes the `prebuild` hook (in docs/package.json), which runs - # generate-api-ref.sh + generate-reference.sh and overwrites the - # tree on disk — so checking drift after the build would always - # pass. The api/ tree is gitignored and regenerated unconditionally; - # only the reference/ tree carries committed content to drift-check. + # Drift gate runs before regen — rationale lives in generate-reference.sh. - name: Verify reference pages match source (drift gate) run: bash docs/scripts/generate-reference.sh --check diff --git a/docs/scripts/generate-reference.sh b/docs/scripts/generate-reference.sh index 292de8393..f1199b0e4 100755 --- a/docs/scripts/generate-reference.sh +++ b/docs/scripts/generate-reference.sh @@ -15,6 +15,22 @@ # # Requirements: # - dotnet 8 SDK +# +# Drift gate (`--check`) — why it runs before the build, not after: +# +# The VitePress build step in .github/workflows/docs.yml invokes the +# `prebuild` npm hook, which calls this script in regenerate mode and +# overwrites docs/reference/ on disk. A drift check after that step +# would therefore always pass: the just-overwritten files are +# byte-equivalent to whatever the generator just emitted, by +# definition. The drift gate is meaningful only against the committed +# state, so the workflow runs `--check` BEFORE the build. +# +# In `--check` mode the script exits non-zero when the committed +# docs/reference/ pages do not match what DocsGen would emit from the +# current source tree — surfacing as a CI failure that names the +# stale page and the diff. The author then re-runs the script without +# `--check`, commits the regenerated files, and pushes. set -euo pipefail From efaf79779bc7a45c1424e567b23280ee3d55e71c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Otto=20Boly=C3=B3s?= Date: Tue, 2 Jun 2026 15:22:13 +0200 Subject: [PATCH 20/27] docs(docs-tests): author XML docs and JSDoc for helpers and constants --- docs/.vitepress/sidebar.ts | 53 ++++++++++------- .../RouteCheckTests.cs | 57 +++++++++++++++++-- 2 files changed, 83 insertions(+), 27 deletions(-) diff --git a/docs/.vitepress/sidebar.ts b/docs/.vitepress/sidebar.ts index 69f28208a..3d3462606 100644 --- a/docs/.vitepress/sidebar.ts +++ b/docs/.vitepress/sidebar.ts @@ -55,11 +55,14 @@ type ApiNode = { overview?: string; }; +/** Allocate an empty namespace-tree node — `children` empty, `types` empty, + * no overview link yet. */ const makeNode = (): ApiNode => ({ children: new Map(), types: [] }); -// Strip the `.md` extension and any leading `/` so the result is a -// clean VitePress route, e.g. `MTConnect.Adapters.AgentClient.md` -> -// `/api/MTConnect.Adapters.AgentClient`. +/** Strip the `.md` extension and any leading `/` so the result is a clean + * VitePress route, e.g. `MTConnect.Adapters.AgentClient.md` → + * `/api/MTConnect.Adapters.AgentClient`. Input: docfx href; output: + * VitePress route string. */ const hrefToRoute = (href: string): string => `/api/${href.replace(/\.md$/, '')}`; @@ -77,6 +80,10 @@ const hrefToRoute = (href: string): string => // ... // - name: // ... +/** Parse the docfx-emitted `toc.yml` body into a flat list of namespace + * entries, each with its overview href and an ordered list of type + * entries. Tolerates section dividers (`- name: Classes` without href) + * by treating them as no-op pending entries. */ const parseToc = (content: string) => { const namespaces: Array<{ name: string; @@ -88,6 +95,8 @@ const parseToc = (content: string) => { let pending: { name: string; href?: string } | null = null; let inItems = false; + /** Commit the buffered type entry to `current.types` if it has an href; + * drop it silently if it was a section-divider (`- name: Classes`). */ const flushPending = () => { if (!pending || !current || !pending.href) return; current.types.push({ name: pending.name, href: pending.href }); @@ -134,9 +143,11 @@ const parseToc = (content: string) => { return namespaces; }; -// Build the nested namespace tree by walking each namespace's -// dot-separated path. Each segment becomes a child node; the final -// segment receives the namespace's overview href and the type list. +/** Build the nested namespace tree by walking each namespace's + * dot-separated path. Each segment becomes a child node; the final + * segment receives the namespace's overview href and the type list. + * Returns the synthetic root whose children are the top-level segments + * (`MTConnect`, …). */ const buildApiTree = ( namespaces: ReturnType, ): ApiNode => { @@ -160,16 +171,17 @@ const buildApiTree = ( return root; }; -// Case-insensitive locale comparator so groups and types sort -// predictably regardless of underlying string ordering quirks -// (e.g. uppercase ASCII grouping ahead of lowercase). +/** Case-insensitive locale comparator so groups and types sort predictably + * regardless of underlying string ordering quirks (e.g. uppercase ASCII + * grouping ahead of lowercase). Stable on equal-keyed inputs. */ const byTextCI = (a: SidebarItem, b: SidebarItem) => a.text.localeCompare(b.text, 'en', { sensitivity: 'base' }); -// Recursively project the tree into VitePress sidebar items. A node -// with children becomes a collapsible group; types are sorted into -// the group alongside any nested child groups. The namespace overview -// (if present) leads the group as an "Overview" entry. +/** Recursively project the tree into VitePress sidebar items. A node with + * children becomes a collapsible group; types are sorted into the group + * alongside any nested child groups. The namespace overview (if present) + * leads the group as an "Overview" entry. Input: the path segment whose + * node we are projecting + the node itself; output: one SidebarItem. */ const projectNode = (segment: string, node: ApiNode): SidebarItem => { const items: SidebarItem[] = []; if (node.overview) { @@ -245,14 +257,13 @@ export const apiSidebar = (): SidebarItem[] => { // ─── /reference/ — Roslyn-generated narrative ───────────────────────────── -// Convert a file name like `environment-variables.md` to a sidebar -// label like `Environment variables` (lower-case-with-hyphens to -// sentence case, keeping mid-word capitals as-is for HTTP/CLI/etc.). -// Overrides cover acronyms only; no trailing-word suffix is added — -// the parent group ("Auto-generated reference") already supplies the -// "reference" context, so adding it per-leaf is redundant and the -// half-applied policy (only on `cli` and `configuration`) was the -// worst of both worlds. +/** Convert a file name like `environment-variables.md` to a sidebar label + * like `Environment variables` (lower-case-with-hyphens to sentence case, + * keeping mid-word capitals as-is for HTTP/CLI/etc.). Overrides cover + * acronyms only; no trailing-word suffix is added — the parent group + * ("Auto-generated reference") already supplies the "reference" context, + * so adding it per-leaf is redundant and the half-applied policy (only on + * `cli` and `configuration`) was the worst of both worlds. */ const labelFor = (slug: string): string => { const overrides: Record = { cli: 'CLI', diff --git a/tests/MTConnect.NET-Docs-Tests/RouteCheckTests.cs b/tests/MTConnect.NET-Docs-Tests/RouteCheckTests.cs index 7b9fb785d..c598b7ec2 100644 --- a/tests/MTConnect.NET-Docs-Tests/RouteCheckTests.cs +++ b/tests/MTConnect.NET-Docs-Tests/RouteCheckTests.cs @@ -27,7 +27,7 @@ namespace MTConnect.NET_Docs_Tests; /// the original sidebar config), missing static assets, JS errors in /// custom theme components. /// -/// Run locally: +/// Run locally (from the repo root): /// /// dotnet test tests/MTConnect.NET-Docs-Tests --filter Category=E2E /// @@ -41,14 +41,31 @@ namespace MTConnect.NET_Docs_Tests; [Category("E2E")] public class RouteCheckTests { - // 8 is the empirical sweet spot on a high-core developer laptop — - // beyond that, vitepress preview's single Node event loop becomes - // the bottleneck and per-route wall time degrades. Capping at the - // visible CPU count keeps a 2-vCPU GitHub-hosted runner from - // over-subscribing (the cap collapses to ProcessorCount there). + /// Worker count for the parallel route walk. 8 is the + /// empirical sweet spot on a high-core developer laptop — beyond + /// that, vitepress preview's single Node event loop becomes the + /// bottleneck and per-route wall time degrades. Capping at the + /// visible CPU count keeps a 2-vCPU GitHub-hosted runner from + /// over-subscribing (the cap collapses to ProcessorCount there). private static readonly int Concurrency = Math.Min(8, Environment.ProcessorCount); + + /// Backoff between TCP-probe attempts while waiting for + /// vitepress preview to bind its port. 200 ms keeps the busy-loop + /// 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. private const int ServerReadyTimeoutMs = 60_000; + + /// 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 + /// the route on rather than waiting indefinitely. private const int PageNavigationTimeoutMs = 30_000; private Process? _previewServer; @@ -85,6 +102,14 @@ private static string RepoRoot } } + /// + /// Build the docs site if needed, install the Playwright chromium + /// binary, spawn `vitepress preview` against the built dist/ tree, + /// and launch a headless browser ready for the route walk. Wraps + /// every stage in a try/catch that rethrows with stage context + + /// drained preview log so a partial failure is diagnosable from + /// the assertion message alone. + /// [OneTimeSetUp] public async Task OneTimeSetUp() { @@ -152,6 +177,13 @@ public async Task OneTimeSetUp() } } + /// + /// Close the browser, dispose the Playwright runtime, kill the + /// preview-server process tree, and await the drain tasks with a + /// bounded timeout. Runs unconditionally — including after a + /// OneTimeSetUp failure — so a partially-allocated state still + /// gets torn down. + /// [OneTimeTearDown] public async Task OneTimeTearDown() { @@ -177,6 +209,13 @@ private static async Task AwaitDrainAsync(Task? drain) } } + /// + /// Negative regression for the 404 detector: visits an unmapped + /// route and asserts at least one of the three signals (.NotFound + /// element, document.title === '404', body-text 'PAGE NOT FOUND') + /// fires. Without this, a future Playwright / VitePress upgrade + /// that broke a signal would silently pass-through every real 404. + /// [Test] public async Task A_Synthetic_Unmapped_Route_Surfaces_As_A_404() { @@ -201,6 +240,12 @@ public async Task A_Synthetic_Unmapped_Route_Surfaces_As_A_404() } } + /// + /// Walks every markdown-backed route the docs/ tree implies and + /// asserts none rendered a 404. Failures are collected across all + /// workers and reported in a single ordered summary so the diff + /// is reviewable in one assertion message. + /// [Test] public async Task Every_Markdown_Backed_Route_Resolves_Without_A_404() { From dbb85b0b87f49c438bed7f77e561fb39c76f195b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Otto=20Boly=C3=B3s?= Date: Tue, 2 Jun 2026 15:22:50 +0200 Subject: [PATCH 21/27] docs(development): add end-to-end route check section to docs-site --- docs/development/docs-site.md | 23 +++++++++++++++++++++++ 1 file changed, 23 insertions(+) diff --git a/docs/development/docs-site.md b/docs/development/docs-site.md index dfdad9271..7e136ebdb 100644 --- a/docs/development/docs-site.md +++ b/docs/development/docs-site.md @@ -59,6 +59,29 @@ A fork or third-party deploy that hosts the same documentation under a different The classic symptom of a base mismatch is a deployed page that renders as raw HTML: title and tagline run together without spacing, the `Search` button reads `SearchK` because the keyboard-shortcut hint sits adjacent to the label without CSS spacing, and the nav links concatenate without separators. Check the page's HTML source for `` and `