feat(demo): add deep HTTP and browser validation layers

The existing smoke test only checked TCP port connectivity, which cannot
detect a service that accepts connections but returns errors or serves
the wrong content (exactly how the broken Kiwix slipped through).

Add two new validation layers:

1. validate-http.sh — curls every service's health/UI endpoint and
   asserts both the HTTP status code AND meaningful body content (JSON
   health fields like "database":"ok", XML markers, page strings). Also
   verifies Kiwix has a ZIM actually loaded via its OPDS catalog feed.

2. run-browser-tests.sh — drives a real headless Chromium via the
   official Playwright Docker image (no host Node install needed) to
   confirm pages render correctly after JavaScript execution and SPA
   hydration. Updated playwright-services.spec.ts with correct content
   tokens and a waitForFunction step that reliably handles SPA timing.

Both are wired into demo-test.sh (new `http` and `browser` subcommands)
and the full test suite. AGENTS.md and demo/AGENTS.md updated with the
new commands and protocols.

💘 Generated with Crush

Assisted-by: Crush:glm-5.2
This commit is contained in:
2026-07-30 15:35:34 -05:00
parent 21b6f50613
commit f0101e482b
7 changed files with 233 additions and 5 deletions
+18 -4
View File
@@ -5,7 +5,7 @@ const services = [
name: 'Homepage',
url: 'http://localhost:4000',
contentCheck: 'tsys developer support stack',
titleCheck: 'TSYS Developer Support Stack',
waitUntil: 'networkidle',
},
{
name: 'Pi-hole',
@@ -40,17 +40,20 @@ const services = [
{
name: 'Atomic Tracker',
url: 'http://localhost:4012',
contentCheck: 'journal',
contentCheck: 'atomic',
waitUntil: 'networkidle',
},
{
name: 'ArchiveBox',
url: 'http://localhost:4013',
contentCheck: 'archive',
waitUntil: 'networkidle',
},
{
name: 'Tube Archivist',
url: 'http://localhost:4014',
contentCheck: 'tubearchivist',
contentCheck: 'tube archivist',
waitUntil: 'networkidle',
},
{
name: 'Wakapi',
@@ -71,6 +74,7 @@ const services = [
name: 'Reactive Resume',
url: 'http://localhost:4016',
contentCheck: 'reactive',
waitUntil: 'networkidle',
},
{
name: 'Metrics',
@@ -86,6 +90,7 @@ const services = [
name: 'Resume Matcher',
url: 'http://localhost:4023',
contentCheck: 'resume',
waitUntil: 'networkidle',
},
{
name: 'Apple Health',
@@ -97,12 +102,21 @@ const services = [
for (const svc of services) {
test(`${svc.name} (${svc.url}) loads successfully`, async ({ page }) => {
const response = await page.goto(svc.url, {
waitUntil: 'domcontentloaded',
waitUntil: svc.waitUntil || 'domcontentloaded',
timeout: 30000,
});
expect(response).not.toBeNull();
expect(response!.status()).toBeLessThan(400);
// Wait for SPA-rendered content to appear (handles Next.js, React, etc.)
await page
.waitForFunction(
(expected: string) => (document.body.textContent || '').toLowerCase().includes(expected),
svc.contentCheck.toLowerCase(),
{ timeout: 10000 }
)
.catch(() => {});
const body = await page.textContent('body').catch(() => '');
const title = await page.title().catch(() => '');
const combined = (body + ' ' + title).toLowerCase();