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: add --shard command #1477

Merged
merged 4 commits into from Jun 14, 2022
Merged
Show file tree
Hide file tree
Changes from 3 commits
Commits
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
29 changes: 26 additions & 3 deletions docs/guide/cli.md
Expand Up @@ -66,14 +66,37 @@ vitest related /src/index.ts /src/hello-world.js
| `--environment <env>` | Runner environment (default: `node`) |
| `--passWithNoTests` | Pass when no tests found |
| `--allowOnly` | Allow tests and suites that are marked as `only` (default: false in CI, true otherwise) |
| `--changed [since]` | Run tests that are affected by the changed files (default: false). See [docs](#changed)
| `--changed [since]` | Run tests that are affected by the changed files (default: false). See [docs](#changed) |
| `--shard <shard>` | Execute tests in a specified shard |
| `-h, --help` | Display available CLI options |

### changed

- **Type**: `boolean | string`
- **Default**: false

Run tests only against changed files. If no value is provided, it will run tests against uncommitted changes (including staged and unstaged).
Run tests only against changed files. If no value is provided, it will run tests against uncommitted changes (including staged and unstaged).

To run tests against changes made in the last commit, you can use `--changed HEAD~1`. You can also pass commit hash or branch name.
To run tests against changes made in the last commit, you can use `--changed HEAD~1`. You can also pass commit hash or branch name.

### shard

- **Type**: `string`
- **Default**: disabled

Test suite shard to execute in a format of `<index>`/`<count>`, where

- `count` is a positive integer, count of divided parts
- `index` is a positive integer, index of divided part

This command will divide all tests into `count` equal parts, and will run only those that happen to be in an `index` part. For example, to split your tests suite into three parts, use this:

```sh
vitest run --shard=1/3
vitest run --shard=2/3
vitest run --shard=3/3
```

:::warning
You cannot use this option with `--watch` enabled (enabled in dev by default).
:::
1 change: 1 addition & 0 deletions packages/vitest/src/node/cli.ts
Expand Up @@ -33,6 +33,7 @@ cli
.option('--environment <env>', 'runner environment (default: node)')
.option('--passWithNoTests', 'pass when no tests found')
.option('--allowOnly', 'Allow tests and suites that are marked as only (default: !process.env.CI)')
.option('--shard <shard>', 'Test suite shard to execute in a format of <index>/<count>')
.option('--changed [since]', 'Run tests that are affected by the changed files (default: false)')
.help()

Expand Down
27 changes: 27 additions & 0 deletions packages/vitest/src/node/config.ts
Expand Up @@ -56,6 +56,16 @@ export function resolveApiConfig<Options extends ApiConfig & UserConfig>(
return api
}

const configError = (error: string): never => {
console.warn(
c.yellow(
`${c.inverse(c.red(' VITEST '))} ${error}\n`,
),
)

process.exit(1)
Copy link
Member

@antfu antfu Jun 14, 2022

Choose a reason for hiding this comment

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

Better to just throw the error, as resolveConfig can be used programmatically, and the caller might want to catch the error.

Copy link
Member Author

Choose a reason for hiding this comment

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

We have a bug, when throwing an error, process doesn't stop 👀

Copy link
Member

Choose a reason for hiding this comment

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

We can catch it and process.exit in the caller, instead of having side-effect for resolveConfig

Copy link
Member Author

Choose a reason for hiding this comment

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

I've added throwing, but I still do process.exit when catching the error

}

export function resolveConfig(
options: UserConfig,
viteConfig: ResolvedViteConfig,
Expand Down Expand Up @@ -90,6 +100,23 @@ export function resolveConfig(

resolved.coverage = resolveC8Options(options.coverage || {}, resolved.root)

if (options.shard) {
if (resolved.watch)
configError('You cannot use --shard option with enabled watch')

const [indexString, countString] = options.shard.split('/')
const index = Math.abs(parseInt(indexString, 10))
const count = Math.abs(parseInt(countString, 10))

if (isNaN(count) || count <= 0)
configError('--shard <count> must be a positive number')

if (isNaN(index) || index <= 0 || index > count)
configError('--shard <index> must be a positive number less then <count>')

resolved.shard = { index, count }
}

resolved.deps = resolved.deps || {}
// vitenode will try to import such file with native node,
// but then our mocker will not work properly
Expand Down
24 changes: 23 additions & 1 deletion packages/vitest/src/node/pool.ts
@@ -1,14 +1,15 @@
import { MessageChannel } from 'worker_threads'
import { pathToFileURL } from 'url'
import { cpus } from 'os'
import { createHash } from 'crypto'
import { resolve } from 'pathe'
import type { Options as TinypoolOptions } from 'tinypool'
import { Tinypool } from 'tinypool'
import { createBirpc } from 'birpc'
import type { RawSourceMap } from 'vite-node'
import type { WorkerContext, WorkerRPC } from '../types'
import { distDir } from '../constants'
import { AggregateError } from '../utils'
import { AggregateError, slash } from '../utils'
import type { Vitest } from './core'

export type RunWithFiles = (files: string[], invalidates?: string[]) => Promise<void>
Expand Down Expand Up @@ -88,6 +89,27 @@ export function createPool(ctx: Vitest): WorkerPool {
}

return async (files, invalidates) => {
if (config.shard) {
const { index, count } = config.shard
const shardSize = Math.ceil(files.length / count)
const shardStart = shardSize * (index - 1)
const shardEnd = shardSize * index
files = files
.map((file) => {
const fullPath = resolve(slash(config.root), slash(file))
const specPath = fullPath.slice(config.root.length)
return {
file,
hash: createHash('sha1')
.update(specPath)
.digest('hex'),
}
})
.sort((a, b) => (a.hash < b.hash ? -1 : a.hash > b.hash ? 1 : 0))
.slice(shardStart, shardEnd)
.map(({ file }) => file)
}

if (!ctx.config.threads) {
await runFiles(files)
}
Expand Down
14 changes: 13 additions & 1 deletion packages/vitest/src/types/config.ts
Expand Up @@ -380,9 +380,17 @@ export interface UserConfig extends InlineConfig {
* @default false
*/
changed?: boolean | string

/**
* Test suite shard to execute in a format of <index>/<count>.
* Will divide tests into a `count` numbers, and run only the `indexed` part.
* Cannot be used with enabled watch.
* @example --shard=2/3
*/
shard?: string
}

export interface ResolvedConfig extends Omit<Required<UserConfig>, 'config' | 'filters' | 'coverage' | 'testNamePattern' | 'related' | 'api' | 'reporters' | 'resolveSnapshotPath'> {
export interface ResolvedConfig extends Omit<Required<UserConfig>, 'config' | 'filters' | 'coverage' | 'testNamePattern' | 'related' | 'api' | 'reporters' | 'resolveSnapshotPath' | 'shard'> {
base?: string

config?: string
Expand All @@ -398,4 +406,8 @@ export interface ResolvedConfig extends Omit<Required<UserConfig>, 'config' | 'f
defines: Record<string, any>

api?: ApiConfig
shard?: {
index: number
count: number
}
}
8 changes: 8 additions & 0 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

11 changes: 11 additions & 0 deletions test/shard/package.json
@@ -0,0 +1,11 @@
{
"name": "@vitest/test-shard",
"private": true,
"scripts": {
"test": "vitest run shard-test.test.ts"
},
"devDependencies": {
"execa": "^6.1.0",
"vitest": "workspace:*"
}
}
50 changes: 50 additions & 0 deletions test/shard/shard-test.test.ts
@@ -0,0 +1,50 @@
import { expect, test } from 'vitest'
import { basename } from 'pathe'
import { execa } from 'execa'

const runVitest = async (args: string[]) => {
const { stdout } = await execa('vitest', ['--run', '--dir', './test', ...args])
return stdout
}

const parsePaths = (stdout: string) => {
return stdout
.split('\n')
.filter(line => line && line.includes('.test.js'))
.map(file => basename(file.trim().split(' ')[1]))
.sort()
}

test('--shard=1/1', async () => {
const stdout = await runVitest(['--shard=1/1'])

const paths = parsePaths(stdout)

expect(paths).toEqual(['1.test.js', '2.test.js', '3.test.js'])
})

test('--shard=1/2', async () => {
const stdout = await runVitest(['--shard=1/2'])

const paths = parsePaths(stdout)

expect(paths).toEqual(['1.test.js', '2.test.js'])
})

test('--shard=2/2', async () => {
const stdout = await runVitest(['--shard=2/2'])

const paths = parsePaths(stdout)

expect(paths).toEqual(['3.test.js'])
})

test('--shard=4/4', async () => {
const stdout = await runVitest(['--shard=4/4'])

const paths = parsePaths(stdout)

// project only has 3 files
// shards > 3 are empty
expect(paths).toEqual([])
})
3 changes: 3 additions & 0 deletions test/shard/test/1.test.js
@@ -0,0 +1,3 @@
import { expect, test } from 'vitest'

test('1', () => expect(1).toBe(1))
3 changes: 3 additions & 0 deletions test/shard/test/2.test.js
@@ -0,0 +1,3 @@
import { expect, test } from 'vitest'

test('2', () => expect(1).toBe(1))
3 changes: 3 additions & 0 deletions test/shard/test/3.test.js
@@ -0,0 +1,3 @@
import { expect, test } from 'vitest'

test('3', () => expect(1).toBe(1))
7 changes: 7 additions & 0 deletions test/shard/vitest.config.ts
@@ -0,0 +1,7 @@
import { defineConfig } from 'vitest/config'

export default defineConfig({
test: {
testTimeout: 50_000,
},
})