-
-
Notifications
You must be signed in to change notification settings - Fork 1.1k
Allow configuration via environment variables #2860
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. Weβll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Looking at this now, I think my steer to have First, there are no collisions with other env vars, and given we also support Second, we already have the list of option names to check (from the default flag config object), so we can have fewer loops and less code by simply iterating over those keys, and for each one forming the env var key and checking |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,76 @@ | ||
| import type { ILogger } from '../environment' | ||
| import { checkSchema } from './check_schema' | ||
| import { DEFAULT_CONFIGURATION } from './default_configuration' | ||
| import type { IConfiguration } from './types' | ||
|
|
||
| const PREFIX = 'CUCUMBER_OPTION_' | ||
|
|
||
| /** | ||
| * Map of environment variable name (e.g. `CUCUMBER_OPTION_RETRY_TAG_FILTER`) to | ||
| * configuration key (e.g. `retryTagFilter`), derived from the known options so | ||
| * that the round-trip is deterministic. | ||
| */ | ||
| const ENV_VAR_TO_OPTION: Record<string, keyof IConfiguration> = Object.fromEntries( | ||
| Object.keys(DEFAULT_CONFIGURATION).map((option) => [ | ||
| PREFIX + toScreamingSnakeCase(option), | ||
| option, | ||
| ]) | ||
| ) as Record<string, keyof IConfiguration> | ||
|
|
||
| function toScreamingSnakeCase(option: string): string { | ||
| return option.replace(/([a-z0-9])([A-Z])/g, '$1_$2').toUpperCase() | ||
| } | ||
|
|
||
| /** | ||
| * Parse a raw environment variable string into a configuration value. | ||
| * | ||
| * Values are parsed as JSON where possible, so that booleans, numbers, arrays | ||
| * and objects can be expressed; anything that isn't valid JSON is kept as a | ||
| * plain string (e.g. a tag expression like `@foo and @bar`). | ||
| */ | ||
| function parseValue(raw: string): unknown { | ||
| try { | ||
| return JSON.parse(raw) | ||
| } catch { | ||
| return raw | ||
| } | ||
| } | ||
|
|
||
| /** | ||
| * Build a partial configuration from environment variables. | ||
| * | ||
| * Each option is sought from the environment by converting its key to | ||
| * uppercase-with-underscores and prefixing with `CUCUMBER_OPTION_`. So | ||
| * `retryTagFilter` is expressed as `CUCUMBER_OPTION_RETRY_TAG_FILTER`. | ||
| * | ||
| * The resulting object is validated against the configuration schema, just like | ||
| * configuration provided programmatically, so type errors are surfaced early. | ||
| */ | ||
| export function fromEnv( | ||
| logger: ILogger, | ||
| env: Record<string, string | undefined> | ||
| ): Partial<IConfiguration> { | ||
| const raw: Record<string, unknown> = {} | ||
| for (const [key, value] of Object.entries(env)) { | ||
| if (!key.startsWith(PREFIX) || value === undefined) { | ||
| continue | ||
| } | ||
| const option = ENV_VAR_TO_OPTION[key] | ||
| if (!option) { | ||
| logger.debug(`Ignoring environment variable "${key}" as it doesn't map to a known option`) | ||
| continue | ||
| } | ||
| raw[option] = parseValue(value) | ||
| } | ||
| if (Object.keys(raw).length === 0) { | ||
| return {} | ||
| } | ||
| logger.debug('Configuration from environment variables:', raw) | ||
| try { | ||
| return checkSchema(raw) | ||
| } catch (error) { | ||
| throw new Error( | ||
| `Environment variable configuration value failed schema validation: ${error.errors.join(' ')}` | ||
| ) | ||
| } | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,82 @@ | ||
| import { expect } from 'chai' | ||
| import { FakeLogger } from '../../test/fake_logger' | ||
| import { fromEnv } from './from_env' | ||
|
|
||
| describe('fromEnv', () => { | ||
| it('should return empty config when no relevant env vars are set', () => { | ||
| const result = fromEnv(new FakeLogger(), {}) | ||
| expect(result).to.deep.eq({}) | ||
| }) | ||
|
|
||
| it('should ignore env vars without the CUCUMBER_OPTION_ prefix', () => { | ||
| const result = fromEnv(new FakeLogger(), { | ||
| TAGS: '@foo', | ||
| CUCUMBER_PUBLISH_ENABLED: 'true', | ||
| }) | ||
| expect(result).to.deep.eq({}) | ||
| }) | ||
|
|
||
| it('should map an env var back to its camelCase configuration key', () => { | ||
| const result = fromEnv(new FakeLogger(), { | ||
| CUCUMBER_OPTION_RETRY_TAG_FILTER: '@flaky', | ||
| }) | ||
| expect(result).to.deep.eq({ retryTagFilter: '@flaky' }) | ||
| }) | ||
|
|
||
| it('should parse boolean values', () => { | ||
| const result = fromEnv(new FakeLogger(), { | ||
| CUCUMBER_OPTION_DRY_RUN: 'true', | ||
| CUCUMBER_OPTION_STRICT: 'false', | ||
| }) | ||
| expect(result).to.deep.eq({ dryRun: true, strict: false }) | ||
| }) | ||
|
|
||
| it('should parse numeric values', () => { | ||
| const result = fromEnv(new FakeLogger(), { | ||
| CUCUMBER_OPTION_PARALLEL: '2', | ||
| CUCUMBER_OPTION_RETRY: '3', | ||
| }) | ||
| expect(result).to.deep.eq({ parallel: 2, retry: 3 }) | ||
| }) | ||
|
|
||
| it('should keep plain string values as strings', () => { | ||
| const result = fromEnv(new FakeLogger(), { | ||
| CUCUMBER_OPTION_TAGS: '@foo and @bar', | ||
| CUCUMBER_OPTION_LANGUAGE: 'en', | ||
| }) | ||
| expect(result).to.deep.eq({ tags: '@foo and @bar', language: 'en' }) | ||
| }) | ||
|
|
||
| it('should parse array values as JSON', () => { | ||
| const result = fromEnv(new FakeLogger(), { | ||
| CUCUMBER_OPTION_PATHS: '["features/**/*.feature"]', | ||
| CUCUMBER_OPTION_FORMAT: '["html:report.html"]', | ||
| }) | ||
| expect(result).to.deep.eq({ | ||
| paths: ['features/**/*.feature'], | ||
| format: ['html:report.html'], | ||
| }) | ||
| }) | ||
|
|
||
| it('should parse object values as JSON', () => { | ||
| const result = fromEnv(new FakeLogger(), { | ||
| CUCUMBER_OPTION_WORLD_PARAMETERS: '{"foo":"bar"}', | ||
| }) | ||
| expect(result).to.deep.eq({ worldParameters: { foo: 'bar' } }) | ||
| }) | ||
|
|
||
| it('should ignore CUCUMBER_OPTION_ vars that do not map to a known option', () => { | ||
| const result = fromEnv(new FakeLogger(), { | ||
| CUCUMBER_OPTION_NOT_A_REAL_OPTION: 'whatever', | ||
| }) | ||
| expect(result).to.deep.eq({}) | ||
| }) | ||
|
|
||
| it('should fail validation for a value of the wrong type', () => { | ||
| expect(() => | ||
| fromEnv(new FakeLogger(), { | ||
| CUCUMBER_OPTION_PARALLEL: 'not-a-number', | ||
| }) | ||
| ).to.throw(/failed schema validation/) | ||
| }) | ||
| }) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This is an implementation detail and we could do without having it in the user-facing documentation.