diff --git a/packages/integrations/nextjs/.gitignore b/packages/integrations/nextjs/.gitignore new file mode 100644 index 00000000..6438eb7f --- /dev/null +++ b/packages/integrations/nextjs/.gitignore @@ -0,0 +1 @@ +test/test-project diff --git a/packages/integrations/nextjs/package.json b/packages/integrations/nextjs/package.json index ab5032e8..20db45ae 100644 --- a/packages/integrations/nextjs/package.json +++ b/packages/integrations/nextjs/package.json @@ -17,7 +17,8 @@ "files": ["dist"], "scripts": { "dev": "tsup --watch", - "build": "tsup" + "build": "tsup", + "test": "vitest" }, "keywords": [ "varlock", @@ -42,8 +43,11 @@ "next": ">=14" }, "devDependencies": { + "@env-spec/utils": "workspace:*", "@types/node": "catalog:", + "outdent": "catalog:", "tsup": "catalog:", - "varlock": "workspace:*" + "varlock": "workspace:*", + "vitest": "catalog:" } } diff --git a/packages/integrations/nextjs/test/basic.test.ts b/packages/integrations/nextjs/test/basic.test.ts new file mode 100644 index 00000000..0d5d1061 --- /dev/null +++ b/packages/integrations/nextjs/test/basic.test.ts @@ -0,0 +1,271 @@ +import { + beforeAll, describe, expect, it, +} from 'vitest'; +import path from 'node:path'; +import fs from 'node:fs'; +import { execSync } from 'node:child_process'; +import outdent from 'outdent'; +import { asyncExec } from '@env-spec/utils/exec-helpers'; + + +const tempRepoDir = path.join(__dirname, 'test-project'); + +async function cliCommand(cmd: string, opts?: { + env?: Record, + throw?: boolean, +}) { + try { + const result = await asyncExec(cmd, { + // stdio: 'inherit', + cwd: tempRepoDir, + ...opts?.env && { + env: { + ...process.env, + ...opts.env, + }, + }, + }); + return { + error: false, + stdout: result.stdout, + }; + } catch (err) { + if (opts?.throw) throw err; + const error = err as any; + return { + error: true, + stdout: error.stdout, + stderr: error.stderr, + }; + } +} +function addFile(filePath: string, content: string) { + const dirPath = path.dirname(path.join(tempRepoDir, filePath)); + if (!fs.existsSync(dirPath)) fs.mkdirSync(dirPath, { recursive: true }); + fs.writeFileSync(path.join(tempRepoDir, filePath), content); +} + +async function setupNextProject(opts?: { + nextVersion?: string, + noConfigPlugin?: boolean, + nextConfigOptions?: any, +}) { + execSync(`mkdir -p ${tempRepoDir}`); + // need pnpm-workspace.yaml so it will not be included in root workspace + addFile('pnpm-workspace.yaml', ''); + addFile('package.json', JSON.stringify({ + scripts: { + dev: 'next dev', + build: 'next build', + start: 'next start', + lint: 'next lint', + }, + dependencies: { + '@varlock/nextjs-integration': 'link:../../', + next: `${opts?.nextVersion || 'latest'}`, + varlock: 'link:../../../../varlock', + }, + devDependencies: { + '@types/react': '19.1.8', + }, + pnpm: { + overrides: { + '@next/env': '$@varlock/nextjs-integration', + }, + }, + }, null, 2)); + await cliCommand('pnpm install', { throw: true }); + // add next config file + addFile('next.config.ts', outdent` + import type { NextConfig } from "next"; + ${opts?.noConfigPlugin ? '' : 'import { varlockNextConfigPlugin } from "@varlock/nextjs-integration/plugin";'} + + console.log('log-in-next-config--'+process.env.SECRET_FOO); + + const nextConfig: NextConfig = ${JSON.stringify({ + eslint: { ignoreDuringBuilds: true }, + ...opts?.nextConfigOptions, + }, null, 2)}; + export default ${opts?.noConfigPlugin ? 'nextConfig' : 'varlockNextConfigPlugin()(nextConfig)'}; + `); + addFile('tsconfig.json', outdent` + { + "compilerOptions": { + "target": "ES2017", + "lib": ["dom", "dom.iterable", "esnext"], + "allowJs": true, + "skipLibCheck": true, + "strict": true, + "noEmit": true, + "esModuleInterop": true, + "module": "esnext", + "moduleResolution": "bundler", + "resolveJsonModule": true, + "isolatedModules": true, + "jsx": "preserve", + "incremental": true, + "plugins": [{ "name": "next" }], + "paths": { "@/*": ["./*"] } + }, + "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"], + "exclude": ["node_modules"] + } + `); + + addFile('.env.schema', outdent` + # @envFlag=APP_ENV + # @defaultSensitive=false + # @generateTypes(lang='ts', path='env.d.ts') + # --- + APP_ENV=development + NEXT_PUBLIC_FOO=next-public-foo + PUBLIC_FOO=public-foo + # @sensitive + SECRET_FOO=secret-foo + ENV_SPECIFIC_OVERRIDE=default + `); + // addFile('.env.test', 'ENV_SPECIFIC_OVERRIDE=test-val'); + // addFile('.env.production', 'ENV_SPECIFIC_OVERRIDE=prod-val'); + // addFile('.env.development', 'ENV_SPECIFIC_OVERRIDE=dev-val'); + addFile('.env.preview', 'ENV_SPECIFIC_OVERRIDE=preview-val'); + addFile('app/layout.tsx', outdent` + export default function RootLayout({ children }: { children: React.ReactNode }) { + return ({children}); + } + `); +} + +function runNextTest(testCase: { + buildCommand?: string, + pageContent: string, + buildOutputContains?: string, + buildOutputNotContains?: string, + buildErrorMessageContains?: string, + pageContains?: string | Array, + pageNotContain?: string | Array, +}) { + return async () => { + addFile('app/page.tsx', testCase.pageContent); + const buildResult = await cliCommand( + testCase.buildCommand || 'pnpm build', + { env: { APP_ENV: 'preview' } }, + ); + if (testCase.buildOutputContains) { + expect(buildResult.stdout).toContain(testCase.buildOutputContains); + } + if (testCase.buildOutputNotContains) { + expect(buildResult.stdout).not.toContain(testCase.buildOutputNotContains); + } + + if (testCase.buildErrorMessageContains) { + expect(buildResult.error, 'build should fail').toBe(true); + console.log('------'); + expect(buildResult.stderr).toContain(testCase.buildErrorMessageContains); + return; + } + if (testCase.pageContains) { + const prerenderedHtmlPath = path.join(tempRepoDir, '.next', 'server', 'app', 'index.html'); + const pageContent = fs.readFileSync(prerenderedHtmlPath, 'utf-8') + .replaceAll('', ''); + + if (testCase.pageContains) { + const containsItems = Array.isArray(testCase.pageContains) ? testCase.pageContains : [testCase.pageContains]; + for (const containsItem of containsItems) { + expect(pageContent).toContain(containsItem); + } + } + + if (testCase.pageNotContain) { + const notContainsItems = Array.isArray(testCase.pageNotContain) + ? testCase.pageNotContain : [testCase.pageNotContain]; + for (const notContainsItem of notContainsItems) { + expect(pageContent).not.toContain(notContainsItem); + } + } + } + }; +} + + +describe('no next.config.ts plugin', () => { + beforeAll(async () => { + await setupNextProject({ + noConfigPlugin: true, + }); + }); + + it('can load and access env vars', runNextTest({ + pageContent: outdent` + export default function Page() { + return

+ - penv--{ process.env.NEXT_PUBLIC_FOO } + - penv--{ process.env.PUBLIC_FOO } + - penv--{ process.env.SECRET_FOO } + - penv--{ process.env.ENV_SPECIFIC_OVERRIDE } +

; + } + `, + // logs are redacted, even without the plugin + buildOutputContains: 'log-in-next-config--se▒', + buildOutputNotContains: 'log-in-next-config--secret-foo', + pageContains: [ + 'penv--next-public-foo', + 'penv--public-foo', // page is server rendered, so it appears even though it is not available in client + 'penv--secret-foo', // leak detection is not enabled, so it will be in the page + 'penv--preview-val', // .env.preview should be loaded + ], + })); + + // TODO: check it works with --turborepo - but will need to run next dev instead +}); + + + + +describe('full integration', () => { + beforeAll(async () => { + await setupNextProject({}); + }); + + it('can load and access env vars', runNextTest({ + pageContent: outdent` + import { ENV } from 'varlock/env'; + export default function Page() { + return

+ venv--{ ENV.NEXT_PUBLIC_FOO } + penv--{ process.env.NEXT_PUBLIC_FOO } + venv--{ ENV.PUBLIC_FOO } + penv--{ process.env.PUBLIC_FOO } +

; + } + `, + pageContains: [ + 'venv--next-public-foo', + 'penv--next-public-foo', + 'venv--public-foo', + 'penv--public-foo', + ], + })); + it('fails the build if a secret is leaked in a static template', runNextTest({ + pageContent: outdent` + import { ENV } from 'varlock/env'; + export default function Page() { + return

{ ENV.SECRET_FOO }

; + } + `, + buildErrorMessageContains: 'LEAK', + })); + + it('fails the build if a secret is leaked in a use client page', runNextTest({ + pageContent: outdent` + 'use client'; + import { ENV } from 'varlock/env'; + export default function Page() { + return

{ ENV.SECRET_FOO }

; + } + `, + buildErrorMessageContains: 'LEAK', + })); +}); + + diff --git a/packages/integrations/nextjs/tsconfig.json b/packages/integrations/nextjs/tsconfig.json index 2e3dad13..e862a524 100644 --- a/packages/integrations/nextjs/tsconfig.json +++ b/packages/integrations/nextjs/tsconfig.json @@ -8,6 +8,6 @@ "skipLibCheck": false, "customConditions": ["ts-src"] }, - "include": ["**/*.ts"], + "include": ["**/*.ts", "!test/test-project/*"], "exclude": ["node_modules", "dist"] } diff --git a/packages/integrations/nextjs/vitest.config.ts b/packages/integrations/nextjs/vitest.config.ts new file mode 100644 index 00000000..f5ba34bf --- /dev/null +++ b/packages/integrations/nextjs/vitest.config.ts @@ -0,0 +1,13 @@ +import { defineConfig } from 'vitest/config'; + +export default defineConfig({ + test: { + watchTriggerPatterns: [ + { + pattern: /test\/test-project\/.*/, + testsToRun: () => [], + }, + ], + testTimeout: 30000, + }, +}); diff --git a/packages/utils/src/exec-helpers.ts b/packages/utils/src/exec-helpers.ts index f04b01f7..455459a9 100644 --- a/packages/utils/src/exec-helpers.ts +++ b/packages/utils/src/exec-helpers.ts @@ -59,4 +59,5 @@ export async function spawnAsync( return execResult; } + export const asyncExec = promisify(exec); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 70f4c9ff..528ecc24 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -136,15 +136,24 @@ importers: specifier: '>=14' version: 15.3.5(react-dom@19.1.0(react@19.1.0))(react@19.1.0) devDependencies: + '@env-spec/utils': + specifier: workspace:* + version: link:../../utils '@types/node': specifier: 'catalog:' version: 22.15.32 + outdent: + specifier: 'catalog:' + version: 0.8.0 tsup: specifier: 'catalog:' version: 8.4.0(postcss@8.5.3)(typescript@5.8.3) varlock: specifier: workspace:* version: link:../../varlock + vitest: + specifier: 'catalog:' + version: 3.2.4(@types/debug@4.1.12)(@types/node@22.15.32) packages/scripts.ignore: dependencies: @@ -3456,9 +3465,6 @@ packages: longest-streak@3.1.0: resolution: {integrity: sha512-9Ri+o0JYgehTaVBBDoMqIl8GXtbWg711O3srftcHhZ0dqnETqLaoIK0x17fUw9rFSlK/0NlsKe0Ahhyl5pXE2g==} - loupe@3.1.3: - resolution: {integrity: sha512-kkIp7XSkP78ZxJEsSxW3712C6teJVoeHHwgo9zJ380de7IYyJ2ISlxojcH2pC5OFLewESmnRi/+XCDIEEVyoug==} - loupe@3.1.4: resolution: {integrity: sha512-wJzkKwJrheKtknCOKNEtDK4iqg/MxmZheEMtSTYvnzRdEYaZzmgH976nenp8WdJRdx5Vc1X/9MO0Oszl6ezeXg==} @@ -7638,7 +7644,7 @@ snapshots: assertion-error: 2.0.1 check-error: 2.1.1 deep-eql: 5.0.2 - loupe: 3.1.3 + loupe: 3.1.4 pathval: 2.0.0 chalk@2.4.2: @@ -9237,8 +9243,6 @@ snapshots: longest-streak@3.1.0: {} - loupe@3.1.3: {} - loupe@3.1.4: {} lru-cache@10.4.3: {}