Skip to content
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

feat: implement --shard option #12546

Merged
merged 54 commits into from Mar 6, 2022
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
Show all changes
54 commits
Select commit Hold shift + click to select a range
3052d3b
feat: implement --shard option #6270
marionebl Mar 3, 2022
9a02991
fix: remove unneeded optional chain
marionebl Mar 4, 2022
1bdab88
fix: validate shard option in runJest too
marionebl Mar 4, 2022
edcd51b
fix: simplify .shard control flow
marionebl Mar 4, 2022
8a6d66f
test: add shard e2e test
marionebl Mar 4, 2022
fc7574d
ci: try dogfooding on circleci
marionebl Mar 4, 2022
d524ecf
test: fix failing test
marionebl Mar 4, 2022
392f2f2
docs: add changelog entry
marionebl Mar 4, 2022
d507ae5
ci: simplify circleci config
marionebl Mar 4, 2022
002e9d5
Apply formatting suggestion
marionebl Mar 5, 2022
17a2f41
Grammar fix
marionebl Mar 5, 2022
c2cc758
fix: validate only once
marionebl Mar 5, 2022
9136939
test: cover negative number validation
marionebl Mar 5, 2022
0e27779
test: add clarifying comment
marionebl Mar 5, 2022
abe7806
test: throw if sharding on non-shardin test sequencer
marionebl Mar 5, 2022
a205153
ci: use actions matrix
marionebl Mar 5, 2022
6f1631d
docs: x-reference between shard and testSequencer
marionebl Mar 5, 2022
f558cd9
feat: use jump consistent hashing
marionebl Mar 5, 2022
1effb45
docs: fix typo
marionebl Mar 5, 2022
3f38e3a
docs: contract
marionebl Mar 5, 2022
61536d8
docs: no relative marker
marionebl Mar 5, 2022
d0366ef
docs: fix typo
marionebl Mar 5, 2022
e598dad
fix: remove unneeded guard
marionebl Mar 5, 2022
e045336
fix: clean up debris
marionebl Mar 5, 2022
1ef0f95
fix: apply format
marionebl Mar 5, 2022
42f5276
style: apply formatting
marionebl Mar 5, 2022
4cc8728
feat: use sha1 for test spreading
marionebl Mar 5, 2022
d8e69b4
test: relax assertions regarding ordering
marionebl Mar 5, 2022
d3cebc4
ci: use matrix parameter
marionebl Mar 5, 2022
6f2a633
ci: chard correct execution
marionebl Mar 5, 2022
2986f1d
feat: replace hardcoded shard config
marionebl Mar 5, 2022
2a08e13
fix: remove snapshot checkins
marionebl Mar 5, 2022
8b60f60
docs: add missing copyright headers
marionebl Mar 5, 2022
dcb6706
Merge branch 'main' into 6270
SimenB Mar 5, 2022
2aed48b
move changelog
SimenB Mar 5, 2022
1b61239
rename
SimenB Mar 5, 2022
9316f6f
doc tweaks
SimenB Mar 5, 2022
2ba718e
tweak test
SimenB Mar 5, 2022
fe1eb34
tweak spacing in cli args descriptions
SimenB Mar 5, 2022
734748e
strings, not regex, in test
SimenB Mar 5, 2022
8fbace8
remove inferred type annotation
SimenB Mar 5, 2022
a542b82
Update docs/CLI.md
SimenB Mar 5, 2022
0d4fb3f
Update docs/Configuration.md
SimenB Mar 5, 2022
062a80c
use relative path in hash
SimenB Mar 5, 2022
3362237
lockfile
SimenB Mar 5, 2022
74b1a0c
localeCompare, not number conversion
SimenB Mar 5, 2022
798fd40
unit test
SimenB Mar 5, 2022
128047f
type errors in test
SimenB Mar 5, 2022
6facf86
oops
SimenB Mar 5, 2022
be17873
move docs and mention in troubleshooting
SimenB Mar 5, 2022
70d073f
maybe?
SimenB Mar 5, 2022
3748808
compare manually
SimenB Mar 5, 2022
75014e9
maybe
SimenB Mar 6, 2022
a2bb729
shard coverage run
SimenB Mar 6, 2022
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
78 changes: 72 additions & 6 deletions .github/workflows/nodejs.yml
Expand Up @@ -65,8 +65,42 @@ jobs:
run: yarn lint:prettier:ci
- name: check copyright headers
run: yarn check-copyright-headers
test:
name: Node v${{ matrix.node-version }} on ${{ matrix.os }}

test-1:
name: Node v${{ matrix.node-version }} on ${{ matrix.os }} (1/2)
strategy:
fail-fast: false
matrix:
node-version: [12.x, 14.x, 16.x, 17.x]
os: [ubuntu-latest, macOS-latest, windows-latest]
runs-on: ${{ matrix.os }}
needs: prepare-yarn-cache

steps:
- name: Set git config
shell: bash
run: |
git config --global core.autocrlf false
git config --global core.symlinks true
if: runner.os == 'Windows'
- uses: actions/checkout@v3
- name: Use Node.js ${{ matrix.node-version }}
uses: actions/setup-node@v3
with:
node-version: ${{ matrix.node-version }}
cache: yarn
- name: install
run: yarn --immutable
- name: build
run: yarn build:js
- name: Get number of CPU cores
id: cpu-cores
uses: SimenB/github-actions-cpu-cores@v1
- name: run tests
run: yarn test-ci-partial:parallel --max-workers ${{ steps.cpu-cores.outputs.count }} --shard=1/2
SimenB marked this conversation as resolved.
Show resolved Hide resolved

test-2:
name: Node v${{ matrix.node-version }} on ${{ matrix.os }} (2/2)
strategy:
fail-fast: false
matrix:
Expand Down Expand Up @@ -96,10 +130,42 @@ jobs:
id: cpu-cores
uses: SimenB/github-actions-cpu-cores@v1
- name: run tests
run: yarn test-ci-partial:parallel --max-workers ${{ steps.cpu-cores.outputs.count }}
run: yarn test-ci-partial:parallel --max-workers ${{ steps.cpu-cores.outputs.count }} --shard=2/2

test-jasmine-1:
name: Node LTS on ${{ matrix.os }} using jest-jasmine2 (1/2)
SimenB marked this conversation as resolved.
Show resolved Hide resolved
strategy:
fail-fast: false
matrix:
os: [ubuntu-latest, macOS-latest, windows-latest]
runs-on: ${{ matrix.os }}
needs: prepare-yarn-cache

steps:
- name: Set git config
shell: bash
run: |
git config --global core.autocrlf false
git config --global core.symlinks true
if: runner.os == 'Windows'
- uses: actions/checkout@v3
- name: Use Node.js LTS
uses: actions/setup-node@v3
with:
node-version: lts/*
cache: yarn
- name: install
run: yarn --immutable
- name: build
run: yarn build:js
- name: Get number of CPU cores
id: cpu-cores
uses: SimenB/github-actions-cpu-cores@v1
- name: run tests using jest-jasmine
run: yarn jest-jasmine-ci --max-workers ${{ steps.cpu-cores.outputs.count }} --shard=1/2
SimenB marked this conversation as resolved.
Show resolved Hide resolved

test-jasmine:
name: Node LTS on ${{ matrix.os }} using jest-jasmine2
test-jasmine-2:
name: Node LTS on ${{ matrix.os }} using jest-jasmine2 (2/2)
strategy:
fail-fast: false
matrix:
Expand Down Expand Up @@ -128,7 +194,7 @@ jobs:
id: cpu-cores
uses: SimenB/github-actions-cpu-cores@v1
- name: run tests using jest-jasmine
run: yarn jest-jasmine-ci --max-workers ${{ steps.cpu-cores.outputs.count }}
run: yarn jest-jasmine-ci --max-workers ${{ steps.cpu-cores.outputs.count }} --shard=2/2

test-coverage:
name: Node LTS on Ubuntu with coverage
Expand Down
10 changes: 10 additions & 0 deletions docs/CLI.md
Expand Up @@ -338,6 +338,16 @@ Run only the tests of the specified projects. Jest uses the attribute `displayNa

A list of paths to modules that run some code to configure or to set up the testing framework before each test. Beware that files imported by the setup scripts will not be mocked during testing.

### `--shard`
marionebl marked this conversation as resolved.
Show resolved Hide resolved

Shard suite to execute in on multiple machines. For example, to split the suite into three shards, each running one third of the tests:
marionebl marked this conversation as resolved.
Show resolved Hide resolved

```
jest --shard=1/3
jest --shard=2/3
jest --shard=3/3
```

### `--showConfig`

Print your Jest config and then exits.
Expand Down
98 changes: 67 additions & 31 deletions packages/jest-cli/src/__tests__/cli/args.test.ts
Expand Up @@ -11,84 +11,78 @@ import {constants} from 'jest-config';
import {buildArgv} from '../../cli';
import {check} from '../../cli/args';

const argv = (input: Partial<Config.Argv>): Config.Argv => input as Config.Argv;
marionebl marked this conversation as resolved.
Show resolved Hide resolved

describe('check', () => {
it('returns true if the arguments are valid', () => {
const argv = {} as Config.Argv;
expect(check(argv)).toBe(true);
expect(check(argv({}))).toBe(true);
});

it('raises an exception if runInBand and maxWorkers are both specified', () => {
const argv = {maxWorkers: 2, runInBand: true} as Config.Argv;
expect(() => check(argv)).toThrow(
expect(() => check(argv({maxWorkers: 2, runInBand: true}))).toThrow(
'Both --runInBand and --maxWorkers were specified',
);
});

it('raises an exception if onlyChanged and watchAll are both specified', () => {
const argv = {onlyChanged: true, watchAll: true} as Config.Argv;
expect(() => check(argv)).toThrow(
expect(() => check(argv({onlyChanged: true, watchAll: true}))).toThrow(
'Both --onlyChanged and --watchAll were specified',
);
});

it('raises an exception if onlyFailures and watchAll are both specified', () => {
const argv = {onlyFailures: true, watchAll: true} as Config.Argv;
expect(() => check(argv)).toThrow(
expect(() => check(argv({onlyFailures: true, watchAll: true}))).toThrow(
'Both --onlyFailures and --watchAll were specified',
);
});

it('raises an exception when lastCommit and watchAll are both specified', () => {
const argv = {lastCommit: true, watchAll: true} as Config.Argv;
expect(() => check(argv)).toThrow(
expect(() => check(argv({lastCommit: true, watchAll: true}))).toThrow(
'Both --lastCommit and --watchAll were specified',
);
});

it('raises an exception if findRelatedTests is specified with no file paths', () => {
const argv = {
_: [] as Array<string>,
findRelatedTests: true,
} as Config.Argv;
expect(() => check(argv)).toThrow(
expect(() =>
check(
argv({
_: [],
findRelatedTests: true,
}),
),
).toThrow(
'The --findRelatedTests option requires file paths to be specified',
);
});

it('raises an exception if maxWorkers is specified with no number', () => {
const argv = {maxWorkers: undefined} as unknown as Config.Argv;
expect(() => check(argv)).toThrow(
expect(() => check(argv({maxWorkers: undefined}))).toThrow(
'The --maxWorkers (-w) option requires a number or string to be specified',
);
});

it('allows maxWorkers to be a %', () => {
const argv = {maxWorkers: '50%'} as unknown as Config.Argv;
expect(() => check(argv)).not.toThrow();
expect(() => check(argv({maxWorkers: '50%'}))).not.toThrow();
});

test.each(constants.JEST_CONFIG_EXT_ORDER.map(e => e.substring(1)))(
'allows using "%s" file for --config option',
ext => {
expect(() => check(argv({config: `jest.config.${ext}`}))).not.toThrow();
expect(() =>
check({config: `jest.config.${ext}`} as Config.Argv),
).not.toThrow();
expect(() =>
check({config: `../test/test/my_conf.${ext}`} as Config.Argv),
check(argv({config: `../test/test/my_conf.${ext}`})),
).not.toThrow();
},
);

it('raises an exception if selectProjects is not provided any project names', () => {
const argv: Config.Argv = {selectProjects: []} as Config.Argv;
expect(() => check(argv)).toThrow(
expect(() => check(argv({selectProjects: []}))).toThrow(
'The --selectProjects option requires the name of at least one project to be specified.\n',
);
});

it('raises an exception if config is not a valid JSON string', () => {
const argv = {config: 'x:1'} as Config.Argv;
expect(() => check(argv)).toThrow(
expect(() => check(argv({config: 'x:1'}))).toThrow(
'The --config option requires a JSON string literal, or a file path with one of these extensions: .js, .ts, .mjs, .cjs, .json',
);
});
Expand All @@ -97,13 +91,55 @@ describe('check', () => {
const message =
'The --config option requires a JSON string literal, or a file path with one of these extensions: .js, .ts, .mjs, .cjs, .json';

expect(() => check({config: 'jest.configjs'} as Config.Argv)).toThrow(
message,
expect(() => check(argv({config: 'jest.configjs'}))).toThrow(message);
expect(() => check(argv({config: 'jest.config.exe'}))).toThrow(message);
});

it('raises an exception if shard has wrong format', () => {
expect(() => check(argv({shard: 'mumblemuble'}))).toThrow(
/string in the format of <n>\/<m>/,
);
});

it('raises an exception if shard pair has to many items', () => {
expect(() => check(argv({shard: '1/2/2'}))).toThrow(
/string in the format of <n>\/<m>/,
);
});

it('raises an exception if shard has floating points', () => {
expect(() => check(argv({shard: '1.0/1'}))).toThrow(
/string in the format of <n>\/<m>/,
);
});

it('raises an exception if first item in shard pair is no number', () => {
expect(() => check(argv({shard: 'a/1'}))).toThrow(
/string in the format of <n>\/<m>/,
);
});

it('raises an exception if second item in shard pair is no number', () => {
expect(() => check(argv({shard: '1/a'}))).toThrow(
/string in the format of <n>\/<m>/,
);
});

it('raises an exception if shard is zero-indexed', () => {
expect(() => check(argv({shard: '0/1'}))).toThrow(
/requires 1-based values, received 0/,
);
expect(() => check({config: 'jest.config.exe'} as Config.Argv)).toThrow(
message,
});

it('raises an exception if shard index is larger than shard count', () => {
expect(() => check(argv({shard: '2/1'}))).toThrow(
/requires <n> to be lower or equal than <m>/,
);
});

it('allows valid shard format', () => {
expect(() => check(argv({shard: '1/2'}))).not.toThrow();
});
});

describe('buildArgv', () => {
Expand Down
34 changes: 34 additions & 0 deletions packages/jest-cli/src/cli/args.ts
Expand Up @@ -87,6 +87,34 @@ export function check(argv: Config.Argv): true {
);
}

if (argv.shard) {
const shardPair = argv?.shard
.split('/')
.filter(d => /^\d+$/.test(d))
.map(d => parseInt(d, 10))
.filter((shard: number) => !Number.isNaN(shard));

if (shardPair.length !== 2) {
throw new Error(
'The --shard option requires a string in the format of <n>/<m>.\n Example usage jest --shard=1/5',
);
}

const [shardIndex, shardCount] = shardPair;

if (shardIndex === 0 || shardCount === 0) {
throw new Error(
'The --shard option requires 1-based values, received 0 in the pair.\n Example usage jest --shard=1/5',
);
}

if (shardIndex > shardCount) {
throw new Error(
'The --shard option <n>/<m> requires <n> to be lower or equal than <m>.\n Example usage jest --shard=1/5',
);
}
}

return true;
}

Expand Down Expand Up @@ -521,6 +549,12 @@ export const options = {
string: true,
type: 'array',
},
shard: {
description:
'Shard tests and execute only the selected shard, specify in ' +
'the form "current/all". 1-based, for example "3/5"',
type: 'string',
},
showConfig: {
description: 'Print your jest config and then exits.',
type: 'boolean',
Expand Down
10 changes: 10 additions & 0 deletions packages/jest-config/src/__tests__/normalize.test.ts
Expand Up @@ -1972,3 +1972,13 @@ describe('moduleLoader', () => {
expect(console.warn).toMatchSnapshot();
});
});

describe('shards', () => {
it('should be object if defined', async () => {
const {options} = await normalize({rootDir: '/root/'}, {
shard: '1/2',
} as Config.Argv);

expect(options.shard).toEqual({shardCount: 2, shardIndex: 1});
});
});
1 change: 1 addition & 0 deletions packages/jest-config/src/index.ts
Expand Up @@ -154,6 +154,7 @@ const groupOptions = (
reporters: options.reporters,
rootDir: options.rootDir,
runTestsByPath: options.runTestsByPath,
shard: options.shard,
silent: options.silent,
skipFilter: options.skipFilter,
snapshotFormat: options.snapshotFormat,
Expand Down
13 changes: 13 additions & 0 deletions packages/jest-config/src/normalize.ts
Expand Up @@ -1220,6 +1220,19 @@ export default async function normalize(
newOptions.logHeapUsage = false;
}

if (argv.shard) {
const [shardIndex, shardCount] = argv?.shard
marionebl marked this conversation as resolved.
Show resolved Hide resolved
.split('/')
.filter(d => /^\d+$/.test(d))
.map(d => parseInt(d, 10))
.filter((shard: number) => !Number.isNaN(shard));

newOptions.shard = {
SimenB marked this conversation as resolved.
Show resolved Hide resolved
shardCount,
shardIndex,
};
}

return {
hasDeprecationWarnings,
options: newOptions,
Expand Down
1 change: 1 addition & 0 deletions packages/jest-core/src/runJest.ts
Expand Up @@ -192,6 +192,7 @@ export default async function runJest({
}),
);

allTests = sequencer.shard?.(allTests, globalConfig.shard) ?? allTests;
SimenB marked this conversation as resolved.
Show resolved Hide resolved
allTests = await sequencer.sort(allTests);

if (globalConfig.listTests) {
Expand Down