Skip to content

Commit 7bc43ec

Browse files
committed
fix(docs-tests): tighten 404 detector to handle '404 | site' title and prose mentions
The previous three-signal detector (`.NotFound` element, `document.title === '404'`, body text contains `PAGE NOT FOUND`) had two real failure modes on the VitePress build: - The negative test on `/this-route-does-not-exist` returned no indicator. VitePress emits `<title>404 | MTConnect.NET</title>` into the static `404.html`, so the strict `=== '404'` equality never matches; the `.NotFound` element only appears once Vue hydrates which is fine but was the only remaining signal. - The positive walk reported `/development/docs-site — body text contains 'PAGE NOT FOUND'`. The page legitimately quotes the phrase in prose (the section that documents the detector itself), so the body-text signal fired a false positive. Switch to a two-signal detector — `.NotFound` element + `document.title` starting with `404` — and update the docs-site page to describe the new scheme. The title prefix match is robust because VitePress always emits `<title>404 | <site-title></title>` for unmapped routes regardless of the configured site title, and prose mentions of the literal phrase `PAGE NOT FOUND` no longer trip the walk.
1 parent c6cd04a commit 7bc43ec

2 files changed

Lines changed: 36 additions & 30 deletions

File tree

docs/development/docs-site.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -71,7 +71,7 @@ dotnet test tests/MTConnect.NET-Docs-Tests --filter Category=E2E
7171

7272
On the first run the fixture installs the chromium binary the Playwright .NET binding drives (~150 MB; cached on subsequent runs) and — if `docs/.vitepress/dist/` is missing — invokes `npm ci && npm run build` from `docs/` to produce a preview-able site. Subsequent runs reuse both, so a warm working tree completes in a couple of minutes; a cold checkout takes longer because the build artefact is rebuilt from scratch.
7373

74-
Failure output names every route that surfaced as a 404 along with which of the three signals firedthe `.NotFound` element, `document.title === '404'`, or body text containing `PAGE NOT FOUND`. Typical fixes:
74+
Failure output names every route that surfaced as a 404 along with which of the two signals firedthe `.NotFound` element rendered by the VitePress default theme's NotFound component, or `document.title` starting with `404` (the static `404.html` emits `<title>404 | MTConnect.NET</title>`, so a prefix match catches it regardless of the trailing site-title suffix). Typical fixes:
7575

7676
| Symptom | Probable cause | Fix |
7777
| --- | --- | --- |
@@ -80,7 +80,7 @@ Failure output names every route that surfaced as a 404 along with which of the
8080
| "playwright install chromium exited NNN" | The chromium download was blocked or interrupted. | Re-run; if the install fails again, run `dotnet tool restore && npx playwright install chromium` manually and re-try. |
8181
| "port NNNN already in use" hang | Another process grabbed the free port between selection and bind (rare; mitigated by the banner-parse fallback). | Re-run the test — the next free port avoids the contended one. |
8282

83-
The negative companion test (`A_Synthetic_Unmapped_Route_Surfaces_As_A_404`) pins the detector itself: a future Playwright or VitePress upgrade that breaks one of the three 404 signals would still see every real route render fine, but the negative test would go red and surface the regression before a real 404 slipped past.
83+
The negative companion test (`A_Synthetic_Unmapped_Route_Surfaces_As_A_404`) pins the detector itself: a future Playwright or VitePress upgrade that breaks one of the two 404 signals would still see every real route render fine, but the negative test would go red and surface the regression before a real 404 slipped past.
8484

8585
## See also
8686

tests/MTConnect.NET-Docs-Tests/RouteCheckTests.cs

Lines changed: 34 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -196,21 +196,20 @@ private static async Task AwaitDrainAsync(Task? drain)
196196

197197
/// <summary>
198198
/// Negative regression for the 404 detector: visits an unmapped
199-
/// route and asserts at least one of the three signals (.NotFound
200-
/// element, document.title === '404', body-text 'PAGE NOT FOUND')
201-
/// fires. Without this, a future Playwright / VitePress upgrade
202-
/// that broke a signal would silently pass-through every real 404.
199+
/// route and asserts at least one of the two signals (.NotFound
200+
/// element, document.title starting with '404') fires. Without
201+
/// this, a future Playwright / VitePress upgrade that broke a
202+
/// signal would silently pass-through every real 404.
203203
/// </summary>
204204
[Test]
205205
public async Task A_Synthetic_Unmapped_Route_Surfaces_As_A_404()
206206
{
207207
// Pins the detector itself per §10a. If a future Playwright / VitePress
208-
// upgrade silently breaks one of the three signals (.NotFound element,
209-
// document.title === '404', body-text 'PAGE NOT FOUND'), the positive
210-
// test would still pass — every real markdown-backed route would
211-
// continue to render fine — but a real 404 would go undetected. This
212-
// test makes sure the detector fires on a URL that has no markdown
213-
// source behind it.
208+
// upgrade silently breaks one of the two signals (.NotFound element,
209+
// document.title startsWith '404'), the positive test would still pass
210+
// — every real markdown-backed route would continue to render fine —
211+
// but a real 404 would go undetected. This test makes sure the detector
212+
// fires on a URL that has no markdown source behind it.
214213
Assert.That(_browser, Is.Not.Null, "browser was not initialised");
215214
var context = await _browser!.NewContextAsync();
216215
try
@@ -292,17 +291,29 @@ public async Task Every_Markdown_Backed_Route_Resolves_Without_A_404()
292291

293292
// ─── Route check ─────────────────────────────────────────────────────────
294293

295-
// Detects VitePress's client-side 404 page. The original Node
296-
// crawler used three signals; mirror them here exactly so the
297-
// test's failure surface matches what the script caught.
294+
// Detects VitePress's client-side 404 page. Two signals are used:
295+
// the `.NotFound` element rendered by the default theme's NotFound
296+
// component, and the `<title>` element — which the static 404.html
297+
// emits as `404 | <site title>` (e.g. `404 | MTConnect.NET`), so a
298+
// prefix match on `404` catches the title regardless of the trailing
299+
// site-title suffix.
300+
//
301+
// The original detector also checked for body text containing
302+
// `PAGE NOT FOUND`, but that signal is too loose — a real
303+
// markdown-backed route may quote the phrase in prose (e.g. the
304+
// docs-site page that documents this very detector). Dropping it
305+
// avoids false positives without sacrificing coverage: every real
306+
// VitePress 404 still renders the `.NotFound` element and the
307+
// `404 | ...` title.
298308
//
299309
// WaitUntilState.Load (not NetworkIdle) is used here deliberately.
300310
// VitePress's SPA keeps background work running indefinitely —
301311
// analytics pings, web-vitals beacons, hot-reload polling — so
302-
// NetworkIdle never settles within any reasonable timeout. All
303-
// three 404 signals (.NotFound selector, document.title, body text)
304-
// are available as soon as the DOM is fully parsed and sub-resources
305-
// have finished loading, which is exactly what Load guarantees.
312+
// NetworkIdle never settles within any reasonable timeout. The
313+
// `<title>` signal is server-rendered into the static 404.html so
314+
// it is available at Load; the `.NotFound` signal appears once Vue
315+
// hydrates, which on a built site happens during the Load event's
316+
// sub-resource phase.
306317
private static async Task<(string Route, string Indicator)?> CheckRouteAsync(IBrowserContext context, string baseUrl, string route)
307318
{
308319
var url = baseUrl + route;
@@ -317,13 +328,11 @@ public async Task Every_Markdown_Backed_Route_Resolves_Without_A_404()
317328

318329
var detection = await page.EvaluateAsync<NotFoundDetection>(@"() => ({
319330
hasClass: !!document.querySelector('.NotFound'),
320-
title404: document.title === '404',
321-
bodyMatches: (document.body?.innerText ?? '').toUpperCase().includes('PAGE NOT FOUND')
331+
title404: (document.title ?? '').startsWith('404')
322332
})");
323333

324334
if (detection.HasClass) return (route, ".NotFound element present");
325-
if (detection.Title404) return (route, "document.title == '404'");
326-
if (detection.BodyMatches) return (route, "body text contains 'PAGE NOT FOUND'");
335+
if (detection.Title404) return (route, "document.title starts with '404'");
327336
return null;
328337
}
329338
finally
@@ -333,20 +342,17 @@ public async Task Every_Markdown_Backed_Route_Resolves_Without_A_404()
333342
}
334343

335344
// The JS payload returned by EvaluateAsync uses camelCase keys
336-
// (`hasClass`, `title404`, `bodyMatches`); pin them explicitly so
337-
// a future Playwright upgrade that tightens case-insensitive
338-
// deserialisation cannot silently turn every route into a
339-
// no-detection pass-through (which would hide a real 404).
345+
// (`hasClass`, `title404`); pin them explicitly so a future
346+
// Playwright upgrade that tightens case-insensitive deserialisation
347+
// cannot silently turn every route into a no-detection pass-through
348+
// (which would hide a real 404).
340349
private sealed class NotFoundDetection
341350
{
342351
[JsonPropertyName("hasClass")]
343352
public bool HasClass { get; set; }
344353

345354
[JsonPropertyName("title404")]
346355
public bool Title404 { get; set; }
347-
348-
[JsonPropertyName("bodyMatches")]
349-
public bool BodyMatches { get; set; }
350356
}
351357

352358
// ─── Preview server lifecycle ────────────────────────────────────────────

0 commit comments

Comments
 (0)