0dc5552c73
A `#` preceded by whitespace and outside quotes now ends the value, so `TR_DEBUG_DRAW=0 # hides the grid` resolves to `0` instead of the whole tail. Values that are still not plain tokens (whitespace, `#`, an unclosed quote) get one `[dotenv] WARNING` line naming file, key, raw value and the fact that the reader falls back to its DEFAULT, instead of being applied silently. Guard test 29 -> 47 checks.
247 lines
10 KiB
Nim
247 lines
10 KiB
Nim
## Guard test for the .env loader (the module the bot uses to read its
|
|
## settings from a `.env` file).
|
|
##
|
|
## NO battle, NO Java, NO server. Run with:
|
|
## nim c -r --path:common_libs common_libs/tests/test_env_dotenv.nim
|
|
##
|
|
## It pins the pieces that must not silently drift:
|
|
## * the parser (comments, `export `, quotes, empty value, CRLF, and a
|
|
## malformed line that MUST raise with the file+line);
|
|
## * resolution precedence (flag > TR_ENV_FILE > ./.env > .env next to the
|
|
## executable > none);
|
|
## * the FILE WINS over the real environment, and the conflict is reported;
|
|
## * a junky value (unrecognised) is WARNED ABOUT at load time instead of
|
|
## silently becoming the reader's default (j139);
|
|
## * an explicitly requested missing file raises (the caller then exits
|
|
## nonzero), while a missing DEFAULT file is a silent no-op.
|
|
|
|
import std/[os, strutils]
|
|
import "../../ModularBot_garage/src/env_dotenv"
|
|
|
|
var failures = 0
|
|
var checks = 0
|
|
|
|
proc check(name: string, ok: bool) =
|
|
inc checks
|
|
if ok: echo "PASS: ", name
|
|
else: echo "FAIL: ", name; inc failures
|
|
|
|
let tmpDir = getTempDir() / "test_env_dotenv_guard"
|
|
createDir(tmpDir)
|
|
|
|
proc tmpPath(name: string): string = tmpDir / name
|
|
|
|
proc writeTmp(name, content: string): string =
|
|
result = tmpPath(name)
|
|
writeFile(result, content)
|
|
|
|
# ── 1. the parser ────────────────────────────────────────────────────────────
|
|
|
|
proc testParser() =
|
|
let entries = parseEnvFileContent("""# a comment, ignored
|
|
|
|
TR_MOVEMENT=tfil
|
|
export TR_RACK_TMHORIZON=both
|
|
QUOTED="hello world"
|
|
SINGLE='x y'
|
|
EMPTY=
|
|
SPACED = trimmed
|
|
""", "sample.env")
|
|
check "comment + blank lines are skipped (6 entries)", entries.len == 6
|
|
if entries.len == 6:
|
|
check "plain KEY=VALUE", entries[0].key == "TR_MOVEMENT" and entries[0].value == "tfil"
|
|
check "leading `export ` is tolerated",
|
|
entries[1].key == "TR_RACK_TMHORIZON" and entries[1].value == "both"
|
|
check "double quotes are stripped",
|
|
entries[2].key == "QUOTED" and entries[2].value == "hello world"
|
|
check "single quotes are stripped",
|
|
entries[3].key == "SINGLE" and entries[3].value == "x y"
|
|
check "an empty value is allowed",
|
|
entries[4].key == "EMPTY" and entries[4].value == ""
|
|
check "whitespace around key and value is trimmed",
|
|
entries[5].key == "SPACED" and entries[5].value == "trimmed"
|
|
|
|
let crlf = parseEnvFileContent("A=1\r\nB=two\r\n", "crlf.env")
|
|
check "CRLF line endings are stripped from the value",
|
|
crlf.len == 2 and crlf[0].value == "1" and crlf[1].value == "two"
|
|
|
|
var raised = false
|
|
try:
|
|
discard parseEnvFileContent("this line has no equals sign\n", "bad.env")
|
|
except ValueError as e:
|
|
raised = true
|
|
check "malformed line error names the file", "bad.env" in e.msg
|
|
check "malformed line error names the line number", ":1:" in e.msg
|
|
check "malformed line RAISES (never silently ignored)", raised
|
|
|
|
var emptyKeyRaised = false
|
|
try:
|
|
discard parseEnvFileContent("=value\n", "bad2.env")
|
|
except ValueError:
|
|
emptyKeyRaised = true
|
|
check "an empty key raises", emptyKeyRaised
|
|
|
|
# ── 2. resolution precedence ─────────────────────────────────────────────────
|
|
|
|
proc testResolution() =
|
|
let flagChoice = chooseEnvFile("/x/flag.env", "/x/tr.env",
|
|
"/cwd/.env", "/exe/.env")
|
|
check "flag beats TR_ENV_FILE and the defaults",
|
|
flagChoice.source == "flag" and flagChoice.path == "/x/flag.env" and
|
|
flagChoice.explicit
|
|
|
|
let envChoice = chooseEnvFile("", "/x/tr.env", "/cwd/.env", "/exe/.env")
|
|
check "TR_ENV_FILE beats the default paths",
|
|
envChoice.source == EnvFileEnvVar and envChoice.path == "/x/tr.env" and
|
|
envChoice.explicit
|
|
|
|
let cwdFile = writeTmp("cwd.env", "A=1\n")
|
|
let exeFile = writeTmp("exe.env", "A=2\n")
|
|
let cwdChoice = chooseEnvFile("", "", cwdFile, exeFile)
|
|
check "./.env (cwd) is chosen when it exists and is not explicit",
|
|
cwdChoice.source == "cwd" and cwdChoice.path == cwdFile and
|
|
not cwdChoice.explicit
|
|
|
|
let exeChoice = chooseEnvFile("", "", tmpPath("missing_cwd.env"), exeFile)
|
|
check ".env next to the executable is the fallback",
|
|
exeChoice.source == "exe" and exeChoice.path == exeFile
|
|
|
|
let noneChoice = chooseEnvFile("", "", tmpPath("missing_cwd.env"),
|
|
tmpPath("missing_exe.env"))
|
|
check "no candidate exists -> none (silent)",
|
|
noneChoice.path == "" and noneChoice.source == "none" and
|
|
not noneChoice.explicit
|
|
|
|
check "--env-file <path> is parsed",
|
|
flagEnvFilePath(@["--server", "x", "--env-file", "/a/b.env"]) == "/a/b.env"
|
|
check "--env-file=<path> is parsed",
|
|
flagEnvFilePath(@["--env-file=/a/b.env"]) == "/a/b.env"
|
|
check "no --env-file yields empty", flagEnvFilePath(@["--server", "x"]) == ""
|
|
var raised = false
|
|
try:
|
|
discard flagEnvFilePath(@["--env-file"])
|
|
except EnvFileError:
|
|
raised = true
|
|
check "--env-file with no argument raises", raised
|
|
|
|
# ── 3. the file wins over the real environment ───────────────────────────────
|
|
|
|
proc testFileWins() =
|
|
let f = writeTmp("wins.env", "TR_TEST_J126=from-file\nTR_TEST_SAME_J126=same\n")
|
|
putEnv("TR_TEST_J126", "from-shell")
|
|
putEnv("TR_TEST_SAME_J126", "same")
|
|
let conflicts = applyEnvFile(f)
|
|
check "the file value WINS over the exported value",
|
|
getEnv("TR_TEST_J126") == "from-file"
|
|
check "the conflict is reported with key, file value and shell value",
|
|
conflicts.len == 1 and conflicts[0].key == "TR_TEST_J126" and
|
|
conflicts[0].fileValue == "from-file" and
|
|
conflicts[0].shellValue == "from-shell"
|
|
check "an identical value is NOT a conflict",
|
|
conflicts.len == 1 # TR_TEST_SAME_J126 is absent from conflicts
|
|
check "keys from the file are tracked for the boot report",
|
|
isFromEnvFile("TR_TEST_J126") and isFromEnvFile("TR_TEST_SAME_J126")
|
|
delEnv("TR_TEST_J126")
|
|
delEnv("TR_TEST_SAME_J126")
|
|
|
|
# ── 4. missing explicit file vs missing default file ─────────────────────────
|
|
|
|
proc testMissingFiles() =
|
|
var raised = false
|
|
try:
|
|
discard loadEnvFile(EnvFileChoice(path: tmpPath("nope.env"),
|
|
source: "flag", explicit: true))
|
|
except EnvFileError as e:
|
|
raised = true
|
|
check "missing explicit file error names the path", "nope.env" in e.msg
|
|
check "missing explicit file RAISES (caller exits nonzero)", raised
|
|
|
|
var quiet = true
|
|
var n = 0
|
|
try:
|
|
n = loadEnvFile(EnvFileChoice(path: tmpPath("missing-default.env"),
|
|
source: "cwd", explicit: false)).len
|
|
except CatchableError:
|
|
quiet = false
|
|
check "missing default file is a silent no-op", quiet and n == 0
|
|
|
|
check "no file at all is a silent no-op",
|
|
loadEnvFile(EnvFileChoice(path: "", source: "none", explicit: false)).len == 0
|
|
|
|
# ── 5. inline comments (j139) and the junk-value warning ─────────────────────
|
|
|
|
proc testInlineComments() =
|
|
let e = parseEnvFileContent(
|
|
"A=value # comment\n" &
|
|
"B=\"a # b\"\n" &
|
|
"C='a # b'\n" &
|
|
"D=value#notacomment\n" &
|
|
"E=# comment\n" &
|
|
"# whole line\n" &
|
|
"F=0 # hides the heat grid\n" &
|
|
"G=both\n", "inline.env")
|
|
check "inline comment file yields 7 entries", e.len == 7
|
|
if e.len == 7:
|
|
check "`KEY=value # comment` -> value", e[0].key == "A" and e[0].value == "value"
|
|
check "a `#` inside double quotes is DATA", e[1].value == "a # b"
|
|
check "a `#` inside single quotes is DATA", e[2].value == "a # b"
|
|
check "`KEY=value#notacomment` keeps the `#` (no whitespace before it)",
|
|
e[3].value == "value#notacomment"
|
|
check "`KEY=# comment` -> empty value (not an error)", e[4].value == ""
|
|
check "the reported bug shape `0 # comment` -> `0`", e[5].value == "0"
|
|
check "a line whose only content is a comment is still skipped", e[6].value == "both"
|
|
|
|
check "stripInlineComment is pure and exact",
|
|
stripInlineComment("x # y").strip == "x" and
|
|
stripInlineComment(" # y").strip == "" and
|
|
stripInlineComment("\"a # b\"").strip == "\"a # b\""
|
|
|
|
proc testJunkWarning() =
|
|
check "a plain token is never junky",
|
|
not looksJunky("0") and not looksJunky("off") and
|
|
not looksJunky("1.0,1.25") and
|
|
not looksJunky("/tmp/modularbot.log") and not looksJunky("")
|
|
check "whitespace / `#` / unbalanced quotes are junky",
|
|
looksJunky("0 # hides the grid") and looksJunky("a # b") and
|
|
looksJunky("\"unclosed")
|
|
|
|
# A junky value the inline-comment rule CANNOT fix: an unclosed quote, so
|
|
# the `#` is (correctly) treated as data and the whole tail stays in the value.
|
|
let f = writeTmp("junk.env", "TR_TEST_JUNK_J139=\"0 # hides the grid\n")
|
|
let conflicts = applyEnvFile(f)
|
|
check "a junky value is still APPLIED verbatim (the reader decides)",
|
|
conflicts.len == 0 and
|
|
getEnv("TR_TEST_JUNK_J139") == "\"0 # hides the grid"
|
|
delEnv("TR_TEST_JUNK_J139")
|
|
|
|
# the warning path itself: one line per offending key, naming file, key,
|
|
# raw value and what is used.
|
|
let w = junkWarnings(parseEnvFileContent(readFile(f), f), f)
|
|
check "a junky value produces exactly one warning",
|
|
w.len == 1
|
|
check "the warning names the file", w.len == 1 and f in w[0]
|
|
check "the warning names the key and the raw value",
|
|
w.len == 1 and "TR_TEST_JUNK_J139" in w[0] and "# hides the grid" in w[0]
|
|
check "the warning says what is used and how to fix it",
|
|
w.len == 1 and "DEFAULT" in w[0] and "its own line" in w[0]
|
|
check "a clean file produces no warning",
|
|
junkWarnings(parseEnvFileContent("A=0 # fine\nB=1.0,1.25\n", "c.env"),
|
|
"c.env").len == 0
|
|
check "one line per key, no spam",
|
|
junkWarnings(parseEnvFileContent("A=x y\nB=p q\n", "d.env"), "d.env").len == 2
|
|
|
|
# ── driver ───────────────────────────────────────────────────────────────────
|
|
|
|
testParser()
|
|
testResolution()
|
|
testFileWins()
|
|
testMissingFiles()
|
|
testInlineComments()
|
|
testJunkWarning()
|
|
|
|
echo "\n", checks, " checks, ", failures, " failure(s)"
|
|
if failures > 0:
|
|
echo checks, " check(s) run, ", failures, " FAILED"
|
|
quit(1)
|
|
echo "All env-dotenv checks passed."
|