From 7192348ffb9c6f79cde18f2242af346414a4cc2d Mon Sep 17 00:00:00 2001 From: Shinya Fujino Date: Fri, 6 Nov 2026 11:44:25 +0900 Subject: [PATCH 1/2] fix(env): keep local values out of Astro's Worker bundle When a module reads `import.meta.env` as a whole, Astro's env plugin adds every local variable whose name occurs anywhere in that module, identifiers and comments included. `void/env` does that read, so `astro build` copied values such as STRIPE_KEY from `.env` or the build shell into the server output and the deploy upload. In Astro builds, Void now replaces that read in `void/env` with the object Vite's define would give it, limited to the public `VITE_*` keys, before Astro's plugin sees the module. Co-Authored-By: Claude Opus 5.5 --- packages/void/src/plugin-framework.ts | 66 +++++++++- packages/void/test/integration/astro.test.ts | 121 ++++++++++++++++++- 2 files changed, 185 insertions(+), 2 deletions(-) diff --git a/packages/void/src/plugin-framework.ts b/packages/void/src/plugin-framework.ts index b45e758e3..f905a1776 100644 --- a/packages/void/src/plugin-framework.ts +++ b/packages/void/src/plugin-framework.ts @@ -1,6 +1,7 @@ import { fileURLToPath } from 'node:url'; +import MagicString from 'magic-string'; import { dirname } from 'pathslash'; -import { defaultServerConditions, type Plugin } from 'vite'; +import { defaultServerConditions, normalizePath, type Plugin } from 'vite'; import { type ApplicationAnalysis } from './application-analysis.ts'; import { ensureWranglerCompat, @@ -34,14 +35,24 @@ import { createBareVoidRedirectPlugin, createCloudflareBuiltinsExternalPlugin, } from './plugin-worker-environment.ts'; +import { isClientEnvKey } from './shared/env.ts'; import { injectSvelteKitBuildHooks, injectSvelteKitHooks } from './sveltekit-hooks-transform.ts'; const handlerFile = fileURLToPath(import.meta.resolve('#self/handler')); const envModuleFile = fileURLToPath(import.meta.resolve('#self/_env')); +const envPublicFile = normalizePath(fileURLToPath(import.meta.resolve('#self/env'))); // Packages Void depends on are installed for code in this directory. const voidDir = dirname(fileURLToPath(import.meta.url)); const ASTRO_SERVER_ENTRY = '@astrojs/cloudflare/entrypoints/server'; + +/** A define value as Vite writes it into code: a string is code, anything else is JSON. */ +function defineValue(value: unknown): string { + if (value === undefined) { + return 'undefined'; + } + return typeof value === 'string' ? value : JSON.stringify(value); +} const ASTRO_SCOPED_SERVER_ENTRY = '\0void:astro-server-entry'; export function createFrameworkPlugins(input: { @@ -310,6 +321,58 @@ export default { } : undefined; + // Astro's env plugin gives a server module that reads `import.meta.env` as a whole every local + // variable whose name occurs anywhere in that module, identifiers and comments included. + // `void/env` reads the object that way to pick the public `VITE_*` values, so before Astro sees + // the module, a build replaces the read with those values, as Vite's define would. + let astroBuildEnv: Record = {}; + const astroPublicEnvPlugin: Plugin | undefined = + detected.name === 'astro' + ? { + name: 'void:astro-public-env', + apply: 'build', + enforce: 'pre', + configResolved(resolvedConfig) { + // Keep the shared object: Void's env plugin removes `.env` client values from it in + // its own configResolved hook, and Vite runs those hooks concurrently. + astroBuildEnv = resolvedConfig.env; + }, + transform: { + filter: { id: /env-public\.mjs$/ }, + handler(code, id) { + if (normalizePath(id) !== envPublicFile || this.environment?.name === 'client') { + return; + } + // Build the object as Vite's define does: the env values, then each define of an + // `import.meta.env.*` key, which wins and keeps its value as code. + const values: Record = {}; + for (const [key, value] of Object.entries(astroBuildEnv)) { + if (isClientEnvKey(key)) { + values[key] = JSON.stringify(value); + } + } + for (const [key, value] of Object.entries(this.environment.config.define ?? {})) { + const name = key.slice('import.meta.env.'.length); + if (key.startsWith('import.meta.env.') && isClientEnvKey(name)) { + values[name] = value; + } + } + const object = `{${Object.keys(values) + .sort() + .map((key) => `${JSON.stringify(key)}: ${defineValue(values[key])}`) + .join(', ')}}`; + const ms = new MagicString(code); + for (const match of code.matchAll(/\bimport\.meta\.env\b/g)) { + ms.overwrite(match.index, match.index + match[0].length, `(${object})`); + } + return ms.hasChanged() + ? { code: ms.toString(), map: ms.generateMap({ hires: true }) } + : undefined; + }, + }, + } + : undefined; + // SvelteKit builds its server in Vite's Node `ssr` environment, and the adapter's Worker runs // that output as it is. For PostgreSQL and MySQL, void/db's driver then fails in workerd: pg // loads pg-cloudflare's Node stub, and CommonJS requires of Node built-ins become @@ -389,6 +452,7 @@ export default { wranglerSyncPlugin, ...(nuxtVite8WebSocketCompatPlugin ? [nuxtVite8WebSocketCompatPlugin] : []), ...(astroServerEntryPlugin ? [astroServerEntryPlugin] : []), + ...(astroPublicEnvPlugin ? [astroPublicEnvPlugin] : []), svelteKitHooksInjectPlugin, ...(svelteKitWorkerBuildPlugin ? [svelteKitWorkerBuildPlugin] : []), ...(svelteKitD1CasingBuildPlugin ? [svelteKitD1CasingBuildPlugin] : []), diff --git a/packages/void/test/integration/astro.test.ts b/packages/void/test/integration/astro.test.ts index 629c348e7..e5ada2f87 100644 --- a/packages/void/test/integration/astro.test.ts +++ b/packages/void/test/integration/astro.test.ts @@ -1,6 +1,14 @@ import { execFileSync } from 'node:child_process'; import { once } from 'node:events'; -import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; +import { + existsSync, + mkdirSync, + readdirSync, + readFileSync, + rmSync, + statSync, + writeFileSync, +} from 'node:fs'; import type { AddressInfo } from 'node:net'; import { join } from 'node:path'; import { PGlite } from '@electric-sql/pglite'; @@ -345,3 +353,114 @@ export default defineScheduled(async () => {}); } }); }); + +describe('playground/astro build', () => { + // Astro embeds a local variable whose name occurs in a module that reads Vite's env object as a + // whole. Each name occurs in `void/env`: STRIPE_KEY in its documentation, the others in its code. + const LOCAL_ENV = { + STRIPE_KEY: 'sk_local_stripe_canary', + registry: 'sk_local_registry_canary', + getClientEnvValues: 'sk_local_function_canary', + }; + const ORIGIN = 'https://build.example.com'; + const DEFINED = 'https://define.example.com'; + let playground: IsolatedPlayground; + const astro = () => join(playground.root, 'node_modules/astro/bin/astro.mjs'); + const env = () => ({ + ...process.env, + ASTRO_DISABLE_UPDATE_CHECK: 'true', + ASTRO_PREVIEW_BACKGROUND: 'false', + VOID_INSPECTOR_PORT: 'false', + }); + + beforeAll(() => { + playground = createIsolatedPlayground(ROOT, PLAYGROUND); + const root = playground.root; + writeFileSync( + join(root, '.env'), + Object.entries(LOCAL_ENV) + .map(([name, value]) => `${name}=${value}\n`) + .join(''), + ); + // Astro's own prefix is PUBLIC_, so the build has VITE_ values only when the app adds it. + // Vite also puts each define of an `import.meta.env.*` key in the env object, over its value. + const config = readFileSync(join(root, 'astro.config.mjs'), 'utf8'); + expect(config).toContain('vite: {'); + writeFileSync( + join(root, 'astro.config.mjs'), + config.replace( + 'vite: {', + `vite: { + envPrefix: ['PUBLIC_', 'VITE_'], + define: { + 'import.meta.env.VITE_DEFINED': ${JSON.stringify(JSON.stringify(DEFINED))}, + 'import.meta.env.VITE_OVERRIDDEN': ${JSON.stringify(JSON.stringify(DEFINED))}, + },`, + ), + ); + mkdirSync(join(root, 'src/pages/api'), { recursive: true }); + writeFileSync( + join(root, 'src/pages/api/env.ts'), + `import { env } from 'void/env'; + +export const prerender = false; + +export const GET = () => + Response.json({ + stripeKey: env.STRIPE_KEY, + origin: env.VITE_ORIGIN, + defined: env.VITE_DEFINED, + overridden: env.VITE_OVERRIDDEN, + }); +`, + ); + execFileSync(process.execPath, [astro(), 'build'], { + cwd: root, + stdio: 'pipe', + env: { ...env(), VITE_ORIGIN: ORIGIN, VITE_OVERRIDDEN: ORIGIN }, + }); + }); + + afterAll(() => { + playground?.cleanup(); + }); + + it('keeps .env values out of the build output', () => { + const dist = join(playground.root, 'dist'); + // Local development reads .dev.vars, and a deploy leaves it out of the upload. + const files = readdirSync(dist, { recursive: true, encoding: 'utf8' }).filter( + (file) => !file.endsWith('.dev.vars') && statSync(join(dist, file)).isFile(), + ); + expect(files.some((file) => file.endsWith('.mjs'))).toBe(true); + const found = files.flatMap((file) => { + const content = readFileSync(join(dist, file), 'utf8'); + return Object.entries(LOCAL_ENV) + .filter(([, value]) => content.includes(value)) + .map(([name]) => `${name} in ${file}`); + }); + expect(found).toEqual([]); + }); + + it('serves .env values from the Worker env and public values as Vite defines them', async () => { + const server = await startServer( + (port) => + spawn( + process.execPath, + [astro(), 'preview', '--host', '127.0.0.1', '--port', String(port)], + { cwd: playground.root, stdio: ['ignore', 'pipe', 'pipe'], env: env() }, + ), + 20_000, + ); + try { + const response = await fetch(`${server.base}/api/env`); + expect(await response.json(), server.output().slice(-4000)).toEqual({ + stripeKey: LOCAL_ENV.STRIPE_KEY, + origin: ORIGIN, + defined: DEFINED, + overridden: DEFINED, + }); + } finally { + await killChild(server.child); + } + }); +}); From 4d2783b1a49a28589609d7815cd732ca701d72ed Mon Sep 17 00:00:00 2001 From: LongYinan Date: Fri, 6 Nov 2026 15:33:07 +0800 Subject: [PATCH 2/2] fix(env): harden the Astro void/env transform and cover build-shell values - Match the void/env module by real path, so a symlinked or Windows short-name id still gets the transform instead of silently leaking again. - Find `import.meta.env` with a small scan instead of a regex; it also skips longer names such as `$import.meta.env` and `a.import.meta.env`. - Move the plugin into createAstroPublicEnvPlugin() with fast unit tests for the scan, define values, the client skip and the path match. - The integration test also checks a build-shell canary: Astro treats every shell variable as private too. - Fix comments: Astro copies values from .env and the build shell, and Void's env plugin adds schema defaults to the shared env object (it does not remove .env values there). Co-Authored-By: Claude Opus 5.5 --- packages/void/src/plugin-framework.ts | 158 ++++++++++++------ packages/void/test/integration/astro.test.ts | 13 +- .../void/test/unit/astro-public-env.test.ts | 109 ++++++++++++ 3 files changed, 221 insertions(+), 59 deletions(-) create mode 100644 packages/void/test/unit/astro-public-env.test.ts diff --git a/packages/void/src/plugin-framework.ts b/packages/void/src/plugin-framework.ts index f905a1776..7253478df 100644 --- a/packages/void/src/plugin-framework.ts +++ b/packages/void/src/plugin-framework.ts @@ -1,3 +1,4 @@ +import { realpathSync } from 'node:fs'; import { fileURLToPath } from 'node:url'; import MagicString from 'magic-string'; import { dirname } from 'pathslash'; @@ -40,20 +41,119 @@ import { injectSvelteKitBuildHooks, injectSvelteKitHooks } from './sveltekit-hoo const handlerFile = fileURLToPath(import.meta.resolve('#self/handler')); const envModuleFile = fileURLToPath(import.meta.resolve('#self/_env')); -const envPublicFile = normalizePath(fileURLToPath(import.meta.resolve('#self/env'))); +const envPublicFile = fileURLToPath(import.meta.resolve('#self/env')); // Packages Void depends on are installed for code in this directory. const voidDir = dirname(fileURLToPath(import.meta.url)); const ASTRO_SERVER_ENTRY = '@astrojs/cloudflare/entrypoints/server'; +const ASTRO_SCOPED_SERVER_ENTRY = '\0void:astro-server-entry'; -/** A define value as Vite writes it into code: a string is code, anything else is JSON. */ +/** + * A define value as Vite writes it into code (its handleDefineValue): a string is code, anything + * else is JSON. + */ function defineValue(value: unknown): string { if (value === undefined) { return 'undefined'; } return typeof value === 'string' ? value : JSON.stringify(value); } -const ASTRO_SCOPED_SERVER_ENTRY = '\0void:astro-server-entry'; + +/** The real path, so a symlinked id (`preserveSymlinks`) or a Windows short name still matches. */ +function realPath(file: string): string { + try { + return normalizePath(realpathSync.native(file)); + } catch { + return normalizePath(file); + } +} + +const IMPORT_META_ENV = 'import.meta.env'; + +function isNameChar(char: string | undefined): boolean { + return ( + char !== undefined && + ((char >= 'a' && char <= 'z') || + (char >= 'A' && char <= 'Z') || + (char >= '0' && char <= '9') || + char === '_' || + char === '$') + ); +} + +/** + * Where `import.meta.env` stands on its own: not inside a longer name, and not a property like + * `a.import.meta.env`. + */ +export function findImportMetaEnv(code: string): Array { + const starts: Array = []; + let index = code.indexOf(IMPORT_META_ENV); + while (index !== -1) { + const before = code[index - 1]; + if ( + !isNameChar(before) && + before !== '.' && + !isNameChar(code[index + IMPORT_META_ENV.length]) + ) { + starts.push(index); + } + index = code.indexOf(IMPORT_META_ENV, index + IMPORT_META_ENV.length); + } + return starts; +} + +// In a build, Astro copies each private env value (from `.env` files or the build shell) into +// any server module that reads `import.meta.env` as a whole and has that variable's name anywhere +// in its text, comments included. `void/env` reads the whole object to pick its `VITE_*` values, +// so before Astro sees that module, swap the read for those values, built as Vite's define builds +// them. Astro then finds no `import.meta.env` there and copies nothing. +export function createAstroPublicEnvPlugin(file: string = envPublicFile): Plugin { + const envPublicPath = realPath(file); + let astroBuildEnv: Record = {}; + return { + name: 'void:astro-public-env', + apply: 'build', + enforce: 'pre', + configResolved(resolvedConfig) { + // Keep the shared object, not a copy: Void's env plugin adds schema defaults to it in its + // own configResolved hook, and Vite runs those hooks concurrently. + astroBuildEnv = resolvedConfig.env; + }, + transform: { + filter: { id: /env-public\.mjs$/ }, + handler(code, id) { + if (this.environment?.name === 'client' || realPath(id) !== envPublicPath) { + return; + } + // Build the object as Vite's define does: the env values, then each define of an + // `import.meta.env.*` key, which wins and keeps its value as code. + const values: Record = {}; + for (const [key, value] of Object.entries(astroBuildEnv)) { + if (isClientEnvKey(key)) { + values[key] = JSON.stringify(value); + } + } + for (const [key, value] of Object.entries(this.environment.config.define ?? {})) { + const name = key.slice('import.meta.env.'.length); + if (key.startsWith('import.meta.env.') && isClientEnvKey(name)) { + values[name] = value; + } + } + const object = `{${Object.keys(values) + .sort() + .map((key) => `${JSON.stringify(key)}: ${defineValue(values[key])}`) + .join(', ')}}`; + const ms = new MagicString(code); + for (const start of findImportMetaEnv(code)) { + ms.overwrite(start, start + IMPORT_META_ENV.length, `(${object})`); + } + return ms.hasChanged() + ? { code: ms.toString(), map: ms.generateMap({ hires: true }) } + : undefined; + }, + }, + }; +} export function createFrameworkPlugins(input: { readonly localD1: ReturnType; @@ -321,57 +421,7 @@ export default { } : undefined; - // Astro's env plugin gives a server module that reads `import.meta.env` as a whole every local - // variable whose name occurs anywhere in that module, identifiers and comments included. - // `void/env` reads the object that way to pick the public `VITE_*` values, so before Astro sees - // the module, a build replaces the read with those values, as Vite's define would. - let astroBuildEnv: Record = {}; - const astroPublicEnvPlugin: Plugin | undefined = - detected.name === 'astro' - ? { - name: 'void:astro-public-env', - apply: 'build', - enforce: 'pre', - configResolved(resolvedConfig) { - // Keep the shared object: Void's env plugin removes `.env` client values from it in - // its own configResolved hook, and Vite runs those hooks concurrently. - astroBuildEnv = resolvedConfig.env; - }, - transform: { - filter: { id: /env-public\.mjs$/ }, - handler(code, id) { - if (normalizePath(id) !== envPublicFile || this.environment?.name === 'client') { - return; - } - // Build the object as Vite's define does: the env values, then each define of an - // `import.meta.env.*` key, which wins and keeps its value as code. - const values: Record = {}; - for (const [key, value] of Object.entries(astroBuildEnv)) { - if (isClientEnvKey(key)) { - values[key] = JSON.stringify(value); - } - } - for (const [key, value] of Object.entries(this.environment.config.define ?? {})) { - const name = key.slice('import.meta.env.'.length); - if (key.startsWith('import.meta.env.') && isClientEnvKey(name)) { - values[name] = value; - } - } - const object = `{${Object.keys(values) - .sort() - .map((key) => `${JSON.stringify(key)}: ${defineValue(values[key])}`) - .join(', ')}}`; - const ms = new MagicString(code); - for (const match of code.matchAll(/\bimport\.meta\.env\b/g)) { - ms.overwrite(match.index, match.index + match[0].length, `(${object})`); - } - return ms.hasChanged() - ? { code: ms.toString(), map: ms.generateMap({ hires: true }) } - : undefined; - }, - }, - } - : undefined; + const astroPublicEnvPlugin = detected.name === 'astro' ? createAstroPublicEnvPlugin() : undefined; // SvelteKit builds its server in Vite's Node `ssr` environment, and the adapter's Worker runs // that output as it is. For PostgreSQL and MySQL, void/db's driver then fails in workerd: pg diff --git a/packages/void/test/integration/astro.test.ts b/packages/void/test/integration/astro.test.ts index e5ada2f87..a1069a66a 100644 --- a/packages/void/test/integration/astro.test.ts +++ b/packages/void/test/integration/astro.test.ts @@ -355,13 +355,16 @@ export default defineScheduled(async () => {}); }); describe('playground/astro build', () => { - // Astro embeds a local variable whose name occurs in a module that reads Vite's env object as a - // whole. Each name occurs in `void/env`: STRIPE_KEY in its documentation, the others in its code. + // Astro copies a private value (from .env or the build shell) into a module that reads Vite's env + // object as a whole and has the variable's name anywhere in its text. Each name occurs in + // `void/env`: STRIPE_KEY in its documentation, the others in its code. const LOCAL_ENV = { STRIPE_KEY: 'sk_local_stripe_canary', registry: 'sk_local_registry_canary', getClientEnvValues: 'sk_local_function_canary', }; + // Set only in the build shell, as a CI secret would be. + const SHELL_ENV = { validatedCache: 'sk_shell_cache_canary' }; const ORIGIN = 'https://build.example.com'; const DEFINED = 'https://define.example.com'; let playground: IsolatedPlayground; @@ -417,7 +420,7 @@ export const GET = () => execFileSync(process.execPath, [astro(), 'build'], { cwd: root, stdio: 'pipe', - env: { ...env(), VITE_ORIGIN: ORIGIN, VITE_OVERRIDDEN: ORIGIN }, + env: { ...env(), ...SHELL_ENV, VITE_ORIGIN: ORIGIN, VITE_OVERRIDDEN: ORIGIN }, }); }); @@ -425,7 +428,7 @@ export const GET = () => playground?.cleanup(); }); - it('keeps .env values out of the build output', () => { + it('keeps private .env and build-shell values out of the build output', () => { const dist = join(playground.root, 'dist'); // Local development reads .dev.vars, and a deploy leaves it out of the upload. const files = readdirSync(dist, { recursive: true, encoding: 'utf8' }).filter( @@ -434,7 +437,7 @@ export const GET = () => expect(files.some((file) => file.endsWith('.mjs'))).toBe(true); const found = files.flatMap((file) => { const content = readFileSync(join(dist, file), 'utf8'); - return Object.entries(LOCAL_ENV) + return Object.entries({ ...LOCAL_ENV, ...SHELL_ENV }) .filter(([, value]) => content.includes(value)) .map(([name]) => `${name} in ${file}`); }); diff --git a/packages/void/test/unit/astro-public-env.test.ts b/packages/void/test/unit/astro-public-env.test.ts new file mode 100644 index 000000000..9534d6c4d --- /dev/null +++ b/packages/void/test/unit/astro-public-env.test.ts @@ -0,0 +1,109 @@ +import { mkdirSync, mkdtempSync, rmSync, symlinkSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import type { ResolvedConfig } from 'vite'; +import { afterEach, describe, expect, it } from 'vite-plus/test'; +import { createAstroPublicEnvPlugin, findImportMetaEnv } from '../../src/plugin-framework.ts'; + +const CODE = 'const values = import.meta.env;\n'; +const tempDirs: Array = []; + +afterEach(() => { + for (const dir of tempDirs.splice(0)) { + rmSync(dir, { recursive: true, force: true }); + } +}); + +/** A real `env-public.mjs` and a symlinked directory that points to it. */ +function createEnvPublicFile() { + const root = mkdtempSync(join(tmpdir(), 'void-astro-public-env-')); + tempDirs.push(root); + const real = join(root, 'real'); + mkdirSync(real); + const file = join(real, 'env-public.mjs'); + writeFileSync(file, CODE); + const linked = join(root, 'linked'); + // A junction needs no extra rights on Windows. + symlinkSync(real, linked, 'junction'); + return { file, linkedFile: join(linked, 'env-public.mjs'), root }; +} + +function transform( + file: string, + input: { + id: string; + env?: Record; + define?: Record; + environment?: string; + }, +) { + const plugin = createAstroPublicEnvPlugin(file); + const configResolved = plugin.configResolved as (config: ResolvedConfig) => void; + configResolved({ env: input.env ?? {} } as ResolvedConfig); + const hook = plugin.transform as { + handler: (this: unknown, code: string, id: string) => { code: string } | undefined; + }; + const result = hook.handler.call( + { environment: { name: input.environment ?? 'ssr', config: { define: input.define } } }, + CODE, + input.id, + ); + return result?.code; +} + +describe('findImportMetaEnv', () => { + it('finds each read of the whole object', () => { + const code = 'a = import.meta.env; b = (import.meta.env).VITE_X; c = import.meta.env.VITE_Y;'; + expect(findImportMetaEnv(code)).toEqual([4, 26, 55]); + }); + + it('skips longer names and property paths', () => { + for (const code of [ + 'a.import.meta.env', + '$import.meta.env', + '_import.meta.env', + 'ximport.meta.env', + 'import.meta.envX', + 'import.meta.env$', + 'import.meta.env_', + 'import.meta.env2', + ]) { + expect(findImportMetaEnv(code), code).toEqual([]); + } + }); +}); + +describe('void:astro-public-env', () => { + it('replaces the read with VITE_ values, where a define wins and stays code', () => { + const { file } = createEnvPublicFile(); + const code = transform(file, { + id: file, + env: { VITE_A: 'from env', VITE_B: 'kept', STRIPE_KEY: 'sk_secret', registry: 'sk_reg' }, + define: { + 'import.meta.env.VITE_A': '"defined"', + 'import.meta.env.VITE_NUMBER': 42, + 'import.meta.env.SECRET': '"no"', + 'process.env.VITE_OTHER': '"no"', + }, + }); + expect(code).toBe( + 'const values = ({"VITE_A": "defined", "VITE_B": "kept", "VITE_NUMBER": 42});\n', + ); + }); + + it('matches the module through a symlinked path', () => { + const { file, linkedFile } = createEnvPublicFile(); + expect(transform(file, { id: linkedFile, env: { VITE_A: 'a' } })).toBe( + 'const values = ({"VITE_A": "a"});\n', + ); + }); + + it('leaves the client build and other env-public.mjs files alone', () => { + const { file, root } = createEnvPublicFile(); + expect(transform(file, { id: file, environment: 'client' })).toBeUndefined(); + const other = join(root, 'other', 'env-public.mjs'); + mkdirSync(join(root, 'other')); + writeFileSync(other, CODE); + expect(transform(file, { id: other })).toBeUndefined(); + }); +});