| | |
| | | #!/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] |
| | | |
| | |
| | | --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(), |
| | | ); |
| | | } |
| | | |
| | | /** |
| | |
| | | * @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; |
| | | } |
| | | |
| | | /** |
| | |
| | | * @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(" "); |
| | | } |
| | | |
| | | /** |
| | |
| | | * @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); |
| | | } |
| | | |
| | | /** |
| | |
| | | * @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(); |
| | | await main(); |