Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
feat: add caching to run failed and longer tests first (#1541)
* feat: add caching to run failed and longer tests first * chore: cleanup * chore: add clearCache * chore: update lockfile * chore: add filesCache * refactor: add sequelizer * chore: lockfile * refactor: renaming * chore: dont override version * chore: fix clearCache * chore: guard slice * chore: cleanup * docs: cleanup * chore: cleanup * chore: remove command for now * refactor: cleanup
- Loading branch information
1 parent
b947be4
commit 9c60757
Showing
19 changed files
with
455 additions
and
32 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,23 @@ | ||
import fs, { type Stats } from 'fs' | ||
|
||
type FileStatsCache = Pick<Stats, 'size'> | ||
|
||
export class FilesStatsCache { | ||
public cache = new Map<string, FileStatsCache>() | ||
|
||
public getStats(fsPath: string): FileStatsCache | undefined { | ||
return this.cache.get(fsPath) | ||
} | ||
|
||
public async updateStats(fsPath: string) { | ||
if (!fs.existsSync(fsPath)) | ||
return | ||
|
||
const stats = await fs.promises.stat(fsPath) | ||
this.cache.set(fsPath, { size: stats.size }) | ||
} | ||
|
||
public removeStats(fsPath: string) { | ||
this.cache.delete(fsPath) | ||
} | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,38 @@ | ||
import fs from 'fs' | ||
import { findUp } from 'find-up' | ||
import { resolve } from 'pathe' | ||
import { loadConfigFromFile } from 'vite' | ||
import { configFiles } from '../../constants' | ||
import type { CliOptions } from '../cli-api' | ||
import { slash } from '../../utils' | ||
|
||
export class VitestCache { | ||
static resolveCacheDir(root: string, dir: string | undefined) { | ||
return resolve(root, slash(dir || 'node_modules/.vitest')) | ||
} | ||
|
||
static async clearCache(options: CliOptions) { | ||
const root = resolve(options.root || process.cwd()) | ||
|
||
const configPath = options.config | ||
? resolve(root, options.config) | ||
: await findUp(configFiles, { cwd: root } as any) | ||
|
||
const config = await loadConfigFromFile({ command: 'serve', mode: 'test' }, configPath) | ||
|
||
const cache = config?.config.test?.cache | ||
|
||
if (cache === false) | ||
throw new Error('Cache is disabled') | ||
|
||
const cachePath = VitestCache.resolveCacheDir(root, cache?.dir) | ||
|
||
let cleared = false | ||
|
||
if (fs.existsSync(cachePath)) { | ||
fs.rmSync(cachePath, { recursive: true, force: true }) | ||
cleared = true | ||
} | ||
return { dir: cachePath, cleared } | ||
} | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,76 @@ | ||
import fs from 'fs' | ||
import { dirname, resolve } from 'pathe' | ||
import type { File, ResolvedConfig } from '../../types' | ||
import { version } from '../../../package.json' | ||
|
||
export interface SuiteResultCache { | ||
failed: boolean | ||
duration: number | ||
} | ||
|
||
export class ResultsCache { | ||
private cache = new Map<string, SuiteResultCache>() | ||
private cachePath: string | null = null | ||
private version: string = version | ||
private root = '/' | ||
|
||
setConfig(root: string, config: ResolvedConfig['cache']) { | ||
this.root = root | ||
if (config) | ||
this.cachePath = resolve(config.dir, 'results.json') | ||
} | ||
|
||
getResults(fsPath: string) { | ||
return this.cache.get(fsPath?.slice(this.root.length)) | ||
} | ||
|
||
async readFromCache() { | ||
if (!this.cachePath) | ||
return | ||
|
||
if (fs.existsSync(this.cachePath)) { | ||
const resultsCache = await fs.promises.readFile(this.cachePath, 'utf8') | ||
const { results, version } = JSON.parse(resultsCache) | ||
this.cache = new Map(results) | ||
this.version = version | ||
} | ||
} | ||
|
||
updateResults(files: File[]) { | ||
files.forEach((file) => { | ||
const result = file.result | ||
if (!result) | ||
return | ||
const duration = result.duration || 0 | ||
// store as relative, so cache would be the same in CI and locally | ||
const relativePath = file.filepath?.slice(this.root.length) | ||
this.cache.set(relativePath, { | ||
duration: duration >= 0 ? duration : 0, | ||
failed: result.state === 'fail', | ||
}) | ||
}) | ||
} | ||
|
||
removeFromCache(filepath: string) { | ||
this.cache.delete(filepath) | ||
} | ||
|
||
async writeToCache() { | ||
if (!this.cachePath) | ||
return | ||
|
||
const results = Array.from(this.cache.entries()) | ||
|
||
const cacheDirname = dirname(this.cachePath) | ||
|
||
if (!fs.existsSync(cacheDirname)) | ||
await fs.promises.mkdir(cacheDirname, { recursive: true }) | ||
|
||
const cache = JSON.stringify({ | ||
version: this.version, | ||
results, | ||
}) | ||
|
||
await fs.promises.writeFile(this.cachePath, cache) | ||
} | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,66 @@ | ||
import { createHash } from 'crypto' | ||
import { resolve } from 'pathe' | ||
import { slash } from 'vite-node/utils' | ||
import type { Vitest } from '../core' | ||
import type { TestSequelizer } from './types' | ||
|
||
export class BaseSequelizer implements TestSequelizer { | ||
protected ctx: Vitest | ||
|
||
constructor(ctx: Vitest) { | ||
this.ctx = ctx | ||
} | ||
|
||
// async so it can be extended by other sequelizers | ||
public async shard(files: string[]): Promise<string[]> { | ||
const { config } = this.ctx | ||
const { index, count } = config.shard! | ||
const shardSize = Math.ceil(files.length / count) | ||
const shardStart = shardSize * (index - 1) | ||
const shardEnd = shardSize * index | ||
return [...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) | ||
} | ||
|
||
// async so it can be extended by other sequelizers | ||
public async sort(files: string[]): Promise<string[]> { | ||
const { state } = this.ctx | ||
return [...files].sort((a, b) => { | ||
const aState = state.getFileTestResults(a) | ||
const bState = state.getFileTestResults(b) | ||
|
||
if (!aState || !bState) { | ||
const statsA = state.getFileStats(a) | ||
const statsB = state.getFileStats(b) | ||
|
||
// run unknown first | ||
if (!statsA || !statsB) | ||
return !statsA && statsB ? -1 : !statsB && statsA ? 1 : 0 | ||
|
||
// run larger files first | ||
return statsB.size - statsA.size | ||
} | ||
|
||
// run failed first | ||
if (aState.failed && !bState.failed) | ||
return -1 | ||
if (!aState.failed && bState.failed) | ||
return 1 | ||
|
||
// run longer first | ||
return bState.duration - aState.duration | ||
}) | ||
} | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,15 @@ | ||
import type { Awaitable } from '../../types' | ||
import type { Vitest } from '../core' | ||
|
||
export interface TestSequelizer { | ||
/** | ||
* Slicing tests into shards. Will be run before `sort`. | ||
* Only run, if `shard` is defined. | ||
*/ | ||
shard(files: string[]): Awaitable<string[]> | ||
sort(files: string[]): Awaitable<string[]> | ||
} | ||
|
||
export interface TestSequelizerContructor { | ||
new (ctx: Vitest): TestSequelizer | ||
} |
Oops, something went wrong.