mirror of
https://github.com/cloudflare/vinext.git
synced 2026-09-14 19:04:59 +08:00
431b044879
* fix: honor build mode dotenv files * fix: preserve test-mode dotenv exclusion in config * fix(build): keep NODE_ENV production with custom mode * fix(build): set production env before config loading * fix(env): preserve dotenv expansion operators * fix(build): preserve explicit NODE_ENV for config loading --------- Co-authored-by: James <james@eli.cx>
313 lines
9.7 KiB
TypeScript
313 lines
9.7 KiB
TypeScript
import { afterEach, beforeEach, describe, expect, it } from "vite-plus/test";
|
|
import fs from "node:fs";
|
|
import os from "node:os";
|
|
import path from "node:path";
|
|
import { loadDotenv, getDotenvFiles } from "../packages/vinext/src/config/dotenv.js";
|
|
|
|
let tmpDir: string;
|
|
|
|
function writeFile(relativePath: string, content: string): void {
|
|
const fullPath = path.join(tmpDir, relativePath);
|
|
fs.mkdirSync(path.dirname(fullPath), { recursive: true });
|
|
fs.writeFileSync(fullPath, content, "utf-8");
|
|
}
|
|
|
|
beforeEach(() => {
|
|
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "vinext-dotenv-test-"));
|
|
});
|
|
|
|
afterEach(() => {
|
|
fs.rmSync(tmpDir, { recursive: true, force: true });
|
|
});
|
|
|
|
describe("getDotenvFiles", () => {
|
|
it("returns correct file list for development", () => {
|
|
expect(getDotenvFiles("development")).toEqual([
|
|
".env.development.local",
|
|
".env.local",
|
|
".env.development",
|
|
".env",
|
|
]);
|
|
});
|
|
|
|
it("returns correct file list for production", () => {
|
|
expect(getDotenvFiles("production")).toEqual([
|
|
".env.production.local",
|
|
".env.local",
|
|
".env.production",
|
|
".env",
|
|
]);
|
|
});
|
|
|
|
it("skips .env.local for test mode", () => {
|
|
const files = getDotenvFiles("test");
|
|
expect(files).toEqual([".env.test.local", ".env.test", ".env"]);
|
|
expect(files).not.toContain(".env.local");
|
|
});
|
|
|
|
it("returns mode-specific files for custom build modes", () => {
|
|
expect(getDotenvFiles("staging")).toEqual([
|
|
".env.staging.local",
|
|
".env.local",
|
|
".env.staging",
|
|
".env",
|
|
]);
|
|
});
|
|
});
|
|
|
|
describe("loadDotenv", () => {
|
|
it("applies Next.js precedence for development mode", () => {
|
|
writeFile(".env", "ORDER=env\nSECOND=env\n");
|
|
writeFile(".env.development", "ORDER=mode\nSECOND=mode\n");
|
|
writeFile(".env.local", "ORDER=local\nSECOND=local\n");
|
|
writeFile(".env.development.local", "ORDER=mode-local\n");
|
|
|
|
const env: Record<string, string | undefined> = {
|
|
ORDER: "process",
|
|
FROM_PROCESS: "yes",
|
|
};
|
|
|
|
const result = loadDotenv({
|
|
root: tmpDir,
|
|
mode: "development",
|
|
processEnv: env,
|
|
});
|
|
|
|
// process.env values always win
|
|
expect(env.ORDER).toBe("process");
|
|
// .env.local beats .env.development and .env
|
|
expect(env.SECOND).toBe("local");
|
|
// pre-existing values are untouched
|
|
expect(env.FROM_PROCESS).toBe("yes");
|
|
|
|
// loadedEnv only contains keys actually written (not pre-existing)
|
|
expect(result.loadedEnv).not.toHaveProperty("ORDER");
|
|
expect(result.loadedEnv.SECOND).toBe("local");
|
|
|
|
// all existing files are reported as loaded
|
|
expect(result.loadedFiles).toEqual([
|
|
".env.development.local",
|
|
".env.local",
|
|
".env.development",
|
|
".env",
|
|
]);
|
|
});
|
|
|
|
it("applies Next.js precedence for production mode", () => {
|
|
writeFile(".env", "DB_HOST=from-env\nAPI_KEY=from-env\n");
|
|
writeFile(".env.production", "DB_HOST=from-prod\n");
|
|
writeFile(".env.local", "DB_HOST=from-local\nAPI_KEY=from-local\n");
|
|
writeFile(".env.production.local", "DB_HOST=from-prod-local\n");
|
|
|
|
const env: Record<string, string | undefined> = {};
|
|
|
|
const result = loadDotenv({
|
|
root: tmpDir,
|
|
mode: "production",
|
|
processEnv: env,
|
|
});
|
|
|
|
// .env.production.local has highest file priority
|
|
expect(env.DB_HOST).toBe("from-prod-local");
|
|
// .env.local beats .env.production and .env
|
|
expect(env.API_KEY).toBe("from-local");
|
|
|
|
expect(result.loadedEnv.DB_HOST).toBe("from-prod-local");
|
|
expect(result.loadedEnv.API_KEY).toBe("from-local");
|
|
expect(result.loadedFiles).toEqual([
|
|
".env.production.local",
|
|
".env.local",
|
|
".env.production",
|
|
".env",
|
|
]);
|
|
});
|
|
|
|
it("skips .env.local in test mode", () => {
|
|
writeFile(".env.local", "TEST_VALUE=from-local\n");
|
|
writeFile(".env.test", "TEST_VALUE=from-test\n");
|
|
writeFile(".env", "TEST_VALUE=from-env\n");
|
|
|
|
const env: Record<string, string | undefined> = {};
|
|
const result = loadDotenv({
|
|
root: tmpDir,
|
|
mode: "test",
|
|
processEnv: env,
|
|
});
|
|
|
|
expect(env.TEST_VALUE).toBe("from-test");
|
|
expect(result.loadedFiles).not.toContain(".env.local");
|
|
expect(result.loadedFiles).toContain(".env.test");
|
|
expect(result.loadedEnv.TEST_VALUE).toBe("from-test");
|
|
});
|
|
|
|
it("loads custom build mode files with normal local precedence", () => {
|
|
writeFile(".env", "NEXT_PUBLIC_TARGET=env\nFALLBACK=env\n");
|
|
writeFile(".env.staging", "NEXT_PUBLIC_TARGET=mode\n");
|
|
writeFile(".env.local", "LOCAL_ONLY=local\n");
|
|
writeFile(".env.staging.local", "NEXT_PUBLIC_TARGET=mode-local\n");
|
|
|
|
const env: Record<string, string | undefined> = {};
|
|
const result = loadDotenv({
|
|
root: tmpDir,
|
|
mode: "staging",
|
|
processEnv: env,
|
|
});
|
|
|
|
expect(env.NEXT_PUBLIC_TARGET).toBe("mode-local");
|
|
expect(env.LOCAL_ONLY).toBe("local");
|
|
expect(env.FALLBACK).toBe("env");
|
|
expect(result.loadedFiles).toEqual([
|
|
".env.staging.local",
|
|
".env.local",
|
|
".env.staging",
|
|
".env",
|
|
]);
|
|
expect(result.mode).toBe("staging");
|
|
});
|
|
|
|
it("expands variables using $VAR syntax", () => {
|
|
writeFile(".env", "BASE_URL=https://example.com\nAPI_URL=$BASE_URL/v1\n");
|
|
|
|
const env: Record<string, string | undefined> = {};
|
|
const result = loadDotenv({
|
|
root: tmpDir,
|
|
mode: "development",
|
|
processEnv: env,
|
|
});
|
|
|
|
expect(env.API_URL).toBe("https://example.com/v1");
|
|
expect(result.loadedEnv.API_URL).toBe("https://example.com/v1");
|
|
});
|
|
|
|
it("expands variables using ${VAR} syntax", () => {
|
|
writeFile(".env", "HOST=localhost\nPORT=3000\nURL=http://${HOST}:${PORT}\n");
|
|
|
|
const env: Record<string, string | undefined> = {};
|
|
loadDotenv({ root: tmpDir, mode: "development", processEnv: env });
|
|
|
|
expect(env.URL).toBe("http://localhost:3000");
|
|
});
|
|
|
|
it("supports dotenv-expand default and alternate operators", () => {
|
|
writeFile(
|
|
".env",
|
|
[
|
|
"SET=value",
|
|
"EMPTY=",
|
|
"DEFAULT_UNSET=${MISSING:-fallback}",
|
|
"DEFAULT_EMPTY=${EMPTY:-fallback}",
|
|
"DEFAULT_DASH=${MISSING-fallback}",
|
|
"ALTERNATE_SET=${SET:+alternate}",
|
|
"ALTERNATE_PLUS=${SET+alternate}",
|
|
"ALTERNATE_MISSING=${MISSING:+alternate}",
|
|
"NESTED_DEFAULT=${MISSING:-${SET}}",
|
|
"SELF_DEFAULT=${SELF_DEFAULT:-self-fallback}",
|
|
].join("\n"),
|
|
);
|
|
|
|
const env: Record<string, string | undefined> = {};
|
|
loadDotenv({ root: tmpDir, mode: "development", processEnv: env });
|
|
|
|
expect(env.DEFAULT_UNSET).toBe("fallback");
|
|
expect(env.DEFAULT_EMPTY).toBe("fallback");
|
|
expect(env.DEFAULT_DASH).toBe("fallback");
|
|
expect(env.ALTERNATE_SET).toBe("alternate");
|
|
expect(env.ALTERNATE_PLUS).toBe("alternate");
|
|
expect(env.ALTERNATE_MISSING).toBe("");
|
|
expect(env.NESTED_DEFAULT).toBe("value");
|
|
expect(env.SELF_DEFAULT).toBe("self-fallback");
|
|
});
|
|
|
|
it("expansion prefers process.env over file values", () => {
|
|
writeFile(
|
|
".env.development.local",
|
|
"BASE_URL=https://from-file.example.com\nAPI_URL=$BASE_URL/v1\n",
|
|
);
|
|
|
|
const env: Record<string, string | undefined> = {
|
|
BASE_URL: "https://from-process.example.com",
|
|
};
|
|
|
|
loadDotenv({
|
|
root: tmpDir,
|
|
mode: "development",
|
|
processEnv: env,
|
|
});
|
|
|
|
// BASE_URL from process.env wins, and expansion uses it
|
|
expect(env.BASE_URL).toBe("https://from-process.example.com");
|
|
expect(env.API_URL).toBe("https://from-process.example.com/v1");
|
|
});
|
|
|
|
it("handles escaped dollar signs", () => {
|
|
writeFile(".env", "PRICE=\\$100\n");
|
|
|
|
const env: Record<string, string | undefined> = {};
|
|
loadDotenv({ root: tmpDir, mode: "development", processEnv: env });
|
|
|
|
expect(env.PRICE).toBe("$100");
|
|
});
|
|
|
|
it("handles circular variable references without infinite loop", () => {
|
|
writeFile(".env", "A=$B\nB=$A\n");
|
|
|
|
const env: Record<string, string | undefined> = {};
|
|
// Should not hang — cycle detection breaks the loop
|
|
loadDotenv({ root: tmpDir, mode: "development", processEnv: env });
|
|
|
|
// Both resolve to empty string (cycle with no base value)
|
|
expect(env.A).toBeDefined();
|
|
expect(env.B).toBeDefined();
|
|
});
|
|
|
|
it("skips missing files without error", () => {
|
|
// Only .env exists, others are missing
|
|
writeFile(".env", "ONLY=here\n");
|
|
|
|
const env: Record<string, string | undefined> = {};
|
|
const result = loadDotenv({
|
|
root: tmpDir,
|
|
mode: "development",
|
|
processEnv: env,
|
|
});
|
|
|
|
expect(env.ONLY).toBe("here");
|
|
expect(result.loadedFiles).toEqual([".env"]);
|
|
});
|
|
|
|
it("expands variables across files (lower-priority file references higher-priority)", () => {
|
|
// DB_HOST is defined in .env.development.local (highest file priority)
|
|
// DB_URL in .env references it — should resolve via processEnv accumulation
|
|
writeFile(".env.development.local", "DB_HOST=localhost\n");
|
|
writeFile(".env", "DB_URL=postgres://$DB_HOST/mydb\n");
|
|
|
|
const env: Record<string, string | undefined> = {};
|
|
loadDotenv({ root: tmpDir, mode: "development", processEnv: env });
|
|
|
|
expect(env.DB_HOST).toBe("localhost");
|
|
expect(env.DB_URL).toBe("postgres://localhost/mydb");
|
|
});
|
|
|
|
it("handles multi-line quoted values", () => {
|
|
writeFile(".env", 'MULTI="line1\nline2\nline3"\nSINGLE=plain\n');
|
|
|
|
const env: Record<string, string | undefined> = {};
|
|
loadDotenv({ root: tmpDir, mode: "development", processEnv: env });
|
|
|
|
expect(env.MULTI).toBe("line1\nline2\nline3");
|
|
expect(env.SINGLE).toBe("plain");
|
|
});
|
|
|
|
it("returns empty result when no .env files exist", () => {
|
|
const env: Record<string, string | undefined> = {};
|
|
const result = loadDotenv({
|
|
root: tmpDir,
|
|
mode: "development",
|
|
processEnv: env,
|
|
});
|
|
|
|
expect(result.loadedFiles).toEqual([]);
|
|
expect(result.loadedEnv).toEqual({});
|
|
});
|
|
});
|