mirror of https://github.com/theNewDynamic/gohugo-theme-ananke.git

Patrick Kollitsch
17.23.2026 efd1e8736a2d3c764007683393954ab270fb5afc
ci(fix): add all quick start steps to the test script
1 files modified
485 ■■■■ changed files
scripts/test-hugo-quickstart.ts 485 ●●●● patch | view | raw | blame | history
scripts/test-hugo-quickstart.ts
@@ -1,8 +1,8 @@
#!/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";
@@ -31,7 +31,6 @@
    configFile: string;
    keepOnSuccess: boolean;
    keepOnFailure: boolean;
    runContentSmokeTest: boolean;
    verbose: boolean;
}
@@ -55,7 +54,6 @@
    configFile: "hugo.toml",
    keepOnSuccess: false,
    keepOnFailure: true,
    runContentSmokeTest: true,
    verbose: true,
};
@@ -63,7 +61,8 @@
 * 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(),
    );
}
/**
@@ -112,11 +106,6 @@
            continue;
        }
        if (arg === "--no-content-smoke-test") {
            options.runContentSmokeTest = false;
            continue;
        }
        if (arg === "--quiet") {
            options.verbose = false;
            continue;
@@ -205,7 +194,9 @@
        child.on("close", (code, signal) => {
            const durationMs = Date.now() - started;
            const combined = [stdout, stderr].filter(Boolean).join(stdout && stderr ? "\n" : "");
            const combined = [stdout, stderr]
                .filter(Boolean)
                .join(stdout && stderr ? "\n" : "");
            resolve({
                code,
@@ -220,16 +211,65 @@
}
/**
 * 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);
}
/**
 * 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.
@@ -263,7 +303,9 @@
                await assertFileExists(absolutePath);
            } catch (error: unknown) {
                const message =
                    error instanceof Error ? error.message : "Unknown file assertion error";
                    error instanceof Error
                        ? error.message
                        : "Unknown file assertion error";
                throw new Error(
                    [
@@ -291,6 +333,43 @@
}
/**
 * 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);
}
/**
 * Escape a string for safe use in a regular expression.
 *
 * @param value Raw string.
@@ -301,21 +380,16 @@
}
/**
 * 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,
@@ -332,7 +406,7 @@
            [
                "Strict assertion failed: theme configuration missing or incorrect.",
                `Config file: ${configPath}`,
                `Expected: theme = '${themeName}'`,
                `Expected to find a line like: theme = '${themeName}'`,
                "Actual file contents:",
                config,
            ].join("\n\n"),
@@ -341,11 +415,7 @@
}
/**
 * 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.
 */
@@ -370,8 +440,7 @@
        },
        {
            description: "homepage contains at least one navigation-related landmark",
            test: (html: string): boolean =>
                /<(nav|header)\b/i.test(html),
            test: (html: string): boolean => /<(nav|header)\b/i.test(html),
        },
        {
            description: "homepage contains theme-generated CSS class markers",
@@ -380,18 +449,16 @@
        },
        {
            description: "homepage contains a main content area",
            test: (html: string): boolean =>
                /<(main|article|section)\b/i.test(html),
            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);
@@ -425,44 +492,179 @@
}
/**
 * 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);
function extractGeneratedDateLine(content: string): string {
    const match = content.match(/^\s*date\s*=\s*.+$/m);
    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"),
        );
    }
    return match[0];
}
/**
 * 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 (!/^---\s*$/m.test(content) && !/^\+\+\+\s*$/m.test(content)) {
        failures.push("- generated content does not appear to contain frontmatter delimiters");
    if (!/<h2[^>]*>\s*Introduction\s*<\/h2>/i.test(pageHtml)) {
        failures.push("- heading 'Introduction' was not rendered as an h2 element");
    }
    if (!/^\s*title\s*:/m.test(content) && !/^\s*title\s*=/m.test(content)) {
        failures.push("- generated content does not contain a title field");
    if (!/<strong>\s*bold\s*<\/strong>/i.test(pageHtml)) {
        failures.push("- bold Markdown was not rendered as a <strong> element");
    }
    if (!/^\s*date\s*:/m.test(content) && !/^\s*date\s*=/m.test(content)) {
        failures.push("- generated content does not contain a date field");
    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: generated content file did not match expected Hugo structure.",
                `Content file: ${contentPath}`,
                "Strict assertion failed: draft page content was not rendered as expected.",
                "Failed assertions:",
                ...failures,
                "",
                "Actual file contents:",
                content,
            ].join("\n"),
        );
    }
}
/**
 * Run the Hugo quick-start verification routine.
 * 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.
@@ -498,7 +700,10 @@
        {
            name: "Configure theme in Hugo config",
            command: "bash",
            args: ["-lc", `printf "\\ntheme = '${options.themeName}'\\n" >> ${JSON.stringify(options.configFile)}`],
            args: [
                "-lc",
                `printf "\\ntheme = '${options.themeName}'\\n" >> ${JSON.stringify(options.configFile)}`,
            ],
            cwd: projectRoot,
            expectedFiles: [options.configFile],
        },
@@ -511,16 +716,6 @@
        },
    ];
    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}`);
@@ -531,7 +726,10 @@
                console.log(`      ${formatCommand(step.command, step.args)}`);
            }
            const report = await executeStep(step);
            const report = isHugoBuildCommand(step)
                ? await executeHugoBuildStep(step, projectRoot)
                : await executeStep(step);
            reports.push(report);
            if (options.verbose) {
@@ -548,6 +746,8 @@
        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);
@@ -557,13 +757,133 @@
        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)})`,
            );
            const trimmedOutput = createContentReport.result.combined.trim();
            if (trimmedOutput) {
                console.log(trimmedOutput);
        }
        }
        console.log("\n[RUN] Replace generated content with quickstart sample");
        await replaceGeneratedContent(contentPath);
        console.log("[OK ] Replace generated content with quickstart sample");
        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);
        if (options.verbose) {
            console.log(
                `      ${formatCommand(draftBuildStep.command, draftBuildStep.args)}`,
            );
            console.log(
                `[OK ] ${draftBuildStep.name} (${draftBuildReport.result.durationMs} ms, exit ${String(draftBuildReport.result.code)})`,
            );
            const trimmedOutput = draftBuildReport.result.combined.trim();
            if (trimmedOutput) {
                console.log(trimmedOutput);
            }
        }
        const draftPageHtml = await readTextFile(draftOutputPath);
        assertDraftPageRendered(draftPageHtml);
        console.log("[OK ] Build drafts and verify rendered draft content");
        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");
@@ -589,7 +909,9 @@
        }
        if (options.keepOnFailure) {
            console.error(`\nKept failing test directory for inspection: ${projectRoot}`);
            console.error(
                `\nKept failing test directory for inspection: ${projectRoot}`,
            );
        } else {
            await rm(sandboxRoot, { recursive: true, force: true });
            console.error(`\nDeleted failing test directory: ${sandboxRoot}`);
@@ -608,7 +930,8 @@
        const exitCode = await runRoutine(options);
        process.exit(exitCode);
    } catch (error: unknown) {
        const message = error instanceof Error ? error.message : "Unknown fatal error";
        const message =
            error instanceof Error ? error.message : "Unknown fatal error";
        console.error(`Fatal error: ${message}`);
        process.exit(1);
    }