Skip to content

Commit 86cfa9e

Browse files
committed
Improve environment determination and site url fetching to be well tested and consistent
1 parent e5a313b commit 86cfa9e

16 files changed

Lines changed: 389 additions & 68 deletions

‎@types/window.d.ts‎

Lines changed: 2 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -22,11 +22,9 @@ declare global {
2222
metaColors?: MetaColors
2323

2424
/**
25-
* Test flag to force error throwing in development
26-
* Used by error handler to re-throw errors for debugging
27-
* Set by E2E tests via page.evaluate()
25+
* Test flag to indicate Playwright control
2826
*/
29-
_throw?: boolean
27+
isPlaywrightControlled?: boolean
3028
}
3129
}
3230

‎astro.config.ts‎

Lines changed: 12 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -8,9 +8,19 @@ import AstroPWA from '@vite-pwa/astro'
88
import vtbot from 'astro-vtbot'
99
import icon from 'astro-icon'
1010
import { defineConfig } from 'astro/config'
11+
/**
12+
* You cannot use path aliases (@lib, @components, etc.) in files that are
13+
* imported by astro.config.ts, because the path alias resolution happens
14+
* AFTER the config is loaded, not before. This means that adding resolve.alias
15+
* paths to the vite section in astro.config.ts would not allow using path
16+
* aliases because it creates a circular dependency problem since the config
17+
* file itself is importing from the paths it needs to configure.
18+
*/
1119
import {
1220
environmentalVariablesConfig,
21+
getSentryAuthToken,
1322
getSiteUrl,
23+
isVercel,
1424
markdownConfig,
1525
serviceWorkerConfig,
1626
vercelConfig,
@@ -19,13 +29,6 @@ import { callToActionValidator } from './src/integrations/CtaValidator'
1929
import { privacyPolicyVersion } from './src/integrations/PrivacyPolicyVersion'
2030
import { createSerializeFunction, pagesJsonWriter } from './src/integrations/sitemapSerialize'
2131

22-
// Type guard for required environment variables (only in Vercel)
23-
const IS_VERCEL = process.env['VERCEL']
24-
const SENTRY_AUTH_TOKEN = process.env['SENTRY_AUTH_TOKEN']
25-
if (IS_VERCEL && !SENTRY_AUTH_TOKEN) {
26-
throw new Error('SENTRY_AUTH_TOKEN environment variable is required in Vercel but not set')
27-
}
28-
2932
export default defineConfig({
3033
adapter: vercelStatic(vercelConfig),
3134
devToolbar: {
@@ -45,10 +48,10 @@ export default defineConfig({
4548
/** Inject privacy policy version from git commit date for GDPR record keeping */
4649
privacyPolicyVersion(),
4750
/** Only include Sentry integration in Vercel environments */
48-
...(IS_VERCEL ? [sentry({
51+
...(isVercel() ? [sentry({
4952
project: "webstack-builders-corporate-website",
5053
org: "webstack-builders",
51-
authToken: SENTRY_AUTH_TOKEN!, // Non-null assertion safe due to check above
54+
authToken: getSentryAuthToken(),
5255
})] : []),
5356
sitemap({
5457
serialize: createSerializeFunction({

‎docs/ENVIRONMENT_VARIABLES‎

Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,72 @@
1+
# Environment variables
2+
3+
## CI / CD
4+
5+
Install Vercel CLI if not already done. Linking it will create a .vercel directory in the project with local configuration details. This directory is gitignored.
6+
7+
```bash
8+
npm install -g vercel
9+
vercel link
10+
```
11+
12+
Pull env vars from Vercel:
13+
14+
```bash
15+
vercel env pull .env
16+
```
17+
18+
See [Deployment](./DEPLOYMENT_SETUP.md) for information on adding environment variables to the GitHub repository for use in workflow Actions.
19+
20+
## Loading
21+
22+
Astro uses Vite to load environment variables, which in turn uses `dotenv` to load them from several `.env` files located in the project's root directory. The specific files loaded, and their priority, depend on the current mode (development or production). All `.env*` files are gitignored.
23+
24+
- `.env`: Loaded in all cases, providing general environment variables.
25+
- `.env.local`: Loaded in all cases, intended for local development settings.
26+
- `.env.[mode]`: Loaded only in the specified mode (`.env.development` when running the Astro dev server, and `.env.production` when running `astro build`).
27+
- `.env.[mode].local`: Loaded only in the specified mode, providing local overrides for a specific mode.
28+
29+
## Availability
30+
31+
1. Auto-loaded environment variables are not available in **config files**. The files are excluded from the ESLint linting rule forbidding `process.env`. They can use Vite's `loadEnv()` method to load `.env*` files.
32+
2. All environment variables are available in **server-side code**.
33+
3. Only environment variables prefixed with `PUBLIC_` are available in **client-side code**. Vite extracts these variables from `.env*` files during loading and bundles them into the client script.
34+
35+
## Typing
36+
37+
There are two ways in an Astro project to type environmental variables in Astro:
38+
39+
1. Add them to `src/lib/config/environmentalVariableValidation.ts`. This provides validation for the variables. Astro will generate a `.astro/env.d.ts` file with the types exported as `const` from `astro sync`, ran automatically on `astro dev` and `astro build`. They will be available as tokens from `astro:env/server` and `astro:env/client` respectively.
40+
2. Add them to `src/env.d.ts`. This will provide TypeScript typing for intellisense but no validation. Environmental variables added this way will not be made available on the `astro:env` imports.
41+
42+
## Use in Code
43+
44+
Import environment variables defined in `environmentalVariableValidation` directly.
45+
46+
All variables:
47+
48+
```typescript
49+
import { ENV_VAR } from 'astro:env/server'
50+
```
51+
52+
Client side variables:
53+
54+
```typescript
55+
import { PUBLIC_ENV_VAR } from 'astro:env/client'
56+
```
57+
58+
The `process.env` and `import.meta.env` methods of accessing environment variables will generate linting errors if used outside of the root-level configuration files where they are necessary.
59+
60+
## Determining Site URL
61+
62+
The site URL can vary based on whether code is being ran from the Astro dev server, being ran in production (including being ran with `astro serve`), being ran from Vitest in unit files,
63+
64+
- Use the `getSiteUrl()` method from `src/components/scripts/utils/siteUrlClient.ts` to determine the site URL in **client code**.
65+
- Use the `getSiteUrl()` method from `src/lib/config/siteUrlServer.ts` to determine the site URL in **server code**.
66+
67+
## Determining Mode
68+
69+
Use well tested methods to determine what mode (dev, prod, ci, vitest, playwright) code is operating in:
70+
71+
- Use methods in `src/components/scripts/utils/environmentClient.ts` to determine mode in **client code**.
72+
- Use methods in `src/lib/config/environmentServer.ts` to determine mode in **server code**.

‎eslint.config.ts‎

Lines changed: 30 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -159,7 +159,7 @@ export default [
159159
'no-new': level,
160160
'no-process-env': [level, , {
161161
"message": "Do not use process.env directly. See docs/ENVIRONMENT_VARIABLES."
162-
}]
162+
}],
163163
'no-restricted-globals': ['error'].concat(restrictedGlobals),
164164
'no-restricted-imports': [
165165
'error', {
@@ -199,14 +199,14 @@ export default [
199199
{
200200
files: ['src/components/scripts/assertions/elements.ts'],
201201
rules: {
202-
// This file implements type guards and legitimately needs type assertions
202+
/** This file implements type guards and legitimately needs type assertions */
203203
'custom-rules/no-html-element-assertions': 'off',
204204
},
205205
},
206206
{
207207
files: ['**/*error.spec.ts'],
208208
rules: {
209-
// Error test files use mock objects which legitimately need type assertions
209+
/** Error test files use mock objects which legitimately need type assertions */
210210
'custom-rules/no-html-element-assertions': 'off',
211211
},
212212
},
@@ -221,7 +221,7 @@ export default [
221221
},
222222
{
223223
files: [
224-
// Database files that use snake_case for database field names
224+
/** Database files that use snake_case for database field names */
225225
'src/components/scripts/consent/db/__tests__/rls.spec.ts',
226226
'src/pages/api/gdpr/consent.ts',
227227
'src/pages/api/gdpr/request-data.ts',
@@ -233,14 +233,14 @@ export default [
233233
},
234234
},
235235
{
236-
// Test files can import from server code in src/lib
236+
/** Test files can import from server code in src/lib */
237237
files: ['src/**/__tests__/**/*'],
238238
rules: {
239239
'import/no-restricted-paths': 'off',
240240
},
241241
},
242242
{
243-
// Test files can import from server code in src/lib
243+
/** Test files can import from server code in src/lib */
244244
'import/no-restricted-paths': [
245245
level,
246246
{
@@ -260,7 +260,7 @@ export default [
260260
],
261261
},
262262
{
263-
// Files that need access to process.env()
263+
/** These directories should can use process.env, which is forbidden in other files */
264264
files: [
265265
'.eslintrc.js',
266266
'astro.config.ts',
@@ -273,6 +273,7 @@ export default [
273273
},
274274
},
275275
{
276+
/** No import.meta.env use in general. */
276277
files: [
277278
'src/components/**/*',
278279
'src/layouts/**/*',
@@ -289,6 +290,7 @@ export default [
289290
},
290291
},
291292
{
293+
/** These files can use import.meta.env */
292294
files: [
293295
'src/lib/config/environmentServer.ts',
294296
'src/lib/config/siteUrlServer.ts',
@@ -304,4 +306,25 @@ export default [
304306
],
305307
},
306308
},
309+
{
310+
/**
311+
* Path aliases cannot be used in files that are imported by astro.config.ts.
312+
*/
313+
files: [
314+
'src/integrations/**/*',
315+
'src/lib/config/**/*',
316+
],
317+
rules: {
318+
'no-restricted-imports': [
319+
'error', {
320+
patterns: [
321+
{
322+
group: ['@/*'],
323+
message: 'Path aliases cannot be used in files that are imported by astro.config.ts. See notes in that config file for the reasons why.',
324+
},
325+
],
326+
},
327+
],
328+
},
329+
},
307330
]
Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
// @vitest-environment happy-dom
2+
/**
3+
* Unit tests for environment detection utilities
4+
* Tests the environment detection functions in a Vitest/happy-dom context
5+
*/
6+
import { describe, it, expect } from 'vitest'
7+
import { isUnitTest, isE2eTest, isTest, isDev, isProd } from '@components/scripts/utils'
8+
9+
describe('Environment detection utilities', () => {
10+
it('isUnitTest should return true when running in Vitest', () => {
11+
expect(isUnitTest()).toBe(true)
12+
})
13+
14+
it('isE2eTest should return false in unit test context (no Playwright control)', () => {
15+
expect(isE2eTest()).toBe(false)
16+
})
17+
18+
it('isTest should return true when ran in unit test', () => {
19+
expect(isTest()).toBe(true)
20+
})
21+
22+
it('isDev should return true when ran in unit test', () => {
23+
expect(isDev()).toBe(true)
24+
})
25+
26+
it('isProd should return false when ran in unit test', () => {
27+
expect(isProd()).toBe(false)
28+
})
29+
})
Lines changed: 14 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -1,26 +1,27 @@
1+
12
/**
2-
* Client-side checks for environment
3+
* We are setting the VITEST env var in vitest.config.ts for unit tests.
34
*/
4-
5-
// @TODO: Implement these methods
6-
7-
export const getSiteUrl = () => {
8-
return "localhost:4321"
5+
export const isUnitTest = () => {
6+
return typeof process !== 'undefined' && !!process.env['VITEST']
97
}
108

11-
export const isCI = () => {
12-
return true
9+
export const isE2eTest = () => {
10+
// In browser context (E2E tests with Playwright)
11+
if (typeof window !== 'undefined') {
12+
return window.isPlaywrightControlled === true
13+
}
14+
return false
1315
}
1416

15-
// Vitest sets the VITEST environment variable and exposes them from your .env files as import.meta.env.
16-
export const isUnitTest = () => {
17-
return true
17+
export const isTest = () => {
18+
return isUnitTest() || isE2eTest()
1819
}
1920

2021
export const isDev = () => {
21-
return true
22+
return import.meta.env.DEV
2223
}
2324

2425
export const isProd = () => {
25-
return true
26+
return import.meta.env.PROD && !isUnitTest()
2627
}

‎src/components/scripts/utils/index.ts‎

Lines changed: 4 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -2,8 +2,6 @@
22
* Scripts Utils - Barrel Export
33
* Single source for all utility functions
44
*/
5-
6-
// Re-export cookie utilities
75
export {
86
type CookieOptions,
97
getCookie,
@@ -12,17 +10,18 @@ export {
1210
getAllCookies,
1311
} from './cookies'
1412

15-
// Re-export data subject ID utilities
1613
export {
1714
getOrCreateDataSubjectId,
1815
deleteDataSubjectId,
1916
} from './dataSubjectId'
2017

21-
// Re-export cookie utilities
2218
export {
23-
getSiteUrl,
2419
isCI,
2520
isUnitTest,
21+
isE2eTest,
22+
isTest,
2623
isDev,
2724
isProd,
2825
} from './environmentClient'
26+
27+
export { getSiteUrl} from './siteUrlClient'
Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
/**
2+
* Client-side method to determine correct URL
3+
*/
4+
5+
import { DEV_SERVER_PORT } from "astro:env/client"
6+
import { isProd, isE2eTest } from '@components/scripts/utils/environmentClient'
7+
8+
export const getSiteUrl = () => {
9+
if (isProd() && !isE2eTest()) {
10+
console.log(
11+
`Using production environment with domain from astro config: ${import.meta.env.SITE}`
12+
)
13+
return `https://${import.meta.env.SITE}`
14+
} else {
15+
console.log(`Using development environment on port ${DEV_SERVER_PORT}.`)
16+
return `http://localhost:${DEV_SERVER_PORT}`
17+
}
18+
}
Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
/**
2+
* Unit tests for cookie utilities
3+
* Tests the wrapper functions around js-cookie library
4+
*/
5+
import { describe, it, expect } from 'vitest'
6+
import {
7+
isDev,
8+
isE2eTest,
9+
isProd,
10+
isTest,
11+
isUnitTest,
12+
} from '@lib/config'
13+
14+
describe('Build-time environment handling utilities', () => {
15+
it('should always report running in Vitest in a unit test', () => {
16+
expect(isUnitTest()).toBeTruthy()
17+
})
18+
19+
it('should report not running in Playwright in a unit test', () => {
20+
expect(isE2eTest()).toBeFalsy()
21+
})
22+
23+
it('should report isTest is true when running in Vitest', () => {
24+
expect(isTest()).toBeTruthy()
25+
})
26+
27+
it('should report running in development environment when running in Vitest', () => {
28+
expect(isDev()).toBeTruthy()
29+
})
30+
31+
it('should report not running in production environment when running in Vitest', () => {
32+
expect(isProd()).toBeFalsy()
33+
})
34+
})

0 commit comments

Comments
 (0)