feat: support custom colors for test.name (#7809)

This commit is contained in:
Ari Perkkiö 2025-05-05 17:21:24 +03:00 committed by GitHub
parent 78a3d27879
commit 4af5df33b5
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
30 changed files with 166 additions and 49 deletions

View File

@ -146,9 +146,11 @@ When defined, Vitest will run all matched files with `import.meta.vitest` inside
### name
- **Type:** `string`
- **Type:** `string | { label: string, color?: LabelColor }`
Assign a custom name to the test project or Vitest process. The name will be visible in the CLI and available in the Node.js API via [`project.name`](/advanced/api/test-project#name).
Assign a custom name to the test project or Vitest process. The name will be visible in the CLI and UI, and available in the Node.js API via [`project.name`](/advanced/api/test-project#name).
Color used by CLI and UI can be changed by providing an object with `color` property.
### server {#server}

View File

@ -102,7 +102,8 @@ export default defineConfig({
{
test: {
include: ['tests/**/*.{node}.test.{ts,js}'],
name: 'node',
// color of the name label can be changed
name: { label: 'node', color: 'green' },
environment: 'node',
}
}

View File

@ -158,7 +158,13 @@ const projectNameTextColor = computed(() => {
case 'blue':
case 'green':
case 'magenta':
case 'black':
case 'red':
return 'white'
case 'yellow':
case 'cyan':
case 'white':
default:
return 'black'
}

View File

@ -161,7 +161,7 @@ watch(
client.rpc.getFiles(),
client.rpc.getConfig(),
client.rpc.getUnhandledErrors(),
client.rpc.getResolvedProjectNames(),
client.rpc.getResolvedProjectLabels(),
])
if (_config.standalone) {
const filenames = await client.rpc.getTestFiles()

View File

@ -51,6 +51,9 @@ export function createStaticClient(): VitestClient {
getResolvedProjectNames: () => {
return metadata.projects
},
getResolvedProjectLabels: () => {
return []
},
getModuleGraph: async (projectName, id) => {
return metadata.moduleGraph[projectName]?.[id]
},

View File

@ -19,6 +19,7 @@ export class ExplorerTree {
private resumeEndRunId: ReturnType<typeof setTimeout> | undefined
constructor(
public projects: string[] = [],
public colors = new Map<string, string | undefined>(),
private onTaskUpdateCalled: boolean = false,
private resumeEndTimeout = 500,
public root = <RootTreeNode>{
@ -54,8 +55,10 @@ export class ExplorerTree {
this.rafCollector = useRafFn(this.runCollect.bind(this), { fpsLimit: 10, immediate: false })
}
loadFiles(remoteFiles: File[], projects: string[]) {
this.projects.splice(0, this.projects.length, ...projects)
loadFiles(remoteFiles: File[], projects: { name: string; color?: string }[]) {
this.projects.splice(0, this.projects.length, ...projects.map(p => p.name))
this.colors = new Map(projects.map(p => [p.name, p.color]))
runLoadFiles(
remoteFiles,
true,

View File

@ -71,7 +71,7 @@ export function createOrUpdateFileNode(
duration: file.result?.duration != null ? Math.round(file.result?.duration) : undefined,
filepath: file.filepath,
projectName: file.projectName || '',
projectNameColor: getProjectNameColor(file.projectName),
projectNameColor: explorerTree.colors.get(file.projectName || '') || getProjectNameColor(file.projectName),
collectDuration: file.collectDuration,
setupDuration: file.setupDuration,
environmentLoad: file.environmentLoad,

View File

@ -6,7 +6,7 @@ import type { WebSocket } from 'ws'
import type { Vitest } from '../node/core'
import type { Reporter } from '../node/types/reporter'
import type { SerializedTestSpecification } from '../runtime/types/utils'
import type { Awaitable, ModuleGraphData, UserConsoleLog } from '../types/general'
import type { Awaitable, LabelColor, ModuleGraphData, UserConsoleLog } from '../types/general'
import type {
TransformResultWithSource,
WebSocketEvents,
@ -90,6 +90,9 @@ export function setup(ctx: Vitest, _server?: ViteDevServer): void {
getResolvedProjectNames(): string[] {
return ctx.projects.map(p => p.name)
},
getResolvedProjectLabels(): { name: string; color?: LabelColor }[] {
return ctx.projects.map(p => ({ name: p.name, color: p.color }))
},
async getTransformResult(projectName: string, id, browser = false) {
const project = ctx.getProjectByName(projectName)
const result: TransformResultWithSource | null | undefined = browser

View File

@ -2,7 +2,7 @@ import type { File, TaskEventPack, TaskResultPack } from '@vitest/runner'
import type { BirpcReturn } from 'birpc'
import type { SerializedConfig } from '../runtime/config'
import type { SerializedTestSpecification } from '../runtime/types/utils'
import type { Awaitable, ModuleGraphData, UserConsoleLog } from '../types/general'
import type { Awaitable, LabelColor, ModuleGraphData, UserConsoleLog } from '../types/general'
interface SourceMap {
file: string
@ -32,7 +32,10 @@ export interface WebSocketHandlers {
getTestFiles: () => Promise<SerializedTestSpecification[]>
getPaths: () => string[]
getConfig: () => SerializedConfig
// TODO: Remove in v4
/** @deprecated -- Use `getResolvedProjectLabels` instead */
getResolvedProjectNames: () => string[]
getResolvedProjectLabels: () => { name: string; color?: LabelColor }[]
getModuleGraph: (
projectName: string,
id: string,

View File

@ -145,6 +145,12 @@ export function resolveConfig(
resolved.project = toArray(resolved.project)
resolved.provide ??= {}
resolved.name = typeof options.name === 'string'
? options.name
: (options.name?.label || '')
resolved.color = typeof options.name !== 'string' ? options.name?.color : undefined
const inspector = resolved.inspect || resolved.inspectBrk
resolved.inspector = {

View File

@ -164,7 +164,7 @@ export class Logger {
const config = project.config
const printConfig = !project.isRootProject() && project.name
if (printConfig) {
this.console.error(`\n${formatProjectName(project.name)}\n`)
this.console.error(`\n${formatProjectName(project)}\n`)
}
if (config.include) {
this.console.error(
@ -243,7 +243,7 @@ export class Logger {
const output = project.isRootProject()
? ''
: formatProjectName(project.name)
: formatProjectName(project)
const provider = project.browser.provider.name
const providerString = provider === 'preview' ? '' : ` by ${c.reset(c.bold(provider))}`
this.log(

View File

@ -59,7 +59,9 @@ export function resolveOptimizerConfig(
(n: string) => !exclude.includes(n),
)
newConfig.cacheDir = (testConfig.cache !== false && testConfig.cache?.dir) || VitestCache.resolveCacheDir(root, viteCacheDir, testConfig.name)
const projectName = typeof testConfig.name === 'string' ? testConfig.name : testConfig.name?.label
newConfig.cacheDir = (testConfig.cache !== false && testConfig.cache?.dir) || VitestCache.resolveCacheDir(root, viteCacheDir, projectName)
newConfig.optimizeDeps = {
...viteOptions,
...testOptions,

View File

@ -44,7 +44,11 @@ export function WorkspaceVitestPlugin(
const testConfig = viteConfig.test || {}
const root = testConfig.root || viteConfig.root || options.root
let name = testConfig.name
let { label: name, color } = typeof testConfig.name === 'string'
? { label: testConfig.name }
: { label: '', ...testConfig.name }
if (!name) {
if (typeof options.workspacePath === 'string') {
// if there is a package.json, read the name from it
@ -108,7 +112,7 @@ export function WorkspaceVitestPlugin(
},
},
test: {
name,
name: { label: name, color },
},
}

View File

@ -14,6 +14,7 @@ import type { WorkspaceSpec as DeprecatedWorkspaceSpec } from './pool'
import type { Reporter } from './reporters'
import type { ParentProjectBrowser, ProjectBrowser } from './types/browser'
import type {
ProjectName,
ResolvedConfig,
SerializedConfig,
TestProjectInlineConfiguration,
@ -195,6 +196,13 @@ export class TestProject {
return this.config.name || ''
}
/**
* The color used when reporting tasks of this project.
*/
public get color(): ProjectName['color'] {
return this.config.color
}
/**
* Serialized project configuration. This is the config that tests receive.
*/

View File

@ -235,7 +235,7 @@ export abstract class BaseReporter implements Reporter {
}
if (testModule.project.name) {
title += ` ${formatProjectName(testModule.project.name, '')}`
title += ` ${formatProjectName(testModule.project, '')}`
}
return ` ${title} ${testModule.task.name} ${suffix}`
@ -560,7 +560,9 @@ export abstract class BaseReporter implements Reporter {
}
const groupName = getFullName(group, c.dim(' > '))
this.log(` ${formatProjectName(bench.file.projectName)}${bench.name}${c.dim(` - ${groupName}`)}`)
const project = this.ctx.projects.find(p => p.name === bench.file.projectName)
this.log(` ${formatProjectName(project)}${bench.name}${c.dim(` - ${groupName}`)}`)
const siblings = group.tasks
.filter(i => i.meta.benchmark && i.result?.benchmark && i !== bench)
@ -609,6 +611,7 @@ export abstract class BaseReporter implements Reporter {
for (const task of tasks) {
const filepath = (task as File)?.filepath || ''
const projectName = (task as File)?.projectName || task.file?.projectName || ''
const project = this.ctx.projects.find(p => p.name === projectName)
let name = getFullName(task, c.dim(' > '))
@ -617,7 +620,7 @@ export abstract class BaseReporter implements Reporter {
}
this.ctx.logger.error(
`${c.bgRed(c.bold(' FAIL '))} ${formatProjectName(projectName)}${name}`,
`${c.bgRed(c.bold(' FAIL '))} ${formatProjectName(project)}${name}`,
)
}

View File

@ -65,7 +65,7 @@ export class BenchmarkReporter extends DefaultReporter {
const duration = testTask.task.result?.duration || 0
if (benches.length > 0 && benches.every(t => t.result?.state !== 'run' && t.result?.state !== 'queued')) {
let title = `\n ${getStateSymbol(testTask.task)} ${formatProjectName(testTask.project.name)}${getFullName(testTask.task, c.dim(' > '))}`
let title = `\n ${getStateSymbol(testTask.task)} ${formatProjectName(testTask.project)}${getFullName(testTask.task, c.dim(' > '))}`
if (duration != null && duration > this.ctx.config.slowTestThreshold) {
title += c.yellow(` ${Math.round(duration)}${c.dim('ms')}`)

View File

@ -1,6 +1,7 @@
import type { Task } from '@vitest/runner'
import type { SnapshotSummary } from '@vitest/snapshot'
import type { Formatter } from 'tinyrainbow'
import type { TestProject } from '../../project'
import { stripVTControlCharacters } from 'node:util'
import { slash } from '@vitest/utils'
import { basename, dirname, isAbsolute, relative } from 'pathe'
@ -23,6 +24,8 @@ export const taskFail: string = c.red(F_CROSS)
export const suiteFail: string = c.red(F_POINTER)
export const pending: string = c.gray('·')
const labelDefaultColors = [c.bgYellow, c.bgCyan, c.bgGreen, c.bgMagenta] as const
function getCols(delta = 0) {
let length = process.stdout?.columns
if (!length || Number.isNaN(length)) {
@ -223,20 +226,25 @@ export function formatTime(time: number): string {
return `${Math.round(time)}ms`
}
export function formatProjectName(name: string | undefined, suffix = ' '): string {
if (!name) {
export function formatProjectName(project?: Pick<TestProject, 'name' | 'color'>, suffix = ' '): string {
if (!project?.name) {
return ''
}
if (!c.isColorSupported) {
return `|${name}|${suffix}`
return `|${project.name}|${suffix}`
}
const index = name
.split('')
.reduce((acc, v, idx) => acc + v.charCodeAt(0) + idx, 0)
const colors = [c.bgYellow, c.bgCyan, c.bgGreen, c.bgMagenta]
let background = project.color && c[`bg${capitalize(project.color)}`]
return c.black(colors[index % colors.length](` ${name} `)) + suffix
if (!background) {
const index = project.name
.split('')
.reduce((acc, v, idx) => acc + v.charCodeAt(0) + idx, 0)
background = labelDefaultColors[index % labelDefaultColors.length]
}
return c.black(background(` ${project.name} `)) + suffix
}
export function withLabel(color: 'red' | 'green' | 'blue' | 'cyan' | 'yellow', label: string, message?: string) {
@ -257,3 +265,7 @@ export function truncateString(text: string, maxLength: number): string {
return `${plainText.slice(0, maxLength - 1)}`
}
function capitalize<T extends string>(text: T) {
return `${text[0].toUpperCase()}${text.slice(1)}` as Capitalize<T>
}

View File

@ -34,6 +34,7 @@ interface SlowTask {
interface RunningModule extends Pick<Counter, 'total' | 'completed'> {
filename: TestModule['task']['name']
projectName: TestModule['project']['name']
projectColor: TestModule['project']['color']
hook?: Omit<SlowTask, 'hook'>
tests: Map<TestCase['id'], SlowTask>
typecheck: boolean
@ -278,7 +279,7 @@ export class SummaryReporter implements Reporter {
const typecheck = testFile.typecheck ? `${c.bgBlue(c.bold(' TS '))} ` : ''
summary.push(
c.bold(c.yellow(` ${F_POINTER} `))
+ formatProjectName(testFile.projectName)
+ formatProjectName({ name: testFile.projectName, color: testFile.projectColor })
+ typecheck
+ testFile.filename
+ c.dim(!testFile.completed && !testFile.total
@ -382,6 +383,7 @@ function initializeStats(module: TestModule): RunningModule {
completed: 0,
filename: module.task.name,
projectName: module.project.name,
projectColor: module.project.color,
tests: new Map(),
typecheck: !!module.task.meta.typecheck,
}

View File

@ -38,7 +38,7 @@ export class VerboseReporter extends DefaultReporter {
let title = ` ${getStateSymbol(test.task)} `
if (test.project.name) {
title += formatProjectName(test.project.name)
title += formatProjectName(test.project)
}
title += getFullName(test.task, c.dim(' > '))

View File

@ -72,6 +72,7 @@ type UnsupportedProperties =
| 'environmentOptions'
| 'server'
| 'benchmark'
| 'name'
export interface BrowserInstanceOption extends BrowserProviderOptions,
Omit<ProjectConfig, UnsupportedProperties>,
@ -88,6 +89,8 @@ export interface BrowserInstanceOption extends BrowserProviderOptions,
* Name of the browser
*/
browser: string
name?: string
}
export interface BrowserConfigOptions {

View File

@ -8,7 +8,7 @@ import type { ViteNodeServerOptions } from 'vite-node'
import type { ChaiConfig } from '../../integrations/chai/config'
import type { SerializedConfig } from '../../runtime/config'
import type { EnvironmentOptions } from '../../types/environment'
import type { Arrayable, ErrorWithDiff, ParsedStack, ProvidedContext } from '../../types/general'
import type { Arrayable, ErrorWithDiff, LabelColor, ParsedStack, ProvidedContext } from '../../types/general'
import type { HappyDOMOptions } from '../../types/happy-dom-options'
import type { JSDOMOptions } from '../../types/jsdom-options'
import type {
@ -50,6 +50,11 @@ export type { EnvironmentOptions, HappyDOMOptions, JSDOMOptions }
export type VitestRunMode = 'test' | 'benchmark'
export interface ProjectName {
label: string
color?: LabelColor
}
interface SequenceOptions {
/**
* Class that handles sorting and sharding algorithm.
@ -238,7 +243,7 @@ export interface InlineConfig {
/**
* Name of the project. Will be used to display in the reporter.
*/
name?: string
name?: string | ProjectName
/**
* Benchmark options.
@ -990,9 +995,12 @@ export interface ResolvedConfig
| 'setupFiles'
| 'snapshotEnvironment'
| 'bail'
| 'name'
> {
mode: VitestRunMode
name: ProjectName['label']
color?: ProjectName['color']
base?: string
diff?: string | SerializedDiffOptions
bail?: number

View File

@ -183,6 +183,7 @@ export type { BrowserTesterOptions } from '../types/browser'
export type {
AfterSuiteRunMeta,
ErrorWithDiff,
LabelColor,
ModuleCache,
ModuleGraphData,
ParsedStack,

View File

@ -46,3 +46,6 @@ export interface ModuleGraphData {
}
export interface ProvidedContext {}
// These need to be compatible with Tinyrainbow's bg-colors, and CSS's background-color
export type LabelColor = 'black' | 'red' | 'green' | 'yellow' | 'blue' | 'magenta' | 'cyan' | 'white'

View File

@ -1,18 +1,25 @@
import type { Pool } from 'vitest/node'
import { defineWorkspace } from 'vitest/config'
function project(pool: Pool) {
return {
export default defineWorkspace([
{
extends: './vite.config.ts',
test: {
name: pool,
pool,
name: { label: 'threads', color: 'red' },
pool: 'threads',
},
}
}
export default defineWorkspace([
project('threads'),
project('forks'),
project('vmThreads'),
},
{
extends: './vite.config.ts',
test: {
name: { label: 'forks', color: 'green' },
pool: 'forks',
},
},
{
extends: './vite.config.ts',
test: {
name: { label: 'vmThreads', color: 'blue' },
pool: 'vmThreads',
},
},
])

View File

@ -19,7 +19,7 @@ export default defineWorkspace([
{
test: {
...config.test,
name: 'v8',
name: { label: 'v8', color: 'green' },
env: { COVERAGE_PROVIDER: 'v8' },
include: [GENERIC_TESTS, V8_TESTS],
exclude: [
@ -54,7 +54,7 @@ export default defineWorkspace([
{
test: {
...config.test,
name: 'istanbul',
name: { label: 'istanbul', color: 'magenta' },
env: { COVERAGE_PROVIDER: 'istanbul' },
include: [GENERIC_TESTS, ISTANBUL_TESTS],
exclude: [
@ -70,7 +70,7 @@ export default defineWorkspace([
{
test: {
...config.test,
name: 'custom',
name: { label: 'custom', color: 'yellow' },
env: { COVERAGE_PROVIDER: 'custom' },
include: [CUSTOM_TESTS],
},
@ -80,7 +80,7 @@ export default defineWorkspace([
{
test: {
...config.test,
name: 'istanbul-browser',
name: { label: 'istanbul-browser', color: 'blue' },
env: { COVERAGE_PROVIDER: 'istanbul', COVERAGE_BROWSER: 'true' },
include: [
BROWSER_TESTS,
@ -106,7 +106,7 @@ export default defineWorkspace([
{
test: {
...config.test,
name: 'v8-browser',
name: { label: 'v8-browser', color: 'red' },
env: { COVERAGE_PROVIDER: 'v8', COVERAGE_BROWSER: 'true' },
include: [
BROWSER_TESTS,
@ -134,7 +134,7 @@ export default defineWorkspace([
{
test: {
...config.test,
name: 'unit',
name: { label: 'unit', color: 'cyan' },
include: [UNIT_TESTS],
typecheck: {
enabled: true,

View File

@ -0,0 +1,5 @@
import { test } from "vitest";
test("example test", () => {
//
})

View File

@ -0,0 +1,17 @@
import { defineConfig } from "vitest/config";
export default defineConfig({
test: {
watch: false,
name: {
label: "Example project",
color: "magenta",
},
env: {
CI: '1',
FORCE_COLOR: '1',
NO_COLOR: undefined,
GITHUB_ACTIONS: undefined,
},
},
});

View File

@ -15,6 +15,7 @@ exports[`html reporter > resolves to "failing" status for test file "json-fail"
"name": "json-fail.test.ts",
"pool": "forks",
"prepareDuration": 0,
"projectName": "",
"result": {
"duration": 0,
"startTime": 0,
@ -125,6 +126,7 @@ exports[`html reporter > resolves to "passing" status for test file "all-passing
"name": "all-passing-or-skipped.test.ts",
"pool": "forks",
"prepareDuration": 0,
"projectName": "",
"result": {
"duration": 0,
"startTime": 0,

View File

@ -1,6 +1,6 @@
import type { TestSpecification } from 'vitest/node'
import { describe, expect, test } from 'vitest'
import { runVitest } from '../../test-utils'
import { runVitest, runVitestCli } from '../../test-utils'
describe('default reporter', async () => {
test('normal', async () => {
@ -227,6 +227,17 @@ describe('default reporter', async () => {
not array: 0 = { k: 'v2' }, 1 = undefined, k = 'v2' [...]ms"
`)
})
test('project name color', async () => {
const { stdout } = await runVitestCli(
{ preserveAnsi: true },
'--root',
'fixtures/project-name',
)
expect(stdout).toContain('Example project')
expect(stdout).toContain('\x1B[30m\x1B[45m Example project \x1B[49m\x1B[39m')
})
}, 120000)
function trimReporterOutput(report: string) {

View File

@ -149,6 +149,7 @@ export async function runVitest(
interface CliOptions extends Partial<Options> {
earlyReturn?: boolean
preserveAnsi?: boolean
}
async function runCli(command: 'vitest' | 'vite-node', _options?: CliOptions | string, ...args: string[]) {
@ -169,6 +170,7 @@ async function runCli(command: 'vitest' | 'vite-node', _options?: CliOptions | s
stdin: subprocess.stdin!,
stdout: subprocess.stdout!,
stderr: subprocess.stderr!,
preserveAnsi: typeof _options !== 'string' ? _options?.preserveAnsi : false,
})
let setDone: (value?: unknown) => void