From cf349d0c4e6d8be5748645f0bfe5fd0faa583f0e Mon Sep 17 00:00:00 2001 From: real-venus Date: Tue, 21 Jul 2026 22:11:20 +0200 Subject: [PATCH] test(selfhost): cover env_get and require_cmd in selfhost-deploy-common Adds unit coverage for the two remaining untested functions in scripts/lib/selfhost-deploy-common.sh, following the existing maybe_infisical_run / env_put / compose_file_args harness in the same file: - require_cmd: present (silent no-op) and missing (exit 1 + stderr message). - env_get: plain value, single/double-quote stripping, whitespace trim + indented key, comment-line skipping + first-match, absent key -> 1, absent file -> 1, and the $ENV_FILE fallback when no file arg is given. Also documents both functions' contracts inline (the file already documents env_put / maybe_infisical_run / compose_file_args this way). env_put (#7766) and compose_file_args (#7765) already have coverage from their bug-fix PRs, so they are left as-is per the issue's "don't duplicate" note. Closes #7769 --- scripts/lib/selfhost-deploy-common.sh | 6 ++ test/unit/selfhost-deploy-common.test.ts | 107 +++++++++++++++++++++++ 2 files changed, 113 insertions(+) diff --git a/scripts/lib/selfhost-deploy-common.sh b/scripts/lib/selfhost-deploy-common.sh index 80898fd320..a9d30b2542 100644 --- a/scripts/lib/selfhost-deploy-common.sh +++ b/scripts/lib/selfhost-deploy-common.sh @@ -3,6 +3,8 @@ # Sourced, not executed: this file has no shebang-driven side effects and defines functions only. # Both callers set ENV_FILE before sourcing this; env_get/env_put fall back to it when no file arg is given. +# Abort with a stderr message + `exit 1` if $1 is not an executable on PATH; a silent no-op when it is. Lets +# the deploy scripts fail fast on a missing dependency (docker, infisical, ...) before doing any real work. require_cmd() { if ! command -v "$1" >/dev/null 2>&1; then echo "error: required command not found: $1" >&2 @@ -10,6 +12,10 @@ require_cmd() { fi } +# Read $1's value from the env file ($2, or $ENV_FILE when the arg is omitted): the FIRST `key=value` line +# (comment/blank lines skipped, leading indentation and whitespace around the value trimmed, and one pair of +# matching surrounding single/double quotes stripped). Returns 1 with no output if the file is absent or the +# key is not present, so callers can distinguish "unset" from an explicit empty value. env_get() { local key="$1" local file="${2:-$ENV_FILE}" diff --git a/test/unit/selfhost-deploy-common.test.ts b/test/unit/selfhost-deploy-common.test.ts index 61a6719483..7c7b475540 100644 --- a/test/unit/selfhost-deploy-common.test.ts +++ b/test/unit/selfhost-deploy-common.test.ts @@ -116,6 +116,113 @@ describe("maybe_infisical_run (#5120)", () => { }); }); +describe("require_cmd (#7769)", () => { + // Source the lib and invoke require_cmd directly with its single command-name arg. + function runRequireCmd(cmd: string) { + const script = `set -uo pipefail; . "${libPath.replace(/\\/g, "/")}"; require_cmd "$1"`; + return spawnSync("bash", ["-c", script, "bash", cmd], { encoding: "utf8" }); + } + + it("is a silent no-op (exit 0) when the command exists on PATH", () => { + const r = runRequireCmd("bash"); + expect(r.status, r.stderr).toBe(0); + expect(r.stdout).toBe(""); + expect(r.stderr).toBe(""); + }); + + it("aborts with exit 1 and a clear stderr message when the command is missing", () => { + const r = runRequireCmd("loopover-definitely-absent-cmd"); + expect(r.status).toBe(1); + expect(r.stderr).toContain("required command not found: loopover-definitely-absent-cmd"); + }); +}); + +describe("env_get (#7769)", () => { + // Source the lib and invoke env_get directly: args are [key] (using $ENV_FILE) or [key, file]. + function runEnvGet(args: string[], env: Record = {}) { + const script = `set -uo pipefail; . "${libPath.replace(/\\/g, "/")}"; env_get "$@"`; + return spawnSync("bash", ["-c", script, "bash", ...args], { encoding: "utf8", env: { ...process.env, ...env } }); + } + + function tempEnvFile(contents: string): { dir: string; file: string } { + const dir = mkdtempSync(join(tmpdir(), "loopover-env-get-")); + const file = join(dir, ".env"); + writeFileSync(file, contents); + return { dir, file }; + } + + it("returns a plain unquoted value for a present key", () => { + const { dir, file } = tempEnvFile("FOO=1\nBAR=hello\n"); + try { + const r = runEnvGet(["BAR", file]); + expect(r.status, r.stderr).toBe(0); + expect(r.stdout).toBe("hello\n"); + } finally { + rmSync(dir, { recursive: true, force: true }); + } + }); + + it("strips one pair of surrounding double OR single quotes from the value", () => { + const { dir, file } = tempEnvFile(`DQ="quoted value"\nSQ='other value'\n`); + try { + expect(runEnvGet(["DQ", file]).stdout).toBe("quoted value\n"); + expect(runEnvGet(["SQ", file]).stdout).toBe("other value\n"); + } finally { + rmSync(dir, { recursive: true, force: true }); + } + }); + + it("trims whitespace around the value and matches a key even when the line is indented", () => { + const { dir, file } = tempEnvFile(" SPACED = spaced-value \n"); + try { + const r = runEnvGet(["SPACED", file]); + expect(r.status, r.stderr).toBe(0); + expect(r.stdout).toBe("spaced-value\n"); + } finally { + rmSync(dir, { recursive: true, force: true }); + } + }); + + it("skips comment lines and returns the first matching assignment", () => { + const { dir, file } = tempEnvFile("# FOO=commented\nFOO=first\nFOO=second\n"); + try { + const r = runEnvGet(["FOO", file]); + expect(r.status, r.stderr).toBe(0); + expect(r.stdout).toBe("first\n"); + } finally { + rmSync(dir, { recursive: true, force: true }); + } + }); + + it("returns 1 with no output when the key is absent", () => { + const { dir, file } = tempEnvFile("FOO=1\n"); + try { + const r = runEnvGet(["MISSING", file]); + expect(r.status).toBe(1); + expect(r.stdout).toBe(""); + } finally { + rmSync(dir, { recursive: true, force: true }); + } + }); + + it("returns 1 when the target file does not exist", () => { + const r = runEnvGet(["FOO", join(tmpdir(), "loopover-env-get-does-not-exist", ".env")]); + expect(r.status).toBe(1); + expect(r.stdout).toBe(""); + }); + + it("falls back to $ENV_FILE when no file argument is given", () => { + const { dir, file } = tempEnvFile("TOKEN=from-env-file\n"); + try { + const r = runEnvGet(["TOKEN"], { ENV_FILE: file }); + expect(r.status, r.stderr).toBe(0); + expect(r.stdout).toBe("from-env-file\n"); + } finally { + rmSync(dir, { recursive: true, force: true }); + } + }); +}); + describe("env_put (#7766 -- atomic write + mode preservation)", () => { // Source the lib and invoke env_put directly with (key, value, file) positional args. function runEnvPut(file: string, key: string, value: string) {