You've already forked hugo-extended
mirror of
https://github.com/jakejarvis/hugo-extended.git
synced 2026-06-12 08:45:27 -04:00
test: improved OS-specific coverage (#181)
Co-authored-by: jake <jake@jarv.is> Co-authored-by: Cursor Agent <cursoragent@cursor.com>
This commit is contained in:
+370
-26
@@ -10,12 +10,13 @@ permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
test:
|
||||
name: Test on ${{ matrix.os }} with Node ${{ matrix.node }}
|
||||
runs-on: ${{ matrix.os }}
|
||||
# Fast unit tests - run first to catch basic issues quickly
|
||||
unit:
|
||||
name: Unit Tests (Node ${{ matrix.node }})
|
||||
runs-on: ubuntu-latest
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
os: [ubuntu-latest, macos-latest, windows-latest]
|
||||
node: ["20", "22", "24"]
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
@@ -35,28 +36,371 @@ jobs:
|
||||
run: npm run build
|
||||
- name: Run unit tests
|
||||
run: npm run test:unit
|
||||
|
||||
# Integration tests - run Hugo commands on each platform
|
||||
integration:
|
||||
name: Integration (${{ matrix.os }}, Node ${{ matrix.node }})
|
||||
needs: unit
|
||||
runs-on: ${{ matrix.os }}
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
os: [ubuntu-latest, macos-latest, windows-latest]
|
||||
node: ["20", "22", "24"]
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: ${{ matrix.node }}
|
||||
cache: "npm"
|
||||
- name: Install dependencies
|
||||
run: npm ci
|
||||
- name: Generate types
|
||||
run: npm run generate-types
|
||||
- name: Build
|
||||
run: npm run build
|
||||
- name: Run integration tests
|
||||
run: npm run test:integration
|
||||
|
||||
# coverage:
|
||||
# name: Coverage
|
||||
# runs-on: ubuntu-latest
|
||||
# steps:
|
||||
# - uses: actions/checkout@v6
|
||||
# - uses: actions/setup-node@v6
|
||||
# with:
|
||||
# node-version: "24"
|
||||
# cache: "npm"
|
||||
# - name: Install dependencies
|
||||
# run: npm ci
|
||||
# - name: Generate types
|
||||
# run: npm run generate-types
|
||||
# - name: Build
|
||||
# run: npm run build
|
||||
# - name: Run tests with coverage
|
||||
# run: npm run test:coverage
|
||||
# - name: Upload coverage reports
|
||||
# uses: codecov/codecov-action@v5
|
||||
# with:
|
||||
# files: ./coverage/coverage-final.json
|
||||
# fail_ci_if_error: false
|
||||
# E2E installation tests - verify the full installation pipeline
|
||||
e2e:
|
||||
name: E2E Installation (${{ matrix.os }})
|
||||
needs: unit
|
||||
runs-on: ${{ matrix.os }}
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
os: [ubuntu-latest, macos-latest, windows-latest]
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: "24"
|
||||
cache: "npm"
|
||||
- name: Install dependencies
|
||||
run: npm ci
|
||||
- name: Generate types
|
||||
run: npm run generate-types
|
||||
- name: Build
|
||||
run: npm run build
|
||||
- name: Run E2E tests
|
||||
run: npm run test:e2e
|
||||
|
||||
# Fresh installation test - simulates a user installing the package
|
||||
fresh-install:
|
||||
name: Fresh Install (${{ matrix.os }})
|
||||
needs: unit
|
||||
runs-on: ${{ matrix.os }}
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
os: [ubuntu-latest, macos-latest, windows-latest]
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: "24"
|
||||
cache: "npm"
|
||||
|
||||
- name: Install dependencies
|
||||
run: npm ci
|
||||
|
||||
- name: Generate types
|
||||
run: npm run generate-types
|
||||
|
||||
- name: Build
|
||||
run: npm run build
|
||||
|
||||
- name: Remove any pre-existing Hugo binary
|
||||
shell: bash
|
||||
run: rm -rf bin/
|
||||
|
||||
- name: Verify bin directory is empty
|
||||
shell: bash
|
||||
run: |
|
||||
if [ -d "bin" ]; then
|
||||
echo "bin/ directory still exists!"
|
||||
ls -la bin/
|
||||
exit 1
|
||||
fi
|
||||
echo "bin/ directory confirmed empty"
|
||||
|
||||
- name: Run postinstall to trigger fresh install
|
||||
run: node postinstall.js
|
||||
|
||||
- name: Verify Hugo binary exists (Unix)
|
||||
if: runner.os != 'Windows'
|
||||
run: |
|
||||
if [ ! -f "bin/hugo" ]; then
|
||||
echo "Hugo binary not found!"
|
||||
exit 1
|
||||
fi
|
||||
echo "Hugo binary found at bin/hugo"
|
||||
ls -la bin/
|
||||
|
||||
- name: Verify Hugo binary exists (Windows)
|
||||
if: runner.os == 'Windows'
|
||||
shell: pwsh
|
||||
run: |
|
||||
if (-not (Test-Path "bin/hugo.exe")) {
|
||||
Write-Error "Hugo binary not found!"
|
||||
exit 1
|
||||
}
|
||||
Write-Host "Hugo binary found at bin/hugo.exe"
|
||||
Get-ChildItem bin/
|
||||
|
||||
- name: Verify Hugo version matches package version
|
||||
shell: bash
|
||||
run: |
|
||||
PKG_VERSION=$(node -p "require('./package.json').version")
|
||||
if [[ "$RUNNER_OS" == "Windows" ]]; then
|
||||
HUGO_VERSION=$(./bin/hugo.exe version)
|
||||
else
|
||||
HUGO_VERSION=$(./bin/hugo version)
|
||||
fi
|
||||
echo "Package version: $PKG_VERSION"
|
||||
echo "Hugo version: $HUGO_VERSION"
|
||||
if [[ "$HUGO_VERSION" != *"v$PKG_VERSION"* ]]; then
|
||||
echo "Version mismatch!"
|
||||
exit 1
|
||||
fi
|
||||
echo "Version verified successfully"
|
||||
|
||||
- name: Verify Extended version (where supported)
|
||||
shell: bash
|
||||
run: |
|
||||
if [[ "$RUNNER_OS" == "Windows" ]]; then
|
||||
HUGO_VERSION=$(./bin/hugo.exe version)
|
||||
else
|
||||
HUGO_VERSION=$(./bin/hugo version)
|
||||
fi
|
||||
# Extended is supported on all GitHub Actions runners (x64)
|
||||
if [[ "$HUGO_VERSION" != *"+extended"* ]]; then
|
||||
echo "Expected Extended version but got: $HUGO_VERSION"
|
||||
exit 1
|
||||
fi
|
||||
echo "Extended version verified"
|
||||
|
||||
# macOS-specific tests (symlink behavior, pkg installation)
|
||||
macos-quirks:
|
||||
name: macOS quirks
|
||||
needs: unit
|
||||
runs-on: macos-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: "24"
|
||||
cache: "npm"
|
||||
|
||||
- name: Install dependencies
|
||||
run: npm ci
|
||||
|
||||
- name: Generate types
|
||||
run: npm run generate-types
|
||||
|
||||
- name: Build
|
||||
run: npm run build
|
||||
|
||||
- name: Remove any pre-existing Hugo binary
|
||||
run: rm -rf bin/
|
||||
|
||||
- name: Run postinstall
|
||||
run: node postinstall.js
|
||||
|
||||
- name: Verify symlink structure
|
||||
run: |
|
||||
if [ ! -L "bin/hugo" ]; then
|
||||
echo "bin/hugo is not a symlink!"
|
||||
exit 1
|
||||
fi
|
||||
TARGET=$(readlink bin/hugo)
|
||||
echo "Symlink target: $TARGET"
|
||||
if [ "$TARGET" != "/usr/local/bin/hugo" ]; then
|
||||
echo "Unexpected symlink target!"
|
||||
exit 1
|
||||
fi
|
||||
echo "Symlink structure verified"
|
||||
|
||||
- name: Verify system Hugo binary
|
||||
run: |
|
||||
if [ ! -f "/usr/local/bin/hugo" ]; then
|
||||
echo "System Hugo binary not found!"
|
||||
exit 1
|
||||
fi
|
||||
/usr/local/bin/hugo version
|
||||
|
||||
# Linux- tests (tar.gz extraction, permissions)
|
||||
linux-quirks:
|
||||
name: Linux quirks
|
||||
needs: unit
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: "24"
|
||||
cache: "npm"
|
||||
|
||||
- name: Install dependencies
|
||||
run: npm ci
|
||||
|
||||
- name: Generate types
|
||||
run: npm run generate-types
|
||||
|
||||
- name: Build
|
||||
run: npm run build
|
||||
|
||||
- name: Remove any pre-existing Hugo binary
|
||||
run: rm -rf bin/
|
||||
|
||||
- name: Run postinstall
|
||||
run: node postinstall.js
|
||||
|
||||
- name: Verify binary is regular file (not symlink)
|
||||
run: |
|
||||
if [ -L "bin/hugo" ]; then
|
||||
echo "bin/hugo should not be a symlink on Linux!"
|
||||
exit 1
|
||||
fi
|
||||
if [ ! -f "bin/hugo" ]; then
|
||||
echo "bin/hugo is not a regular file!"
|
||||
exit 1
|
||||
fi
|
||||
echo "Binary is a regular file"
|
||||
|
||||
- name: Verify executable permissions
|
||||
run: |
|
||||
if [ ! -x "bin/hugo" ]; then
|
||||
echo "bin/hugo is not executable!"
|
||||
exit 1
|
||||
fi
|
||||
PERMS=$(stat -c "%a" bin/hugo)
|
||||
echo "Permissions: $PERMS"
|
||||
# Should have at least 755 (owner rwx, group rx, others rx)
|
||||
if [ "$PERMS" -lt "755" ]; then
|
||||
echo "Insufficient permissions!"
|
||||
exit 1
|
||||
fi
|
||||
echo "Permissions verified"
|
||||
|
||||
- name: Verify binary runs
|
||||
run: ./bin/hugo version
|
||||
|
||||
# Windows- tests (zip extraction)
|
||||
windows-quirks:
|
||||
name: Windows quirks
|
||||
needs: unit
|
||||
runs-on: windows-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: "24"
|
||||
cache: "npm"
|
||||
|
||||
- name: Install dependencies
|
||||
run: npm ci
|
||||
|
||||
- name: Generate types
|
||||
run: npm run generate-types
|
||||
|
||||
- name: Build
|
||||
run: npm run build
|
||||
|
||||
- name: Remove any pre-existing Hugo binary
|
||||
shell: pwsh
|
||||
run: |
|
||||
if (Test-Path "bin") {
|
||||
Remove-Item -Recurse -Force "bin"
|
||||
}
|
||||
|
||||
- name: Run postinstall
|
||||
run: node postinstall.js
|
||||
|
||||
- name: Verify binary exists with correct extension
|
||||
shell: pwsh
|
||||
run: |
|
||||
if (-not (Test-Path "bin/hugo.exe")) {
|
||||
Write-Error "bin/hugo.exe not found!"
|
||||
exit 1
|
||||
}
|
||||
Write-Host "Binary found: bin/hugo.exe"
|
||||
Get-ChildItem bin/
|
||||
|
||||
- name: Verify binary runs
|
||||
run: ./bin/hugo.exe version
|
||||
|
||||
# Re-installation test - simulates the "Hugo disappeared" recovery path
|
||||
reinstall:
|
||||
name: Re-installation Recovery (${{ matrix.os }})
|
||||
needs: unit
|
||||
runs-on: ${{ matrix.os }}
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
os: [ubuntu-latest, macos-latest, windows-latest]
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: "24"
|
||||
cache: "npm"
|
||||
|
||||
- name: Install dependencies
|
||||
run: npm ci
|
||||
|
||||
- name: Generate types
|
||||
run: npm run generate-types
|
||||
|
||||
- name: Build
|
||||
run: npm run build
|
||||
|
||||
- name: Initial install
|
||||
run: node postinstall.js
|
||||
|
||||
- name: Verify initial install (Unix)
|
||||
if: runner.os != 'Windows'
|
||||
run: ./bin/hugo version
|
||||
|
||||
- name: Verify initial install (Windows)
|
||||
if: runner.os == 'Windows'
|
||||
run: ./bin/hugo.exe version
|
||||
|
||||
- name: Remove Hugo binary to simulate disappearance
|
||||
shell: bash
|
||||
run: rm -rf bin/
|
||||
|
||||
- name: Run Node.js script that triggers auto-reinstall
|
||||
run: |
|
||||
node -e "
|
||||
import('./dist/hugo.mjs').then(async (m) => {
|
||||
const path = await m.default();
|
||||
console.log('Hugo reinstalled at:', path);
|
||||
const { execWithOutput } = m;
|
||||
const { stdout } = await execWithOutput('version');
|
||||
console.log('Version:', stdout.trim());
|
||||
}).catch(e => {
|
||||
console.error('Failed:', e);
|
||||
process.exit(1);
|
||||
});
|
||||
"
|
||||
|
||||
- name: Verify Hugo was reinstalled (Unix)
|
||||
if: runner.os != 'Windows'
|
||||
run: |
|
||||
if [ ! -f "bin/hugo" ]; then
|
||||
echo "Hugo was not reinstalled!"
|
||||
exit 1
|
||||
fi
|
||||
./bin/hugo version
|
||||
|
||||
- name: Verify Hugo was reinstalled (Windows)
|
||||
if: runner.os == 'Windows'
|
||||
shell: pwsh
|
||||
run: |
|
||||
if (-not (Test-Path "bin/hugo.exe")) {
|
||||
Write-Error "Hugo was not reinstalled!"
|
||||
exit 1
|
||||
}
|
||||
./bin/hugo.exe version
|
||||
|
||||
@@ -67,6 +67,7 @@ npm test # all tests (vitest run)
|
||||
npm run test:watch # watch mode
|
||||
npm run test:unit # unit tests only
|
||||
npm run test:integration # integration tests only (runs real Hugo)
|
||||
npm run test:e2e # end-to-end installation tests
|
||||
npm run test:coverage # coverage via v8
|
||||
```
|
||||
|
||||
@@ -76,18 +77,25 @@ npm run test:coverage # coverage via v8
|
||||
- Fast, pure TS/JS (no Hugo execution).
|
||||
- Example: `tests/unit/args.test.ts` covers argv building behavior driven by `flags.json`.
|
||||
- Example: `tests/unit/types.test.ts` uses `expectTypeOf` to validate type surfaces.
|
||||
- Example: `tests/unit/utils.test.ts` covers platform detection, release filename resolution.
|
||||
- Example: `tests/unit/install.test.ts` covers checksum parsing, archive type detection.
|
||||
|
||||
- `tests/integration/*`
|
||||
- Executes real Hugo commands and does real filesystem work in temp dirs.
|
||||
- **Avoid `process.chdir()`** in tests: Vitest worker contexts may not support it.
|
||||
- Prefer passing Hugo’s global `--source` via `{ source: sitePath }`.
|
||||
- Prefer passing Hugo's global `--source` via `{ source: sitePath }`.
|
||||
|
||||
- `tests/e2e/*`
|
||||
- End-to-end tests for the full installation pipeline.
|
||||
- Verifies binary installation, permissions, symlinks (macOS), and version matching.
|
||||
- Platform-specific tests use `it.skipIf()` to skip on unsupported platforms.
|
||||
|
||||
### Integration test expectations to keep in mind
|
||||
|
||||
- Hugo output is noisy (e.g. “Congratulations! Your new Hugo site…”). Tests should assert on filesystem results instead of brittle stdout text.
|
||||
- Hugo output is noisy (e.g. "Congratulations! Your new Hugo site…"). Tests should assert on filesystem results instead of brittle stdout text.
|
||||
- Hugo scaffolding changes over time:
|
||||
- Example: `hugo new theme` in 0.154.x generates a theme skeleton with `hugo.toml` / `hugo.yaml` rather than `theme.toml`.
|
||||
- Some flags may exist but not behave as you’d intuit for a given command:
|
||||
- Some flags may exist but not behave as you'd intuit for a given command:
|
||||
- Example: `hugo new site --force` does **not** overwrite an existing `hugo.toml` in 0.154.x.
|
||||
|
||||
## Practical tips for agents making changes
|
||||
@@ -102,4 +110,3 @@ npm run test:coverage # coverage via v8
|
||||
|
||||
- If you touch exports in `src/hugo.ts`:
|
||||
- Remember: consumers rely on the **default export being callable** (binary path) and having builder methods attached.
|
||||
|
||||
|
||||
@@ -52,6 +52,7 @@
|
||||
"test:watch": "vitest",
|
||||
"test:unit": "vitest run tests/unit",
|
||||
"test:integration": "vitest run tests/integration",
|
||||
"test:e2e": "vitest run tests/e2e",
|
||||
"test:coverage": "vitest run --coverage",
|
||||
"postinstall": "node postinstall.js",
|
||||
"prepublishOnly": "npm run generate-types && npm run build"
|
||||
|
||||
+57
-9
@@ -19,6 +19,51 @@ import {
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
|
||||
/**
|
||||
* Archive types supported by the installer.
|
||||
*/
|
||||
export type ArchiveType = "zip" | "tar.gz" | "pkg" | null;
|
||||
|
||||
/**
|
||||
* Detects the archive type from a filename based on its extension.
|
||||
*
|
||||
* @param filename - The filename to check
|
||||
* @returns The detected archive type, or null if unknown
|
||||
*/
|
||||
export function getArchiveType(filename: string): ArchiveType {
|
||||
if (filename.endsWith(".zip")) return "zip";
|
||||
if (filename.endsWith(".tar.gz")) return "tar.gz";
|
||||
if (filename.endsWith(".pkg")) return "pkg";
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parses a checksums file content into a lookup map.
|
||||
*
|
||||
* The checksums file format is: "sha256hash filename" (hash followed by whitespace and filename).
|
||||
* This is the standard format used by Hugo releases.
|
||||
*
|
||||
* @param content - The raw content of the checksums file
|
||||
* @returns A Map of filename to SHA-256 hash
|
||||
*/
|
||||
export function parseChecksumFile(content: string): Map<string, string> {
|
||||
const checksums = new Map<string, string>();
|
||||
|
||||
for (const line of content.split("\n")) {
|
||||
const trimmed = line.trim();
|
||||
if (!trimmed) continue;
|
||||
|
||||
const tokens = trimmed.split(/\s+/);
|
||||
if (tokens.length >= 2) {
|
||||
const hash = tokens[0] as string;
|
||||
const filename = tokens[tokens.length - 1] as string;
|
||||
checksums.set(filename, hash);
|
||||
}
|
||||
}
|
||||
|
||||
return checksums;
|
||||
}
|
||||
|
||||
/**
|
||||
* Downloads a file from a URL to a local destination path.
|
||||
*
|
||||
@@ -59,14 +104,10 @@ async function verifyChecksum(
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to download checksums: ${response.statusText}`);
|
||||
}
|
||||
const checksums = await response.text();
|
||||
|
||||
// checksums file format: "sha256 filename"
|
||||
const expectedChecksum = checksums
|
||||
.split("\n")
|
||||
.map((line) => line.trim().split(/\s+/))
|
||||
.find((tokens) => tokens[tokens.length - 1] === filename)?.[0];
|
||||
const checksumContent = await response.text();
|
||||
const checksums = parseChecksumFile(checksumContent);
|
||||
|
||||
const expectedChecksum = checksums.get(filename);
|
||||
if (!expectedChecksum) {
|
||||
throw new Error(`Checksum for ${filename} not found in checksums file.`);
|
||||
}
|
||||
@@ -165,13 +206,14 @@ async function install(): Promise<string> {
|
||||
} else {
|
||||
console.info("📦 Extracting...");
|
||||
|
||||
if (releaseFile.endsWith(".zip")) {
|
||||
const archiveType = getArchiveType(releaseFile);
|
||||
if (archiveType === "zip") {
|
||||
const zip = new AdmZip(downloadPath);
|
||||
zip.extractAllTo(binDir, true);
|
||||
|
||||
// Cleanup zip
|
||||
fs.unlinkSync(downloadPath);
|
||||
} else if (releaseFile.endsWith(".tar.gz")) {
|
||||
} else if (archiveType === "tar.gz") {
|
||||
await tar.x({
|
||||
file: downloadPath,
|
||||
cwd: binDir,
|
||||
@@ -179,6 +221,12 @@ async function install(): Promise<string> {
|
||||
|
||||
// Cleanup tar.gz
|
||||
fs.unlinkSync(downloadPath);
|
||||
} else {
|
||||
// Defensive: should not happen since unsupported platforms are caught earlier
|
||||
// and pkg files are handled in the darwin branch above
|
||||
throw new Error(
|
||||
`Unexpected archive type for ${releaseFile}. Expected .zip or .tar.gz for this platform.`,
|
||||
);
|
||||
}
|
||||
|
||||
const binPath = path.join(binDir, binFile);
|
||||
|
||||
@@ -0,0 +1,149 @@
|
||||
import { execFileSync } from "node:child_process";
|
||||
import { existsSync, lstatSync, readlinkSync, statSync } from "node:fs";
|
||||
import { beforeAll, describe, expect, it } from "vitest";
|
||||
import hugo, { execWithOutput, getHugoBinary } from "../../src/hugo";
|
||||
import {
|
||||
getBinFilename,
|
||||
getBinPath,
|
||||
getPkgVersion,
|
||||
getReleaseFilename,
|
||||
isExtended,
|
||||
} from "../../src/lib/utils";
|
||||
|
||||
/**
|
||||
* End-to-end tests for Hugo installation.
|
||||
*
|
||||
* These tests verify:
|
||||
* - Binary is installed correctly for the current platform
|
||||
* - Binary has correct permissions
|
||||
* - Binary is executable and returns expected version
|
||||
* - Extended version is installed where supported
|
||||
*
|
||||
* Note: These tests use the actual installed Hugo binary and require
|
||||
* npm install/postinstall to have completed successfully.
|
||||
*/
|
||||
describe("Hugo Installation E2E", () => {
|
||||
let binaryPath: string;
|
||||
|
||||
beforeAll(async () => {
|
||||
// Ensure Hugo is installed
|
||||
binaryPath = await hugo();
|
||||
});
|
||||
|
||||
describe("Binary Installation", () => {
|
||||
it("should have Hugo binary installed", () => {
|
||||
expect(existsSync(binaryPath)).toBe(true);
|
||||
});
|
||||
|
||||
it("should have binary at expected path", () => {
|
||||
const expectedPath = getBinPath();
|
||||
expect(binaryPath).toBe(expectedPath);
|
||||
});
|
||||
|
||||
it("should have correct binary filename for platform", () => {
|
||||
const expectedFilename = getBinFilename();
|
||||
const actualFilename = binaryPath.split(/[\\/]/).pop();
|
||||
expect(actualFilename).toBe(expectedFilename);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Binary Permissions (Unix)", () => {
|
||||
it.skipIf(process.platform === "win32")(
|
||||
"should have executable permissions",
|
||||
() => {
|
||||
const stats = statSync(binaryPath);
|
||||
// Check that at least owner has execute permission (0o100)
|
||||
const hasExecute = (stats.mode & 0o100) !== 0;
|
||||
expect(hasExecute).toBe(true);
|
||||
},
|
||||
);
|
||||
|
||||
it.skipIf(process.platform !== "darwin")(
|
||||
"should be a symlink to /usr/local/bin/hugo on macOS",
|
||||
() => {
|
||||
const isSymlink = lstatSync(binaryPath).isSymbolicLink();
|
||||
expect(isSymlink).toBe(true);
|
||||
|
||||
const target = readlinkSync(binaryPath);
|
||||
expect(target).toBe("/usr/local/bin/hugo");
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
describe("Binary Execution", () => {
|
||||
it("should execute successfully", () => {
|
||||
const result = execFileSync(binaryPath, ["version"]);
|
||||
expect(result).toBeTruthy();
|
||||
});
|
||||
|
||||
it("should return version string", () => {
|
||||
const result = execFileSync(binaryPath, ["version"]).toString().trim();
|
||||
expect(result).toContain("hugo v");
|
||||
});
|
||||
|
||||
it("should match package version", () => {
|
||||
const pkgVersion = getPkgVersion();
|
||||
const result = execFileSync(binaryPath, ["version"]).toString().trim();
|
||||
expect(result).toContain(`v${pkgVersion}`);
|
||||
});
|
||||
|
||||
it.skipIf(!isExtended(getReleaseFilename(getPkgVersion()) ?? ""))(
|
||||
"should be Extended version where supported",
|
||||
() => {
|
||||
const result = execFileSync(binaryPath, ["version"]).toString().trim();
|
||||
expect(result).toContain("+extended");
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
describe("API Integration", () => {
|
||||
it("should work with default export (callable)", async () => {
|
||||
const path = await hugo();
|
||||
expect(path).toBe(binaryPath);
|
||||
});
|
||||
|
||||
it("should work with getHugoBinary()", async () => {
|
||||
const path = await getHugoBinary();
|
||||
expect(path).toBe(binaryPath);
|
||||
});
|
||||
|
||||
it("should work with execWithOutput()", async () => {
|
||||
const { stdout } = await execWithOutput("version");
|
||||
expect(stdout).toContain("hugo v");
|
||||
});
|
||||
|
||||
it("should work with builder API", async () => {
|
||||
// hugo.version() uses exec() which inherits stdio, so we use execWithOutput
|
||||
const { stdout } = await execWithOutput("version");
|
||||
expect(stdout).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
describe("Environment Info", () => {
|
||||
it("should report correct GOOS", async () => {
|
||||
const { stdout } = await execWithOutput("env");
|
||||
|
||||
const expectedGoos =
|
||||
process.platform === "win32"
|
||||
? "windows"
|
||||
: process.platform === "darwin"
|
||||
? "darwin"
|
||||
: "linux";
|
||||
|
||||
expect(stdout).toContain(`GOOS="${expectedGoos}"`);
|
||||
});
|
||||
|
||||
it("should report correct GOARCH", async () => {
|
||||
const { stdout } = await execWithOutput("env");
|
||||
|
||||
const expectedGoarch =
|
||||
process.arch === "x64"
|
||||
? "amd64"
|
||||
: process.arch === "arm64"
|
||||
? "arm64"
|
||||
: process.arch;
|
||||
|
||||
expect(stdout).toContain(`GOARCH="${expectedGoarch}"`);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,209 @@
|
||||
import crypto from "node:crypto";
|
||||
import { afterEach, assert, beforeEach, describe, expect, it } from "vitest";
|
||||
import { getArchiveType, parseChecksumFile } from "../../src/lib/install";
|
||||
import { getReleaseFilename } from "../../src/lib/utils";
|
||||
|
||||
/**
|
||||
* Unit tests for installation logic that can be tested without network calls.
|
||||
* These tests verify:
|
||||
* - Checksum file parsing
|
||||
* - Archive type detection
|
||||
* - SHA-256 computation
|
||||
*/
|
||||
describe("Installation Logic", () => {
|
||||
describe("SHA-256 Computation", () => {
|
||||
it("should correctly compute SHA-256 hash", () => {
|
||||
const testData = "Hello, Hugo!";
|
||||
const hash = crypto.createHash("sha256");
|
||||
hash.update(Buffer.from(testData));
|
||||
const digest = hash.digest("hex");
|
||||
|
||||
// Expected SHA-256 hash for "Hello, Hugo!"
|
||||
// Computed via: echo -n "Hello, Hugo!" | sha256sum
|
||||
// Cross-verified by computing with a second method below
|
||||
const expectedHash =
|
||||
"766a2e18bc3e2f7e217b4566b7988ca3a28e1de8cd70d995219088497a0830e5";
|
||||
|
||||
expect(digest).toBe(expectedHash);
|
||||
expect(digest).toHaveLength(64);
|
||||
|
||||
// Cross-verify by computing with a fresh hash instance
|
||||
const verifyHash = crypto.createHash("sha256");
|
||||
verifyHash.update(testData, "utf8");
|
||||
expect(verifyHash.digest("hex")).toBe(expectedHash);
|
||||
});
|
||||
});
|
||||
|
||||
describe("parseChecksumFile", () => {
|
||||
it("should parse checksums file format correctly", () => {
|
||||
const checksumContent = `
|
||||
abc123def456 hugo_0.154.3_linux-amd64.tar.gz
|
||||
def789abc012 hugo_extended_0.154.3_linux-amd64.tar.gz
|
||||
ghi345jkl678 hugo_0.154.3_windows-amd64.zip
|
||||
`.trim();
|
||||
|
||||
const checksums = parseChecksumFile(checksumContent);
|
||||
|
||||
expect(checksums.size).toBe(3);
|
||||
expect(checksums.get("hugo_0.154.3_linux-amd64.tar.gz")).toBe(
|
||||
"abc123def456",
|
||||
);
|
||||
expect(checksums.get("hugo_extended_0.154.3_linux-amd64.tar.gz")).toBe(
|
||||
"def789abc012",
|
||||
);
|
||||
expect(checksums.get("hugo_0.154.3_windows-amd64.zip")).toBe(
|
||||
"ghi345jkl678",
|
||||
);
|
||||
});
|
||||
|
||||
it("should find correct checksum for a given filename", () => {
|
||||
const checksumContent = `
|
||||
abc123def456 hugo_0.154.3_linux-amd64.tar.gz
|
||||
def789abc012 hugo_extended_0.154.3_linux-amd64.tar.gz
|
||||
ghi345jkl678 hugo_0.154.3_windows-amd64.zip
|
||||
`;
|
||||
const checksums = parseChecksumFile(checksumContent);
|
||||
|
||||
expect(checksums.get("hugo_extended_0.154.3_linux-amd64.tar.gz")).toBe(
|
||||
"def789abc012",
|
||||
);
|
||||
});
|
||||
|
||||
it("should return undefined when filename not in checksums", () => {
|
||||
const checksumContent = `
|
||||
abc123def456 hugo_0.154.3_linux-amd64.tar.gz
|
||||
`;
|
||||
const checksums = parseChecksumFile(checksumContent);
|
||||
|
||||
expect(checksums.get("hugo_0.154.3_windows-amd64.zip")).toBeUndefined();
|
||||
});
|
||||
|
||||
it("should handle empty content", () => {
|
||||
const checksums = parseChecksumFile("");
|
||||
expect(checksums.size).toBe(0);
|
||||
});
|
||||
|
||||
it("should handle content with only whitespace lines", () => {
|
||||
const checksums = parseChecksumFile(" \n\n \n");
|
||||
expect(checksums.size).toBe(0);
|
||||
});
|
||||
|
||||
it("should handle real-world Hugo checksums format", () => {
|
||||
// Real format from Hugo releases uses two spaces between hash and filename
|
||||
const realChecksumContent = `
|
||||
e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855 hugo_0.154.3_checksums.txt
|
||||
a1b2c3d4e5f67890abcdef1234567890abcdef1234567890abcdef1234567890 hugo_extended_0.154.3_darwin-universal.pkg
|
||||
f0e9d8c7b6a5432109876543210fedcba0987654321fedcba0987654321fedc hugo_extended_0.154.3_linux-amd64.tar.gz
|
||||
`;
|
||||
const checksums = parseChecksumFile(realChecksumContent);
|
||||
|
||||
expect(checksums.size).toBe(3);
|
||||
expect(checksums.get("hugo_extended_0.154.3_darwin-universal.pkg")).toBe(
|
||||
"a1b2c3d4e5f67890abcdef1234567890abcdef1234567890abcdef1234567890",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("getArchiveType", () => {
|
||||
it("should identify zip files", () => {
|
||||
expect(getArchiveType("hugo_extended_0.154.3_windows-amd64.zip")).toBe(
|
||||
"zip",
|
||||
);
|
||||
});
|
||||
|
||||
it("should identify tar.gz files", () => {
|
||||
expect(getArchiveType("hugo_extended_0.154.3_linux-amd64.tar.gz")).toBe(
|
||||
"tar.gz",
|
||||
);
|
||||
});
|
||||
|
||||
it("should identify pkg files", () => {
|
||||
expect(getArchiveType("hugo_extended_0.154.3_darwin-universal.pkg")).toBe(
|
||||
"pkg",
|
||||
);
|
||||
});
|
||||
|
||||
it("should return null for unknown extensions", () => {
|
||||
expect(getArchiveType("hugo_0.154.3_readme.txt")).toBeNull();
|
||||
expect(getArchiveType("hugo.exe")).toBeNull();
|
||||
expect(getArchiveType("checksums.txt")).toBeNull();
|
||||
});
|
||||
|
||||
it("should correctly detect archive type for all platform release filenames", () => {
|
||||
// Windows x64 -> zip
|
||||
expect(getArchiveType("hugo_extended_0.154.3_windows-amd64.zip")).toBe(
|
||||
"zip",
|
||||
);
|
||||
|
||||
// Windows arm64 -> zip
|
||||
expect(getArchiveType("hugo_0.154.3_windows-arm64.zip")).toBe("zip");
|
||||
|
||||
// Linux x64 -> tar.gz
|
||||
expect(getArchiveType("hugo_extended_0.154.3_linux-amd64.tar.gz")).toBe(
|
||||
"tar.gz",
|
||||
);
|
||||
|
||||
// Linux arm64 -> tar.gz
|
||||
expect(getArchiveType("hugo_extended_0.154.3_linux-arm64.tar.gz")).toBe(
|
||||
"tar.gz",
|
||||
);
|
||||
|
||||
// macOS -> pkg
|
||||
expect(getArchiveType("hugo_extended_0.154.3_darwin-universal.pkg")).toBe(
|
||||
"pkg",
|
||||
);
|
||||
|
||||
// FreeBSD -> tar.gz
|
||||
expect(getArchiveType("hugo_0.154.3_freebsd-amd64.tar.gz")).toBe(
|
||||
"tar.gz",
|
||||
);
|
||||
|
||||
// OpenBSD -> tar.gz
|
||||
expect(getArchiveType("hugo_0.154.3_openbsd-amd64.tar.gz")).toBe(
|
||||
"tar.gz",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("getReleaseFilename + getArchiveType integration", () => {
|
||||
let originalPlatform: NodeJS.Platform;
|
||||
let originalArch: NodeJS.Architecture;
|
||||
|
||||
beforeEach(() => {
|
||||
originalPlatform = process.platform;
|
||||
originalArch = process.arch;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
Object.defineProperty(process, "platform", { value: originalPlatform });
|
||||
Object.defineProperty(process, "arch", { value: originalArch });
|
||||
});
|
||||
|
||||
it("should return zip for Windows release filenames", () => {
|
||||
Object.defineProperty(process, "platform", { value: "win32" });
|
||||
Object.defineProperty(process, "arch", { value: "x64" });
|
||||
|
||||
const filename = getReleaseFilename("0.154.3");
|
||||
assert(filename !== null, "Expected Windows x64 to have a release file");
|
||||
expect(getArchiveType(filename)).toBe("zip");
|
||||
});
|
||||
|
||||
it("should return tar.gz for Linux release filenames", () => {
|
||||
Object.defineProperty(process, "platform", { value: "linux" });
|
||||
Object.defineProperty(process, "arch", { value: "x64" });
|
||||
|
||||
const filename = getReleaseFilename("0.154.3");
|
||||
assert(filename !== null, "Expected Linux x64 to have a release file");
|
||||
expect(getArchiveType(filename)).toBe("tar.gz");
|
||||
});
|
||||
|
||||
it("should return pkg for macOS release filenames", () => {
|
||||
Object.defineProperty(process, "platform", { value: "darwin" });
|
||||
Object.defineProperty(process, "arch", { value: "arm64" });
|
||||
|
||||
const filename = getReleaseFilename("0.154.3");
|
||||
assert(filename !== null, "Expected macOS arm64 to have a release file");
|
||||
expect(getArchiveType(filename)).toBe("pkg");
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,192 @@
|
||||
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
||||
import {
|
||||
getBinFilename,
|
||||
getChecksumFilename,
|
||||
getReleaseFilename,
|
||||
getReleaseUrl,
|
||||
isExtended,
|
||||
} from "../../src/lib/utils";
|
||||
|
||||
describe("utils", () => {
|
||||
describe("getBinFilename", () => {
|
||||
let originalPlatform: NodeJS.Platform;
|
||||
|
||||
beforeEach(() => {
|
||||
originalPlatform = process.platform;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
Object.defineProperty(process, "platform", { value: originalPlatform });
|
||||
});
|
||||
|
||||
it("should return hugo.exe on Windows", () => {
|
||||
Object.defineProperty(process, "platform", { value: "win32" });
|
||||
expect(getBinFilename()).toBe("hugo.exe");
|
||||
});
|
||||
|
||||
it("should return hugo on Linux", () => {
|
||||
Object.defineProperty(process, "platform", { value: "linux" });
|
||||
expect(getBinFilename()).toBe("hugo");
|
||||
});
|
||||
|
||||
it("should return hugo on macOS", () => {
|
||||
Object.defineProperty(process, "platform", { value: "darwin" });
|
||||
expect(getBinFilename()).toBe("hugo");
|
||||
});
|
||||
|
||||
it("should return hugo on other platforms", () => {
|
||||
Object.defineProperty(process, "platform", { value: "freebsd" });
|
||||
expect(getBinFilename()).toBe("hugo");
|
||||
});
|
||||
});
|
||||
|
||||
describe("getReleaseFilename", () => {
|
||||
let originalPlatform: NodeJS.Platform;
|
||||
let originalArch: NodeJS.Architecture;
|
||||
|
||||
beforeEach(() => {
|
||||
originalPlatform = process.platform;
|
||||
originalArch = process.arch;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
Object.defineProperty(process, "platform", { value: originalPlatform });
|
||||
Object.defineProperty(process, "arch", { value: originalArch });
|
||||
});
|
||||
|
||||
describe("macOS", () => {
|
||||
it("should return universal pkg for darwin x64", () => {
|
||||
Object.defineProperty(process, "platform", { value: "darwin" });
|
||||
Object.defineProperty(process, "arch", { value: "x64" });
|
||||
expect(getReleaseFilename("0.154.3")).toBe(
|
||||
"hugo_extended_0.154.3_darwin-universal.pkg",
|
||||
);
|
||||
});
|
||||
|
||||
it("should return universal pkg for darwin arm64", () => {
|
||||
Object.defineProperty(process, "platform", { value: "darwin" });
|
||||
Object.defineProperty(process, "arch", { value: "arm64" });
|
||||
expect(getReleaseFilename("0.154.3")).toBe(
|
||||
"hugo_extended_0.154.3_darwin-universal.pkg",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Windows", () => {
|
||||
it("should return extended zip for win32 x64", () => {
|
||||
Object.defineProperty(process, "platform", { value: "win32" });
|
||||
Object.defineProperty(process, "arch", { value: "x64" });
|
||||
expect(getReleaseFilename("0.154.3")).toBe(
|
||||
"hugo_extended_0.154.3_windows-amd64.zip",
|
||||
);
|
||||
});
|
||||
|
||||
it("should return vanilla zip for win32 arm64 (no extended support)", () => {
|
||||
Object.defineProperty(process, "platform", { value: "win32" });
|
||||
Object.defineProperty(process, "arch", { value: "arm64" });
|
||||
expect(getReleaseFilename("0.154.3")).toBe(
|
||||
"hugo_0.154.3_windows-arm64.zip",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Linux", () => {
|
||||
it("should return extended tar.gz for linux x64", () => {
|
||||
Object.defineProperty(process, "platform", { value: "linux" });
|
||||
Object.defineProperty(process, "arch", { value: "x64" });
|
||||
expect(getReleaseFilename("0.154.3")).toBe(
|
||||
"hugo_extended_0.154.3_linux-amd64.tar.gz",
|
||||
);
|
||||
});
|
||||
|
||||
it("should return extended tar.gz for linux arm64", () => {
|
||||
Object.defineProperty(process, "platform", { value: "linux" });
|
||||
Object.defineProperty(process, "arch", { value: "arm64" });
|
||||
expect(getReleaseFilename("0.154.3")).toBe(
|
||||
"hugo_extended_0.154.3_linux-arm64.tar.gz",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("BSD", () => {
|
||||
it("should return vanilla tar.gz for freebsd x64", () => {
|
||||
Object.defineProperty(process, "platform", { value: "freebsd" });
|
||||
Object.defineProperty(process, "arch", { value: "x64" });
|
||||
expect(getReleaseFilename("0.154.3")).toBe(
|
||||
"hugo_0.154.3_freebsd-amd64.tar.gz",
|
||||
);
|
||||
});
|
||||
|
||||
it("should return vanilla tar.gz for openbsd x64", () => {
|
||||
Object.defineProperty(process, "platform", { value: "openbsd" });
|
||||
Object.defineProperty(process, "arch", { value: "x64" });
|
||||
expect(getReleaseFilename("0.154.3")).toBe(
|
||||
"hugo_0.154.3_openbsd-amd64.tar.gz",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("unsupported platforms", () => {
|
||||
it("should return null for unsupported platform", () => {
|
||||
Object.defineProperty(process, "platform", { value: "sunos" });
|
||||
Object.defineProperty(process, "arch", { value: "x64" });
|
||||
expect(getReleaseFilename("0.154.3")).toBeNull();
|
||||
});
|
||||
|
||||
it("should return null for unsupported arch on linux", () => {
|
||||
Object.defineProperty(process, "platform", { value: "linux" });
|
||||
Object.defineProperty(process, "arch", { value: "ia32" });
|
||||
expect(getReleaseFilename("0.154.3")).toBeNull();
|
||||
});
|
||||
|
||||
it("should return null for unsupported arch on freebsd", () => {
|
||||
Object.defineProperty(process, "platform", { value: "freebsd" });
|
||||
Object.defineProperty(process, "arch", { value: "arm64" });
|
||||
expect(getReleaseFilename("0.154.3")).toBeNull();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("getChecksumFilename", () => {
|
||||
it("should return correct checksum filename", () => {
|
||||
expect(getChecksumFilename("0.154.3")).toBe("hugo_0.154.3_checksums.txt");
|
||||
});
|
||||
|
||||
it("should handle different version formats", () => {
|
||||
expect(getChecksumFilename("1.0.0")).toBe("hugo_1.0.0_checksums.txt");
|
||||
expect(getChecksumFilename("0.100.0")).toBe("hugo_0.100.0_checksums.txt");
|
||||
});
|
||||
});
|
||||
|
||||
describe("isExtended", () => {
|
||||
it("should return true for extended releases", () => {
|
||||
expect(isExtended("hugo_extended_0.154.3_linux-amd64.tar.gz")).toBe(true);
|
||||
expect(isExtended("hugo_extended_0.154.3_darwin-universal.pkg")).toBe(
|
||||
true,
|
||||
);
|
||||
expect(isExtended("hugo_extended_0.154.3_windows-amd64.zip")).toBe(true);
|
||||
});
|
||||
|
||||
it("should return false for vanilla releases", () => {
|
||||
expect(isExtended("hugo_0.154.3_windows-arm64.zip")).toBe(false);
|
||||
expect(isExtended("hugo_0.154.3_freebsd-amd64.tar.gz")).toBe(false);
|
||||
expect(isExtended("hugo_0.154.3_openbsd-amd64.tar.gz")).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("getReleaseUrl", () => {
|
||||
it("should return correct GitHub release URL", () => {
|
||||
expect(
|
||||
getReleaseUrl("0.154.3", "hugo_extended_0.154.3_linux-amd64.tar.gz"),
|
||||
).toBe(
|
||||
"https://github.com/gohugoio/hugo/releases/download/v0.154.3/hugo_extended_0.154.3_linux-amd64.tar.gz",
|
||||
);
|
||||
});
|
||||
|
||||
it("should work with checksum files", () => {
|
||||
expect(getReleaseUrl("0.154.3", "hugo_0.154.3_checksums.txt")).toBe(
|
||||
"https://github.com/gohugoio/hugo/releases/download/v0.154.3/hugo_0.154.3_checksums.txt",
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user