feat: adopt strict TDD framework and add adversarial test suite

- Update AGENTS.md and .agents/rules/blog-system.md with strict TDD mandate
- Extract Eleventy custom filters into modular src/_filters/index.js for unit testing
- Add npm test script using Node.js native test runner (node:test)
- Create test/site-config.test.js for environment validation and fatal error assertions
- Create test/filters.test.js for adversarial filter edge cases (dates, limits, reading time, headings)
- Create test/frontmatter-schema.test.js enforcing post metadata contracts
- Create test/design-rules.test.js enforcing strict anti-patterns (no glassmorphism, no shadows, max 4px radius)
- Create test/build-integrity.test.js checking static output, zero 404 links, and TOC anchor targets
This commit is contained in:
venus
2026-09-13 01:16:20 -05:00
parent fc6c17b541
commit e4601dbbcb
11 changed files with 532 additions and 66 deletions
+15
View File
@@ -54,3 +54,18 @@
- Wikilinks `[[slug]]` or `[[slug|Label]]` must be transformed into standard web URLs (`/posts/slug/`). - Wikilinks `[[slug]]` or `[[slug|Label]]` must be transformed into standard web URLs (`/posts/slug/`).
- Callout blocks (`> [!NOTE]`, `> [!WARNING]`, etc.) must render as terminal-style alert callouts. - Callout blocks (`> [!NOTE]`, `> [!WARNING]`, etc.) must render as terminal-style alert callouts.
- Images must be processed into `src/assets/images/posts/` and linked via standard `<img>` or Markdown image syntax. - Images must be processed into `src/assets/images/posts/` and linked via standard `<img>` or Markdown image syntax.
---
## 5. Test-Driven Development (TDD) & Adversarial Verification
1. **Red-Green-Refactor Lifecycle:**
- Write tests in `test/*.test.js` using Node's native test runner (`node:test`, `node:assert/strict`).
- Test inputs before implementing filters, data loaders, or template macros.
2. **Adversarial Test Coverage Required:**
- **Data & Configuration:** Test that missing required environment variables (e.g. `SITE_DOMAIN`) throw fatal errors.
- **Filters:** Test edge cases: empty strings, undefined values, single-word inputs, massive word counts, non-date objects.
- **Content Schemas:** Validate all Markdown files in `src/posts/` against frontmatter schemas.
- **Design Spec Compliance:** Automate regex/AST assertions on `src/assets/css/` ensuring zero violations of the "Do Not Use" list (`backdrop-filter`, `box-shadow`, `text-shadow`, gradients, floaty transitions).
- **Link & Anchor Integrity:** Verify that every compiled page has zero broken internal links and that all Table of Contents anchor links resolve to valid heading IDs.
3. **Execution Invariant:**
- Always run `npm test` before committing. All tests must pass cleanly with 0 failures.
+10
View File
@@ -30,6 +30,16 @@ Every component, style, and layout change must strictly adhere to [`sample-desig
- **Interactivity:** Instant binary hover state transitions (no 300ms easing); high-contrast 1px/2px focus outlines for keyboard navigation. - **Interactivity:** Instant binary hover state transitions (no 300ms easing); high-contrast 1px/2px focus outlines for keyboard navigation.
- **Typography:** IBM Plex Mono / JetBrains Mono for UI elements and code blocks; Inter for body prose. - **Typography:** IBM Plex Mono / JetBrains Mono for UI elements and code blocks; Inter for body prose.
### 1.4 Strict Test-Driven Development (TDD) & Adversarial Verification Mandate
- **Test-First Development:** For all new features, custom filters, template helpers, frontmatter schemas, or configuration changes, write automated tests asserting expected behaviors and edge cases *before* or alongside implementing the code.
- **Adversarial Mindset:** Actively design tests to break assumptions:
- Missing environment variables (e.g. unset `SITE_DOMAIN` must throw).
- Malformed frontmatter (missing titles, invalid dates, empty tags).
- Edge-case inputs to filters (empty strings, giant numbers, special characters).
- Design rule violations (asserting 0 instances of `backdrop-filter`, `box-shadow`, gradients, or floaty animations).
- Broken internal links and missing anchor tags in generated HTML.
- **Verification Gate:** Every code change must be verified by running `npm test`. No feature or commit is considered complete unless 100% of the test suite passes.
--- ---
## 2. Code Modularity & Isolation Guidelines ## 2. Code Modularity & Isolation Guidelines
+4 -51
View File
@@ -1,7 +1,7 @@
import syntaxHighlight from "@11ty/eleventy-plugin-syntaxhighlight"; import syntaxHighlight from "@11ty/eleventy-plugin-syntaxhighlight";
import markdownIt from "markdown-it"; import markdownIt from "markdown-it";
import markdownItAnchor from "markdown-it-anchor"; import markdownItAnchor from "markdown-it-anchor";
import { DateTime } from "luxon"; import { filters } from "./src/_filters/index.js";
export default function (eleventyConfig) { export default function (eleventyConfig) {
// 1. Static Asset Passthrough Copy // 1. Static Asset Passthrough Copy
@@ -32,57 +32,10 @@ export default function (eleventyConfig) {
eleventyConfig.setLibrary("md", markdownLib); eleventyConfig.setLibrary("md", markdownLib);
// 4. Custom Filters // 4. Custom Filters (imported from modular src/_filters/index.js)
// Format readable dates: SEP 12, 2026 for (const [name, fn] of Object.entries(filters)) {
eleventyConfig.addFilter("postDate", (dateObj) => { eleventyConfig.addFilter(name, fn);
if (!dateObj) return "";
return DateTime.fromJSDate(new Date(dateObj), { zone: "utc" })
.toFormat("LLL dd, yyyy")
.toUpperCase();
});
// ISO date format for <time datetime="...">
eleventyConfig.addFilter("isoDate", (dateObj) => {
if (!dateObj) return "";
return DateTime.fromJSDate(new Date(dateObj), { zone: "utc" }).toISODate();
});
// Limit array items
eleventyConfig.addFilter("limit", (arr, limit) => {
if (!Array.isArray(arr)) return [];
return arr.slice(0, limit);
});
// Filter out internal system tags
eleventyConfig.addFilter("filterTagList", (tags) => {
const internalTags = ["all", "nav", "post", "posts"];
return (tags || []).filter((tag) => !internalTags.includes(tag.toLowerCase()));
});
// Reading time estimator (defaults to 200 words/min if frontmatter lacks readingTime)
eleventyConfig.addFilter("readingTime", (text, frontmatterTime) => {
if (frontmatterTime) return frontmatterTime;
if (!text) return "1 MIN READ";
const wordCount = text.trim().split(/\s+/).length;
const minutes = Math.ceil(wordCount / 200);
return `${minutes} MIN READ`;
});
// Extract H2 and H3 headings with IDs for dynamic Table of Contents
eleventyConfig.addFilter("extractHeadings", (htmlContent) => {
if (!htmlContent) return [];
const headingRegex = /<h([23])\s+id="([^"]+)"[^>]*>(.*?)<\/h\1>/gis;
const headings = [];
let match;
while ((match = headingRegex.exec(htmlContent)) !== null) {
headings.push({
level: parseInt(match[1], 10),
id: match[2],
title: match[3].replace(/<[^>]+>/g, "").trim()
});
} }
return headings;
});
// 5. Custom Collections // 5. Custom Collections
// Chronological blog posts collection // Chronological blog posts collection
+1
View File
@@ -7,6 +7,7 @@
"scripts": { "scripts": {
"dev": "eleventy --serve --watch", "dev": "eleventy --serve --watch",
"build": "eleventy", "build": "eleventy",
"test": "node --test 'test/**/*.test.js'",
"clean": "rm -rf _site", "clean": "rm -rf _site",
"start": "npm run dev" "start": "npm run dev"
}, },
+5 -3
View File
@@ -1,8 +1,9 @@
import fs from "node:fs"; import fs from "node:fs";
import path from "node:path"; import path from "node:path";
// Load .env variables into process.env if present // Load .env variables into process.env if SITE_DOMAIN is not already defined
try { if (!process.env.SITE_DOMAIN) {
try {
const envPath = path.resolve(process.cwd(), ".env"); const envPath = path.resolve(process.cwd(), ".env");
if (fs.existsSync(envPath)) { if (fs.existsSync(envPath)) {
const envContent = fs.readFileSync(envPath, "utf-8"); const envContent = fs.readFileSync(envPath, "utf-8");
@@ -17,8 +18,9 @@ try {
} }
} }
} }
} catch (e) { } catch (e) {
// Silent fallback // Silent fallback
}
} }
const siteDomain = process.env.SITE_DOMAIN; const siteDomain = process.env.SITE_DOMAIN;
+74
View File
@@ -0,0 +1,74 @@
import { DateTime } from "luxon";
/**
* Custom Eleventy Filters for River Rooks Blog
*/
export const postDate = (dateObj) => {
if (!dateObj) return "";
const date = typeof dateObj === "string" ? new Date(dateObj) : dateObj;
const dt = DateTime.fromJSDate(date, { zone: "utc" });
if (!dt.isValid) return "";
return dt.toFormat("LLL dd, yyyy").toUpperCase();
};
export const isoDate = (dateObj) => {
if (!dateObj) return "";
const date = typeof dateObj === "string" ? new Date(dateObj) : dateObj;
const dt = DateTime.fromJSDate(date, { zone: "utc" });
if (!dt.isValid) return "";
return dt.toISODate() || "";
};
export const limit = (arr, count) => {
if (!Array.isArray(arr)) return [];
if (count <= 0) return [];
return arr.slice(0, count);
};
export const filterTagList = (tags) => {
const internalTags = ["all", "nav", "post", "posts"];
if (!tags) return [];
const tagArray = Array.isArray(tags) ? tags : [tags];
return tagArray.filter((tag) => {
if (typeof tag !== "string") return false;
const clean = tag.replace(/^#/, "").toLowerCase();
return !internalTags.includes(clean);
});
};
export const readingTime = (text, frontmatterTime) => {
if (frontmatterTime) return frontmatterTime;
if (!text || typeof text !== "string") return "1 MIN READ";
const clean = text.replace(/<[^>]+>/g, "").trim();
if (!clean) return "1 MIN READ";
const words = clean.split(/\s+/).filter(Boolean).length;
const minutes = Math.max(1, Math.ceil(words / 200));
return `${minutes} MIN READ`;
};
export const extractHeadings = (htmlContent) => {
if (!htmlContent || typeof htmlContent !== "string") return [];
const headingRegex = /<h([23])\s+id="([^"]+)"[^>]*>(.*?)<\/h\1>/gis;
const headings = [];
let match;
while ((match = headingRegex.exec(htmlContent)) !== null) {
headings.push({
level: parseInt(match[1], 10),
id: match[2],
title: match[3].replace(/<[^>]+>/g, "").trim()
});
}
return headings;
};
export const filters = {
postDate,
isoDate,
limit,
filterTagList,
readingTime,
extractHeadings
};
export default filters;
+83
View File
@@ -0,0 +1,83 @@
import test from "node:test";
import assert from "node:assert/strict";
import fs from "node:fs";
import path from "node:path";
import { execSync } from "node:child_process";
const siteDir = path.resolve("_site");
function getAllHtmlFiles(dir) {
let results = [];
const list = fs.readdirSync(dir);
for (const file of list) {
const filePath = path.join(dir, file);
const stat = fs.statSync(filePath);
if (stat && stat.isDirectory()) {
results = results.concat(getAllHtmlFiles(filePath));
} else if (file.endsWith(".html")) {
results.push(filePath);
}
}
return results;
}
test("Build & Link Integrity: Output inspection and link verification", () => {
// Ensure fresh build exists
if (!fs.existsSync(siteDir)) {
execSync("npm run build", { stdio: "pipe" });
}
// 1. Core Pages Exist
assert.ok(fs.existsSync(path.join(siteDir, "index.html")), "Root _site/index.html must exist");
assert.ok(fs.existsSync(path.join(siteDir, "dispatches/index.html")), "_site/dispatches/index.html must exist");
assert.ok(fs.existsSync(path.join(siteDir, "assets/css/main.css")), "_site/assets/css/main.css must exist");
const htmlFiles = getAllHtmlFiles(siteDir);
assert.ok(htmlFiles.length >= 4, "Must compile all core pages and articles");
for (const htmlFile of htmlFiles) {
const relHtml = path.relative(siteDir, htmlFile);
const content = fs.readFileSync(htmlFile, "utf-8");
// 2. HTML Boilerplate Invariants
assert.match(content, /<!DOCTYPE html>/i, `${relHtml} must contain <!DOCTYPE html>`);
assert.match(content, /<meta\s+name="viewport"/i, `${relHtml} must have viewport meta tag`);
assert.match(content, /<link\s+rel="stylesheet"\s+href="\/assets\/css\/main\.css">/i, `${relHtml} must link to main.css`);
// 3. Internal Link Integrity (No 404s!)
const internalLinks = [...content.matchAll(/href="(\/[^"#?]*)"/g)].map((m) => m[1]);
for (const link of internalLinks) {
if (link.startsWith("/feed.xml")) continue; // dynamic RSS route
// Normalize link to file on disk: /dispatches/ -> _site/dispatches/index.html or _site/dispatches.html
let targetPath;
if (link.endsWith("/")) {
targetPath = path.join(siteDir, link, "index.html");
} else if (path.extname(link)) {
targetPath = path.join(siteDir, link);
} else {
targetPath = path.join(siteDir, `${link}.html`);
if (!fs.existsSync(targetPath)) {
targetPath = path.join(siteDir, link, "index.html");
}
}
assert.ok(
fs.existsSync(targetPath),
`Broken internal link in ${relHtml}: href="${link}" resolves to missing file: ${targetPath}`
);
}
// 4. Anchor Link Integrity (TOC anchor links match actual heading IDs)
const anchorLinks = [...content.matchAll(/href="#([^"]+)"/g)].map((m) => m[1]);
for (const anchor of anchorLinks) {
// Ignore skip-to-content or standard body anchors
if (anchor === "main-content") continue;
const expectedIdRegex = new RegExp(`id="${anchor}"`, "i");
assert.match(
content,
expectedIdRegex,
`Broken TOC anchor link in ${relHtml}: href="#${anchor}" has no corresponding id="${anchor}"`
);
}
}
});
+86
View File
@@ -0,0 +1,86 @@
import test from "node:test";
import assert from "node:assert/strict";
import fs from "node:fs";
import path from "node:path";
const cssDir = path.resolve("src/assets/css");
function getAllCssFiles(dir) {
let results = [];
const list = fs.readdirSync(dir);
for (const file of list) {
const filePath = path.join(dir, file);
const stat = fs.statSync(filePath);
if (stat && stat.isDirectory()) {
results = results.concat(getAllCssFiles(filePath));
} else if (file.endsWith(".css")) {
results.push(filePath);
}
}
return results;
}
test("Design Specification: Strict Anti-Pattern Regression Testing", () => {
const cssFiles = getAllCssFiles(cssDir);
assert.ok(cssFiles.length >= 5, "Must inspect all modular CSS files");
for (const file of cssFiles) {
const relPath = path.relative(process.cwd(), file);
const content = fs.readFileSync(file, "utf-8");
// 1. Anti-Pattern: NO Glassmorphism (backdrop-filter)
assert.doesNotMatch(
content,
/backdrop-filter\s*:/i,
`Violation in ${relPath}: Glassmorphism (backdrop-filter) is strictly forbidden`
);
// 2. Anti-Pattern: NO Drop Shadows or Glows (box-shadow, text-shadow)
// Allowed: box-shadow: none; or text-shadow: none;
const shadowMatches = content.match(/(box-shadow|text-shadow)\s*:\s*([^;]+);/gi) || [];
for (const match of shadowMatches) {
assert.match(
match,
/(box-shadow|text-shadow)\s*:\s*none\s*;/i,
`Violation in ${relPath}: Drop shadows and glows are forbidden (found: '${match}')`
);
}
// 3. Anti-Pattern: NO Gradients (linear, radial, conic)
assert.doesNotMatch(
content,
/(linear-gradient|radial-gradient|conic-gradient)/i,
`Violation in ${relPath}: Gradients are strictly forbidden`
);
// 4. Anti-Pattern: NO Rounded Bubbles (border-radius capped at 4px max)
const radiusMatches = content.match(/border-radius\s*:\s*([^;]+);/gi) || [];
for (const match of radiusMatches) {
// Check for pixel values > 4px (e.g. 5px, 6px, 8px, 12px, 20px)
const pxMatch = match.match(/(\d+)\s*px/i);
if (pxMatch) {
const px = parseInt(pxMatch[1], 10);
assert.ok(
px <= 4,
`Violation in ${relPath}: Border radius must be 0px to 4px max, found: ${px}px in '${match}'`
);
}
// Check for percentages (e.g. 50%)
assert.doesNotMatch(
match,
/\d+%/,
`Violation in ${relPath}: Rounded pill/circular percentages forbidden in '${match}'`
);
}
// 5. Anti-Pattern: NO Float/Spring Transitions (Must be instant snaps)
const transitionMatches = content.match(/transition\s*:\s*([^;]+);/gi) || [];
for (const match of transitionMatches) {
assert.match(
match,
/transition\s*:\s*(none(\s*!important)?|0s\s*);/i,
`Violation in ${relPath}: Transitions must be instant (none or 0s), found: '${match}'`
);
}
}
});
+107
View File
@@ -0,0 +1,107 @@
import test from "node:test";
import assert from "node:assert/strict";
import {
postDate,
isoDate,
limit,
filterTagList,
readingTime,
extractHeadings
} from "../src/_filters/index.js";
test("Filters - postDate: formats valid dates", () => {
assert.equal(postDate("2026-07-20"), "JUL 20, 2026");
assert.equal(postDate(new Date("2026-12-31T00:00:00Z")), "DEC 31, 2026");
});
test("Filters - postDate: handles adversarial / invalid inputs gracefully", () => {
assert.equal(postDate(null), "");
assert.equal(postDate(undefined), "");
assert.equal(postDate("invalid-date-string"), "");
assert.equal(postDate(""), "");
});
test("Filters - isoDate: formats ISO strings", () => {
assert.equal(isoDate("2026-07-20"), "2026-07-20");
assert.equal(isoDate(new Date("2026-05-15T00:00:00Z")), "2026-05-15");
});
test("Filters - isoDate: handles adversarial / invalid inputs gracefully", () => {
assert.equal(isoDate(null), "");
assert.equal(isoDate(undefined), "");
assert.equal(isoDate("not-a-date"), "");
});
test("Filters - limit: slices arrays correctly", () => {
assert.deepEqual(limit([1, 2, 3, 4, 5], 3), [1, 2, 3]);
assert.deepEqual(limit([1, 2], 5), [1, 2]);
});
test("Filters - limit: handles adversarial inputs (negative, 0, non-array)", () => {
assert.deepEqual(limit([1, 2, 3], 0), []);
assert.deepEqual(limit([1, 2, 3], -5), []);
assert.deepEqual(limit(null, 3), []);
assert.deepEqual(limit("not-an-array", 3), []);
});
test("Filters - filterTagList: removes internal system tags", () => {
const input = ["firmware", "posts", "security", "all", "nav", "post"];
assert.deepEqual(filterTagList(input), ["firmware", "security"]);
});
test("Filters - filterTagList: handles adversarial inputs (# prefix, casing, non-string)", () => {
const input = ["#firmware", "#POSTS", "ALL", "#homelab", 42, null, undefined];
assert.deepEqual(filterTagList(input), ["#firmware", "#homelab"]);
assert.deepEqual(filterTagList(null), []);
assert.deepEqual(filterTagList(undefined), []);
});
test("Filters - readingTime: calculates reading time from word count", () => {
// 199 words -> 1 min
const shortText = "word ".repeat(199);
assert.equal(readingTime(shortText), "1 MIN READ");
// 201 words -> 2 min
const mediumText = "word ".repeat(201);
assert.equal(readingTime(mediumText), "2 MIN READ");
// 1000 words -> 5 min
const longText = "word ".repeat(1000);
assert.equal(readingTime(longText), "5 MIN READ");
});
test("Filters - readingTime: respects explicit frontmatter override", () => {
assert.equal(readingTime("any text", "15 MIN READ"), "15 MIN READ");
});
test("Filters - readingTime: handles adversarial HTML tags without inflating word count", () => {
// HTML tags should be stripped before counting words
const htmlText = "<div><span>word</span> <p>another</p></div>";
assert.equal(readingTime(htmlText), "1 MIN READ");
assert.equal(readingTime(""), "1 MIN READ");
assert.equal(readingTime(null), "1 MIN READ");
});
test("Filters - extractHeadings: extracts H2 and H3 with IDs and titles", () => {
const html = `
<h1>Title To Ignore</h1>
<h2 id="section-1">1.0 First Section</h2>
<p>Some text</p>
<h3 id="sub-1">1.1 Sub Section</h3>
<h4 id="h4-ignore">Ignore H4</h4>
<h2 id="section-2">2.0 Second Section with <code>code</code> and <span class="tag">span</span></h2>
`;
const headings = extractHeadings(html);
assert.equal(headings.length, 3);
assert.deepEqual(headings[0], { level: 2, id: "section-1", title: "1.0 First Section" });
assert.deepEqual(headings[1], { level: 3, id: "sub-1", title: "1.1 Sub Section" });
assert.deepEqual(headings[2], { level: 2, id: "section-2", title: "2.0 Second Section with code and span" });
});
test("Filters - extractHeadings: handles adversarial HTML inputs gracefully", () => {
assert.deepEqual(extractHeadings(""), []);
assert.deepEqual(extractHeadings(null), []);
assert.deepEqual(extractHeadings(undefined), []);
assert.deepEqual(extractHeadings("<p>No headings here</p>"), []);
});
+71
View File
@@ -0,0 +1,71 @@
import test from "node:test";
import assert from "node:assert/strict";
import fs from "node:fs";
import path from "node:path";
const postsDir = path.resolve("src/posts");
test("Content Contract: All posts in src/posts/ satisfy frontmatter schema", () => {
assert.ok(fs.existsSync(postsDir), "src/posts directory must exist");
const postFiles = fs.readdirSync(postsDir).filter((f) => f.endsWith(".md"));
assert.ok(postFiles.length > 0, "There must be at least one published post");
for (const file of postFiles) {
const filePath = path.join(postsDir, file);
const content = fs.readFileSync(filePath, "utf-8");
// 1. Filename Convention: YYYY-MM-DD-slug.md
assert.match(
file,
/^\d{4}-\d{2}-\d{2}-[a-z0-9-]+\.md$/,
`Post filename '${file}' must follow 'YYYY-MM-DD-slug.md' naming standard`
);
// 2. YAML Frontmatter Boundary check
assert.match(
content,
/^---\n([\s\S]*?)\n---/,
`Post '${file}' must start with valid YAML frontmatter delimiters`
);
// Extract frontmatter block
const fmMatch = content.match(/^---\n([\s\S]*?)\n---/);
const fmText = fmMatch[1];
// 3. Title Invariant
assert.match(
fmText,
/title:\s*"[^"]{5,}"/,
`Post '${file}' must contain a valid title of at least 5 characters`
);
// 4. Date Invariant (ISO Format)
assert.match(
fmText,
/date:\s*\d{4}-\d{2}-\d{2}/,
`Post '${file}' must define an ISO date (YYYY-MM-DD)`
);
// 5. Layout Invariant
assert.match(
fmText,
/layout:\s*layouts\/post\.njk/,
`Post '${file}' must specify 'layout: layouts/post.njk'`
);
// 6. Tags Invariant
assert.match(
fmText,
/tags:\s*\n(\s+-\s+[a-z0-9-]+\n)+/,
`Post '${file}' must specify a non-empty YAML tags list`
);
// 7. Excerpt Invariant
assert.match(
fmText,
/excerpt:\s*"[^"]{20,}"/,
`Post '${file}' must provide a substantive excerpt of at least 20 characters`
);
}
});
+64
View File
@@ -0,0 +1,64 @@
import test from "node:test";
import assert from "node:assert/strict";
import { execSync } from "node:child_process";
test("Site Config: throws explicit error when SITE_DOMAIN is unset", () => {
// Run isolated Node process with empty environment to verify error throw
const cmd = `node -e '
process.env.SITE_DOMAIN = "";
delete process.env.SITE_DOMAIN;
// mock empty cwd without .env
process.chdir("/tmp");
import("${process.cwd()}/src/_data/site.js")
.then(() => process.exit(0))
.catch(err => {
if (err.message.includes("FATAL CONFIGURATION ERROR: SITE_DOMAIN")) {
process.exit(42);
}
process.exit(1);
});
'`;
try {
execSync(cmd, { stdio: "pipe" });
assert.fail("Should have thrown error when SITE_DOMAIN is missing");
} catch (err) {
assert.equal(err.status, 42, "Process exited with status 42 (expected error caught)");
}
});
test("Site Config: correctly parses SITE_DOMAIN and derives service subdomains", async () => {
process.env.SITE_DOMAIN = "testcluster.internal";
delete process.env.SITE_URL;
// Import site configuration dynamically
const { default: site } = await import(`../src/_data/site.js?t=${Date.now()}`);
assert.equal(site.domain, "testcluster.internal");
assert.equal(site.url, "https://testcluster.internal");
// Verify dynamic subdomains
const gitService = site.services.find((s) => s.name === "Forgejo Git");
assert.ok(gitService, "Forgejo Git service exists");
assert.equal(gitService.url, "https://git.testcluster.internal");
assert.equal(gitService.tag, "git.testcluster.internal:2222");
const chefService = site.services.find((s) => s.name === "CyberChef");
assert.ok(chefService, "CyberChef service exists");
assert.equal(chefService.url, "https://chef.testcluster.internal");
// Verify navigation Git Forge URL
const navGit = site.nav.find((n) => n.label === "Git Forge");
assert.ok(navGit, "Nav Git Forge link exists");
assert.equal(navGit.url, "https://git.testcluster.internal");
});
test("Site Config: respects explicit SITE_URL override", async () => {
process.env.SITE_DOMAIN = "riverrooks.dev";
process.env.SITE_URL = "http://localhost:8080";
const { default: site } = await import(`../src/_data/site.js?t=${Date.now()}`);
assert.equal(site.domain, "riverrooks.dev");
assert.equal(site.url, "http://localhost:8080");
});