@prosopo/dotenv 3.0.48 → 3.0.50
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.turbo/turbo-build$colon$cjs.log +11 -8
- package/.turbo/turbo-build$colon$tsc.log +7 -7
- package/.turbo/turbo-build.log +12 -9
- package/CHANGELOG.md +29 -0
- package/dist/_virtual/_rolldown/runtime.js +3 -0
- package/dist/cjs/_virtual/_rolldown/runtime.cjs +23 -0
- package/dist/cjs/env.cjs +73 -65
- package/dist/cjs/index.cjs +4 -5
- package/dist/env.d.ts.map +1 -1
- package/dist/env.js +65 -62
- package/dist/env.js.map +1 -1
- package/dist/index.js +2 -5
- package/dist/tests/env.test-d.d.ts +2 -0
- package/dist/tests/env.test-d.d.ts.map +1 -0
- package/dist/tests/env.test-d.js +50 -0
- package/dist/tests/env.test-d.js.map +1 -0
- package/dist/tests/env.unit.test.d.ts +2 -0
- package/dist/tests/env.unit.test.d.ts.map +1 -0
- package/dist/tests/env.unit.test.js +358 -0
- package/dist/tests/env.unit.test.js.map +1 -0
- package/package.json +7 -6
- package/src/env.ts +6 -2
- package/src/tests/env.test-d.ts +113 -0
- package/src/tests/env.unit.test.ts +624 -0
- package/tsconfig.tsbuildinfo +1 -1
|
@@ -0,0 +1,624 @@
|
|
|
1
|
+
// Copyright 2021-2026 Prosopo (UK) Ltd.
|
|
2
|
+
//
|
|
3
|
+
// Licensed under the Apache License, Version 2.0 (the "License");
|
|
4
|
+
// you may not use this file except in compliance with the License.
|
|
5
|
+
// You may obtain a copy of the License at
|
|
6
|
+
//
|
|
7
|
+
// http://www.apache.org/licenses/LICENSE-2.0
|
|
8
|
+
//
|
|
9
|
+
// Unless required by applicable law or agreed to in writing, software
|
|
10
|
+
// distributed under the License is distributed on an "AS IS" BASIS,
|
|
11
|
+
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
12
|
+
// See the License for the specific language governing permissions and
|
|
13
|
+
// limitations under the License.
|
|
14
|
+
|
|
15
|
+
import fs from "node:fs";
|
|
16
|
+
import os from "node:os";
|
|
17
|
+
import path from "node:path";
|
|
18
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
19
|
+
// Imported through the package barrel rather than ../env.js directly, so the
|
|
20
|
+
// public export surface is exercised too.
|
|
21
|
+
import { getEnv, getEnvFile, loadEnv } from "../index.js";
|
|
22
|
+
|
|
23
|
+
// Testing strategy
|
|
24
|
+
// ----------------
|
|
25
|
+
// getEnvFile/loadEnv are filesystem-discovery functions: their entire contract
|
|
26
|
+
// is "walk the directory tree and find/read a file". Mocking node:fs would mean
|
|
27
|
+
// re-implementing directory-walk semantics inside the mock and asserting against
|
|
28
|
+
// that re-implementation, which tests the mock rather than the code. So the happy
|
|
29
|
+
// paths and boundary conditions run against a real temp directory tree, and real
|
|
30
|
+
// dotenv, for full fidelity.
|
|
31
|
+
//
|
|
32
|
+
// Mocks (vi.spyOn) are used only for the cases a real filesystem cannot produce
|
|
33
|
+
// on demand: a read that fails after the existence check passed (permissions
|
|
34
|
+
// revoked, file deleted mid-call, network mount dropping), and dotenv itself
|
|
35
|
+
// throwing. That is the "service sporadically unavailable" class of failure.
|
|
36
|
+
|
|
37
|
+
// A workspace-root package.json is the sentinel that stops the upward search.
|
|
38
|
+
const WORKSPACE_PKG_JSON: string = JSON.stringify({
|
|
39
|
+
name: "@prosopo/captcha-private",
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
describe("dotenv/env", () => {
|
|
43
|
+
let tmpRoot: string;
|
|
44
|
+
let envBackup: NodeJS.ProcessEnv;
|
|
45
|
+
|
|
46
|
+
beforeEach(() => {
|
|
47
|
+
// Real temp tree, unique per test, so tests cannot see each other's files.
|
|
48
|
+
tmpRoot = fs.mkdtempSync(path.join(os.tmpdir(), "prosopo-dotenv-test-"));
|
|
49
|
+
// Snapshot the whole environment: loadEnv mutates process.env by design.
|
|
50
|
+
envBackup = { ...process.env };
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
afterEach(() => {
|
|
54
|
+
fs.rmSync(tmpRoot, { recursive: true, force: true });
|
|
55
|
+
// Restore in place — reassigning process.env does not propagate in Node.
|
|
56
|
+
for (const key of Object.keys(process.env)) {
|
|
57
|
+
if (!(key in envBackup)) delete process.env[key];
|
|
58
|
+
}
|
|
59
|
+
Object.assign(process.env, envBackup);
|
|
60
|
+
vi.restoreAllMocks();
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
/** Create a directory (recursively) inside the temp tree and return its path. */
|
|
64
|
+
const mkdir = (...segments: string[]): string => {
|
|
65
|
+
const dir = path.join(tmpRoot, ...segments);
|
|
66
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
67
|
+
return dir;
|
|
68
|
+
};
|
|
69
|
+
|
|
70
|
+
/** Write a file, creating parent directories as needed. */
|
|
71
|
+
const writeFile = (dir: string, name: string, contents: string): string => {
|
|
72
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
73
|
+
const file = path.join(dir, name);
|
|
74
|
+
fs.writeFileSync(file, contents);
|
|
75
|
+
return file;
|
|
76
|
+
};
|
|
77
|
+
|
|
78
|
+
describe("getEnv", () => {
|
|
79
|
+
it("returns 'development' when NODE_ENV is not set", () => {
|
|
80
|
+
process.env.NODE_ENV = undefined;
|
|
81
|
+
// biome-ignore lint/performance/noDelete: must be absent, not undefined
|
|
82
|
+
delete process.env.NODE_ENV;
|
|
83
|
+
|
|
84
|
+
expect(getEnv()).toBe("development");
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
// Empty string is falsy, so it takes the default branch rather than
|
|
88
|
+
// returning "". Worth pinning: an explicitly-blank NODE_ENV in a shell
|
|
89
|
+
// script behaves identically to an unset one.
|
|
90
|
+
it("returns 'development' when NODE_ENV is the empty string", () => {
|
|
91
|
+
process.env.NODE_ENV = "";
|
|
92
|
+
|
|
93
|
+
expect(getEnv()).toBe("development");
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
it("returns NODE_ENV unchanged when it is already word characters only", () => {
|
|
97
|
+
process.env.NODE_ENV = "test";
|
|
98
|
+
|
|
99
|
+
expect(getEnv()).toBe("test");
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
it("strips non-word characters", () => {
|
|
103
|
+
process.env.NODE_ENV = "pro-duction!";
|
|
104
|
+
|
|
105
|
+
expect(getEnv()).toBe("production");
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
it("preserves digits and underscores, which are word characters", () => {
|
|
109
|
+
process.env.NODE_ENV = "staging_2";
|
|
110
|
+
|
|
111
|
+
expect(getEnv()).toBe("staging_2");
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
it("strips whitespace, including a value that is only whitespace", () => {
|
|
115
|
+
process.env.NODE_ENV = " test ";
|
|
116
|
+
expect(getEnv()).toBe("test");
|
|
117
|
+
|
|
118
|
+
process.env.NODE_ENV = " ";
|
|
119
|
+
expect(getEnv()).toBe("");
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
it("strips non-ASCII characters", () => {
|
|
123
|
+
process.env.NODE_ENV = "tëst";
|
|
124
|
+
|
|
125
|
+
expect(getEnv()).toBe("tst");
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
// A NODE_ENV made entirely of punctuation sanitises down to "". getEnv
|
|
129
|
+
// reports that faithfully; getEnvFile is responsible for not turning it
|
|
130
|
+
// into a ".env." filename — see "falls back to the unsuffixed filename
|
|
131
|
+
// when the sanitised env is empty".
|
|
132
|
+
it("returns the empty string when NODE_ENV is entirely non-word characters", () => {
|
|
133
|
+
process.env.NODE_ENV = "!!!";
|
|
134
|
+
|
|
135
|
+
expect(getEnv()).toBe("");
|
|
136
|
+
});
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
describe("getEnvFile", () => {
|
|
140
|
+
it("finds the env file directly in rootDir", () => {
|
|
141
|
+
const expected = writeFile(tmpRoot, ".env.test", "");
|
|
142
|
+
|
|
143
|
+
expect(getEnvFile(tmpRoot, ".env", undefined, "test")).toBe(expected);
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
it("walks up the tree to find the env file in an ancestor", () => {
|
|
147
|
+
const expected = writeFile(tmpRoot, ".env.test", "");
|
|
148
|
+
const deep = mkdir("a", "b", "c");
|
|
149
|
+
|
|
150
|
+
expect(getEnvFile(deep, ".env", undefined, "test")).toBe(expected);
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
it("returns the nearest match when the file exists at several levels", () => {
|
|
154
|
+
writeFile(tmpRoot, ".env.test", "");
|
|
155
|
+
const nearestDir = mkdir("a", "b");
|
|
156
|
+
const nearest = writeFile(nearestDir, ".env.test", "");
|
|
157
|
+
const start = mkdir("a", "b", "c");
|
|
158
|
+
|
|
159
|
+
expect(getEnvFile(start, ".env", undefined, "test")).toBe(nearest);
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
it("respects a custom filename", () => {
|
|
163
|
+
const expected = writeFile(tmpRoot, "custom.test", "");
|
|
164
|
+
|
|
165
|
+
expect(getEnvFile(tmpRoot, "custom", undefined, "test")).toBe(expected);
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
it("uses the explicit nodeEnv argument in preference to NODE_ENV", () => {
|
|
169
|
+
process.env.NODE_ENV = "development";
|
|
170
|
+
const expected = writeFile(tmpRoot, ".env.production", "");
|
|
171
|
+
|
|
172
|
+
expect(getEnvFile(tmpRoot, ".env", undefined, "production")).toBe(
|
|
173
|
+
expected,
|
|
174
|
+
);
|
|
175
|
+
});
|
|
176
|
+
|
|
177
|
+
it("falls back to NODE_ENV when nodeEnv is the empty string", () => {
|
|
178
|
+
// "" is falsy, so `nodeEnv || getEnv()` reaches for the environment.
|
|
179
|
+
process.env.NODE_ENV = "staging";
|
|
180
|
+
const expected = writeFile(tmpRoot, ".env.staging", "");
|
|
181
|
+
|
|
182
|
+
expect(getEnvFile(tmpRoot, ".env", undefined, "")).toBe(expected);
|
|
183
|
+
});
|
|
184
|
+
|
|
185
|
+
it("falls back to NODE_ENV when nodeEnv is omitted", () => {
|
|
186
|
+
process.env.NODE_ENV = "staging";
|
|
187
|
+
const expected = writeFile(tmpRoot, ".env.staging", "");
|
|
188
|
+
|
|
189
|
+
expect(getEnvFile(tmpRoot)).toBe(expected);
|
|
190
|
+
});
|
|
191
|
+
|
|
192
|
+
it("resolves a relative rootDir against the current working directory", () => {
|
|
193
|
+
const cwd = process.cwd();
|
|
194
|
+
const relative = path.relative(cwd, tmpRoot);
|
|
195
|
+
const expected = writeFile(tmpRoot, ".env.test", "");
|
|
196
|
+
|
|
197
|
+
expect(getEnvFile(relative, ".env", undefined, "test")).toBe(expected);
|
|
198
|
+
});
|
|
199
|
+
|
|
200
|
+
it("treats an empty rootDir as the current working directory", () => {
|
|
201
|
+
// "" is falsy so path.resolve(".") is used. The search then walks up
|
|
202
|
+
// from cwd; assert only that it terminates and yields an absolute path
|
|
203
|
+
// for a filename that cannot exist anywhere.
|
|
204
|
+
const result = getEnvFile("", ".env", undefined, "nonexistent-env-xyz");
|
|
205
|
+
|
|
206
|
+
expect(path.isAbsolute(result)).toBe(true);
|
|
207
|
+
expect(result.endsWith(".env.nonexistent-env-xyz")).toBe(true);
|
|
208
|
+
});
|
|
209
|
+
|
|
210
|
+
// A NODE_ENV of "!!!" sanitises to "", which must not produce the
|
|
211
|
+
// filename ".env." — nothing creates such a file, so discovery would
|
|
212
|
+
// always miss and silently fall through to the not-found path.
|
|
213
|
+
it("falls back to the unsuffixed filename when the sanitised env is empty", () => {
|
|
214
|
+
process.env.NODE_ENV = "!!!";
|
|
215
|
+
const expected = writeFile(tmpRoot, ".env", "");
|
|
216
|
+
|
|
217
|
+
expect(getEnvFile(tmpRoot)).toBe(expected);
|
|
218
|
+
});
|
|
219
|
+
|
|
220
|
+
it("never appends a bare dot when the sanitised env is empty", () => {
|
|
221
|
+
process.env.NODE_ENV = "!!!";
|
|
222
|
+
// No env file on disk at all, so the not-found fallback path is taken:
|
|
223
|
+
// it must carry the unsuffixed name too, not ".env.".
|
|
224
|
+
const result = getEnvFile(tmpRoot);
|
|
225
|
+
|
|
226
|
+
expect(result).toBe(path.join(tmpRoot, ".env"));
|
|
227
|
+
expect(result.endsWith(".")).toBe(false);
|
|
228
|
+
});
|
|
229
|
+
|
|
230
|
+
it("uses the unsuffixed name for a custom filename when the env is empty", () => {
|
|
231
|
+
process.env.NODE_ENV = "!!!";
|
|
232
|
+
const expected = writeFile(tmpRoot, "custom", "");
|
|
233
|
+
|
|
234
|
+
expect(getEnvFile(tmpRoot, "custom")).toBe(expected);
|
|
235
|
+
});
|
|
236
|
+
|
|
237
|
+
it("prefers the unsuffixed file over a stale trailing-dot file", () => {
|
|
238
|
+
process.env.NODE_ENV = "!!!";
|
|
239
|
+
writeFile(tmpRoot, ".env.", "");
|
|
240
|
+
const expected = writeFile(tmpRoot, ".env", "");
|
|
241
|
+
|
|
242
|
+
expect(getEnvFile(tmpRoot)).toBe(expected);
|
|
243
|
+
});
|
|
244
|
+
|
|
245
|
+
describe("workspace root sentinel", () => {
|
|
246
|
+
it("stops searching at the workspace-root package.json", () => {
|
|
247
|
+
// The env file sits ABOVE the workspace root, so an unbounded search
|
|
248
|
+
// would find it. The sentinel must prevent that.
|
|
249
|
+
writeFile(tmpRoot, ".env.test", "");
|
|
250
|
+
const workspaceRoot = mkdir("workspace");
|
|
251
|
+
writeFile(workspaceRoot, "package.json", WORKSPACE_PKG_JSON);
|
|
252
|
+
const start = mkdir("workspace", "packages", "thing");
|
|
253
|
+
|
|
254
|
+
const result = getEnvFile(start, ".env", undefined, "test");
|
|
255
|
+
|
|
256
|
+
// Search stopped at the workspace root, where no env file exists,
|
|
257
|
+
// so the fallback (rootDir-based) path is returned.
|
|
258
|
+
expect(result).toBe(path.join(start, ".env.test"));
|
|
259
|
+
expect(fs.existsSync(result)).toBe(false);
|
|
260
|
+
});
|
|
261
|
+
|
|
262
|
+
it("keeps walking past a package.json belonging to another project", () => {
|
|
263
|
+
const expected = writeFile(tmpRoot, ".env.test", "");
|
|
264
|
+
const intermediate = mkdir("some-package");
|
|
265
|
+
writeFile(
|
|
266
|
+
intermediate,
|
|
267
|
+
"package.json",
|
|
268
|
+
JSON.stringify({ name: "@prosopo/dotenv" }),
|
|
269
|
+
);
|
|
270
|
+
const start = mkdir("some-package", "src");
|
|
271
|
+
|
|
272
|
+
expect(getEnvFile(start, ".env", undefined, "test")).toBe(expected);
|
|
273
|
+
});
|
|
274
|
+
|
|
275
|
+
it("keeps walking past a package.json with no name field", () => {
|
|
276
|
+
const expected = writeFile(tmpRoot, ".env.test", "");
|
|
277
|
+
const intermediate = mkdir("nameless");
|
|
278
|
+
writeFile(intermediate, "package.json", JSON.stringify({ version: 1 }));
|
|
279
|
+
const start = mkdir("nameless", "src");
|
|
280
|
+
|
|
281
|
+
expect(getEnvFile(start, ".env", undefined, "test")).toBe(expected);
|
|
282
|
+
});
|
|
283
|
+
|
|
284
|
+
it("keeps walking when package.json is valid JSON but not an object", () => {
|
|
285
|
+
// JSON.parse("123") is a number; reading .name off it yields
|
|
286
|
+
// undefined rather than throwing, so the walk continues.
|
|
287
|
+
const expected = writeFile(tmpRoot, ".env.test", "");
|
|
288
|
+
const intermediate = mkdir("weird");
|
|
289
|
+
writeFile(intermediate, "package.json", "123");
|
|
290
|
+
const start = mkdir("weird", "src");
|
|
291
|
+
|
|
292
|
+
expect(getEnvFile(start, ".env", undefined, "test")).toBe(expected);
|
|
293
|
+
});
|
|
294
|
+
|
|
295
|
+
it("finds the env file at the workspace root itself", () => {
|
|
296
|
+
// The sentinel breaks the loop, but the loop condition is checked
|
|
297
|
+
// first, so a file sitting at the workspace root is still found.
|
|
298
|
+
const workspaceRoot = mkdir("workspace");
|
|
299
|
+
writeFile(workspaceRoot, "package.json", WORKSPACE_PKG_JSON);
|
|
300
|
+
const expected = writeFile(workspaceRoot, ".env.test", "");
|
|
301
|
+
const start = mkdir("workspace", "packages", "thing");
|
|
302
|
+
|
|
303
|
+
expect(getEnvFile(start, ".env", undefined, "test")).toBe(expected);
|
|
304
|
+
});
|
|
305
|
+
});
|
|
306
|
+
|
|
307
|
+
describe("depth limit", () => {
|
|
308
|
+
it("gives up after ascending more than 10 levels", () => {
|
|
309
|
+
// 14 levels deep with no env file and no package.json anywhere:
|
|
310
|
+
// the only thing that can stop the walk is the level counter.
|
|
311
|
+
const segments = Array.from({ length: 14 }, (_, i) => `d${i}`);
|
|
312
|
+
const start = mkdir(...segments);
|
|
313
|
+
|
|
314
|
+
const result = getEnvFile(start, ".env", undefined, "test");
|
|
315
|
+
|
|
316
|
+
expect(result).toBe(path.join(start, ".env.test"));
|
|
317
|
+
});
|
|
318
|
+
|
|
319
|
+
it("still finds a file exactly 10 levels up", () => {
|
|
320
|
+
// Boundary: the counter increments after the move, and the guard is
|
|
321
|
+
// `> 10`, so 10 ancestors remain reachable.
|
|
322
|
+
const segments = Array.from({ length: 10 }, (_, i) => `d${i}`);
|
|
323
|
+
const expected = writeFile(tmpRoot, ".env.test", "");
|
|
324
|
+
const start = mkdir(...segments);
|
|
325
|
+
|
|
326
|
+
expect(getEnvFile(start, ".env", undefined, "test")).toBe(expected);
|
|
327
|
+
});
|
|
328
|
+
|
|
329
|
+
it("terminates at the filesystem root instead of looping forever", () => {
|
|
330
|
+
// path.resolve("/", "..") is "/", so without the level counter this
|
|
331
|
+
// would spin indefinitely. Use a filename that cannot exist.
|
|
332
|
+
const result = getEnvFile(
|
|
333
|
+
path.parse(tmpRoot).root,
|
|
334
|
+
".env",
|
|
335
|
+
undefined,
|
|
336
|
+
"definitely-not-a-real-env-9f3a",
|
|
337
|
+
);
|
|
338
|
+
|
|
339
|
+
expect(result).toBe(
|
|
340
|
+
path.join(
|
|
341
|
+
path.parse(tmpRoot).root,
|
|
342
|
+
".env.definitely-not-a-real-env-9f3a",
|
|
343
|
+
),
|
|
344
|
+
);
|
|
345
|
+
});
|
|
346
|
+
});
|
|
347
|
+
|
|
348
|
+
describe("fallback path when nothing is found", () => {
|
|
349
|
+
it("falls back to rootDir when rootDir was supplied", () => {
|
|
350
|
+
const start = mkdir("a");
|
|
351
|
+
|
|
352
|
+
expect(getEnvFile(start, ".env", "/some/other/place", "test")).toBe(
|
|
353
|
+
path.join(start, ".env.test"),
|
|
354
|
+
);
|
|
355
|
+
});
|
|
356
|
+
|
|
357
|
+
it("falls back to the filepath argument when rootDir is falsy", () => {
|
|
358
|
+
// With rootDir empty the search starts at cwd; when it finds nothing
|
|
359
|
+
// the `filepath` argument becomes the base of the returned path.
|
|
360
|
+
const result = getEnvFile("", ".env", tmpRoot, "nonexistent-env-xyz");
|
|
361
|
+
|
|
362
|
+
expect(result).toBe(path.join(tmpRoot, ".env.nonexistent-env-xyz"));
|
|
363
|
+
});
|
|
364
|
+
});
|
|
365
|
+
|
|
366
|
+
describe("failure modes", () => {
|
|
367
|
+
// These propagate out of getEnvFile uncaught. getEnvFile has no
|
|
368
|
+
// try/catch and neither does loadEnv, so the exception surfaces at the
|
|
369
|
+
// application entry point that called loadEnv — typically before any
|
|
370
|
+
// logger is configured. Pinning the behaviour so a future change to
|
|
371
|
+
// swallow-and-continue is a deliberate, visible decision.
|
|
372
|
+
it("propagates a SyntaxError when an ancestor package.json is malformed", () => {
|
|
373
|
+
writeFile(tmpRoot, ".env.test", "");
|
|
374
|
+
const intermediate = mkdir("broken");
|
|
375
|
+
writeFile(intermediate, "package.json", "{ not valid json");
|
|
376
|
+
const start = mkdir("broken", "src");
|
|
377
|
+
|
|
378
|
+
expect(() => getEnvFile(start, ".env", undefined, "test")).toThrow(
|
|
379
|
+
SyntaxError,
|
|
380
|
+
);
|
|
381
|
+
});
|
|
382
|
+
|
|
383
|
+
it("propagates a TypeError when an ancestor package.json contains null", () => {
|
|
384
|
+
// JSON.parse("null") is null, and reading .name off null throws.
|
|
385
|
+
writeFile(tmpRoot, ".env.test", "");
|
|
386
|
+
const intermediate = mkdir("nullish");
|
|
387
|
+
writeFile(intermediate, "package.json", "null");
|
|
388
|
+
const start = mkdir("nullish", "src");
|
|
389
|
+
|
|
390
|
+
expect(() => getEnvFile(start, ".env", undefined, "test")).toThrow(
|
|
391
|
+
TypeError,
|
|
392
|
+
);
|
|
393
|
+
});
|
|
394
|
+
|
|
395
|
+
it("propagates a read error when package.json vanishes after the existence check", () => {
|
|
396
|
+
// Time-of-check/time-of-use: existsSync passes, then the read fails
|
|
397
|
+
// (file deleted, permissions revoked, network mount dropped).
|
|
398
|
+
writeFile(tmpRoot, ".env.test", "");
|
|
399
|
+
const intermediate = mkdir("racy");
|
|
400
|
+
writeFile(intermediate, "package.json", WORKSPACE_PKG_JSON);
|
|
401
|
+
const start = mkdir("racy", "src");
|
|
402
|
+
|
|
403
|
+
const readError = Object.assign(
|
|
404
|
+
new Error("EACCES: permission denied"),
|
|
405
|
+
{
|
|
406
|
+
code: "EACCES",
|
|
407
|
+
},
|
|
408
|
+
);
|
|
409
|
+
vi.spyOn(fs, "readFileSync").mockImplementation(() => {
|
|
410
|
+
throw readError;
|
|
411
|
+
});
|
|
412
|
+
|
|
413
|
+
expect(() => getEnvFile(start, ".env", undefined, "test")).toThrow(
|
|
414
|
+
readError,
|
|
415
|
+
);
|
|
416
|
+
});
|
|
417
|
+
|
|
418
|
+
it("propagates an error thrown by the existence check itself", () => {
|
|
419
|
+
const statError = Object.assign(new Error("EIO: i/o error"), {
|
|
420
|
+
code: "EIO",
|
|
421
|
+
});
|
|
422
|
+
vi.spyOn(fs, "existsSync").mockImplementation(() => {
|
|
423
|
+
throw statError;
|
|
424
|
+
});
|
|
425
|
+
|
|
426
|
+
expect(() => getEnvFile(tmpRoot, ".env", undefined, "test")).toThrow(
|
|
427
|
+
statError,
|
|
428
|
+
);
|
|
429
|
+
});
|
|
430
|
+
});
|
|
431
|
+
});
|
|
432
|
+
|
|
433
|
+
describe("loadEnv", () => {
|
|
434
|
+
it("loads variables from the discovered file into process.env", () => {
|
|
435
|
+
writeFile(tmpRoot, ".env.test", "PROSOPO_TEST_VALUE=hello\n");
|
|
436
|
+
|
|
437
|
+
const returned = loadEnv(tmpRoot, ".env", undefined, "test");
|
|
438
|
+
|
|
439
|
+
expect(returned).toBe(path.join(tmpRoot, ".env.test"));
|
|
440
|
+
expect(process.env.PROSOPO_TEST_VALUE).toBe("hello");
|
|
441
|
+
});
|
|
442
|
+
|
|
443
|
+
it("loads multiple variables and ignores comments and blank lines", () => {
|
|
444
|
+
writeFile(
|
|
445
|
+
tmpRoot,
|
|
446
|
+
".env.test",
|
|
447
|
+
"# a comment\n\nPROSOPO_A=1\nPROSOPO_B=two\n",
|
|
448
|
+
);
|
|
449
|
+
|
|
450
|
+
loadEnv(tmpRoot, ".env", undefined, "test");
|
|
451
|
+
|
|
452
|
+
expect(process.env.PROSOPO_A).toBe("1");
|
|
453
|
+
expect(process.env.PROSOPO_B).toBe("two");
|
|
454
|
+
});
|
|
455
|
+
|
|
456
|
+
it("handles an empty env file without error", () => {
|
|
457
|
+
// Length-zero input: nothing to parse, nothing should break.
|
|
458
|
+
const expected = writeFile(tmpRoot, ".env.test", "");
|
|
459
|
+
|
|
460
|
+
expect(loadEnv(tmpRoot, ".env", undefined, "test")).toBe(expected);
|
|
461
|
+
});
|
|
462
|
+
|
|
463
|
+
it("does not overwrite existing variables by default", () => {
|
|
464
|
+
process.env.PROSOPO_TEST_VALUE = "original";
|
|
465
|
+
writeFile(tmpRoot, ".env.test", "PROSOPO_TEST_VALUE=replacement\n");
|
|
466
|
+
|
|
467
|
+
loadEnv(tmpRoot, ".env", undefined, "test");
|
|
468
|
+
|
|
469
|
+
expect(process.env.PROSOPO_TEST_VALUE).toBe("original");
|
|
470
|
+
});
|
|
471
|
+
|
|
472
|
+
it("overwrites existing variables when override is true", () => {
|
|
473
|
+
process.env.PROSOPO_TEST_VALUE = "original";
|
|
474
|
+
writeFile(tmpRoot, ".env.test", "PROSOPO_TEST_VALUE=replacement\n");
|
|
475
|
+
|
|
476
|
+
loadEnv(tmpRoot, ".env", undefined, "test", true);
|
|
477
|
+
|
|
478
|
+
expect(process.env.PROSOPO_TEST_VALUE).toBe("replacement");
|
|
479
|
+
});
|
|
480
|
+
|
|
481
|
+
it("uses the explicit nodeEnv to choose which file to load", () => {
|
|
482
|
+
process.env.NODE_ENV = "development";
|
|
483
|
+
writeFile(tmpRoot, ".env.development", "PROSOPO_WHICH=development\n");
|
|
484
|
+
writeFile(tmpRoot, ".env.production", "PROSOPO_WHICH=production\n");
|
|
485
|
+
|
|
486
|
+
loadEnv(tmpRoot, ".env", undefined, "production");
|
|
487
|
+
|
|
488
|
+
expect(process.env.PROSOPO_WHICH).toBe("production");
|
|
489
|
+
});
|
|
490
|
+
|
|
491
|
+
it("respects a custom filename", () => {
|
|
492
|
+
writeFile(tmpRoot, "custom.test", "PROSOPO_CUSTOM=yes\n");
|
|
493
|
+
|
|
494
|
+
const returned = loadEnv(tmpRoot, "custom", undefined, "test");
|
|
495
|
+
|
|
496
|
+
expect(returned).toBe(path.join(tmpRoot, "custom.test"));
|
|
497
|
+
expect(process.env.PROSOPO_CUSTOM).toBe("yes");
|
|
498
|
+
});
|
|
499
|
+
|
|
500
|
+
// Business-logic note: loadEnv returns a path unconditionally. When no
|
|
501
|
+
// env file exists, dotenv.config reports the error on its return value —
|
|
502
|
+
// which loadEnv discards — so the caller receives a plausible-looking path
|
|
503
|
+
// to a file that was never read, and no indication that nothing loaded.
|
|
504
|
+
// Callers that need to know must stat the returned path themselves.
|
|
505
|
+
it("returns a path and stays silent when no env file exists", () => {
|
|
506
|
+
const start = mkdir("empty");
|
|
507
|
+
|
|
508
|
+
const returned = loadEnv(start, ".env", undefined, "nonexistent-env-xyz");
|
|
509
|
+
|
|
510
|
+
expect(returned).toBe(path.join(start, ".env.nonexistent-env-xyz"));
|
|
511
|
+
expect(fs.existsSync(returned)).toBe(false);
|
|
512
|
+
});
|
|
513
|
+
|
|
514
|
+
it("loads the unsuffixed file when the sanitised env is empty", () => {
|
|
515
|
+
process.env.NODE_ENV = "!!!";
|
|
516
|
+
const expected = writeFile(tmpRoot, ".env", "PROSOPO_UNSUFFIXED=yes\n");
|
|
517
|
+
|
|
518
|
+
expect(loadEnv(tmpRoot)).toBe(expected);
|
|
519
|
+
expect(process.env.PROSOPO_UNSUFFIXED).toBe("yes");
|
|
520
|
+
});
|
|
521
|
+
|
|
522
|
+
it("defaults to the current working directory when rootDir is omitted", () => {
|
|
523
|
+
// Uses an env name that cannot exist so the search is guaranteed to
|
|
524
|
+
// fail and nothing real is loaded into process.env.
|
|
525
|
+
const returned = loadEnv(undefined, ".env", undefined, "nonexistent-xyz");
|
|
526
|
+
|
|
527
|
+
expect(path.isAbsolute(returned)).toBe(true);
|
|
528
|
+
expect(returned.endsWith(".env.nonexistent-xyz")).toBe(true);
|
|
529
|
+
});
|
|
530
|
+
|
|
531
|
+
it("propagates errors thrown during discovery", () => {
|
|
532
|
+
const intermediate = mkdir("broken");
|
|
533
|
+
writeFile(intermediate, "package.json", "{ not valid json");
|
|
534
|
+
const start = mkdir("broken", "src");
|
|
535
|
+
|
|
536
|
+
expect(() => loadEnv(start, ".env", undefined, "test")).toThrow(
|
|
537
|
+
SyntaxError,
|
|
538
|
+
);
|
|
539
|
+
});
|
|
540
|
+
|
|
541
|
+
// Business-logic note: this is a silent failure. dotenv.config catches the
|
|
542
|
+
// read error internally and reports it on its return value (`{ error }`),
|
|
543
|
+
// which loadEnv discards. So an unreadable env file — wrong permissions
|
|
544
|
+
// after a deploy, a dropped network mount — produces no throw, no log, and
|
|
545
|
+
// a returned path that looks exactly like success, while every variable the
|
|
546
|
+
// application expects is missing. The first symptom is a downstream
|
|
547
|
+
// "undefined config" error far from the real cause. Pinning the current
|
|
548
|
+
// behaviour; making loadEnv inspect the result is a separate decision.
|
|
549
|
+
it("silently ignores a read failure on the env file", () => {
|
|
550
|
+
writeFile(tmpRoot, ".env.test", "PROSOPO_TEST_VALUE=hello\n");
|
|
551
|
+
const readError = Object.assign(new Error("EACCES: permission denied"), {
|
|
552
|
+
code: "EACCES",
|
|
553
|
+
});
|
|
554
|
+
vi.spyOn(fs, "readFileSync").mockImplementation(() => {
|
|
555
|
+
throw readError;
|
|
556
|
+
});
|
|
557
|
+
|
|
558
|
+
const returned = loadEnv(tmpRoot, ".env", undefined, "test");
|
|
559
|
+
|
|
560
|
+
// Reports the path as though the load succeeded...
|
|
561
|
+
expect(returned).toBe(path.join(tmpRoot, ".env.test"));
|
|
562
|
+
// ...but nothing was actually loaded.
|
|
563
|
+
expect(process.env.PROSOPO_TEST_VALUE).toBeUndefined();
|
|
564
|
+
});
|
|
565
|
+
});
|
|
566
|
+
|
|
567
|
+
// The logger.debug calls take a thunk that builds the log payload, and the
|
|
568
|
+
// logger only invokes it when debug level is enabled. At the default level
|
|
569
|
+
// those thunk bodies never run, so a mistake inside one (a bad property
|
|
570
|
+
// access, a stale variable name) would only surface in production the moment
|
|
571
|
+
// someone raised the log level. These tests run the module at debug level so
|
|
572
|
+
// every payload builder is actually executed.
|
|
573
|
+
//
|
|
574
|
+
// The logger is constructed once at module load from PROSOPO_LOG_LEVEL, so
|
|
575
|
+
// the level must be set before the module is imported — hence resetModules
|
|
576
|
+
// plus a dynamic import rather than the top-level import used elsewhere.
|
|
577
|
+
describe("debug logging", () => {
|
|
578
|
+
const importAtDebugLevel = async (): Promise<
|
|
579
|
+
typeof import("../index.js")
|
|
580
|
+
> => {
|
|
581
|
+
process.env.PROSOPO_LOG_LEVEL = "debug";
|
|
582
|
+
vi.resetModules();
|
|
583
|
+
return import("../index.js");
|
|
584
|
+
};
|
|
585
|
+
|
|
586
|
+
it("builds the search payload without throwing", async () => {
|
|
587
|
+
const { getEnvFile: getEnvFileDebug } = await importAtDebugLevel();
|
|
588
|
+
const expected = writeFile(tmpRoot, ".env.test", "");
|
|
589
|
+
|
|
590
|
+
expect(getEnvFileDebug(tmpRoot, ".env", undefined, "test")).toBe(
|
|
591
|
+
expected,
|
|
592
|
+
);
|
|
593
|
+
});
|
|
594
|
+
|
|
595
|
+
it("builds the workspace-root payload without throwing", async () => {
|
|
596
|
+
const { getEnvFile: getEnvFileDebug } = await importAtDebugLevel();
|
|
597
|
+
const workspaceRoot = mkdir("workspace");
|
|
598
|
+
writeFile(workspaceRoot, "package.json", WORKSPACE_PKG_JSON);
|
|
599
|
+
const start = mkdir("workspace", "packages", "thing");
|
|
600
|
+
|
|
601
|
+
expect(getEnvFileDebug(start, ".env", undefined, "test")).toBe(
|
|
602
|
+
path.join(start, ".env.test"),
|
|
603
|
+
);
|
|
604
|
+
});
|
|
605
|
+
|
|
606
|
+
it("builds the depth-limit payload without throwing", async () => {
|
|
607
|
+
const { getEnvFile: getEnvFileDebug } = await importAtDebugLevel();
|
|
608
|
+
const segments = Array.from({ length: 14 }, (_, i) => `d${i}`);
|
|
609
|
+
const start = mkdir(...segments);
|
|
610
|
+
|
|
611
|
+
expect(getEnvFileDebug(start, ".env", undefined, "test")).toBe(
|
|
612
|
+
path.join(start, ".env.test"),
|
|
613
|
+
);
|
|
614
|
+
});
|
|
615
|
+
|
|
616
|
+
it("builds the load payload without throwing", async () => {
|
|
617
|
+
const { loadEnv: loadEnvDebug } = await importAtDebugLevel();
|
|
618
|
+
const expected = writeFile(tmpRoot, ".env.test", "PROSOPO_DEBUG_V=1\n");
|
|
619
|
+
|
|
620
|
+
expect(loadEnvDebug(tmpRoot, ".env", undefined, "test")).toBe(expected);
|
|
621
|
+
expect(process.env.PROSOPO_DEBUG_V).toBe("1");
|
|
622
|
+
});
|
|
623
|
+
});
|
|
624
|
+
});
|