From 6e793c64a1952bee8948dee908b7f7d5ae857f5d Mon Sep 17 00:00:00 2001 From: Hiroshi Ogawa Date: Fri, 8 Nov 2024 18:01:07 +0900 Subject: [PATCH 01/28] fix(ui): remove crossorigin attributes for same origin assets (#6883) --- packages/ui/vite.config.ts | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/packages/ui/vite.config.ts b/packages/ui/vite.config.ts index 3b57c9d061ff..5dbaec0c72be 100644 --- a/packages/ui/vite.config.ts +++ b/packages/ui/vite.config.ts @@ -80,6 +80,17 @@ export const config: UserConfig = { // return html.replace('', ``) // }, // }, + { + // workaround `crossorigin` issues on some browsers + // https://github.com/vitejs/vite/issues/6648 + name: 'no-crossorigin-for-same-assets', + apply: 'build', + transformIndexHtml(html) { + return html + .replace('crossorigin src="./assets/', 'src="./assets/') + .replace('crossorigin href="./assets/', 'href="./assets/') + }, + }, ], build: { outDir: './dist/client', From b01df47da0a37004d8cc27df9231b35744ccde34 Mon Sep 17 00:00:00 2001 From: Vladimir Date: Fri, 8 Nov 2024 10:02:23 +0100 Subject: [PATCH 02/28] fix: stop the runner before restarting, restart on workspace config change (#6859) --- packages/vitest/src/node/cli/cli-api.ts | 4 - packages/vitest/src/node/core.ts | 27 ++++-- packages/vitest/src/node/workspace.ts | 3 +- test/test-utils/index.ts | 59 ++++++++++++ test/watch/test/config-watching.test.ts | 93 +++++++++++++++++++ test/watch/vitest.config.ts | 5 + .../space_browser/vitest.config.ts | 4 +- test/workspaces-browser/vitest.workspace.ts | 4 +- 8 files changed, 180 insertions(+), 19 deletions(-) create mode 100644 test/watch/test/config-watching.test.ts diff --git a/packages/vitest/src/node/cli/cli-api.ts b/packages/vitest/src/node/cli/cli-api.ts index 63a49f567fe1..57ce175cc310 100644 --- a/packages/vitest/src/node/cli/cli-api.ts +++ b/packages/vitest/src/node/cli/cli-api.ts @@ -73,10 +73,6 @@ export async function startVitest( stdinCleanup = registerConsoleShortcuts(ctx, stdin, stdout) } - ctx.onServerRestart((reason) => { - ctx.report('onServerRestart', reason) - }) - ctx.onAfterSetServer(() => { if (ctx.config.standalone) { ctx.init() diff --git a/packages/vitest/src/node/core.ts b/packages/vitest/src/node/core.ts index 763fec4c468a..d1eb91d4c6f2 100644 --- a/packages/vitest/src/node/core.ts +++ b/packages/vitest/src/node/core.ts @@ -83,6 +83,7 @@ export class Vitest { public distPath = distDir private _cachedSpecs = new Map() + private _workspaceConfigPath?: string /** @deprecated use `_cachedSpecs` */ projectTestFiles = this._cachedSpecs @@ -110,6 +111,10 @@ export class Vitest { this._browserLastPort = defaultBrowserPort this.pool?.close?.() this.pool = undefined + this.closingPromise = undefined + this.projects = [] + this.resolvedProjects = [] + this._workspaceConfigPath = undefined this.coverageProvider = undefined this.runningPromise = undefined this._cachedSpecs.clear() @@ -145,22 +150,22 @@ export class Vitest { const serverRestart = server.restart server.restart = async (...args) => { await Promise.all(this._onRestartListeners.map(fn => fn())) + this.report('onServerRestart') + await this.close() await serverRestart(...args) - // watcher is recreated on restart - this.unregisterWatcher() - this.registerWatcher() } // since we set `server.hmr: false`, Vite does not auto restart itself server.watcher.on('change', async (file) => { file = normalize(file) const isConfig = file === server.config.configFile + || this.resolvedProjects.some(p => p.server.config.configFile === file) + || file === this._workspaceConfigPath if (isConfig) { await Promise.all(this._onRestartListeners.map(fn => fn('config'))) + this.report('onServerRestart', 'config') + await this.close() await serverRestart() - // watcher is recreated on restart - this.unregisterWatcher() - this.registerWatcher() } }) } @@ -175,8 +180,6 @@ export class Vitest { } catch { } - await Promise.all(this._onSetServer.map(fn => fn())) - const projects = await this.resolveWorkspace(cliOptions) this.resolvedProjects = projects this.projects = projects @@ -193,6 +196,8 @@ export class Vitest { if (this.config.testNamePattern) { this.configOverride.testNamePattern = this.config.testNamePattern } + + await Promise.all(this._onSetServer.map(fn => fn())) } public provide(key: T, value: ProvidedContext[T]) { @@ -235,7 +240,7 @@ export class Vitest { || this.projects[0] } - private async getWorkspaceConfigPath(): Promise { + private async getWorkspaceConfigPath(): Promise { if (this.config.workspace) { return this.config.workspace } @@ -251,7 +256,7 @@ export class Vitest { }) if (!workspaceConfigName) { - return null + return undefined } return join(configDir, workspaceConfigName) @@ -260,6 +265,8 @@ export class Vitest { private async resolveWorkspace(cliOptions: UserConfig) { const workspaceConfigPath = await this.getWorkspaceConfigPath() + this._workspaceConfigPath = workspaceConfigPath + if (!workspaceConfigPath) { return [await this._createCoreProject()] } diff --git a/packages/vitest/src/node/workspace.ts b/packages/vitest/src/node/workspace.ts index 38135d1ff6c9..d60ba846853c 100644 --- a/packages/vitest/src/node/workspace.ts +++ b/packages/vitest/src/node/workspace.ts @@ -432,6 +432,7 @@ export class WorkspaceProject { ) } + this.closingPromise = undefined this.testProject = new TestProject(this) this.server = server @@ -476,7 +477,7 @@ export class WorkspaceProject { if (!this.closingPromise) { this.closingPromise = Promise.all( [ - this.server.close(), + this.server?.close(), this.typechecker?.stop(), this.browser?.close(), this.clearTmpDir(), diff --git a/test/test-utils/index.ts b/test/test-utils/index.ts index 15b8c761a935..62623d7e68fd 100644 --- a/test/test-utils/index.ts +++ b/test/test-utils/index.ts @@ -1,5 +1,6 @@ import type { Options } from 'tinyexec' import type { UserConfig as ViteUserConfig } from 'vite' +import type { WorkspaceProjectConfiguration } from 'vitest/config' import type { UserConfig, Vitest, VitestRunMode } from 'vitest/node' import fs from 'node:fs' import { Readable, Writable } from 'node:stream' @@ -234,3 +235,61 @@ export function resolvePath(baseUrl: string, path: string) { const filename = fileURLToPath(baseUrl) return resolve(dirname(filename), path) } + +export function useFS(root: string, structure: Record) { + const files = new Set() + const hasConfig = Object.keys(structure).some(file => file.includes('.config.')) + if (!hasConfig) { + structure['./vitest.config.js'] = {} + } + for (const file in structure) { + const filepath = resolve(root, file) + files.add(filepath) + const content = typeof structure[file] === 'string' + ? structure[file] + : `export default ${JSON.stringify(structure[file])}` + fs.mkdirSync(dirname(filepath), { recursive: true }) + fs.writeFileSync(filepath, String(content), 'utf-8') + } + onTestFinished(() => { + if (process.env.VITEST_FS_CLEANUP !== 'false') { + fs.rmSync(root, { recursive: true, force: true }) + } + }) + return { + editFile: (file: string, callback: (content: string) => string) => { + const filepath = resolve(root, file) + if (!files.has(filepath)) { + throw new Error(`file ${file} is outside of the test file system`) + } + const content = fs.readFileSync(filepath, 'utf-8') + fs.writeFileSync(filepath, callback(content)) + }, + createFile: (file: string, content: string) => { + if (file.startsWith('..')) { + throw new Error(`file ${file} is outside of the test file system`) + } + const filepath = resolve(root, file) + if (!files.has(filepath)) { + throw new Error(`file ${file} already exists in the test file system`) + } + createFile(filepath, content) + }, + } +} + +export async function runInlineTests( + structure: Record, + config?: UserConfig, +) { + const root = resolve(process.cwd(), `vitest-test-${crypto.randomUUID()}`) + const fs = useFS(root, structure) + const vitest = await runVitest({ + root, + ...config, + }) + return { + fs, + ...vitest, + } +} diff --git a/test/watch/test/config-watching.test.ts b/test/watch/test/config-watching.test.ts new file mode 100644 index 000000000000..5f9b1040f115 --- /dev/null +++ b/test/watch/test/config-watching.test.ts @@ -0,0 +1,93 @@ +import { expect, test } from 'vitest' +import { runInlineTests } from '../../test-utils' + +const ts = String.raw + +test('reruns tests when configs change', async () => { + const { fs, vitest } = await runInlineTests({ + 'vitest.workspace.ts': [ + './project-1', + './project-2', + ], + 'vitest.config.ts': {}, + 'project-1/vitest.config.ts': {}, + 'project-1/basic.test.ts': ts` + import { test } from 'vitest' + test('basic test 1', () => {}) + `, + 'project-2/vitest.config.ts': {}, + 'project-2/basic.test.ts': ts` + import { test } from 'vitest' + test('basic test 2', () => {}) + `, + }, { watch: true }) + + await vitest.waitForStdout('Waiting for file changes') + vitest.resetOutput() + + // editing the project config should trigger a restart + fs.editFile('./project-1/vitest.config.ts', c => `\n${c}`) + + await vitest.waitForStdout('Restarting due to config changes...') + await vitest.waitForStdout('Waiting for file changes') + vitest.resetOutput() + + // editing the root config should trigger a restart + fs.editFile('./vitest.config.ts', c => `\n${c}`) + + await vitest.waitForStdout('Restarting due to config changes...') + await vitest.waitForStdout('Waiting for file changes') + vitest.resetOutput() + + // editing the workspace config should trigger a restart + fs.editFile('./vitest.workspace.ts', c => `\n${c}`) + + await vitest.waitForStdout('Restarting due to config changes...') + await vitest.waitForStdout('Waiting for file changes') +}) + +test('rerun stops the previous browser server and restarts multiple times without port mismatch', async () => { + const { fs, vitest } = await runInlineTests({ + 'vitest.workspace.ts': [ + './project-1', + ], + 'vitest.config.ts': {}, + 'project-1/vitest.config.ts': { + test: { + browser: { + enabled: true, + name: 'chromium', + provider: 'playwright', + headless: true, + }, + }, + }, + 'project-1/basic.test.ts': ts` + import { test } from 'vitest' + test('basic test 1', () => {}) + `, + }, { watch: true }) + + await vitest.waitForStdout('Waiting for file changes') + vitest.resetOutput() + + // editing the project config the first time restarts the browser server + fs.editFile('./project-1/vitest.config.ts', c => `\n${c}`) + + await vitest.waitForStdout('Restarting due to config changes...') + await vitest.waitForStdout('Waiting for file changes') + + expect(vitest.stdout).not.toContain('is in use, trying another one...') + expect(vitest.stderr).not.toContain('is in use, trying another one...') + vitest.resetOutput() + + // editing the project the second time also restarts the server + fs.editFile('./project-1/vitest.config.ts', c => `\n${c}`) + + await vitest.waitForStdout('Restarting due to config changes...') + await vitest.waitForStdout('Waiting for file changes') + + expect(vitest.stdout).not.toContain('is in use, trying another one...') + expect(vitest.stderr).not.toContain('is in use, trying another one...') + vitest.resetOutput() +}) diff --git a/test/watch/vitest.config.ts b/test/watch/vitest.config.ts index 3eda83706106..72c91906bebc 100644 --- a/test/watch/vitest.config.ts +++ b/test/watch/vitest.config.ts @@ -1,6 +1,11 @@ import { defineConfig } from 'vitest/config' export default defineConfig({ + server: { + watch: { + ignored: ['**/fixtures/**'], + }, + }, test: { reporters: 'verbose', include: ['test/**/*.test.*'], diff --git a/test/workspaces-browser/space_browser/vitest.config.ts b/test/workspaces-browser/space_browser/vitest.config.ts index c3ac0296bb67..9ef18935bfc6 100644 --- a/test/workspaces-browser/space_browser/vitest.config.ts +++ b/test/workspaces-browser/space_browser/vitest.config.ts @@ -4,9 +4,9 @@ export default defineProject({ test: { browser: { enabled: true, - name: process.env.BROWSER || 'chrome', + name: process.env.BROWSER || 'chromium', headless: true, - provider: process.env.PROVIDER || 'webdriverio', + provider: process.env.PROVIDER || 'playwright', }, }, }) diff --git a/test/workspaces-browser/vitest.workspace.ts b/test/workspaces-browser/vitest.workspace.ts index 1bbb3f765f51..f42cf2b1256b 100644 --- a/test/workspaces-browser/vitest.workspace.ts +++ b/test/workspaces-browser/vitest.workspace.ts @@ -8,9 +8,9 @@ export default defineWorkspace([ root: './space_browser_inline', browser: { enabled: true, - name: process.env.BROWSER || 'chrome', + name: process.env.BROWSER || 'chromium', headless: true, - provider: process.env.PROVIDER || 'webdriverio', + provider: process.env.PROVIDER || 'playwright', }, alias: { 'test-alias-from-vitest': new URL('./space_browser_inline/test-alias-to.ts', import.meta.url).pathname, From 5969d8da703ebd6a9b6411c013fde1683d9d50b4 Mon Sep 17 00:00:00 2001 From: Hiroshi Ogawa Date: Fri, 8 Nov 2024 18:05:35 +0900 Subject: [PATCH 03/28] fix(browser): support non US key input (#6873) --- .../browser/src/node/commands/keyboard.ts | 23 +++++++++--- .../fixtures/user-event/keyboard.test.ts | 36 +++++++++++++++++++ test/browser/specs/runner.test.ts | 1 + 3 files changed, 56 insertions(+), 4 deletions(-) create mode 100644 test/browser/fixtures/user-event/keyboard.test.ts diff --git a/packages/browser/src/node/commands/keyboard.ts b/packages/browser/src/node/commands/keyboard.ts index ebe2e425519b..c0be014d0804 100644 --- a/packages/browser/src/node/commands/keyboard.ts +++ b/packages/browser/src/node/commands/keyboard.ts @@ -72,6 +72,10 @@ export const keyboardCleanup: UserEventCommand<(state: KeyboardState) => Promise } } +// fallback to insertText for non US key +// https://github.com/microsoft/playwright/blob/50775698ae13642742f2a1e8983d1d686d7f192d/packages/playwright-core/src/server/input.ts#L95 +const VALID_KEYS = new Set(['Escape', 'F1', 'F2', 'F3', 'F4', 'F5', 'F6', 'F7', 'F8', 'F9', 'F10', 'F11', 'F12', 'Backquote', '`', '~', 'Digit1', '1', '!', 'Digit2', '2', '@', 'Digit3', '3', '#', 'Digit4', '4', '$', 'Digit5', '5', '%', 'Digit6', '6', '^', 'Digit7', '7', '&', 'Digit8', '8', '*', 'Digit9', '9', '(', 'Digit0', '0', ')', 'Minus', '-', '_', 'Equal', '=', '+', 'Backslash', '\\', '|', 'Backspace', 'Tab', 'KeyQ', 'q', 'Q', 'KeyW', 'w', 'W', 'KeyE', 'e', 'E', 'KeyR', 'r', 'R', 'KeyT', 't', 'T', 'KeyY', 'y', 'Y', 'KeyU', 'u', 'U', 'KeyI', 'i', 'I', 'KeyO', 'o', 'O', 'KeyP', 'p', 'P', 'BracketLeft', '[', '{', 'BracketRight', ']', '}', 'CapsLock', 'KeyA', 'a', 'A', 'KeyS', 's', 'S', 'KeyD', 'd', 'D', 'KeyF', 'f', 'F', 'KeyG', 'g', 'G', 'KeyH', 'h', 'H', 'KeyJ', 'j', 'J', 'KeyK', 'k', 'K', 'KeyL', 'l', 'L', 'Semicolon', ';', ':', 'Quote', '\'', '"', 'Enter', '\n', '\r', 'ShiftLeft', 'Shift', 'KeyZ', 'z', 'Z', 'KeyX', 'x', 'X', 'KeyC', 'c', 'C', 'KeyV', 'v', 'V', 'KeyB', 'b', 'B', 'KeyN', 'n', 'N', 'KeyM', 'm', 'M', 'Comma', ',', '<', 'Period', '.', '>', 'Slash', '/', '?', 'ShiftRight', 'ControlLeft', 'Control', 'MetaLeft', 'Meta', 'AltLeft', 'Alt', 'Space', ' ', 'AltRight', 'AltGraph', 'MetaRight', 'ContextMenu', 'ControlRight', 'PrintScreen', 'ScrollLock', 'Pause', 'PageUp', 'PageDown', 'Insert', 'Delete', 'Home', 'End', 'ArrowLeft', 'ArrowUp', 'ArrowRight', 'ArrowDown', 'NumLock', 'NumpadDivide', 'NumpadMultiply', 'NumpadSubtract', 'Numpad7', 'Numpad8', 'Numpad9', 'Numpad4', 'Numpad5', 'Numpad6', 'NumpadAdd', 'Numpad1', 'Numpad2', 'Numpad3', 'Numpad0', 'NumpadDecimal', 'NumpadEnter']) + export async function keyboardImplementation( pressed: Set, provider: BrowserProvider, @@ -91,7 +95,9 @@ export async function keyboardImplementation( // together, and call `type` once for all non special keys, // and then `press` for special keys if (pressed.has(key)) { - await page.keyboard.up(key) + if (VALID_KEYS.has(key)) { + await page.keyboard.up(key) + } pressed.delete(key) } @@ -102,11 +108,18 @@ export async function keyboardImplementation( } for (let i = 1; i <= repeat; i++) { - await page.keyboard.down(key) + if (VALID_KEYS.has(key)) { + await page.keyboard.down(key) + } + else { + await page.keyboard.insertText(key) + } } if (releaseSelf) { - await page.keyboard.up(key) + if (VALID_KEYS.has(key)) { + await page.keyboard.up(key) + } } else { pressed.add(key) @@ -116,7 +129,9 @@ export async function keyboardImplementation( if (!skipRelease && pressed.size) { for (const key of pressed) { - await page.keyboard.up(key) + if (VALID_KEYS.has(key)) { + await page.keyboard.up(key) + } } } } diff --git a/test/browser/fixtures/user-event/keyboard.test.ts b/test/browser/fixtures/user-event/keyboard.test.ts new file mode 100644 index 000000000000..372eecd62a0a --- /dev/null +++ b/test/browser/fixtures/user-event/keyboard.test.ts @@ -0,0 +1,36 @@ +import { expect, test } from 'vitest' +import { userEvent, page, server } from '@vitest/browser/context' + +test('non US keys', async () => { + document.body.innerHTML = ` + + + + + `; + + await userEvent.type(page.getByPlaceholder("type-#7396"), 'éèù') + await expect.element(page.getByPlaceholder("type-#7396")).toHaveValue('éèù') + await userEvent.fill(page.getByPlaceholder("fill-#7396"), 'éèù') + await expect.element(page.getByPlaceholder("fill-#7396")).toHaveValue('éèù') + + // playwright: garbled characters + // webdriverio: error: invalid argument: missing command parameters + // preview: ok + try { + await userEvent.type(page.getByPlaceholder("type-emoji"), '😊😍') + await expect.element(page.getByPlaceholder("type-emoji")).toHaveValue('😊😍') + } catch (e) { + console.error(e) + } + + // playwright: ok + // webdriverio: error: ChromeDriver only supports characters in the BMP + // preview: ok + try { + await userEvent.fill(page.getByPlaceholder("fill-emoji"), '😊😍') + await expect.element(page.getByPlaceholder("fill-emoji")).toHaveValue('😊😍') + } catch (e) { + console.error(e) + } +}) diff --git a/test/browser/specs/runner.test.ts b/test/browser/specs/runner.test.ts index 0347d9be2d42..15820327d6b2 100644 --- a/test/browser/specs/runner.test.ts +++ b/test/browser/specs/runner.test.ts @@ -147,6 +147,7 @@ test('user-event', async () => { "cleanup-retry.test.ts": "pass", "cleanup1.test.ts": "pass", "cleanup2.test.ts": "pass", + "keyboard.test.ts": "pass", } `) }) From 9b3c3de27b079ba16b2e9b306c89af016dbbe2a5 Mon Sep 17 00:00:00 2001 From: Kaio Duarte Date: Sat, 9 Nov 2024 05:54:58 +0000 Subject: [PATCH 04/28] chore: add JSDoc to Jest assertions and asymmetric matchers (#6822) Co-authored-by: Hiroshi Ogawa --- packages/expect/src/types.ts | 499 +++++++++++++++++++++++++++- packages/vitest/src/types/global.ts | 32 ++ 2 files changed, 529 insertions(+), 2 deletions(-) diff --git a/packages/expect/src/types.ts b/packages/expect/src/types.ts index 7a64137e60b5..c1f033437980 100644 --- a/packages/expect/src/types.ts +++ b/packages/expect/src/types.ts @@ -106,60 +106,484 @@ export interface ExpectStatic } export interface AsymmetricMatchersContaining { + /** + * Matches if the received string contains the expected substring. + * + * @example + * expect('I have an apple').toEqual(expect.stringContaining('apple')); + * expect({ a: 'test string' }).toEqual({ a: expect.stringContaining('test') }); + */ stringContaining: (expected: string) => any + + /** + * Matches if the received object contains all properties of the expected object. + * + * @example + * expect({ a: '1', b: 2 }).toEqual(expect.objectContaining({ a: '1' })) + */ objectContaining: (expected: T) => any + + /** + * Matches if the received array contains all elements in the expected array. + * + * @example + * expect(['a', 'b', 'c']).toEqual(expect.arrayContaining(['b', 'a'])); + */ arrayContaining: (expected: Array) => any + + /** + * Matches if the received string or regex matches the expected pattern. + * + * @example + * expect('hello world').toEqual(expect.stringMatching(/^hello/)); + * expect('hello world').toEqual(expect.stringMatching('hello')); + */ stringMatching: (expected: string | RegExp) => any + + /** + * Matches if the received number is within a certain precision of the expected number. + * + * @param precision - Optional decimal precision for comparison. Default is 2. + * + * @example + * expect(10.45).toEqual(expect.closeTo(10.5, 1)); + * expect(5.11).toEqual(expect.closeTo(5.12)); // with default precision + */ closeTo: (expected: number, precision?: number) => any } export interface JestAssertion extends jest.Matchers { - // Jest compact + /** + * Used when you want to check that two objects have the same value. + * This matcher recursively checks the equality of all fields, rather than checking for object identity. + * + * @example + * expect(user).toEqual({ name: 'Alice', age: 30 }); + */ toEqual: (expected: E) => void + + /** + * Use to test that objects have the same types as well as structure. + * + * @example + * expect(user).toStrictEqual({ name: 'Alice', age: 30 }); + */ toStrictEqual: (expected: E) => void + + /** + * Checks that a value is what you expect. It calls `Object.is` to compare values. + * Don't use `toBe` with floating-point numbers. + * + * @example + * expect(result).toBe(42); + * expect(status).toBe(true); + */ toBe: (expected: E) => void + + /** + * Check that a string matches a regular expression. + * + * @example + * expect(message).toMatch(/hello/); + * expect(greeting).toMatch('world'); + */ toMatch: (expected: string | RegExp) => void + + /** + * Used to check that a JavaScript object matches a subset of the properties of an object + * + * @example + * expect(user).toMatchObject({ + * name: 'Alice', + * address: { city: 'Wonderland' } + * }); + */ toMatchObject: (expected: E) => void + + /** + * Used when you want to check that an item is in a list. + * For testing the items in the list, this uses `===`, a strict equality check. + * + * @example + * expect(items).toContain('apple'); + * expect(numbers).toContain(5); + */ toContain: (item: E) => void + + /** + * Used when you want to check that an item is in a list. + * For testing the items in the list, this matcher recursively checks the + * equality of all fields, rather than checking for object identity. + * + * @example + * expect(items).toContainEqual({ name: 'apple', quantity: 1 }); + */ toContainEqual: (item: E) => void + + /** + * Use when you don't care what a value is, you just want to ensure a value + * is true in a boolean context. In JavaScript, there are six falsy values: + * `false`, `0`, `''`, `null`, `undefined`, and `NaN`. Everything else is truthy. + * + * @example + * expect(user.isActive).toBeTruthy(); + */ toBeTruthy: () => void + + /** + * When you don't care what a value is, you just want to + * ensure a value is false in a boolean context. + * + * @example + * expect(user.isActive).toBeFalsy(); + */ toBeFalsy: () => void + + /** + * For comparing floating point numbers. + * + * @example + * expect(score).toBeGreaterThan(10); + */ toBeGreaterThan: (num: number | bigint) => void + + /** + * For comparing floating point numbers. + * + * @example + * expect(score).toBeGreaterThanOrEqual(10); + */ toBeGreaterThanOrEqual: (num: number | bigint) => void + + /** + * For comparing floating point numbers. + * + * @example + * expect(score).toBeLessThan(10); + */ toBeLessThan: (num: number | bigint) => void + + /** + * For comparing floating point numbers. + * + * @example + * expect(score).toBeLessThanOrEqual(10); + */ toBeLessThanOrEqual: (num: number | bigint) => void + + /** + * Used to check that a variable is NaN. + * + * @example + * expect(value).toBeNaN(); + */ toBeNaN: () => void + + /** + * Used to check that a variable is undefined. + * + * @example + * expect(value).toBeUndefined(); + */ toBeUndefined: () => void + + /** + * This is the same as `.toBe(null)` but the error messages are a bit nicer. + * So use `.toBeNull()` when you want to check that something is null. + * + * @example + * expect(value).toBeNull(); + */ toBeNull: () => void + + /** + * Ensure that a variable is not undefined. + * + * @example + * expect(value).toBeDefined(); + */ toBeDefined: () => void + + /** + * Ensure that an object is an instance of a class. + * This matcher uses `instanceof` underneath. + * + * @example + * expect(new Date()).toBeInstanceOf(Date); + */ toBeInstanceOf: (expected: E) => void - toBeCalledTimes: (times: number) => void + + /** + * Used to check that an object has a `.length` property + * and it is set to a certain numeric value. + * + * @example + * expect([1, 2, 3]).toHaveLength(3); + * expect('hello').toHaveLength(5); + */ toHaveLength: (length: number) => void + + /** + * Use to check if a property at the specified path exists on an object. + * For checking deeply nested properties, you may use dot notation or an array containing + * the path segments for deep references. + * + * Optionally, you can provide a value to check if it matches the value present at the path + * on the target object. This matcher uses 'deep equality' (like `toEqual()`) and recursively checks + * the equality of all fields. + * + * @example + * expect(user).toHaveProperty('address.city', 'New York'); + * expect(config).toHaveProperty(['settings', 'theme'], 'dark'); + */ toHaveProperty: ( property: string | (string | number)[], value?: E ) => void + + /** + * Using exact equality with floating point numbers is a bad idea. + * Rounding means that intuitive things fail. + * The default for `precision` is 2. + * + * @example + * expect(price).toBeCloseTo(9.99, 2); + */ toBeCloseTo: (number: number, numDigits?: number) => void + + /** + * Ensures that a mock function is called an exact number of times. + * + * Also under the alias `expect.toBeCalledTimes`. + * + * @example + * expect(mockFunc).toHaveBeenCalledTimes(2); + */ toHaveBeenCalledTimes: (times: number) => void + + /** + * Ensures that a mock function is called an exact number of times. + * + * Alias for `expect.toHaveBeenCalledTimes`. + * + * @example + * expect(mockFunc).toBeCalledTimes(2); + */ + toBeCalledTimes: (times: number) => void + + /** + * Ensures that a mock function is called. + * + * Also under the alias `expect.toBeCalled`. + * + * @example + * expect(mockFunc).toHaveBeenCalled(); + */ + toHaveBeenCalled: () => void + + /** + * Ensures that a mock function is called. + * + * Alias for `expect.toHaveBeenCalled`. + * + * @example + * expect(mockFunc).toBeCalled(); + */ toBeCalled: () => void + + /** + * Ensure that a mock function is called with specific arguments. + * + * Also under the alias `expect.toBeCalledWith`. + * + * @example + * expect(mockFunc).toHaveBeenCalledWith('arg1', 42); + */ toHaveBeenCalledWith: (...args: E) => void + + /** + * Ensure that a mock function is called with specific arguments. + * + * Alias for `expect.toHaveBeenCalledWith`. + * + * @example + * expect(mockFunc).toBeCalledWith('arg1', 42); + */ toBeCalledWith: (...args: E) => void + + /** + * Ensure that a mock function is called with specific arguments on an Nth call. + * + * Also under the alias `expect.nthCalledWith`. + * + * @example + * expect(mockFunc).toHaveBeenNthCalledWith(2, 'secondArg'); + */ toHaveBeenNthCalledWith: (n: number, ...args: E) => void + + /** + * Ensure that a mock function is called with specific arguments on an Nth call. + * + * Alias for `expect.toHaveBeenNthCalledWith`. + * + * @example + * expect(mockFunc).nthCalledWith(2, 'secondArg'); + */ nthCalledWith: (nthCall: number, ...args: E) => void + + /** + * If you have a mock function, you can use `.toHaveBeenLastCalledWith` + * to test what arguments it was last called with. + * + * Also under the alias `expect.lastCalledWith`. + * + * @example + * expect(mockFunc).toHaveBeenLastCalledWith('lastArg'); + */ toHaveBeenLastCalledWith: (...args: E) => void + + /** + * If you have a mock function, you can use `.lastCalledWith` + * to test what arguments it was last called with. + * + * Alias for `expect.toHaveBeenLastCalledWith`. + * + * @example + * expect(mockFunc).lastCalledWith('lastArg'); + */ lastCalledWith: (...args: E) => void + + /** + * Used to test that a function throws when it is called. + * + * Also under the alias `expect.toThrowError`. + * + * @example + * expect(() => functionWithError()).toThrow('Error message'); + * expect(() => parseJSON('invalid')).toThrow(SyntaxError); + */ toThrow: (expected?: string | Constructable | RegExp | Error) => void + + /** + * Used to test that a function throws when it is called. + * + * Alias for `expect.toThrow`. + * + * @example + * expect(() => functionWithError()).toThrowError('Error message'); + * expect(() => parseJSON('invalid')).toThrowError(SyntaxError); + */ toThrowError: (expected?: string | Constructable | RegExp | Error) => void + + /** + * Use to test that the mock function successfully returned (i.e., did not throw an error) at least one time + * + * Alias for `expect.toHaveReturned`. + * + * @example + * expect(mockFunc).toReturn(); + */ toReturn: () => void + + /** + * Use to test that the mock function successfully returned (i.e., did not throw an error) at least one time + * + * Also under the alias `expect.toReturn`. + * + * @example + * expect(mockFunc).toHaveReturned(); + */ toHaveReturned: () => void + + /** + * Use to ensure that a mock function returned successfully (i.e., did not throw an error) an exact number of times. + * Any calls to the mock function that throw an error are not counted toward the number of times the function returned. + * + * Alias for `expect.toHaveReturnedTimes`. + * + * @example + * expect(mockFunc).toReturnTimes(3); + */ toReturnTimes: (times: number) => void + + /** + * Use to ensure that a mock function returned successfully (i.e., did not throw an error) an exact number of times. + * Any calls to the mock function that throw an error are not counted toward the number of times the function returned. + * + * Also under the alias `expect.toReturnTimes`. + * + * @example + * expect(mockFunc).toHaveReturnedTimes(3); + */ toHaveReturnedTimes: (times: number) => void + + /** + * Use to ensure that a mock function returned a specific value. + * + * Alias for `expect.toHaveReturnedWith`. + * + * @example + * expect(mockFunc).toReturnWith('returnValue'); + */ toReturnWith: (value: E) => void + + /** + * Use to ensure that a mock function returned a specific value. + * + * Also under the alias `expect.toReturnWith`. + * + * @example + * expect(mockFunc).toHaveReturnedWith('returnValue'); + */ toHaveReturnedWith: (value: E) => void + + /** + * Use to test the specific value that a mock function last returned. + * If the last call to the mock function threw an error, then this matcher will fail + * no matter what value you provided as the expected return value. + * + * Also under the alias `expect.lastReturnedWith`. + * + * @example + * expect(mockFunc).toHaveLastReturnedWith('lastValue'); + */ toHaveLastReturnedWith: (value: E) => void + + /** + * Use to test the specific value that a mock function last returned. + * If the last call to the mock function threw an error, then this matcher will fail + * no matter what value you provided as the expected return value. + * + * Alias for `expect.toHaveLastReturnedWith`. + * + * @example + * expect(mockFunc).lastReturnedWith('lastValue'); + */ lastReturnedWith: (value: E) => void + + /** + * Use to test the specific value that a mock function returned for the nth call. + * If the nth call to the mock function threw an error, then this matcher will fail + * no matter what value you provided as the expected return value. + * + * Also under the alias `expect.nthReturnedWith`. + * + * @example + * expect(mockFunc).toHaveNthReturnedWith(2, 'nthValue'); + */ toHaveNthReturnedWith: (nthCall: number, value: E) => void + + /** + * Use to test the specific value that a mock function returned for the nth call. + * If the nth call to the mock function threw an error, then this matcher will fail + * no matter what value you provided as the expected return value. + * + * Alias for `expect.toHaveNthReturnedWith`. + * + * @example + * expect(mockFunc).nthReturnedWith(2, 'nthValue'); + */ nthReturnedWith: (nthCall: number, value: E) => void } @@ -184,6 +608,13 @@ export type PromisifyAssertion = Promisify> export interface Assertion extends VitestAssertion, JestAssertion { + /** + * Ensures a value is of a specific type. + * + * @example + * expect(value).toBeTypeOf('string'); + * expect(number).toBeTypeOf('number'); + */ toBeTypeOf: ( expected: | 'bigint' @@ -195,16 +626,80 @@ export interface Assertion | 'symbol' | 'undefined' ) => void + + /** + * Asserts that a mock function was called exactly once. + * + * @example + * expect(mockFunc).toHaveBeenCalledOnce(); + */ toHaveBeenCalledOnce: () => void + + /** + * Checks that a value satisfies a custom matcher function. + * + * @param matcher - A function returning a boolean based on the custom condition + * @param message - Optional custom error message on failure + * + * @example + * expect(age).toSatisfy(val => val >= 18, 'Age must be at least 18'); + */ toSatisfy: (matcher: (value: E) => boolean, message?: string) => void + /** + * Checks that a promise resolves successfully at least once. + * + * @example + * await expect(promise).toHaveResolved(); + */ toHaveResolved: () => void + + /** + * Checks that a promise resolves to a specific value. + * + * @example + * await expect(promise).toHaveResolvedWith('success'); + */ toHaveResolvedWith: (value: E) => void + + /** + * Ensures a promise resolves a specific number of times. + * + * @example + * expect(mockAsyncFunc).toHaveResolvedTimes(3); + */ toHaveResolvedTimes: (times: number) => void + + /** + * Asserts that the last resolved value of a promise matches an expected value. + * + * @example + * await expect(mockAsyncFunc).toHaveLastResolvedWith('finalResult'); + */ toHaveLastResolvedWith: (value: E) => void + + /** + * Ensures a specific value was returned by a promise on the nth resolution. + * + * @example + * await expect(mockAsyncFunc).toHaveNthResolvedWith(2, 'secondResult'); + */ toHaveNthResolvedWith: (nthCall: number, value: E) => void + /** + * Verifies that a promise resolves. + * + * @example + * await expect(someAsyncFunc).resolves.toBe(42); + */ resolves: PromisifyAssertion + + /** + * Verifies that a promise rejects. + * + * @example + * await expect(someAsyncFunc).rejects.toThrow('error'); + */ rejects: PromisifyAssertion } diff --git a/packages/vitest/src/types/global.ts b/packages/vitest/src/types/global.ts index ee157a28d9ad..c9ba65420ac3 100644 --- a/packages/vitest/src/types/global.ts +++ b/packages/vitest/src/types/global.ts @@ -64,11 +64,43 @@ declare module '@vitest/expect' { matchSnapshot: SnapshotMatcher toMatchSnapshot: SnapshotMatcher toMatchInlineSnapshot: InlineSnapshotMatcher + + /** + * Checks that an error thrown by a function matches a previously recorded snapshot. + * + * @param message - Optional custom error message. + * + * @example + * expect(functionWithError).toThrowErrorMatchingSnapshot(); + */ toThrowErrorMatchingSnapshot: (message?: string) => void + + /** + * Checks that an error thrown by a function matches an inline snapshot within the test file. + * Useful for keeping snapshots close to the test code. + * + * @param snapshot - Optional inline snapshot string to match. + * @param message - Optional custom error message. + * + * @example + * const throwError = () => { throw new Error('Error occurred') }; + * expect(throwError).toThrowErrorMatchingInlineSnapshot(`"Error occurred"`); + */ toThrowErrorMatchingInlineSnapshot: ( snapshot?: string, message?: string ) => void + + /** + * Compares the received value to a snapshot saved in a specified file. + * Useful for cases where snapshot content is large or needs to be shared across tests. + * + * @param filepath - Path to the snapshot file. + * @param message - Optional custom error message. + * + * @example + * await expect(largeData).toMatchFileSnapshot('path/to/snapshot.json'); + */ toMatchFileSnapshot: (filepath: string, message?: string) => Promise } } From 00ebea6414b8a92f00a1f0ce00eeda4abcfeea3e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ari=20Perkki=C3=B6?= Date: Mon, 11 Nov 2024 09:56:38 +0200 Subject: [PATCH 05/28] refactor(reporters): base reporter readability improvements (#6889) --- packages/vitest/src/node/logger.ts | 77 +-- packages/vitest/src/node/reporters/base.ts | 487 ++++++++---------- packages/vitest/src/node/reporters/default.ts | 2 +- .../src/node/reporters/renderers/utils.ts | 6 + test/config/test/console-color.test.ts | 6 +- test/reporters/tests/merge-reports.test.ts | 4 +- 6 files changed, 246 insertions(+), 336 deletions(-) diff --git a/packages/vitest/src/node/logger.ts b/packages/vitest/src/node/logger.ts index 4d882b0c4b52..7ac51abb63bb 100644 --- a/packages/vitest/src/node/logger.ts +++ b/packages/vitest/src/node/logger.ts @@ -12,7 +12,7 @@ import { createLogUpdate } from 'log-update' import c from 'tinyrainbow' import { highlightCode } from '../utils/colors' import { printError } from './error' -import { divider } from './reporters/renderers/utils' +import { divider, withLabel } from './reporters/renderers/utils' import { RandomSequencer } from './sequencers/RandomSequencer' export interface ErrorOptions { @@ -25,6 +25,8 @@ export interface ErrorOptions { showCodeFrame?: boolean } +const PAD = ' ' + const ESC = '\x1B[' const ERASE_DOWN = `${ESC}J` const ERASE_SCROLLBACK = `${ESC}3J` @@ -64,13 +66,18 @@ export class Logger { this.console.warn(...args) } - clearFullScreen(message: string) { + clearFullScreen(message = '') { if (!this.ctx.config.clearScreen) { this.console.log(message) return } - this.console.log(`${CLEAR_SCREEN}${ERASE_SCROLLBACK}${message}`) + if (message) { + this.console.log(`${CLEAR_SCREEN}${ERASE_SCROLLBACK}${message}`) + } + else { + (this.outputStream as Writable).write(`${CLEAR_SCREEN}${ERASE_SCROLLBACK}`) + } } clearScreen(message: string, force = false) { @@ -201,23 +208,13 @@ export class Logger { printBanner() { this.log() - const versionTest = this.ctx.config.watch - ? c.blue(`v${this.ctx.version}`) - : c.cyan(`v${this.ctx.version}`) - const mode = this.ctx.config.watch ? c.blue(' DEV ') : c.cyan(' RUN ') + const color = this.ctx.config.watch ? 'blue' : 'cyan' + const mode = this.ctx.config.watch ? 'DEV' : 'RUN' - this.log( - `${c.inverse(c.bold(mode))} ${versionTest} ${c.gray( - this.ctx.config.root, - )}`, - ) + this.log(withLabel(color, mode, `v${this.ctx.version} `) + c.gray(this.ctx.config.root)) if (this.ctx.config.sequence.sequencer === RandomSequencer) { - this.log( - c.gray( - ` Running tests with seed "${this.ctx.config.sequence.seed}"`, - ), - ) + this.log(PAD + c.gray(`Running tests with seed "${this.ctx.config.sequence.seed}"`)) } this.ctx.projects.forEach((project) => { @@ -231,52 +228,32 @@ export class Logger { const origin = resolvedUrls?.local[0] ?? resolvedUrls?.network[0] const provider = project.browser.provider.name const providerString = provider === 'preview' ? '' : ` by ${provider}` - this.log( - c.dim( - c.green( - ` ${output} Browser runner started${providerString} at ${new URL('/', origin)}`, - ), - ), - ) + + this.log(PAD + c.dim(c.green(`${output} Browser runner started${providerString} at ${new URL('/', origin)}`))) }) if (this.ctx.config.ui) { - this.log( - c.dim( - c.green( - ` UI started at http://${ - this.ctx.config.api?.host || 'localhost' - }:${c.bold(`${this.ctx.server.config.server.port}`)}${ - this.ctx.config.uiBase - }`, - ), - ), - ) + const host = this.ctx.config.api?.host || 'localhost' + const port = this.ctx.server.config.server.port + const base = this.ctx.config.uiBase + + this.log(PAD + c.dim(c.green(`UI started at http://${host}:${c.bold(port)}${base}`))) } else if (this.ctx.config.api?.port) { const resolvedUrls = this.ctx.server.resolvedUrls // workaround for https://github.com/vitejs/vite/issues/15438, it was fixed in vite 5.1 - const fallbackUrl = `http://${this.ctx.config.api.host || 'localhost'}:${ - this.ctx.config.api.port - }` - const origin - = resolvedUrls?.local[0] ?? resolvedUrls?.network[0] ?? fallbackUrl - this.log(c.dim(c.green(` API started at ${new URL('/', origin)}`))) + const fallbackUrl = `http://${this.ctx.config.api.host || 'localhost'}:${this.ctx.config.api.port}` + const origin = resolvedUrls?.local[0] ?? resolvedUrls?.network[0] ?? fallbackUrl + + this.log(PAD + c.dim(c.green(`API started at ${new URL('/', origin)}`))) } if (this.ctx.coverageProvider) { - this.log( - c.dim(' Coverage enabled with ') - + c.yellow(this.ctx.coverageProvider.name), - ) + this.log(PAD + c.dim('Coverage enabled with ') + c.yellow(this.ctx.coverageProvider.name)) } if (this.ctx.config.standalone) { - this.log( - c.yellow( - `\nVitest is running in standalone mode. Edit a test file to rerun tests.`, - ), - ) + this.log(c.yellow(`\nVitest is running in standalone mode. Edit a test file to rerun tests.`)) } else { this.log() diff --git a/packages/vitest/src/node/reporters/base.ts b/packages/vitest/src/node/reporters/base.ts index 01e42b5958af..f4b2d7f4596b 100644 --- a/packages/vitest/src/node/reporters/base.ts +++ b/packages/vitest/src/node/reporters/base.ts @@ -11,33 +11,9 @@ import c from 'tinyrainbow' import { isCI, isDeno, isNode } from '../../utils/env' import { hasFailedSnapshot } from '../../utils/tasks' import { F_CHECK, F_POINTER, F_RIGHT } from './renderers/figures' -import { - countTestErrors, - divider, - formatProjectName, - formatTimeString, - getStateString, - getStateSymbol, - renderSnapshotSummary, - taskFail, -} from './renderers/utils' +import { countTestErrors, divider, formatProjectName, formatTimeString, getStateString, getStateSymbol, renderSnapshotSummary, taskFail, withLabel } from './renderers/utils' const BADGE_PADDING = ' ' -const HELP_HINT = `${c.dim('press ')}${c.bold('h')}${c.dim(' to show help')}` -const HELP_UPDATE_SNAP - = c.dim('press ') + c.bold(c.yellow('u')) + c.dim(' to update snapshot') -const HELP_QUITE = `${c.dim('press ')}${c.bold('q')}${c.dim(' to quit')}` - -const WAIT_FOR_CHANGE_PASS = `\n${c.bold( - c.inverse(c.green(' PASS ')), -)}${c.green(' Waiting for file changes...')}` -const WAIT_FOR_CHANGE_FAIL = `\n${c.bold(c.inverse(c.red(' FAIL ')))}${c.red( - ' Tests failed. Watching for file changes...', -)}` -const WAIT_FOR_CHANGE_CANCELLED = `\n${c.bold( - c.inverse(c.red(' CANCELLED ')), -)}${c.red(' Test run cancelled. Watching for file changes...')}` - const LAST_RUN_LOG_TIMEOUT = 1_500 export interface BaseOptions { @@ -55,35 +31,36 @@ export abstract class BaseReporter implements Reporter { protected verbose = false private _filesInWatchMode = new Map() + private _timeStart = formatTimeString(new Date()) private _lastRunTimeout = 0 private _lastRunTimer: NodeJS.Timeout | undefined private _lastRunCount = 0 - private _timeStart = new Date() constructor(options: BaseOptions = {}) { this.isTTY = options.isTTY ?? ((isNode || isDeno) && process.stdout?.isTTY && !isCI) } - get mode() { - return this.ctx.config.mode - } - onInit(ctx: Vitest) { this.ctx = ctx - ctx.logger.printBanner() + + this.ctx.logger.printBanner() this.start = performance.now() } + log(...messages: any) { + this.ctx.logger.log(...messages) + } + + error(...messages: any) { + this.ctx.logger.error(...messages) + } + relative(path: string) { return relative(this.ctx.config.root, path) } - onFinished( - files = this.ctx.state.getFiles(), - errors = this.ctx.state.getUnhandledErrors(), - ) { + onFinished(files = this.ctx.state.getFiles(), errors = this.ctx.state.getUnhandledErrors()) { this.end = performance.now() - this.reportSummary(files, errors) } @@ -93,6 +70,7 @@ export abstract class BaseReporter implements Reporter { } for (const pack of packs) { const task = this.ctx.state.idMap.get(pack[0]) + if (task) { this.printTask(task) } @@ -106,55 +84,57 @@ export abstract class BaseReporter implements Reporter { || task.result?.state === 'run') { return } - const logger = this.ctx.logger const tests = getTests(task) const failed = tests.filter(t => t.result?.state === 'fail') - const skipped = tests.filter( - t => t.mode === 'skip' || t.mode === 'todo', - ) + const skipped = tests.filter(t => t.mode === 'skip' || t.mode === 'todo') + let state = c.dim(`${tests.length} test${tests.length > 1 ? 's' : ''}`) + if (failed.length) { - state += ` ${c.dim('|')} ${c.red(`${failed.length} failed`)}` + state += c.dim(' | ') + c.red(`${failed.length} failed`) } + if (skipped.length) { - state += ` ${c.dim('|')} ${c.yellow(`${skipped.length} skipped`)}` + state += c.dim(' | ') + c.yellow(`${skipped.length} skipped`) } - let suffix = c.dim(' (') + state + c.dim(')') - suffix += this.getDurationPrefix(task) + + let suffix = c.dim('(') + state + c.dim(')') + this.getDurationPrefix(task) + if (this.ctx.config.logHeapUsage && task.result.heap != null) { - suffix += c.magenta( - ` ${Math.floor(task.result.heap / 1024 / 1024)} MB heap used`, - ) + suffix += c.magenta(` ${Math.floor(task.result.heap / 1024 / 1024)} MB heap used`) } - let title = ` ${getStateSymbol(task)} ` + let title = getStateSymbol(task) + if (task.meta.typecheck) { - title += `${c.bgBlue(c.bold(' TS '))} ` + title += ` ${c.bgBlue(c.bold(' TS '))}` } + if (task.projectName) { - title += formatProjectName(task.projectName) + title += ` ${formatProjectName(task.projectName, '')}` } - title += `${task.name} ${suffix}` - logger.log(title) + + this.log(` ${title} ${task.name} ${suffix}`) for (const test of tests) { const duration = test.result?.duration + if (test.result?.state === 'fail') { const suffix = this.getDurationPrefix(test) - logger.log(c.red(` ${taskFail} ${getTestName(test, c.dim(' > '))}${suffix}`)) + this.log(c.red(` ${taskFail} ${getTestName(test, c.dim(' > '))}${suffix}`)) test.result?.errors?.forEach((e) => { // print short errors, full errors will be at the end in summary - logger.log(c.red(` ${F_RIGHT} ${(e as any)?.message}`)) + this.log(c.red(` ${F_RIGHT} ${e?.message}`)) }) } + // also print slow tests else if (duration && duration > this.ctx.config.slowTestThreshold) { - logger.log( - ` ${c.yellow(c.dim(F_CHECK))} ${getTestName(test, c.dim(' > '))}${c.yellow( - ` ${Math.round(duration)}${c.dim('ms')}`, - )}`, + this.log( + ` ${c.yellow(c.dim(F_CHECK))} ${getTestName(test, c.dim(' > '))}` + + ` ${c.yellow(Math.round(duration) + c.dim('ms'))}`, ) } } @@ -164,42 +144,39 @@ export abstract class BaseReporter implements Reporter { if (!task.result?.duration) { return '' } + const color = task.result.duration > this.ctx.config.slowTestThreshold ? c.yellow : c.gray + return color(` ${Math.round(task.result.duration)}${c.dim('ms')}`) } - onWatcherStart( - files = this.ctx.state.getFiles(), - errors = this.ctx.state.getUnhandledErrors(), - ) { + onWatcherStart(files = this.ctx.state.getFiles(), errors = this.ctx.state.getUnhandledErrors()) { this.resetLastRunLog() const failed = errors.length > 0 || hasFailed(files) - const failedSnap = hasFailedSnapshot(files) - const cancelled = this.ctx.isCancelling if (failed) { - this.ctx.logger.log(WAIT_FOR_CHANGE_FAIL) + this.log(withLabel('red', 'FAIL', 'Tests failed. Watching for file changes...')) } - else if (cancelled) { - this.ctx.logger.log(WAIT_FOR_CHANGE_CANCELLED) + else if (this.ctx.isCancelling) { + this.log(withLabel('red', 'CANCELLED', 'Test run cancelled. Watching for file changes...')) } else { - this.ctx.logger.log(WAIT_FOR_CHANGE_PASS) + this.log(withLabel('green', 'PASS', 'Waiting for file changes...')) } - const hints: string[] = [] - hints.push(HELP_HINT) - if (failedSnap) { - hints.unshift(HELP_UPDATE_SNAP) + const hints = [c.dim('press ') + c.bold('h') + c.dim(' to show help')] + + if (hasFailedSnapshot(files)) { + hints.unshift(c.dim('press ') + c.bold(c.yellow('u')) + c.dim(' to update snapshot')) } else { - hints.push(HELP_QUITE) + hints.push(c.dim('press ') + c.bold('q') + c.dim(' to quit')) } - this.ctx.logger.log(BADGE_PADDING + hints.join(c.dim(', '))) + this.log(BADGE_PADDING + hints.join(c.dim(', '))) if (this._lastRunCount) { const LAST_RUN_TEXT = `rerun x${this._lastRunCount}` @@ -233,57 +210,51 @@ export abstract class BaseReporter implements Reporter { onWatcherRerun(files: string[], trigger?: string) { this.resetLastRunLog() this.watchFilters = files - this.failedUnwatchedFiles = this.ctx.state.getFiles().filter((file) => { - return !files.includes(file.filepath) && hasFailed(file) - }) + this.failedUnwatchedFiles = this.ctx.state.getFiles().filter(file => + !files.includes(file.filepath) && hasFailed(file), + ) + // Update re-run count for each file files.forEach((filepath) => { let reruns = this._filesInWatchMode.get(filepath) ?? 0 this._filesInWatchMode.set(filepath, ++reruns) }) - const BADGE = c.inverse(c.bold(c.blue(' RERUN '))) - const TRIGGER = trigger ? c.dim(` ${this.relative(trigger)}`) : '' - const FILENAME_PATTERN = this.ctx.filenamePattern - ? `${BADGE_PADDING} ${c.dim('Filename pattern: ')}${c.blue( - this.ctx.filenamePattern, - )}\n` - : '' - const TESTNAME_PATTERN = this.ctx.configOverride.testNamePattern - ? `${BADGE_PADDING} ${c.dim('Test name pattern: ')}${c.blue( - String(this.ctx.configOverride.testNamePattern), - )}\n` - : '' - const PROJECT_FILTER = this.ctx.configOverride.project - ? `${BADGE_PADDING} ${c.dim('Project name: ')}${c.blue( - toArray(this.ctx.configOverride.project).join(', '), - )}\n` - : '' + let banner = trigger ? c.dim(`${this.relative(trigger)} `) : '' if (files.length > 1 || !files.length) { // we need to figure out how to handle rerun all from stdin - this.ctx.logger.clearFullScreen( - `\n${BADGE}${TRIGGER}\n${PROJECT_FILTER}${FILENAME_PATTERN}${TESTNAME_PATTERN}`, - ) this._lastRunCount = 0 } else if (files.length === 1) { const rerun = this._filesInWatchMode.get(files[0]) ?? 1 - this._lastRunCount = rerun - this.ctx.logger.clearFullScreen( - `\n${BADGE}${TRIGGER} ${c.blue( - `x${rerun}`, - )}\n${PROJECT_FILTER}${FILENAME_PATTERN}${TESTNAME_PATTERN}`, - ) + banner += c.blue(`x${rerun} `) + } + + this.ctx.logger.clearFullScreen() + this.log(withLabel('blue', 'RERUN', banner)) + + if (this.ctx.configOverride.project) { + this.log(BADGE_PADDING + c.dim(' Project name: ') + c.blue(toArray(this.ctx.configOverride.project).join(', '))) + } + + if (this.ctx.filenamePattern) { + this.log(BADGE_PADDING + c.dim(' Filename pattern: ') + c.blue(this.ctx.filenamePattern)) + } + + if (this.ctx.configOverride.testNamePattern) { + this.log(BADGE_PADDING + c.dim(' Test name pattern: ') + c.blue(String(this.ctx.configOverride.testNamePattern))) } + this.log('') + if (!this.isTTY) { for (const task of this.failedUnwatchedFiles) { this.printTask(task) } } - this._timeStart = new Date() + this._timeStart = formatTimeString(new Date()) this.start = performance.now() } @@ -291,27 +262,25 @@ export abstract class BaseReporter implements Reporter { if (!this.shouldLog(log)) { return } - const task = log.taskId ? this.ctx.state.idMap.get(log.taskId) : undefined - const header = c.gray( - log.type - + c.dim( - ` | ${ - task - ? getFullName(task, c.dim(' > ')) - : log.taskId !== '__vitest__unknown_test__' - ? log.taskId - : 'unknown test' - }`, - ), - ) const output = log.type === 'stdout' ? this.ctx.logger.outputStream : this.ctx.logger.errorStream + const write = (msg: string) => (output as any).write(msg) - write(`${header}\n${log.content}`) + let headerText = 'unknown test' + const task = log.taskId ? this.ctx.state.idMap.get(log.taskId) : undefined + + if (task) { + headerText = getFullName(task, c.dim(' > ')) + } + else if (log.taskId && log.taskId !== '__vitest__unknown_test__') { + headerText = log.taskId + } + + write(c.gray(log.type + c.dim(` | ${headerText}\n`)) + log.content) if (log.origin) { // browser logs don't have an extra end of line at the end like Node.js does @@ -327,29 +296,30 @@ export abstract class BaseReporter implements Reporter { ? (project.browser?.parseStacktrace(log.origin) || []) : parseStacktrace(log.origin) - const highlight = task - ? stack.find(i => i.file === task.file.filepath) - : null + const highlight = task && stack.find(i => i.file === task.file.filepath) + for (const frame of stack) { const color = frame === highlight ? c.cyan : c.gray const path = relative(project.config.root, frame.file) - write( - color( - ` ${c.dim(F_POINTER)} ${[ - frame.method, - `${path}:${c.dim(`${frame.line}:${frame.column}`)}`, - ] - .filter(Boolean) - .join(' ')}\n`, - ), - ) + const positions = [ + frame.method, + `${path}:${c.dim(`${frame.line}:${frame.column}`)}`, + ] + .filter(Boolean) + .join(' ') + + write(color(` ${c.dim(F_POINTER)} ${positions}\n`)) } } write('\n') } + onTestRemoved(trigger?: string) { + this.log(c.yellow('Test removed...') + (trigger ? c.dim(` [ ${this.relative(trigger)} ]\n`) : '')) + } + shouldLog(log: UserConsoleLog) { if (this.ctx.config.silent) { return false @@ -362,20 +332,17 @@ export abstract class BaseReporter implements Reporter { } onServerRestart(reason?: string) { - this.ctx.logger.log( - c.bold( - c.magenta( - reason === 'config' - ? '\nRestarting due to config changes...' - : '\nRestarting Vitest...', - ), - ), - ) + this.log(c.bold(c.magenta( + reason === 'config' + ? '\nRestarting due to config changes...' + : '\nRestarting Vitest...', + ))) } reportSummary(files: File[], errors: unknown[]) { this.printErrorsSummary(files, errors) - if (this.mode === 'benchmark') { + + if (this.ctx.config.mode === 'benchmark') { this.reportBenchmarkSummary(files) } else { @@ -389,240 +356,194 @@ export abstract class BaseReporter implements Reporter { ...files, ] const tests = getTests(affectedFiles) - const logger = this.ctx.logger - - const executionTime = this.end - this.start - const collectTime = files.reduce( - (acc, test) => acc + Math.max(0, test.collectDuration || 0), - 0, - ) - const setupTime = files.reduce( - (acc, test) => acc + Math.max(0, test.setupDuration || 0), - 0, - ) - const testsTime = files.reduce( - (acc, test) => acc + Math.max(0, test.result?.duration || 0), - 0, - ) - const transformTime = this.ctx.projects - .flatMap(w => w.vitenode.getTotalDuration()) - .reduce((a, b) => a + b, 0) - const environmentTime = files.reduce( - (acc, file) => acc + Math.max(0, file.environmentLoad || 0), - 0, - ) - const prepareTime = files.reduce( - (acc, file) => acc + Math.max(0, file.prepareDuration || 0), - 0, - ) - const threadTime = collectTime + testsTime + setupTime - - // show top 10 costly transform module - // console.log(Array.from(this.ctx.vitenode.fetchCache.entries()).filter(i => i[1].duration) - // .sort((a, b) => b[1].duration! - a[1].duration!) - // .map(i => `${time(i[1].duration!)} ${i[0]}`) - // .slice(0, 10) - // .join('\n'), - // ) const snapshotOutput = renderSnapshotSummary( this.ctx.config.root, this.ctx.snapshot.summary, ) - if (snapshotOutput.length) { - logger.log( - snapshotOutput - .map((t, i) => - i === 0 ? `${padTitle('Snapshots')} ${t}` : `${padTitle('')} ${t}`, - ) - .join('\n'), - ) - if (snapshotOutput.length > 1) { - logger.log() - } + + for (const [index, snapshot] of snapshotOutput.entries()) { + const title = index === 0 ? 'Snapshots' : '' + this.log(`${padTitle(title)} ${snapshot}`) + } + + if (snapshotOutput.length > 1) { + this.log() } - logger.log(padTitle('Test Files'), getStateString(affectedFiles)) - logger.log(padTitle('Tests'), getStateString(tests)) + this.log(padTitle('Test Files'), getStateString(affectedFiles)) + this.log(padTitle('Tests'), getStateString(tests)) + if (this.ctx.projects.some(c => c.config.typecheck.enabled)) { - const failed = tests.filter( - t => t.meta?.typecheck && t.result?.errors?.length, - ) - logger.log( + const failed = tests.filter(t => t.meta?.typecheck && t.result?.errors?.length) + + this.log( padTitle('Type Errors'), failed.length ? c.bold(c.red(`${failed.length} failed`)) : c.dim('no errors'), ) } + if (errors.length) { - logger.log( + this.log( padTitle('Errors'), c.bold(c.red(`${errors.length} error${errors.length > 1 ? 's' : ''}`)), ) } - logger.log(padTitle('Start at'), formatTimeString(this._timeStart)) + + this.log(padTitle('Start at'), this._timeStart) + + const collectTime = sum(files, file => file.collectDuration) + const testsTime = sum(files, file => file.result?.duration) + const setupTime = sum(files, file => file.setupDuration) + if (this.watchFilters) { - logger.log(padTitle('Duration'), time(threadTime)) + this.log(padTitle('Duration'), time(collectTime + testsTime + setupTime)) } else { - let timers = `transform ${time(transformTime)}, setup ${time( - setupTime, - )}, collect ${time(collectTime)}, tests ${time( - testsTime, - )}, environment ${time(environmentTime)}, prepare ${time(prepareTime)}` - const typecheck = this.ctx.projects.reduce( - (acc, c) => acc + (c.typechecker?.getResult().time || 0), - 0, - ) - if (typecheck) { - timers += `, typecheck ${time(typecheck)}` - } - logger.log( - padTitle('Duration'), - time(executionTime) + c.dim(` (${timers})`), - ) - } - - logger.log() + const executionTime = this.end - this.start + const environmentTime = sum(files, file => file.environmentLoad) + const prepareTime = sum(files, file => file.prepareDuration) + const transformTime = sum(this.ctx.projects, project => project.vitenode.getTotalDuration()) + const typecheck = sum(this.ctx.projects, project => project.typechecker?.getResult().time) + + const timers = [ + `transform ${time(transformTime)}`, + `setup ${time(setupTime)}`, + `collect ${time(collectTime)}`, + `tests ${time(testsTime)}`, + `environment ${time(environmentTime)}`, + `prepare ${time(prepareTime)}`, + typecheck && `typecheck ${time(typecheck)}`, + ].filter(Boolean).join(', ') + + this.log(padTitle('Duration'), time(executionTime) + c.dim(` (${timers})`)) + } + + this.log() } private printErrorsSummary(files: File[], errors: unknown[]) { - const logger = this.ctx.logger const suites = getSuites(files) const tests = getTests(files) const failedSuites = suites.filter(i => i.result?.errors) const failedTests = tests.filter(i => i.result?.state === 'fail') - const failedTotal - = countTestErrors(failedSuites) + countTestErrors(failedTests) + const failedTotal = countTestErrors(failedSuites) + countTestErrors(failedTests) let current = 1 - - const errorDivider = () => - logger.error( - `${c.red( - c.dim(divider(`[${current++}/${failedTotal}]`, undefined, 1)), - )}\n`, - ) + const errorDivider = () => this.error(`${c.red(c.dim(divider(`[${current++}/${failedTotal}]`, undefined, 1)))}\n`) if (failedSuites.length) { - logger.error( - c.red( - divider(c.bold(c.inverse(` Failed Suites ${failedSuites.length} `))), - ), - ) - logger.error() + this.error(`${errorBanner(`Failed Suites ${failedSuites.length}`)}\n`) this.printTaskErrors(failedSuites, errorDivider) } if (failedTests.length) { - logger.error( - c.red( - divider(c.bold(c.inverse(` Failed Tests ${failedTests.length} `))), - ), - ) - logger.error() - + this.error(`${errorBanner(`Failed Tests ${failedTests.length}`)}\n`) this.printTaskErrors(failedTests, errorDivider) } + if (errors.length) { - logger.printUnhandledErrors(errors) - logger.error() + this.ctx.logger.printUnhandledErrors(errors) + this.error() } - return tests } reportBenchmarkSummary(files: File[]) { - const logger = this.ctx.logger const benches = getTests(files) - const topBenches = benches.filter(i => i.result?.benchmark?.rank === 1) - logger.log( - `\n${c.cyan(c.inverse(c.bold(' BENCH ')))} ${c.cyan('Summary')}\n`, - ) + this.log(withLabel('cyan', 'BENCH', 'Summary\n')) + for (const bench of topBenches) { const group = bench.suite || bench.file + if (!group) { continue } + const groupName = getFullName(group, c.dim(' > ')) - logger.log(` ${bench.name}${c.dim(` - ${groupName}`)}`) + this.log(` ${bench.name}${c.dim(` - ${groupName}`)}`) + const siblings = group.tasks .filter(i => i.meta.benchmark && i.result?.benchmark && i !== bench) .sort((a, b) => a.result!.benchmark!.rank - b.result!.benchmark!.rank) - if (siblings.length === 0) { - logger.log('') - continue - } + for (const sibling of siblings) { - const number = `${( - sibling.result!.benchmark!.mean / bench.result!.benchmark!.mean - ).toFixed(2)}x` - logger.log( - ` ${c.green(number)} ${c.gray('faster than')} ${sibling.name}`, - ) + const number = (sibling.result!.benchmark!.mean / bench.result!.benchmark!.mean).toFixed(2) + this.log(c.green(` ${number}x `) + c.gray('faster than ') + sibling.name) } - logger.log('') + + this.log('') } } private printTaskErrors(tasks: Task[], errorDivider: () => void) { const errorsQueue: [error: ErrorWithDiff | undefined, tests: Task[]][] = [] + for (const task of tasks) { - // merge identical errors + // Merge identical errors task.result?.errors?.forEach((error) => { - const errorItem - = error?.stackStr - && errorsQueue.find((i) => { - const hasStr = i[0]?.stackStr === error.stackStr - if (!hasStr) { + let previous + + if (error?.stackStr) { + previous = errorsQueue.find((i) => { + if (i[0]?.stackStr !== error.stackStr) { return false } - const currentProjectName - = (task as File)?.projectName || task.file?.projectName || '' - const projectName - = (i[1][0] as File)?.projectName || i[1][0].file?.projectName || '' + + const currentProjectName = (task as File)?.projectName || task.file?.projectName || '' + const projectName = (i[1][0] as File)?.projectName || i[1][0].file?.projectName || '' + return projectName === currentProjectName }) - if (errorItem) { - errorItem[1].push(task) + } + + if (previous) { + previous[1].push(task) } else { errorsQueue.push([error, [task]]) } }) } + for (const [error, tasks] of errorsQueue) { for (const task of tasks) { const filepath = (task as File)?.filepath || '' - const projectName - = (task as File)?.projectName || task.file?.projectName || '' + const projectName = (task as File)?.projectName || task.file?.projectName || '' + let name = getFullName(task, c.dim(' > ')) + if (filepath) { - name = `${name} ${c.dim(`[ ${this.relative(filepath)} ]`)}` + name += c.dim(` [ ${this.relative(filepath)} ]`) } this.ctx.logger.error( - `${c.red(c.bold(c.inverse(' FAIL ')))} ${formatProjectName( - projectName, - )}${name}`, + `${c.red(c.bold(c.inverse(' FAIL ')))}${formatProjectName(projectName)} ${name}`, ) } - const screenshots = tasks.filter(t => t.meta?.failScreenshotPath).map(t => t.meta?.failScreenshotPath as string) - const project = this.ctx.getProjectByTaskId(tasks[0].id) + + const screenshotPaths = tasks.map(t => t.meta?.failScreenshotPath).filter(screenshot => screenshot != null) + this.ctx.logger.printError(error, { - project, + project: this.ctx.getProjectByTaskId(tasks[0].id), verbose: this.verbose, - screenshotPaths: screenshots, + screenshotPaths, task: tasks[0], }) + errorDivider() } } } +function errorBanner(message: string) { + return c.red(divider(c.bold(c.inverse(` ${message} `)))) +} + function padTitle(str: string) { return c.dim(`${str.padStart(11)} `) } @@ -633,3 +554,9 @@ function time(time: number) { } return `${Math.round(time)}ms` } + +function sum(items: T[], cb: (_next: T) => number | undefined) { + return items.reduce((total, next) => { + return total + Math.max(cb(next) || 0, 0) + }, 0) +} diff --git a/packages/vitest/src/node/reporters/default.ts b/packages/vitest/src/node/reporters/default.ts index 0c16761ab3b6..639f1ba3a8a5 100644 --- a/packages/vitest/src/node/reporters/default.ts +++ b/packages/vitest/src/node/reporters/default.ts @@ -41,7 +41,7 @@ export class DefaultReporter extends BaseReporter { this.rendererOptions.showHeap = this.ctx.config.logHeapUsage this.rendererOptions.slowTestThreshold = this.ctx.config.slowTestThreshold - this.rendererOptions.mode = this.mode + this.rendererOptions.mode = this.ctx.config.mode const files = this.ctx.state.getFiles(this.watchFilters) if (!this.renderer) { this.renderer = createListRenderer(files, this.rendererOptions).start() diff --git a/packages/vitest/src/node/reporters/renderers/utils.ts b/packages/vitest/src/node/reporters/renderers/utils.ts index a895bc9a284c..9be1bff56974 100644 --- a/packages/vitest/src/node/reporters/renderers/utils.ts +++ b/packages/vitest/src/node/reporters/renderers/utils.ts @@ -254,6 +254,12 @@ export function formatProjectName(name: string | undefined, suffix = ' ') { const index = name .split('') .reduce((acc, v, idx) => acc + v.charCodeAt(0) + idx, 0) + const colors = [c.blue, c.yellow, c.cyan, c.green, c.magenta] + return colors[index % colors.length](`|${name}|`) + suffix } + +export function withLabel(color: 'red' | 'green' | 'blue' | 'cyan', label: string, message: string) { + return `${c.bold(c.inverse(c[color](` ${label} `)))} ${c[color](message)}` +} diff --git a/test/config/test/console-color.test.ts b/test/config/test/console-color.test.ts index 3ecd19cc2c8a..1678a55cacfe 100644 --- a/test/config/test/console-color.test.ts +++ b/test/config/test/console-color.test.ts @@ -1,7 +1,7 @@ import { x } from 'tinyexec' import { expect, test } from 'vitest' -// use "x" directly since "runVitestCli" strips color +// use "tinyexec" directly since "runVitestCli" strips color test('with color', async () => { const proc = await x('vitest', ['run', '--root=./fixtures/console-color'], { @@ -14,7 +14,7 @@ test('with color', async () => { }, }, }) - expect(proc.stdout).toContain('\n\x1B[33mtrue\x1B[39m\n') + expect(proc.stdout).toContain('\x1B[33mtrue\x1B[39m\n') }) test('without color', async () => { @@ -28,5 +28,5 @@ test('without color', async () => { }, }, }) - expect(proc.stdout).toContain('\ntrue\n') + expect(proc.stdout).toContain('true\n') }) diff --git a/test/reporters/tests/merge-reports.test.ts b/test/reporters/tests/merge-reports.test.ts index 1ca7cc3cadf2..1cf3493142fe 100644 --- a/test/reporters/tests/merge-reports.test.ts +++ b/test/reporters/tests/merge-reports.test.ts @@ -88,13 +88,13 @@ test('merge reports', async () => { beforeEach test 1-2 - ❯ first.test.ts (2 tests | 1 failed)