Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
22 commits
Select commit Hold shift + click to select a range
b5f7d10
feat: add support for custom environment directory using Vite config
Andrei-Danciu-Redslim Dec 25, 2025
d3f8bb5
Merge branch 'main' into feat/support-custom-env-directory
danciudev Dec 25, 2025
02c8b0d
[autofix.ci] apply automated fixes
autofix-ci[bot] Dec 25, 2025
4853027
test: add fixture tests for custom environment directory configuration
Andrei-Danciu-Redslim Dec 26, 2025
59e5fa2
Merge branch 'main' into feat/support-custom-env-directory
danciudev Dec 26, 2025
6fab7db
feat: add support for custom environment directory using Vite config
Andrei-Danciu-Redslim Dec 25, 2025
ccf7d80
[autofix.ci] apply automated fixes
autofix-ci[bot] Dec 25, 2025
8db9e70
test: add fixture tests for custom environment directory configuration
Andrei-Danciu-Redslim Dec 26, 2025
316c7d8
Merge branch 'feat/support-custom-env-directory' of https://github.co…
Andrei-Danciu-Redslim Dec 26, 2025
d933782
Merge branch 'main' into feat/support-custom-env-directory
yamcodes Dec 30, 2025
b23a89b
feat: test custom `envDir` usage by directly asserting `vite.loadEnv`…
yamcodes Dec 30, 2025
5e338c0
test: verify `loadEnv` call with mode, envDir, and prefix
yamcodes Dec 30, 2025
5462375
Remove `stubEnvVars` utility and its usage from tests, and delete the…
yamcodes Dec 30, 2025
e88bdbb
refactor: Update Vite plugin to use `.env` files for tests and improv…
yamcodes Dec 30, 2025
d195cb8
feat(vite-plugin): add support for Vite's `envDir` configuration option
yamcodes Dec 30, 2025
9ae1b8e
test: add .env.test fixture files and configure test mode in Vite plu…
yamcodes Dec 30, 2025
605c02f
test: Configure Vite build with `mode: "test"` and prioritize `.env.t…
yamcodes Dec 30, 2025
0b3987f
refactor: update Vite plugin tests to spy on `arkenv`'s real implemen…
yamcodes Dec 30, 2025
12a8ed4
test: remove unused `mockLoadEnv` declaration
yamcodes Dec 30, 2025
c3e7f43
feat: add .gitkeep to vite-plugin empty-dir fixture
yamcodes Dec 30, 2025
acb2210
test: replace mockReset with mockClear for mock cleanup in tests
yamcodes Dec 30, 2025
9bf741b
feat: load and merge multiple .env files in tests with Vite's precede…
yamcodes Dec 30, 2025
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions .changeset/red-peaches-pay.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
---
"@arkenv/vite-plugin": patch
---

#### Support Vite's `envDir` for custom env directories

The plugin now internally passes along [Vite's `envDir` config option](https://vite.dev/config/shared-options.html#envdir), allowing users to specify a custom directory for environment files.
2 changes: 2 additions & 0 deletions packages/vite-plugin/src/__fixtures__/basic/.env.test
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
VITE_API_URL=https://api.example.com
VITE_DEBUG=true
2 changes: 0 additions & 2 deletions packages/vite-plugin/src/__fixtures__/basic/env.test

This file was deleted.

6 changes: 6 additions & 0 deletions packages/vite-plugin/src/__fixtures__/with-env-dir/config.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
import { type } from "arkenv";

export const Env = type({
VITE_CUSTOM_VAR: "string",
VITE_FROM_ENV_DIR: "string",
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
VITE_CUSTOM_VAR=custom-value
VITE_FROM_ENV_DIR=loaded-from-env-dir
EXTRA_VAR=extra-value
Empty file.
5 changes: 5 additions & 0 deletions packages/vite-plugin/src/__fixtures__/with-env-dir/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
// Test file that imports environment variables from custom envDir
export const config = {
customVar: import.meta.env.VITE_CUSTOM_VAR || "",
fromEnvDir: import.meta.env.VITE_FROM_ENV_DIR || "",
};
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
/// <reference types="vite/client" />
264 changes: 231 additions & 33 deletions packages/vite-plugin/src/index.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,53 +4,65 @@ import * as vite from "vite";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";

// Mock the arkenv module to capture calls
vi.mock("arkenv", () => ({
__esModule: true,
default: vi.fn(),
createEnv: vi.fn(),
}));
// Mock the arkenv module with a spy that calls the real implementation by default
vi.mock("arkenv", async (importActual) => {
const actual = await importActual<typeof import("arkenv")>();
return {
...actual,
default: vi.fn(actual.default),
createEnv: vi.fn(actual.createEnv),
};
});

// Mock the vite module to capture loadEnv calls
vi.mock("vite", async (importActual) => {
const actual = await importActual<typeof import("vite")>();
return {
...actual,
loadEnv: vi.fn(actual.loadEnv),
};
});

import arkenvPlugin from "./index.js";

const fixturesDir = join(__dirname, "__fixtures__");

// Get the mocked functions
const { createEnv: mockCreateEnv } = (await vi.importMock("arkenv")) as {
createEnv: ReturnType<typeof vi.fn>;
};
const { createEnv: mockCreateEnv } = vi.mocked(await import("arkenv"));

const mockLoadEnv = vi.mocked(vite.loadEnv);

// Run fixture-based tests
for (const name of readdirSync(fixturesDir)) {
// Run fixture-based tests for standard fixtures
// (Specialized fixtures like 'with-env-dir' are handled by dedicated integration tests below)
for (const name of readdirSync(fixturesDir).filter(
(n) => n !== "with-env-dir",
)) {
const fixtureDir = join(fixturesDir, name);

describe(`Fixture: ${name}`, () => {
beforeEach(() => {
// Clear environment variables and mock cleanup
vi.unstubAllEnvs();
mockCreateEnv.mockClear();
mockLoadEnv.mockClear();
});

afterEach(() => {
// Complete cleanup: restore environment and reset mocks
vi.unstubAllEnvs();
mockCreateEnv.mockReset();
mockLoadEnv.mockReset();
});

it("should build successfully with the plugin", async () => {
const config = await readTestConfig(fixtureDir);

// Mock createEnv to return a valid object
mockCreateEnv.mockReturnValue(config.envVars || {});

// Set up environment variables from the fixture
if (config.envVars) {
for (const [key, value] of Object.entries(config.envVars)) {
vi.stubEnv(key, value);
}
}
// We no longer mock createEnv return value here,
// letting it run with real implementations.

await expect(
vite.build({
mode: "test",
configFile: false,
root: config.root,
plugins: [arkenvPlugin(config.Env)],
Expand Down Expand Up @@ -80,11 +92,13 @@ describe("Plugin Unit Tests", () => {
beforeEach(() => {
vi.unstubAllEnvs();
mockCreateEnv.mockClear();
mockLoadEnv.mockClear();
});

afterEach(() => {
vi.unstubAllEnvs();
mockCreateEnv.mockReset();
mockLoadEnv.mockReset();
});

it("should create a plugin function", () => {
Expand Down Expand Up @@ -527,6 +541,184 @@ describe("Plugin Unit Tests", () => {
// Verify variables not matching any prefix are NOT exposed
expect(result.define).not.toHaveProperty("import.meta.env.SECRET_KEY");
});

it("should use custom envDir when provided in config", async () => {
mockCreateEnv.mockReturnValue({ VITE_TEST: "test" });

const pluginInstance = arkenvPlugin({ VITE_TEST: "string" });

if (pluginInstance.config && typeof pluginInstance.config === "function") {
const mockContext = {
meta: {
framework: "vite",
version: "1.0.0",
rollupVersion: "4.0.0",
viteVersion: "5.0.0",
},
error: vi.fn(),
warn: vi.fn(),
info: vi.fn(),
debug: vi.fn(),
} as any;
// Pass custom envDir in config
pluginInstance.config.call(
mockContext,
{ envDir: "/custom/env/dir" },
{ mode: "test", command: "build" },
);
}

// Assert that loadEnv was called with the mode ("test"), the custom envDir ("/custom/env/dir"), and the expected prefix ("")
expect(mockLoadEnv).toHaveBeenCalledWith("test", "/custom/env/dir", "");

// Verify createEnv was called - the envDir is used by loadEnv internally
expect(mockCreateEnv).toHaveBeenCalledWith(
{ VITE_TEST: "string" },
{
env: expect.any(Object),
},
);
});

it("should default to process.cwd() when envDir is not configured", () => {
mockCreateEnv.mockReturnValue({ VITE_TEST: "test" });

const pluginInstance = arkenvPlugin({ VITE_TEST: "string" });

if (pluginInstance.config && typeof pluginInstance.config === "function") {
const mockContext = {
meta: {
framework: "vite",
version: "1.0.0",
rollupVersion: "4.0.0",
viteVersion: "5.0.0",
},
error: vi.fn(),
warn: vi.fn(),
info: vi.fn(),
debug: vi.fn(),
} as any;
// Pass config without envDir (should default to process.cwd())
pluginInstance.config.call(
mockContext,
{},
{ mode: "test", command: "build" },
);
}

// Assert that loadEnv was called with the mode ("test"), the default envDir (process.cwd()), and the expected prefix ("")
expect(mockLoadEnv).toHaveBeenCalledWith("test", process.cwd(), "");

// Verify createEnv was called successfully with default behavior
expect(mockCreateEnv).toHaveBeenCalledWith(
{ VITE_TEST: "string" },
{
env: expect.any(Object),
},
);
});
});

// Integration tests using with-env-dir fixture for custom envDir configuration
describe("Custom envDir Configuration (with-env-dir fixture)", () => {
const withEnvDirFixture = join(fixturesDir, "with-env-dir");
const customEnvDir = join(withEnvDirFixture, "custom-dir");

// Expected env vars from custom-dir/env.test fixture
const expectedEnvVars = {
VITE_CUSTOM_VAR: "custom-value",
VITE_FROM_ENV_DIR: "loaded-from-env-dir",
} as const;

// Reusable build config factory
const createBuildConfig = (
envDir: string,
schema: Record<string, string>,
) => ({
mode: "test" as const,
configFile: false as const,
root: withEnvDirFixture,
envDir,
plugins: [arkenvPlugin(schema)],
logLevel: "error" as const,
build: {
lib: { entry: "index.ts", formats: ["es" as const] },
rollupOptions: { external: ["arkenv"] },
},
});

beforeEach(() => {
vi.unstubAllEnvs();
mockCreateEnv.mockClear();
mockLoadEnv.mockClear();
});

afterEach(() => {
vi.unstubAllEnvs();
mockCreateEnv.mockClear();
mockLoadEnv.mockClear();
});

it("should load environment variables from custom envDir", async () => {
const config = await readTestConfig(withEnvDirFixture);

await expect(
vite.build(createBuildConfig(customEnvDir, config.Env)),
).resolves.not.toThrow();

expect(mockCreateEnv).toHaveBeenCalledWith(config.Env, {
env: expect.objectContaining(expectedEnvVars),
});
});

it("should fail validation when envDir points to non-existent directory", async () => {
const config = await readTestConfig(withEnvDirFixture);
const nonExistentEnvDir = join(withEnvDirFixture, "non-existent-dir");

await expect(
vite.build(createBuildConfig(nonExistentEnvDir, config.Env)),
).rejects.toThrow();

expect(mockCreateEnv).toHaveBeenCalledWith(config.Env, {
env: expect.any(Object),
});
});

it("should fail when using root directory without .env files", async () => {
const config = await readTestConfig(withEnvDirFixture);
const emptyEnvDir = join(withEnvDirFixture, "empty-dir");

await expect(
vite.build(createBuildConfig(emptyEnvDir, config.Env)),
).rejects.toThrow();
});

it("should prioritize envDir over root when both are specified", async () => {
const config = await readTestConfig(withEnvDirFixture);

await expect(
vite.build(createBuildConfig(customEnvDir, config.Env)),
).resolves.not.toThrow();

expect(mockCreateEnv).toHaveBeenCalledWith(config.Env, {
env: expect.objectContaining(expectedEnvVars),
});
});

it("should pass all loaded env vars to createEnv, not just schema keys", async () => {
const config = await readTestConfig(withEnvDirFixture);
const envWithExtra = {
...expectedEnvVars,
EXTRA_VAR: "extra-value",
};

await vite.build(createBuildConfig(customEnvDir, config.Env));

// Verify that all env vars (including non-schema ones) are passed
expect(mockCreateEnv).toHaveBeenCalledWith(config.Env, {
env: expect.objectContaining(envWithExtra),
});
});
});

async function readTestConfig(fixtureDir: string) {
Expand All @@ -540,21 +732,27 @@ async function readTestConfig(fixtureDir: string) {
// config.ts file doesn't exist, that's fine
}

// Read environment variables from env.test file if it exists
// Read and merge environment variables from .env files (matching Vite's loadEnv behavior)
let envVars: Record<string, string> = {};
try {
const envContent = readFileSync(join(fixtureDir, "env.test"), "utf-8");
envVars = Object.fromEntries(
envContent
.split("\n")
.filter((line) => line.trim() && !line.startsWith("#"))
.map((line) => {
const [key, ...valueParts] = line.split("=");
return [key.trim(), valueParts.join("=").trim()];
}),
);
} catch {
// env.test file doesn't exist, that's fine
const envFiles = [".env", ".env.local", ".env.test"];

for (const envFile of envFiles) {
try {
const envContent = readFileSync(join(fixtureDir, envFile), "utf-8");
const fileVars = Object.fromEntries(
envContent
.split("\n")
.filter((line) => line.trim() && !line.startsWith("#"))
.map((line) => {
const [key, ...valueParts] = line.split("=");
return [key.trim(), valueParts.join("=").trim()];
}),
);
// Merge with precedence: later files override earlier ones
envVars = { ...envVars, ...fileVars };
} catch {
// Try next file
}
}

return {
Expand Down
4 changes: 3 additions & 1 deletion packages/vite-plugin/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -58,11 +58,13 @@ export default function arkenv<const T extends SchemaShape>(
const envPrefix = config.envPrefix ?? "VITE_";
const prefixes = Array.isArray(envPrefix) ? envPrefix : [envPrefix];

// Load environment based on the custom config
const envDir = config.envDir ?? config.root ?? process.cwd();
// TODO: We're using type assertions and explicitly pass in the type arguments here to avoid
// "Type instantiation is excessively deep and possibly infinite" errors.
// Ideally, we should find a way to avoid these assertions while maintaining type safety.
const env = createEnv<T>(options, {
env: loadEnv(mode, process.cwd(), ""),
env: loadEnv(mode, envDir, ""),
});

// Filter to only include environment variables matching the prefix
Expand Down