diff --git a/CHANGELOG.md b/CHANGELOG.md index dc6cce155..e468bd68b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,7 @@ Please see [CONTRIBUTING.md](./CONTRIBUTING.md) on how to contribute to Cucumber ## [Unreleased] ### Added - Export configuration types ([#2598](https://github.com/cucumber/cucumber-js/pull/2598)) +- Add support for execution sharding ([#2303](https://github.com/cucumber/cucumber-js/pull/2303)) ## [12.1.0] - 2025-07-19 ### Added diff --git a/compatibility/cck_spec.ts b/compatibility/cck_spec.ts index d336cefaa..30674acda 100644 --- a/compatibility/cck_spec.ts +++ b/compatibility/cck_spec.ts @@ -37,6 +37,7 @@ describe('Cucumber Compatibility Kit', () => { names: [], tagExpression: '', order: 'defined', + shard: '', }, support: { requireModules: ['ts-node/register'], diff --git a/exports/api/report.api.md b/exports/api/report.api.md index b62cecb99..c91883647 100644 --- a/exports/api/report.api.md +++ b/exports/api/report.api.md @@ -28,6 +28,7 @@ export interface IConfiguration { requireModule: string[]; retry: number; retryTagFilter: string; + shard: string; strict: boolean; tags: string; worldParameters: JsonObject; @@ -146,6 +147,7 @@ export interface ISourcesCoordinates { names: string[]; order: IPickleOrder; paths: string[]; + shard: string; tagExpression: string; } diff --git a/exports/root/report.api.md b/exports/root/report.api.md index 4ca38e953..1c3c01580 100644 --- a/exports/root/report.api.md +++ b/exports/root/report.api.md @@ -223,6 +223,7 @@ export interface IConfiguration { requireModule: string[]; retry: number; retryTagFilter: string; + shard: string; strict: boolean; tags: string; worldParameters: JsonObject; diff --git a/features/sharding.feature b/features/sharding.feature new file mode 100644 index 000000000..d49a69e2d --- /dev/null +++ b/features/sharding.feature @@ -0,0 +1,68 @@ +Feature: Running scenarios using sharding + As a developer running features + I want an easy way to run specific scenarios by tag + So that I don't waste time running my whole test suite when I don't need to + + Background: + Given a file named "features/a.feature" with: + """ + Feature: some feature + @a + Scenario: first scenario + Given a step + + @b + Scenario Outline: second scenario - + Given a step + + @c + Examples: + | ID | + | X | + | Y | + + @d + Examples: + | ID | + | Z | + """ + And a file named "features/step_definitions/cucumber_steps.js" with: + """ + const {Given} = require('@cucumber/cucumber') + + Given('a step', function() {}) + """ + + Scenario: run a single scenario + When I run cucumber-js with `--shard 1/5` + Then it passes + And it runs the scenario "first scenario" + + Scenario: run every other scenario starting at 1 + When I run cucumber-js with `--shard 1/2` + Then it passes + And it runs the scenarios: + | NAME | + | first scenario | + | second scenario - Y | + + Scenario: run every 3rd scenario starting at 1 + When I run cucumber-js with `--shard 1/3` + Then it passes + And it runs the scenarios: + | NAME | + | first scenario | + | second scenario - Z | + + Scenario: run even scenarios + When I run cucumber-js with `--shard 2/2` + Then it passes + And it runs the scenarios: + | NAME | + | second scenario - X | + | second scenario - Z | + + Scenario: no scenarios in shard + When I run cucumber-js with `--shard 5/5` + Then it passes + And it runs 0 scenarios diff --git a/src/api/convert_configuration.ts b/src/api/convert_configuration.ts index d39d24746..837cc953e 100644 --- a/src/api/convert_configuration.ts +++ b/src/api/convert_configuration.ts @@ -19,6 +19,7 @@ export async function convertConfiguration( names: flatConfiguration.name, tagExpression: flatConfiguration.tags, order: flatConfiguration.order, + shard: flatConfiguration.shard, }, support: { requireModules: flatConfiguration.requireModule, diff --git a/src/api/convert_configuration_spec.ts b/src/api/convert_configuration_spec.ts index ea150dbcf..d5c488709 100644 --- a/src/api/convert_configuration_spec.ts +++ b/src/api/convert_configuration_spec.ts @@ -36,6 +36,7 @@ describe('convertConfiguration', () => { order: 'defined', paths: [], tagExpression: '', + shard: '', }, support: { requireModules: [], diff --git a/src/api/load_sources_spec.ts b/src/api/load_sources_spec.ts index c1d3eb9db..8e42a8f01 100644 --- a/src/api/load_sources_spec.ts +++ b/src/api/load_sources_spec.ts @@ -51,6 +51,7 @@ describe('loadSources', () => { paths: [], names: [], tagExpression: '', + shard: '', }, environment ) @@ -112,6 +113,7 @@ describe('loadSources', () => { paths: ['features/test.feature:8'], names: [], tagExpression: '', + shard: '', }, environment ) @@ -127,6 +129,7 @@ describe('loadSources', () => { paths: [], names: ['two'], tagExpression: '', + shard: '', }, environment ) @@ -142,6 +145,7 @@ describe('loadSources', () => { paths: [], names: [], tagExpression: '@tag2', + shard: '', }, environment ) @@ -157,6 +161,7 @@ describe('loadSources', () => { paths: ['@rerun.txt'], names: [], tagExpression: '', + shard: '', }, environment ) diff --git a/src/api/plugins.ts b/src/api/plugins.ts index 0f3a0f2d7..28ac415e4 100644 --- a/src/api/plugins.ts +++ b/src/api/plugins.ts @@ -1,6 +1,7 @@ import { PluginManager } from '../plugin' import publishPlugin from '../publish' import filterPlugin from '../filter' +import shardingPlugin from '../sharding' import { UsableEnvironment } from '../environment' import { IRunConfiguration, ISourcesCoordinates } from './types' @@ -37,5 +38,10 @@ export async function initializeForRunCucumber( filterPlugin, configuration.sources ) + await pluginManager.initCoordinator( + 'runCucumber', + shardingPlugin, + configuration.sources + ) return pluginManager } diff --git a/src/api/types.ts b/src/api/types.ts index 33f86e658..e8d0fadf8 100644 --- a/src/api/types.ts +++ b/src/api/types.ts @@ -80,6 +80,13 @@ export interface ISourcesCoordinates { * Run in the order defined, or in a random order */ order: IPickleOrder + /** + * Shard tests and execute only the selected shard, format `/` + * @example 1/4 + * @remarks + * Shards use 1-based numbering + */ + shard: string } /** diff --git a/src/configuration/argv_parser.ts b/src/configuration/argv_parser.ts index 3ea304263..a594e2634 100644 --- a/src/configuration/argv_parser.ts +++ b/src/configuration/argv_parser.ts @@ -123,6 +123,10 @@ const ArgvParser = { '--order ', 'run scenarios in the specified order. Type should be `defined` or `random`' ) + .option( + '--shard ', + 'run shard INDEX of TOTAL shards. The index starts at 1' + ) .option( '-p, --profile ', 'specify the profile to use (repeatable)', diff --git a/src/configuration/default_configuration.ts b/src/configuration/default_configuration.ts index 4842e3659..488752be9 100644 --- a/src/configuration/default_configuration.ts +++ b/src/configuration/default_configuration.ts @@ -19,6 +19,7 @@ export const DEFAULT_CONFIGURATION: IConfiguration = { requireModule: [], retry: 0, retryTagFilter: '', + shard: '', strict: true, tags: '', worldParameters: {}, diff --git a/src/configuration/types.ts b/src/configuration/types.ts index 771645c3c..c5e8e7ebd 100644 --- a/src/configuration/types.ts +++ b/src/configuration/types.ts @@ -97,6 +97,11 @@ export interface IConfiguration { * @see {@link https://github.com/cucumber/cucumber-js/blob/main/docs/parallel.md} */ parallel: number + /** + * Shard tests and execute only the selected shard, format `/` + * @default "" + */ + shard: string /** * Publish a report of your test run to https://reports.cucumber.io/ * @default false diff --git a/src/sharding/index.ts b/src/sharding/index.ts new file mode 100644 index 000000000..b032912cc --- /dev/null +++ b/src/sharding/index.ts @@ -0,0 +1,3 @@ +import { shardingPlugin } from './sharding_plugin' + +export default shardingPlugin diff --git a/src/sharding/sharding_plugin.ts b/src/sharding/sharding_plugin.ts new file mode 100644 index 000000000..505fe7325 --- /dev/null +++ b/src/sharding/sharding_plugin.ts @@ -0,0 +1,19 @@ +import { InternalPlugin } from '../plugin' +import { ISourcesCoordinates } from '../api' + +export const shardingPlugin: InternalPlugin = { + type: 'plugin', + coordinator: async ({ on, options }) => { + on('pickles:filter', async (allPickles) => { + if (!options.shard) { + return allPickles + } + + const [shardIndexStr, shardTotalStr] = options.shard.split('/') + const shardIndex = parseInt(shardIndexStr, 10) - 1 + const shardTotal = parseInt(shardTotalStr, 10) + + return allPickles.filter((_, i) => i % shardTotal === shardIndex) + }) + }, +}