From efd1e8736a2d3c764007683393954ab270fb5afc Mon Sep 17 00:00:00 2001
From: Patrick Kollitsch <davidsneighbourdev+gh@gmail.com>
Date: Fri, 17 Apr 2026 00:23:52 +0000
Subject: [PATCH] ci(fix): add all quick start steps to the test script
---
scripts/test-hugo-quickstart.ts | 1171 +++++++++++++++++++++++++++++++++++++---------------------
1 files changed, 747 insertions(+), 424 deletions(-)
diff --git a/scripts/test-hugo-quickstart.ts b/scripts/test-hugo-quickstart.ts
index 03ea8bc..5bedaae 100644
--- a/scripts/test-hugo-quickstart.ts
+++ b/scripts/test-hugo-quickstart.ts
@@ -1,69 +1,68 @@
#!/usr/bin/env node
import { spawn } from "node:child_process";
-import { mkdtemp, readFile, rm, access } from "node:fs/promises";
import { constants } from "node:fs";
+import { access, mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
interface CommandResult {
- code: number | null;
- signal: NodeJS.Signals | null;
- stdout: string;
- stderr: string;
- combined: string;
- durationMs: number;
+ code: number | null;
+ signal: NodeJS.Signals | null;
+ stdout: string;
+ stderr: string;
+ combined: string;
+ durationMs: number;
}
interface StepDefinition {
- name: string;
- command: string;
- args: string[];
- cwd: string;
- expectedFiles?: string[];
+ name: string;
+ command: string;
+ args: string[];
+ cwd: string;
+ expectedFiles?: string[];
}
interface RoutineOptions {
- projectName: string;
- themeRepo: string;
- themeDir: string;
- themeName: string;
- configFile: string;
- keepOnSuccess: boolean;
- keepOnFailure: boolean;
- runContentSmokeTest: boolean;
- verbose: boolean;
+ projectName: string;
+ themeRepo: string;
+ themeDir: string;
+ themeName: string;
+ configFile: string;
+ keepOnSuccess: boolean;
+ keepOnFailure: boolean;
+ verbose: boolean;
}
interface StepReport {
- step: string;
- commandLine: string;
- cwd: string;
- result: CommandResult;
+ step: string;
+ commandLine: string;
+ cwd: string;
+ result: CommandResult;
}
interface HtmlAssertion {
- description: string;
- test: (html: string) => boolean;
+ description: string;
+ test: (html: string) => boolean;
}
const DEFAULT_OPTIONS: RoutineOptions = {
- projectName: "quickstart",
- themeRepo: "https://github.com/theNewDynamic/gohugo-theme-ananke.git",
- themeDir: "themes/ananke",
- themeName: "ananke",
- configFile: "hugo.toml",
- keepOnSuccess: false,
- keepOnFailure: true,
- runContentSmokeTest: true,
- verbose: true,
+ projectName: "quickstart",
+ themeRepo: "https://github.com/theNewDynamic/gohugo-theme-ananke.git",
+ themeDir: "themes/ananke",
+ themeName: "ananke",
+ configFile: "hugo.toml",
+ keepOnSuccess: false,
+ keepOnFailure: true,
+ verbose: true,
};
/**
* Print CLI help.
*/
function printHelp(): void {
- console.log(`
+ console.log(
+ `
Usage:
node scripts/test-hugo-quickstart.ts [options]
@@ -75,15 +74,10 @@
--config-file=<file> Hugo config file to update
--keep-on-success Do not delete the temp directory when the test passes
--no-keep-on-failure Delete the temp directory when the test fails
- --no-content-smoke-test Skip "hugo new foo.md"
--quiet Reduce step logging
--help Show this help
-
-Examples:
- node scripts/test-hugo-quickstart.ts
- node scripts/test-hugo-quickstart.ts --keep-on-success
- node scripts/test-hugo-quickstart.ts --project-name=quickstart-test
-`.trim());
+`.trim(),
+ );
}
/**
@@ -94,63 +88,58 @@
* @throws Error when an unknown argument is passed.
*/
function parseArgs(argv: string[]): RoutineOptions {
- const options: RoutineOptions = { ...DEFAULT_OPTIONS };
+ const options: RoutineOptions = { ...DEFAULT_OPTIONS };
- for (const arg of argv) {
- if (arg === "--help") {
- printHelp();
- process.exit(0);
- }
+ for (const arg of argv) {
+ if (arg === "--help") {
+ printHelp();
+ process.exit(0);
+ }
- if (arg === "--keep-on-success") {
- options.keepOnSuccess = true;
- continue;
- }
+ if (arg === "--keep-on-success") {
+ options.keepOnSuccess = true;
+ continue;
+ }
- if (arg === "--no-keep-on-failure") {
- options.keepOnFailure = false;
- continue;
- }
+ if (arg === "--no-keep-on-failure") {
+ options.keepOnFailure = false;
+ continue;
+ }
- if (arg === "--no-content-smoke-test") {
- options.runContentSmokeTest = false;
- continue;
- }
+ if (arg === "--quiet") {
+ options.verbose = false;
+ continue;
+ }
- if (arg === "--quiet") {
- options.verbose = false;
- continue;
- }
+ if (arg.startsWith("--project-name=")) {
+ options.projectName = arg.slice("--project-name=".length);
+ continue;
+ }
- if (arg.startsWith("--project-name=")) {
- options.projectName = arg.slice("--project-name=".length);
- continue;
- }
+ if (arg.startsWith("--theme-repo=")) {
+ options.themeRepo = arg.slice("--theme-repo=".length);
+ continue;
+ }
- if (arg.startsWith("--theme-repo=")) {
- options.themeRepo = arg.slice("--theme-repo=".length);
- continue;
- }
+ if (arg.startsWith("--theme-dir=")) {
+ options.themeDir = arg.slice("--theme-dir=".length);
+ continue;
+ }
- if (arg.startsWith("--theme-dir=")) {
- options.themeDir = arg.slice("--theme-dir=".length);
- continue;
- }
+ if (arg.startsWith("--theme-name=")) {
+ options.themeName = arg.slice("--theme-name=".length);
+ continue;
+ }
- if (arg.startsWith("--theme-name=")) {
- options.themeName = arg.slice("--theme-name=".length);
- continue;
- }
+ if (arg.startsWith("--config-file=")) {
+ options.configFile = arg.slice("--config-file=".length);
+ continue;
+ }
- if (arg.startsWith("--config-file=")) {
- options.configFile = arg.slice("--config-file=".length);
- continue;
- }
+ throw new Error(`Unknown argument: ${arg}`);
+ }
- throw new Error(`Unknown argument: ${arg}`);
- }
-
- return options;
+ return options;
}
/**
@@ -161,9 +150,9 @@
* @returns Full command line.
*/
function formatCommand(command: string, args: string[]): string {
- return [command, ...args]
- .map((part) => (/\s/.test(part) ? JSON.stringify(part) : part))
- .join(" ");
+ return [command, ...args]
+ .map((part) => (/\s/.test(part) ? JSON.stringify(part) : part))
+ .join(" ");
}
/**
@@ -175,119 +164,209 @@
* @returns Command execution result.
*/
async function runCommand(
- command: string,
- args: string[],
- cwd: string,
+ command: string,
+ args: string[],
+ cwd: string,
): Promise<CommandResult> {
- const started = Date.now();
+ const started = Date.now();
- return new Promise<CommandResult>((resolve, reject) => {
- const child = spawn(command, args, {
- cwd,
- env: process.env,
- stdio: ["ignore", "pipe", "pipe"],
- });
+ return new Promise<CommandResult>((resolve, reject) => {
+ const child = spawn(command, args, {
+ cwd,
+ env: process.env,
+ stdio: ["ignore", "pipe", "pipe"],
+ });
- let stdout = "";
- let stderr = "";
+ let stdout = "";
+ let stderr = "";
- child.stdout.on("data", (chunk: Buffer | string) => {
- stdout += chunk.toString();
- });
+ child.stdout.on("data", (chunk: Buffer | string) => {
+ stdout += chunk.toString();
+ });
- child.stderr.on("data", (chunk: Buffer | string) => {
- stderr += chunk.toString();
- });
+ child.stderr.on("data", (chunk: Buffer | string) => {
+ stderr += chunk.toString();
+ });
- child.on("error", (error: Error) => {
- reject(error);
- });
+ child.on("error", (error: Error) => {
+ reject(error);
+ });
- child.on("close", (code, signal) => {
- const durationMs = Date.now() - started;
- const combined = [stdout, stderr].filter(Boolean).join(stdout && stderr ? "\n" : "");
+ child.on("close", (code, signal) => {
+ const durationMs = Date.now() - started;
+ const combined = [stdout, stderr]
+ .filter(Boolean)
+ .join(stdout && stderr ? "\n" : "");
- resolve({
- code,
- signal,
- stdout,
- stderr,
- combined,
- durationMs,
- });
- });
- });
+ resolve({
+ code,
+ signal,
+ stdout,
+ stderr,
+ combined,
+ durationMs,
+ });
+ });
+ });
}
/**
- * Ensure a file exists.
+ * Ensure a file or directory exists.
*
- * @param filePath Absolute file path.
+ * @param filePath Absolute path to check.
*/
async function assertFileExists(filePath: string): Promise<void> {
- await access(filePath, constants.F_OK);
+ await access(filePath, constants.F_OK);
}
/**
- * Execute one quick-start step and validate success.
+ * Ensure a file or directory does not exist.
+ *
+ * @param filePath Absolute path to check.
+ */
+async function assertFileDoesNotExist(filePath: string): Promise<void> {
+ try {
+ await access(filePath, constants.F_OK);
+ throw new Error(`Unexpected path exists: ${filePath}`);
+ } catch (error: unknown) {
+ if (
+ error instanceof Error &&
+ error.message.startsWith("Unexpected path exists:")
+ ) {
+ throw error;
+ }
+ }
+}
+
+/**
+ * Read a UTF-8 text file.
+ *
+ * @param filePath Absolute file path.
+ * @returns File contents.
+ */
+async function readTextFile(filePath: string): Promise<string> {
+ return readFile(filePath, "utf8");
+}
+
+/**
+ * Write a UTF-8 text file.
+ *
+ * @param filePath Absolute file path.
+ * @param content File contents.
+ */
+async function writeTextFile(filePath: string, content: string): Promise<void> {
+ await writeFile(filePath, content, "utf8");
+}
+
+/**
+ * Remove the generated public directory inside the temporary project.
+ *
+ * @param projectRoot Absolute path to the temporary quickstart project.
+ */
+async function removePublicDir(projectRoot: string): Promise<void> {
+ const publicPath = join(projectRoot, "public");
+ await rm(publicPath, { recursive: true, force: true });
+}
+
+/**
+ * Execute one step and validate success.
*
* @param step Step definition.
* @returns Step report.
* @throws Error when the command fails or an expected file is missing.
*/
async function executeStep(step: StepDefinition): Promise<StepReport> {
- const result = await runCommand(step.command, step.args, step.cwd);
- const commandLine = formatCommand(step.command, step.args);
+ const result = await runCommand(step.command, step.args, step.cwd);
+ const commandLine = formatCommand(step.command, step.args);
- if (result.code !== 0) {
- const details = [
- `Step failed: ${step.name}`,
- `Command: ${commandLine}`,
- `Working directory: ${step.cwd}`,
- `Exit code: ${String(result.code)}`,
- result.signal ? `Signal: ${result.signal}` : "",
- result.stdout ? `STDOUT:\n${result.stdout}` : "",
- result.stderr ? `STDERR:\n${result.stderr}` : "",
- ]
- .filter(Boolean)
- .join("\n\n");
+ if (result.code !== 0) {
+ const details = [
+ `Step failed: ${step.name}`,
+ `Command: ${commandLine}`,
+ `Working directory: ${step.cwd}`,
+ `Exit code: ${String(result.code)}`,
+ result.signal ? `Signal: ${result.signal}` : "",
+ result.stdout ? `STDOUT:\n${result.stdout}` : "",
+ result.stderr ? `STDERR:\n${result.stderr}` : "",
+ ]
+ .filter(Boolean)
+ .join("\n\n");
- throw new Error(details);
- }
+ throw new Error(details);
+ }
- if (step.expectedFiles) {
- for (const relativePath of step.expectedFiles) {
- const absolutePath = join(step.cwd, relativePath);
+ if (step.expectedFiles) {
+ for (const relativePath of step.expectedFiles) {
+ const absolutePath = join(step.cwd, relativePath);
- try {
- await assertFileExists(absolutePath);
- } catch (error: unknown) {
- const message =
- error instanceof Error ? error.message : "Unknown file assertion error";
+ try {
+ await assertFileExists(absolutePath);
+ } catch (error: unknown) {
+ const message =
+ error instanceof Error
+ ? error.message
+ : "Unknown file assertion error";
- throw new Error(
- [
- `Step failed: ${step.name}`,
- `Command: ${commandLine}`,
- `Working directory: ${step.cwd}`,
- `Expected file missing: ${absolutePath}`,
- `Details: ${message}`,
- result.stdout ? `STDOUT:\n${result.stdout}` : "",
- result.stderr ? `STDERR:\n${result.stderr}` : "",
- ]
- .filter(Boolean)
- .join("\n\n"),
- );
- }
- }
- }
+ throw new Error(
+ [
+ `Step failed: ${step.name}`,
+ `Command: ${commandLine}`,
+ `Working directory: ${step.cwd}`,
+ `Expected file missing: ${absolutePath}`,
+ `Details: ${message}`,
+ result.stdout ? `STDOUT:\n${result.stdout}` : "",
+ result.stderr ? `STDERR:\n${result.stderr}` : "",
+ ]
+ .filter(Boolean)
+ .join("\n\n"),
+ );
+ }
+ }
+ }
- return {
- step: step.name,
- commandLine,
- cwd: step.cwd,
- result,
- };
+ return {
+ step: step.name,
+ commandLine,
+ cwd: step.cwd,
+ result,
+ };
+}
+
+/**
+ * Determine whether a Hugo command generates output in `public/`.
+ *
+ * @param step Step definition.
+ * @returns True when the command is a build command.
+ */
+function isHugoBuildCommand(step: StepDefinition): boolean {
+ if (step.command !== "hugo") {
+ return false;
+ }
+
+ if (step.args.length === 0) {
+ return true;
+ }
+
+ if (step.args.includes("--buildDrafts")) {
+ return true;
+ }
+
+ return false;
+}
+
+/**
+ * Execute a Hugo build command after clearing the generated public directory.
+ *
+ * @param step Step definition.
+ * @param projectRoot Absolute path to the temporary quickstart project.
+ * @returns Step report.
+ */
+async function executeHugoBuildStep(
+ step: StepDefinition,
+ projectRoot: string,
+): Promise<StepReport> {
+ await removePublicDir(projectRoot);
+ return executeStep(step);
}
/**
@@ -297,321 +376,565 @@
* @returns Escaped string.
*/
function escapeRegExp(value: string): string {
- return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
+ return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
}
/**
- * Read a text file as UTF-8.
+ * Assert that Hugo config contains the expected theme assignment somewhere in
+ * the file, without requiring the whole config to match a fixed template.
*
- * @param filePath Absolute file path.
- * @returns File contents.
- */
-async function readTextFile(filePath: string): Promise<string> {
- return readFile(filePath, "utf8");
-}
-
-/**
- * Assert that Hugo config contains the expected theme assignment.
+ * Accepts either single or double quotes, for example:
+ * - theme = 'ananke'
+ * - theme = "ananke"
*
* @param configPath Absolute config path.
* @param themeName Expected theme name.
- * @throws Error when the config does not contain the expected theme.
+ * @throws Error when the config does not contain the expected theme line.
*/
async function assertThemeConfigured(
- configPath: string,
- themeName: string,
+ configPath: string,
+ themeName: string,
): Promise<void> {
- const config = await readTextFile(configPath);
- const themePattern = new RegExp(
- String.raw`^\s*theme\s*=\s*['"]${escapeRegExp(themeName)}['"]\s*$`,
- "m",
- );
+ const config = await readTextFile(configPath);
+ const themePattern = new RegExp(
+ String.raw`^\s*theme\s*=\s*['"]${escapeRegExp(themeName)}['"]\s*$`,
+ "m",
+ );
- if (!themePattern.test(config)) {
- throw new Error(
- [
- "Strict assertion failed: theme configuration missing or incorrect.",
- `Config file: ${configPath}`,
- `Expected: theme = '${themeName}'`,
- "Actual file contents:",
- config,
- ].join("\n\n"),
- );
- }
+ if (!themePattern.test(config)) {
+ throw new Error(
+ [
+ "Strict assertion failed: theme configuration missing or incorrect.",
+ `Config file: ${configPath}`,
+ `Expected to find a line like: theme = '${themeName}'`,
+ "Actual file contents:",
+ config,
+ ].join("\n\n"),
+ );
+ }
}
/**
- * Return the strict homepage assertions.
- *
- * These are intentionally a mix of generic HTML checks and theme-oriented checks.
- * The goal is not to pin every byte of output, but to prove that the theme actually
- * rendered a plausible Ananke homepage.
+ * Return homepage assertions for the initial static build.
*
* @returns List of homepage assertions.
*/
function getHomepageAssertions(): HtmlAssertion[] {
- return [
- {
- description: "homepage contains an HTML document root",
- test: (html: string): boolean => /<html\b/i.test(html),
- },
- {
- description: "homepage contains a document title",
- test: (html: string): boolean => /<title>[\s\S]*?<\/title>/i.test(html),
- },
- {
- description: "homepage contains a body element",
- test: (html: string): boolean => /<body\b/i.test(html),
- },
- {
- description: "homepage contains at least one stylesheet reference",
- test: (html: string): boolean =>
- /<link\b[^>]*rel=["']stylesheet["'][^>]*>/i.test(html),
- },
- {
- description: "homepage contains at least one navigation-related landmark",
- test: (html: string): boolean =>
- /<(nav|header)\b/i.test(html),
- },
- {
- description: "homepage contains theme-generated CSS class markers",
- test: (html: string): boolean =>
- /\b(ma[0-9]|pa[0-9]|bg-black|near-white|sans-serif)\b/i.test(html),
- },
- {
- description: "homepage contains a main content area",
- test: (html: string): boolean =>
- /<(main|article|section)\b/i.test(html),
- },
- ];
+ return [
+ {
+ description: "homepage contains an HTML document root",
+ test: (html: string): boolean => /<html\b/i.test(html),
+ },
+ {
+ description: "homepage contains a document title",
+ test: (html: string): boolean => /<title>[\s\S]*?<\/title>/i.test(html),
+ },
+ {
+ description: "homepage contains a body element",
+ test: (html: string): boolean => /<body\b/i.test(html),
+ },
+ {
+ description: "homepage contains at least one stylesheet reference",
+ test: (html: string): boolean =>
+ /<link\b[^>]*rel=["']stylesheet["'][^>]*>/i.test(html),
+ },
+ {
+ description: "homepage contains at least one navigation-related landmark",
+ test: (html: string): boolean => /<(nav|header)\b/i.test(html),
+ },
+ {
+ description: "homepage contains theme-generated CSS class markers",
+ test: (html: string): boolean =>
+ /\b(ma[0-9]|pa[0-9]|bg-black|near-white|sans-serif)\b/i.test(html),
+ },
+ {
+ description: "homepage contains a main content area",
+ test: (html: string): boolean => /<(main|article|section)\b/i.test(html),
+ },
+ ];
}
/**
- * Assert that the generated homepage looks like a real themed render and not just
- * an empty or broken output file.
+ * Assert that the generated homepage looks like a real themed render.
*
* @param homepagePath Absolute path to `public/index.html`.
- * @throws Error when one or more strict assertions fail.
+ * @throws Error when one or more assertions fail.
*/
async function assertHomepageLooksValid(homepagePath: string): Promise<void> {
- const html = await readTextFile(homepagePath);
- const failures: string[] = [];
+ const html = await readTextFile(homepagePath);
+ const failures: string[] = [];
- if (html.trim().length === 0) {
- throw new Error(
- [
- "Strict assertion failed: generated homepage is empty.",
- `Homepage file: ${homepagePath}`,
- ].join("\n\n"),
- );
- }
+ if (html.trim().length === 0) {
+ throw new Error(
+ [
+ "Strict assertion failed: generated homepage is empty.",
+ `Homepage file: ${homepagePath}`,
+ ].join("\n\n"),
+ );
+ }
- for (const assertion of getHomepageAssertions()) {
- if (!assertion.test(html)) {
- failures.push(`- ${assertion.description}`);
- }
- }
+ for (const assertion of getHomepageAssertions()) {
+ if (!assertion.test(html)) {
+ failures.push(`- ${assertion.description}`);
+ }
+ }
- if (failures.length > 0) {
- throw new Error(
- [
- "Strict assertion failed: generated homepage did not match expected render checks.",
- `Homepage file: ${homepagePath}`,
- "Failed assertions:",
- ...failures,
- ].join("\n"),
- );
- }
+ if (failures.length > 0) {
+ throw new Error(
+ [
+ "Strict assertion failed: generated homepage did not match expected render checks.",
+ `Homepage file: ${homepagePath}`,
+ "Failed assertions:",
+ ...failures,
+ ].join("\n"),
+ );
+ }
}
/**
- * Assert that generated sample content contains frontmatter and basic metadata.
+ * Extract the auto-generated date line from a Hugo content file with TOML frontmatter.
*
- * @param contentPath Absolute path to the created content file.
- * @throws Error when the content file does not look like a valid Hugo content file.
+ * @param content Raw file contents.
+ * @returns Original date line.
+ * @throws Error when the date line is missing.
*/
-async function assertGeneratedContentLooksValid(contentPath: string): Promise<void> {
- const content = await readTextFile(contentPath);
- const failures: string[] = [];
+function extractGeneratedDateLine(content: string): string {
+ const match = content.match(/^\s*date\s*=\s*.+$/m);
- if (!/^---\s*$/m.test(content) && !/^\+\+\+\s*$/m.test(content)) {
- failures.push("- generated content does not appear to contain frontmatter delimiters");
- }
+ if (!match) {
+ throw new Error(
+ [
+ "Strict assertion failed: could not find auto-generated date line in content file.",
+ "Actual file contents:",
+ content,
+ ].join("\n\n"),
+ );
+ }
- if (!/^\s*title\s*:/m.test(content) && !/^\s*title\s*=/m.test(content)) {
- failures.push("- generated content does not contain a title field");
- }
-
- if (!/^\s*date\s*:/m.test(content) && !/^\s*date\s*=/m.test(content)) {
- failures.push("- generated content does not contain a date field");
- }
-
- if (failures.length > 0) {
- throw new Error(
- [
- "Strict assertion failed: generated content file did not match expected Hugo structure.",
- `Content file: ${contentPath}`,
- "Failed assertions:",
- ...failures,
- "",
- "Actual file contents:",
- content,
- ].join("\n"),
- );
- }
+ return match[0];
}
/**
- * Run the Hugo quick-start verification routine.
+ * Replace the generated content with the requested sample draft while preserving
+ * the original date line created by `hugo new`.
+ *
+ * @param contentPath Absolute path to the content file.
+ */
+async function replaceGeneratedContent(contentPath: string): Promise<void> {
+ const original = await readTextFile(contentPath);
+ const dateLine = extractGeneratedDateLine(original);
+
+ const updated = [
+ "+++",
+ "title = 'My First Post'",
+ dateLine,
+ "draft = true",
+ "+++",
+ "## Introduction",
+ "",
+ "This is **bold** text, and this is *emphasized* text.",
+ "",
+ "Visit the [Hugo](https://gohugo.io) website!",
+ "",
+ ].join("\n");
+
+ await writeTextFile(contentPath, updated);
+}
+
+/**
+ * Replace the root Hugo config with the requested quickstart config.
+ *
+ * @param configPath Absolute path to `hugo.toml`.
+ * @param themeName Theme name to set.
+ */
+async function replaceHugoConfig(
+ configPath: string,
+ themeName: string,
+): Promise<void> {
+ const content = [
+ "baseURL = 'https://example.com/'",
+ "locale = 'en-gb'",
+ "title = 'Ananke Test Quickstart'",
+ `theme = '${themeName}'`,
+ "",
+ ].join("\n");
+
+ await writeTextFile(configPath, content);
+}
+
+/**
+ * Assert that the generated page contains the expected rendered draft content.
+ *
+ * @param pageHtml HTML from `public/foo/index.html`.
+ */
+function assertDraftPageRendered(pageHtml: string): void {
+ const failures: string[] = [];
+
+ if (!/<h2[^>]*>\s*Introduction\s*<\/h2>/i.test(pageHtml)) {
+ failures.push("- heading 'Introduction' was not rendered as an h2 element");
+ }
+
+ if (!/<strong>\s*bold\s*<\/strong>/i.test(pageHtml)) {
+ failures.push("- bold Markdown was not rendered as a <strong> element");
+ }
+
+ if (!/<em>\s*emphasized\s*<\/em>/i.test(pageHtml)) {
+ failures.push("- emphasized Markdown was not rendered as an <em> element");
+ }
+
+ if (
+ !/<a[^>]+href=["']https:\/\/gohugo\.io["'][^>]*>\s*Hugo\s*<\/a>/i.test(
+ pageHtml,
+ )
+ ) {
+ failures.push("- Markdown link was not rendered as an anchor element");
+ }
+
+ if (!/My First Post/i.test(pageHtml)) {
+ failures.push("- post title was not visible on the rendered page");
+ }
+
+ if (failures.length > 0) {
+ throw new Error(
+ [
+ "Strict assertion failed: draft page content was not rendered as expected.",
+ "Failed assertions:",
+ ...failures,
+ ].join("\n"),
+ );
+ }
+}
+
+/**
+ * Assert that the generated homepage reflects updated title and locale configuration.
+ *
+ * Locale is checked strictly on the `<html>` tag.
+ *
+ * @param homepageHtml HTML from `public/index.html`.
+ */
+function assertUpdatedConfigInOutput(homepageHtml: string): void {
+ const failures: string[] = [];
+
+ if (!/Ananke Test Quickstart/i.test(homepageHtml)) {
+ failures.push(
+ "- updated site title was not visible in the generated output",
+ );
+ }
+
+ if (!/<html[^>]+lang=["']en-gb["'][^>]*>/i.test(homepageHtml)) {
+ failures.push(
+ "- updated locale 'en-gb' was not present in the <html lang=\"en-gb\"> tag",
+ );
+ }
+
+ if (failures.length > 0) {
+ throw new Error(
+ [
+ "Strict assertion failed: updated config was not reflected in the generated output.",
+ "Failed assertions:",
+ ...failures,
+ ].join("\n"),
+ );
+ }
+}
+
+/**
+ * Assert that the draft page is not part of the production build.
+ *
+ * @param projectRoot Project root.
+ * @param homepagePath Absolute path to `public/index.html`.
+ */
+async function assertDraftHiddenInProduction(
+ projectRoot: string,
+ homepagePath: string,
+): Promise<void> {
+ const draftOutputPath = join(projectRoot, "public", "foo", "index.html");
+ await assertFileDoesNotExist(draftOutputPath);
+
+ const homepageHtml = await readTextFile(homepagePath);
+
+ if (/My First Post/i.test(homepageHtml)) {
+ throw new Error(
+ [
+ "Strict assertion failed: draft post title was visible in the production homepage output.",
+ `Homepage file: ${homepagePath}`,
+ ].join("\n\n"),
+ );
+ }
+}
+
+/**
+ * Run the full Hugo quickstart verification routine.
*
* @param options Runtime options.
* @returns Process exit code.
*/
async function runRoutine(options: RoutineOptions): Promise<number> {
- const sandboxRoot = await mkdtemp(join(tmpdir(), "hugo-quickstart-"));
- const projectRoot = join(sandboxRoot, options.projectName);
+ const sandboxRoot = await mkdtemp(join(tmpdir(), "hugo-quickstart-"));
+ const projectRoot = join(sandboxRoot, options.projectName);
- const reports: StepReport[] = [];
+ const reports: StepReport[] = [];
- const steps: StepDefinition[] = [
- {
- name: "Create Hugo project",
- command: "hugo",
- args: ["new", "project", options.projectName],
- cwd: sandboxRoot,
- expectedFiles: [join(options.projectName, options.configFile)],
- },
- {
- name: "Initialise Git repository",
- command: "git",
- args: ["init"],
- cwd: projectRoot,
- expectedFiles: [".git"],
- },
- {
- name: "Add theme as Git submodule",
- command: "git",
- args: ["submodule", "add", options.themeRepo, options.themeDir],
- cwd: projectRoot,
- expectedFiles: [options.themeDir, ".gitmodules"],
- },
- {
- name: "Configure theme in Hugo config",
- command: "bash",
- args: ["-lc", `printf "\\ntheme = '${options.themeName}'\\n" >> ${JSON.stringify(options.configFile)}`],
- cwd: projectRoot,
- expectedFiles: [options.configFile],
- },
- {
- name: "Build site",
- command: "hugo",
- args: [],
- cwd: projectRoot,
- expectedFiles: ["public/index.html"],
- },
- ];
+ const steps: StepDefinition[] = [
+ {
+ name: "Create Hugo project",
+ command: "hugo",
+ args: ["new", "project", options.projectName],
+ cwd: sandboxRoot,
+ expectedFiles: [join(options.projectName, options.configFile)],
+ },
+ {
+ name: "Initialise Git repository",
+ command: "git",
+ args: ["init"],
+ cwd: projectRoot,
+ expectedFiles: [".git"],
+ },
+ {
+ name: "Add theme as Git submodule",
+ command: "git",
+ args: ["submodule", "add", options.themeRepo, options.themeDir],
+ cwd: projectRoot,
+ expectedFiles: [options.themeDir, ".gitmodules"],
+ },
+ {
+ name: "Configure theme in Hugo config",
+ command: "bash",
+ args: [
+ "-lc",
+ `printf "\\ntheme = '${options.themeName}'\\n" >> ${JSON.stringify(options.configFile)}`,
+ ],
+ cwd: projectRoot,
+ expectedFiles: [options.configFile],
+ },
+ {
+ name: "Build site",
+ command: "hugo",
+ args: [],
+ cwd: projectRoot,
+ expectedFiles: ["public/index.html"],
+ },
+ ];
- if (options.runContentSmokeTest) {
- steps.push({
- name: "Create sample content",
- command: "hugo",
- args: ["new", "foo.md"],
- cwd: projectRoot,
- expectedFiles: ["content/foo.md"],
- });
- }
+ try {
+ console.log(`Test root: ${sandboxRoot}`);
+ console.log(`Project root: ${projectRoot}`);
- try {
- console.log(`Test root: ${sandboxRoot}`);
- console.log(`Project root: ${projectRoot}`);
+ for (const step of steps) {
+ if (options.verbose) {
+ console.log(`\n[RUN] ${step.name}`);
+ console.log(` ${formatCommand(step.command, step.args)}`);
+ }
- for (const step of steps) {
- if (options.verbose) {
- console.log(`\n[RUN] ${step.name}`);
- console.log(` ${formatCommand(step.command, step.args)}`);
- }
+ const report = isHugoBuildCommand(step)
+ ? await executeHugoBuildStep(step, projectRoot)
+ : await executeStep(step);
- const report = await executeStep(step);
- reports.push(report);
+ reports.push(report);
- if (options.verbose) {
- console.log(
- `[OK ] ${step.name} (${report.result.durationMs} ms, exit ${String(report.result.code)})`,
- );
+ if (options.verbose) {
+ console.log(
+ `[OK ] ${step.name} (${report.result.durationMs} ms, exit ${String(report.result.code)})`,
+ );
- const trimmedOutput = report.result.combined.trim();
- if (trimmedOutput) {
- console.log(trimmedOutput);
- }
- }
- }
+ const trimmedOutput = report.result.combined.trim();
+ if (trimmedOutput) {
+ console.log(trimmedOutput);
+ }
+ }
+ }
- const configPath = join(projectRoot, options.configFile);
- const homepagePath = join(projectRoot, "public/index.html");
+ const configPath = join(projectRoot, options.configFile);
+ const homepagePath = join(projectRoot, "public/index.html");
+ const contentPath = join(projectRoot, "content/foo.md");
+ const draftOutputPath = join(projectRoot, "public", "foo", "index.html");
- console.log("\n[RUN] Strict config assertion");
- await assertThemeConfigured(configPath, options.themeName);
- console.log("[OK ] Strict config assertion");
+ console.log("\n[RUN] Strict config assertion");
+ await assertThemeConfigured(configPath, options.themeName);
+ console.log("[OK ] Strict config assertion");
- console.log("\n[RUN] Strict homepage assertion");
- await assertHomepageLooksValid(homepagePath);
- console.log("[OK ] Strict homepage assertion");
+ console.log("\n[RUN] Strict homepage assertion");
+ await assertHomepageLooksValid(homepagePath);
+ console.log("[OK ] Strict homepage assertion");
- if (options.runContentSmokeTest) {
- const contentPath = join(projectRoot, "content/foo.md");
+ console.log("\n[RUN] Create sample content");
+ const createContentStep: StepDefinition = {
+ name: "Create sample content",
+ command: "hugo",
+ args: ["new", "foo.md"],
+ cwd: projectRoot,
+ expectedFiles: ["content/foo.md"],
+ };
+ const createContentReport = await executeStep(createContentStep);
+ reports.push(createContentReport);
- console.log("\n[RUN] Strict generated content assertion");
- await assertGeneratedContentLooksValid(contentPath);
- console.log("[OK ] Strict generated content assertion");
- }
+ if (options.verbose) {
+ console.log(
+ ` ${formatCommand(createContentStep.command, createContentStep.args)}`,
+ );
+ console.log(
+ `[OK ] ${createContentStep.name} (${createContentReport.result.durationMs} ms, exit ${String(createContentReport.result.code)})`,
+ );
- console.log("\nResult: PASS");
+ const trimmedOutput = createContentReport.result.combined.trim();
+ if (trimmedOutput) {
+ console.log(trimmedOutput);
+ }
+ }
- if (options.keepOnSuccess) {
- console.log(`Keeping successful test directory: ${projectRoot}`);
- } else {
- await rm(sandboxRoot, { recursive: true, force: true });
- console.log(`Deleted successful test directory: ${sandboxRoot}`);
- }
+ console.log("\n[RUN] Replace generated content with quickstart sample");
+ await replaceGeneratedContent(contentPath);
+ console.log("[OK ] Replace generated content with quickstart sample");
- return 0;
- } catch (error: unknown) {
- const message = error instanceof Error ? error.message : "Unknown error";
+ console.log("\n[RUN] Build drafts and verify rendered draft content");
+ const draftBuildStep: StepDefinition = {
+ name: "Build site with drafts",
+ command: "hugo",
+ args: ["--buildDrafts"],
+ cwd: projectRoot,
+ expectedFiles: ["public/index.html", "public/foo/index.html"],
+ };
+ const draftBuildReport = await executeHugoBuildStep(
+ draftBuildStep,
+ projectRoot,
+ );
+ reports.push(draftBuildReport);
- console.error("\nResult: FAIL");
- console.error(message);
+ if (options.verbose) {
+ console.log(
+ ` ${formatCommand(draftBuildStep.command, draftBuildStep.args)}`,
+ );
+ console.log(
+ `[OK ] ${draftBuildStep.name} (${draftBuildReport.result.durationMs} ms, exit ${String(draftBuildReport.result.code)})`,
+ );
- if (reports.length > 0) {
- console.error("\nCompleted command steps before failure:");
- for (const report of reports) {
- console.error(`- ${report.step}`);
- }
- }
+ const trimmedOutput = draftBuildReport.result.combined.trim();
+ if (trimmedOutput) {
+ console.log(trimmedOutput);
+ }
+ }
- if (options.keepOnFailure) {
- console.error(`\nKept failing test directory for inspection: ${projectRoot}`);
- } else {
- await rm(sandboxRoot, { recursive: true, force: true });
- console.error(`\nDeleted failing test directory: ${sandboxRoot}`);
- }
+ const draftPageHtml = await readTextFile(draftOutputPath);
+ assertDraftPageRendered(draftPageHtml);
+ console.log("[OK ] Build drafts and verify rendered draft content");
- return 1;
- }
+ console.log("\n[RUN] Replace root hugo.toml with quickstart config");
+ await replaceHugoConfig(configPath, options.themeName);
+ console.log("[OK ] Replace root hugo.toml with quickstart config");
+
+ console.log("\n[RUN] Build drafts and verify updated title and locale");
+ const configBuildStep: StepDefinition = {
+ name: "Build site with updated config and drafts",
+ command: "hugo",
+ args: ["--buildDrafts"],
+ cwd: projectRoot,
+ expectedFiles: ["public/index.html", "public/foo/index.html"],
+ };
+ const configBuildReport = await executeHugoBuildStep(
+ configBuildStep,
+ projectRoot,
+ );
+ reports.push(configBuildReport);
+
+ if (options.verbose) {
+ console.log(
+ ` ${formatCommand(configBuildStep.command, configBuildStep.args)}`,
+ );
+ console.log(
+ `[OK ] ${configBuildStep.name} (${configBuildReport.result.durationMs} ms, exit ${String(configBuildReport.result.code)})`,
+ );
+
+ const trimmedOutput = configBuildReport.result.combined.trim();
+ if (trimmedOutput) {
+ console.log(trimmedOutput);
+ }
+ }
+
+ const updatedHomepageHtml = await readTextFile(homepagePath);
+ assertUpdatedConfigInOutput(updatedHomepageHtml);
+ console.log("[OK ] Build drafts and verify updated title and locale");
+
+ console.log("\n[RUN] Production build should exclude draft content");
+ const productionBuildStep: StepDefinition = {
+ name: "Build production site without drafts",
+ command: "hugo",
+ args: [],
+ cwd: projectRoot,
+ expectedFiles: ["public/index.html"],
+ };
+ const productionBuildReport = await executeHugoBuildStep(
+ productionBuildStep,
+ projectRoot,
+ );
+ reports.push(productionBuildReport);
+
+ if (options.verbose) {
+ console.log(
+ ` ${formatCommand(productionBuildStep.command, productionBuildStep.args)}`,
+ );
+ console.log(
+ `[OK ] ${productionBuildStep.name} (${productionBuildReport.result.durationMs} ms, exit ${String(productionBuildReport.result.code)})`,
+ );
+
+ const trimmedOutput = productionBuildReport.result.combined.trim();
+ if (trimmedOutput) {
+ console.log(trimmedOutput);
+ }
+ }
+
+ await assertDraftHiddenInProduction(projectRoot, homepagePath);
+ console.log("[OK ] Production build should exclude draft content");
+
+ console.log("\nResult: PASS");
+
+ if (options.keepOnSuccess) {
+ console.log(`Keeping successful test directory: ${projectRoot}`);
+ } else {
+ await rm(sandboxRoot, { recursive: true, force: true });
+ console.log(`Deleted successful test directory: ${sandboxRoot}`);
+ }
+
+ return 0;
+ } catch (error: unknown) {
+ const message = error instanceof Error ? error.message : "Unknown error";
+
+ console.error("\nResult: FAIL");
+ console.error(message);
+
+ if (reports.length > 0) {
+ console.error("\nCompleted command steps before failure:");
+ for (const report of reports) {
+ console.error(`- ${report.step}`);
+ }
+ }
+
+ if (options.keepOnFailure) {
+ console.error(
+ `\nKept failing test directory for inspection: ${projectRoot}`,
+ );
+ } else {
+ await rm(sandboxRoot, { recursive: true, force: true });
+ console.error(`\nDeleted failing test directory: ${sandboxRoot}`);
+ }
+
+ return 1;
+ }
}
/**
* Main entry point.
*/
async function main(): Promise<void> {
- try {
- const options = parseArgs(process.argv.slice(2));
- const exitCode = await runRoutine(options);
- process.exit(exitCode);
- } catch (error: unknown) {
- const message = error instanceof Error ? error.message : "Unknown fatal error";
- console.error(`Fatal error: ${message}`);
- process.exit(1);
- }
+ try {
+ const options = parseArgs(process.argv.slice(2));
+ const exitCode = await runRoutine(options);
+ process.exit(exitCode);
+ } catch (error: unknown) {
+ const message =
+ error instanceof Error ? error.message : "Unknown fatal error";
+ console.error(`Fatal error: ${message}`);
+ process.exit(1);
+ }
}
-await main();
\ No newline at end of file
+await main();
--
Gitblit v1.10.0