Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
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
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,8 @@ and this project adheres to [Semantic Versioning](http://semver.org/).
Please see [CONTRIBUTING.md](./CONTRIBUTING.md) on how to contribute to Cucumber.

## [Unreleased]
### Added
- Allow configuration via `CUCUMBER_OPTION_*` environment variables ([#2860](https://github.com/cucumber/cucumber-js/pull/2860))

## [13.0.0] - 2026-06-02
### Added
Expand Down
29 changes: 27 additions & 2 deletions docs/configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -84,12 +84,37 @@ export default {
} satisfies Partial<IConfiguration>
```

## Environment variables

You can also set configuration options via environment variables. For each option, take its key, convert it to uppercase-with-underscores, and prefix it with `CUCUMBER_OPTION_`. For example, `retryTagFilter` becomes `CUCUMBER_OPTION_RETRY_TAG_FILTER`:

```shell
CUCUMBER_OPTION_PARALLEL=2 CUCUMBER_OPTION_RETRY_TAG_FILTER='@flaky' cucumber-js
```

Values are parsed as JSON where possible, so booleans, numbers, arrays and objects can be expressed, while anything that isn't valid JSON (like a tag expression) is kept as a plain string:

Copy link
Copy Markdown
Member

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.


```shell
# boolean
CUCUMBER_OPTION_DRY_RUN=true
# number
CUCUMBER_OPTION_PARALLEL=2
# string
CUCUMBER_OPTION_TAGS='@foo and @bar'
# array
CUCUMBER_OPTION_FORMAT='["html:cucumber-report.html"]'
# object
CUCUMBER_OPTION_WORLD_PARAMETERS='{"baseUrl":"https://example.com"}'
```

Environment variables take precedence over the configuration file and defaults, but not over the CLI, which always wins because it's passed directly in the command. Each option keeps its usual merge-vs-overwrite behaviour, so for example tag expressions from the environment and the CLI are combined rather than replaced. Profile-level options aren't supported via environment variables.

## Options

These options can be used in a configuration file (see [above](#files)) or on the [CLI](./cli.md), or both.
These options can be used in a configuration file (see [above](#files)), via [environment variables](#environment-variables) or on the [CLI](./cli.md), or any combination.

- Where options are repeatable, they are appended/merged if provided more than once.
- Where options aren't repeatable, the CLI takes precedence over a configuration file.
- Where options aren't repeatable, the order of precedence (highest first) is CLI, environment variable, configuration file.

| Name | Type | Repeatable | CLI Option | Description | Default |
|-------------------|------------|------------|---------------------------|--------------------------------------------------------------------------------------------------------------------|---------|
Expand Down
3 changes: 3 additions & 0 deletions src/api/load_configuration.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import {
DEFAULT_CONFIGURATION,
fromEnv,
fromFile,
mergeConfigurations,
parseConfiguration,
Expand Down Expand Up @@ -33,6 +34,7 @@ export async function loadConfiguration(
const profileConfiguration = configFile
? await fromFile(logger, cwd, configFile, options.profiles)
: {}
const environmentConfiguration = fromEnv(logger, env)
const providedConfiguration = parseConfiguration(logger, 'Provided', options.provided)
if (profileConfiguration.paths?.length > 0 && providedConfiguration.paths?.length > 0) {
const configPaths = profileConfiguration.paths
Expand All @@ -49,6 +51,7 @@ export async function loadConfiguration(
const original = mergeConfigurations(
DEFAULT_CONFIGURATION,
profileConfiguration,
environmentConfiguration,
providedConfiguration
)
logger.debug('Resolved configuration:', original)
Expand Down
40 changes: 40 additions & 0 deletions src/api/load_configuration_spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,4 +38,44 @@ describe('loadConfiguration', function () {
expect(useConfiguration.requireModule).to.deep.eq([])
expect(useConfiguration.require).to.deep.eq([])
})

describe('environment variables', () => {
it('should source configuration from CUCUMBER_OPTION_ environment variables', async () => {
const { useConfiguration } = await loadConfiguration(
{ file: false },
{ ...environment, env: { CUCUMBER_OPTION_DRY_RUN: 'true' } }
)

expect(useConfiguration.dryRun).to.eq(true)
})

it('should take precedence over the configuration file', async () => {
const { useConfiguration } = await loadConfiguration(
{},
{ ...environment, env: { CUCUMBER_OPTION_DRY_RUN: 'true' } }
)

// the config file (cucumber.mjs) does not set dryRun, so env wins over default
expect(useConfiguration.dryRun).to.eq(true)
})

it('should be overridden by directly provided (CLI) configuration', async () => {
const { useConfiguration } = await loadConfiguration(
{ file: false, provided: ['--no-strict'] },
{ ...environment, env: { CUCUMBER_OPTION_STRICT: 'true' } }
)

// CLI --no-strict wins over CUCUMBER_OPTION_STRICT=true
expect(useConfiguration.strict).to.eq(false)
})

it('should merge with directly provided (CLI) values for additive options', async () => {
const { useConfiguration } = await loadConfiguration(
{ file: false, provided: ['--tags', '@bar or @baz'] },
{ ...environment, env: { CUCUMBER_OPTION_TAGS: '@foo' } }
)

expect(useConfiguration.tags).to.eq('(@foo) and (@bar or @baz)')
})
})
})
76 changes: 76 additions & 0 deletions src/configuration/from_env.ts

@davidjgoss davidjgoss Jul 8, 2026

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Looking at this now, I think my steer to have CUCUMBER_OPTION be the prefix we look for was a mistake and it's led us to a more complex implementation than is needed.

First, there are no collisions with other env vars, and given we also support CUCUMBER_PUBLISH_URL and CUCUMBER_PUBLISH_TOKEN, having the extra prefix causes fragmentation from a user perspective. So I think just e.g. CUCUMBER_RETRY_TAG_FILTER is fine.

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 env for it - no need to iterate over the whole env object.

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(' ')}`
)
}
}
82 changes: 82 additions & 0 deletions src/configuration/from_env_spec.ts
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/)
})
})
1 change: 1 addition & 0 deletions src/configuration/index.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
export { default as ArgvParser } from './argv_parser'
export * from './default_configuration'
export * from './from_env'
export * from './from_file'
export * from './helpers'
export * from './merge_configurations'
Expand Down