diff --git a/packages/browser/src/client/logger.ts b/packages/browser/src/client/logger.ts new file mode 100644 index 000000000000..55f64953808f --- /dev/null +++ b/packages/browser/src/client/logger.ts @@ -0,0 +1,104 @@ +import { rpc } from './rpc' +import { importId } from './utils' + +const { Date, console } = globalThis + +export const setupConsoleLogSpy = async () => { + const { stringify, format, utilInspect } = await importId('vitest/utils') as typeof import('vitest/utils') + const { log, info, error, dir, dirxml, trace, time, timeEnd, timeLog, warn, debug, count, countReset } = console + const formatInput = (input: unknown) => { + if (input instanceof Node) + return stringify(input) + return format(input) + } + const processLog = (args: unknown[]) => args.map(formatInput).join(' ') + const sendLog = (type: 'stdout' | 'stderr', content: string) => { + if (content.startsWith('[vite]')) + return + const unknownTestId = '__vitest__unknown_test__' + // @ts-expect-error untyped global + const taskId = globalThis.__vitest_worker__?.current?.id ?? unknownTestId + rpc().sendLog({ + content, + time: Date.now(), + taskId, + type, + size: content.length, + }) + } + const stdout = (base: (...args: unknown[]) => void) => (...args: unknown[]) => { + sendLog('stdout', processLog(args)) + return base(...args) + } + const stderr = (base: (...args: unknown[]) => void) => (...args: unknown[]) => { + sendLog('stderr', processLog(args)) + return base(...args) + } + console.log = stdout(log) + console.debug = stdout(debug) + console.info = stdout(info) + + console.error = stderr(error) + console.warn = stderr(warn) + + console.dir = (item, options) => { + sendLog('stdout', utilInspect(item, options)) + return dir(item, options) + } + + console.dirxml = (...args) => { + sendLog('stdout', processLog(args)) + return dirxml(...args) + } + + console.trace = (...args: unknown[]) => { + const content = processLog(args) + const error = new Error('Trace') + const stack = (error.stack || '').split('\n').slice(2).join('\n') + sendLog('stdout', `${content}\n${stack}`) + return trace(...args) + } + + const timeLabels: Record = {} + + console.time = (label = 'default') => { + const now = performance.now() + time(label) + timeLabels[label] = now + } + + console.timeLog = (label = 'default') => { + timeLog(label) + if (!(label in timeLabels)) + sendLog('stderr', `Timer "${label}" does not exist`) + else + sendLog('stdout', `${label}: ${timeLabels[label]} ms`) + } + + console.timeEnd = (label = 'default') => { + const end = performance.now() + timeEnd(label) + const start = timeLabels[label] + if (!(label in timeLabels)) { + sendLog('stderr', `Timer "${label}" does not exist`) + } + else if (start) { + const duration = end - start + sendLog('stdout', `${label}: ${duration} ms`) + } + } + + const countLabels: Record = {} + + console.count = (label = 'default') => { + const counter = (countLabels[label] ?? 0) + 1 + countLabels[label] = counter + sendLog('stdout', `${label}: ${counter}`) + return count(label) + } + + console.countReset = (label = 'default') => { + countLabels[label] = 0 + return countReset(label) + } +} diff --git a/packages/browser/src/client/main.ts b/packages/browser/src/client/main.ts index 3ff07184f5cd..20d8140a732e 100644 --- a/packages/browser/src/client/main.ts +++ b/packages/browser/src/client/main.ts @@ -1,10 +1,12 @@ -import type { VitestClient } from '@vitest/ws-client' import { createClient } from '@vitest/ws-client' // eslint-disable-next-line no-restricted-imports import type { ResolvedConfig } from 'vitest' import type { VitestRunner } from '@vitest/runner' import { createBrowserRunner } from './runner' import { BrowserSnapshotEnvironment } from './snapshot' +import { importId } from './utils' +import { setupConsoleLogSpy } from './logger' +import { createSafeRpc, rpc, rpcDone } from './rpc' // @ts-expect-error mocking some node apis globalThis.process = { env: {}, argv: [], cwd: () => '/', stdout: { write: () => {} }, nextTick: cb => cb() } @@ -51,12 +53,16 @@ async function loadConfig() { ws.addEventListener('open', async () => { await loadConfig() + const { getSafeTimers } = await importId('vitest/utils') as typeof import('vitest/utils') + const safeRpc = createSafeRpc(client, getSafeTimers) + // @ts-expect-error mocking vitest apis globalThis.__vitest_worker__ = { config, browserHashMap, moduleCache: new Map(), rpc: client.rpc, + safeRpc, } const paths = getQueryPaths() @@ -64,29 +70,26 @@ ws.addEventListener('open', async () => { const iFrame = document.getElementById('vitest-ui') as HTMLIFrameElement iFrame.setAttribute('src', '/__vitest__/') - await runTests(paths, config, client) + await setupConsoleLogSpy() + await runTests(paths, config) }) let hasSnapshot = false -async function runTests(paths: string[], config: any, client: VitestClient) { +async function runTests(paths: string[], config: any) { // need to import it before any other import, otherwise Vite optimizer will hang const viteClientPath = '/@vite/client' await import(viteClientPath) - // we use dynamic import here, because this file is bundled with UI, - // but we need to resolve correct path at runtime - const path = '/__vitest_index__' - const { startTests, setupCommonEnv, setupSnapshotEnvironment } = await import(path) as typeof import('vitest/browser') + const { startTests, setupCommonEnv, setupSnapshotEnvironment } = await importId('vitest/browser') as typeof import('vitest/browser') if (!runner) { - const runnerPath = '/__vitest_runners__' - const { VitestTestRunner } = await import(runnerPath) as typeof import('vitest/runners') + const { VitestTestRunner } = await importId('vitest/runners') as typeof import('vitest/runners') const BrowserRunner = createBrowserRunner(VitestTestRunner) - runner = new BrowserRunner({ config, client, browserHashMap }) + runner = new BrowserRunner({ config, browserHashMap }) } if (!hasSnapshot) { - setupSnapshotEnvironment(new BrowserSnapshotEnvironment(client)) + setupSnapshotEnvironment(new BrowserSnapshotEnvironment()) hasSnapshot = true } @@ -102,6 +105,7 @@ async function runTests(paths: string[], config: any, client: VitestClient) { await startTests(files, runner) } finally { - await client.rpc.onDone(testId) + await rpcDone() + await rpc().onDone(testId) } } diff --git a/packages/browser/src/client/rpc.ts b/packages/browser/src/client/rpc.ts new file mode 100644 index 000000000000..af1b6e7790ec --- /dev/null +++ b/packages/browser/src/client/rpc.ts @@ -0,0 +1,74 @@ +import type { + getSafeTimers, +} from '@vitest/utils' +import type { VitestClient } from '@vitest/ws-client' + +const { get } = Reflect +const safeRandom = Math.random + +function withSafeTimers(getTimers: typeof getSafeTimers, fn: () => void) { + const { setTimeout, clearTimeout, nextTick, setImmediate, clearImmediate } = getTimers() + + const currentSetTimeout = globalThis.setTimeout + const currentClearTimeout = globalThis.clearTimeout + const currentRandom = globalThis.Math.random + const currentNextTick = globalThis.process.nextTick + const currentSetImmediate = globalThis.setImmediate + const currentClearImmediate = globalThis.clearImmediate + + try { + globalThis.setTimeout = setTimeout + globalThis.clearTimeout = clearTimeout + globalThis.Math.random = safeRandom + globalThis.process.nextTick = nextTick + globalThis.setImmediate = setImmediate + globalThis.clearImmediate = clearImmediate + + const result = fn() + return result + } + finally { + globalThis.setTimeout = currentSetTimeout + globalThis.clearTimeout = currentClearTimeout + globalThis.Math.random = currentRandom + globalThis.setImmediate = currentSetImmediate + globalThis.clearImmediate = currentClearImmediate + nextTick(() => { + globalThis.process.nextTick = currentNextTick + }) + } +} + +const promises = new Set>() + +export const rpcDone = async () => { + if (!promises.size) + return + const awaitable = Array.from(promises) + return Promise.all(awaitable) +} + +export const createSafeRpc = (client: VitestClient, getTimers: () => any): VitestClient['rpc'] => { + return new Proxy(client.rpc, { + get(target, p, handler) { + const sendCall = get(target, p, handler) + const safeSendCall = (...args: any[]) => withSafeTimers(getTimers, async () => { + const result = sendCall(...args) + promises.add(result) + try { + return await result + } + finally { + promises.delete(result) + } + }) + safeSendCall.asEvent = sendCall.asEvent + return safeSendCall + }, + }) +} + +export const rpc = (): VitestClient['rpc'] => { + // @ts-expect-error not typed global + return globalThis.__vitest_worker__.safeRpc +} diff --git a/packages/browser/src/client/runner.ts b/packages/browser/src/client/runner.ts index db6a72af9bc4..3e3a3a536e76 100644 --- a/packages/browser/src/client/runner.ts +++ b/packages/browser/src/client/runner.ts @@ -1,10 +1,9 @@ import type { File, TaskResult, Test } from '@vitest/runner' -import type { VitestClient } from '@vitest/ws-client' +import { rpc } from './rpc' import type { ResolvedConfig } from '#types' interface BrowserRunnerOptions { config: ResolvedConfig - client: VitestClient browserHashMap: Map } @@ -12,13 +11,11 @@ export function createBrowserRunner(original: any) { return class BrowserTestRunner extends original { public config: ResolvedConfig hashMap = new Map() - client: VitestClient constructor(options: BrowserRunnerOptions) { super(options.config) this.config = options.config this.hashMap = options.browserHashMap - this.client = options.client } async onAfterRunTest(task: Test) { @@ -29,11 +26,11 @@ export function createBrowserRunner(original: any) { } onCollected(files: File[]): unknown { - return this.client.rpc.onCollected(files) + return rpc().onCollected(files) } onTaskUpdate(task: [string, TaskResult | undefined][]): Promise { - return this.client.rpc.onTaskUpdate(task) + return rpc().onTaskUpdate(task) } async importFile(filepath: string) { diff --git a/packages/browser/src/client/snapshot.ts b/packages/browser/src/client/snapshot.ts index d7b26a666364..ec4b810b1092 100644 --- a/packages/browser/src/client/snapshot.ts +++ b/packages/browser/src/client/snapshot.ts @@ -1,26 +1,24 @@ -import type { VitestClient } from '@vitest/ws-client' +import { rpc } from './rpc' import type { SnapshotEnvironment } from '#types' export class BrowserSnapshotEnvironment implements SnapshotEnvironment { - constructor(private client: VitestClient) {} - readSnapshotFile(filepath: string): Promise { - return this.client.rpc.readFile(filepath) + return rpc().readFile(filepath) } saveSnapshotFile(filepath: string, snapshot: string): Promise { - return this.client.rpc.writeFile(filepath, snapshot) + return rpc().writeFile(filepath, snapshot) } resolvePath(filepath: string): Promise { - return this.client.rpc.resolveSnapshotPath(filepath) + return rpc().resolveSnapshotPath(filepath) } removeSnapshotFile(filepath: string): Promise { - return this.client.rpc.removeFile(filepath) + return rpc().removeFile(filepath) } async prepareDirectory(filepath: string): Promise { - await this.client.rpc.createDirectory(filepath) + await rpc().createDirectory(filepath) } } diff --git a/packages/browser/src/client/utils.ts b/packages/browser/src/client/utils.ts new file mode 100644 index 000000000000..dc6f8229d5d0 --- /dev/null +++ b/packages/browser/src/client/utils.ts @@ -0,0 +1,4 @@ +export const importId = (id: string) => { + const name = `/@id/${id}` + return import(name) +} diff --git a/packages/browser/src/node/index.ts b/packages/browser/src/node/index.ts index 39bf632b082a..7b089537f8c4 100644 --- a/packages/browser/src/node/index.ts +++ b/packages/browser/src/node/index.ts @@ -18,21 +18,6 @@ export default (base = '/'): Plugin[] => { { enforce: 'pre', name: 'vitest:browser', - async resolveId(id) { - if (id === '/__vitest_index__') - return this.resolve('vitest/browser') - - if (id === '/__vitest_runners__') - return this.resolve('vitest/runners') - - if (id.startsWith('node:')) - id = id.slice(5) - - if (polyfills.includes(id)) - return polyfillPath(normalizeId(id)) - - return null - }, async configureServer(server) { server.middlewares.use( base, @@ -45,8 +30,16 @@ export default (base = '/'): Plugin[] => { }, { name: 'modern-node-polyfills', + enforce: 'pre', + config() { + return { + optimizeDeps: { + exclude: [...polyfills, ...builtinModules], + }, + } + }, async resolveId(id) { - if (!builtinModules.includes(id)) + if (!builtinModules.includes(id) && !polyfills.includes(id) && !id.startsWith('node:')) return id = normalizeId(id) diff --git a/packages/runner/src/utils/collect.ts b/packages/runner/src/utils/collect.ts index 753797b0a0b2..013faa8db3ad 100644 --- a/packages/runner/src/utils/collect.ts +++ b/packages/runner/src/utils/collect.ts @@ -1,4 +1,5 @@ import type { Suite, TaskBase } from '../types' +import { processError } from './error' /** * If any tasks been marked as `only`, mark all other tasks as `skip`. @@ -65,7 +66,7 @@ function skipAllTasks(suite: Suite) { function checkAllowOnly(task: TaskBase, allowOnly?: boolean) { if (allowOnly) return - const error = new Error('[Vitest] Unexpected .only modifier. Remove it or pass --allowOnly argument to bypass this error') + const error = processError(new Error('[Vitest] Unexpected .only modifier. Remove it or pass --allowOnly argument to bypass this error')) task.result = { state: 'fail', error, diff --git a/packages/utils/src/display.ts b/packages/utils/src/display.ts index 59403888944a..3b51d0a5891b 100644 --- a/packages/utils/src/display.ts +++ b/packages/utils/src/display.ts @@ -8,8 +8,12 @@ export function format(...args: any[]) { return util.format(...args) } +export function utilInspect(item: unknown, options?: util.InspectOptions) { + return util.inspect(item, options) +} + // chai utils -export function inspect(obj: unknown): string { +export function loupeInspect(obj: unknown): string { return loupe(obj, { depth: 2, truncate: 40, @@ -18,7 +22,7 @@ export function inspect(obj: unknown): string { export function objDisplay(obj: unknown) { const truncateThreshold = 40 - const str = inspect(obj) + const str = loupeInspect(obj) const type = Object.prototype.toString.call(obj) if (str.length >= truncateThreshold) { diff --git a/packages/vitest/package.json b/packages/vitest/package.json index 23b48dd6481f..2804e2abe9c4 100644 --- a/packages/vitest/package.json +++ b/packages/vitest/package.json @@ -61,6 +61,10 @@ "types": "./dist/environments.d.ts", "import": "./dist/environments.js" }, + "./utils": { + "types": "./dist/utils.d.ts", + "import": "./dist/utils.js" + }, "./config": { "types": "./config.d.ts", "require": "./dist/config.cjs", diff --git a/packages/vitest/rollup.config.js b/packages/vitest/rollup.config.js index fe021e67f5ff..fb4ef9e6eaeb 100644 --- a/packages/vitest/rollup.config.js +++ b/packages/vitest/rollup.config.js @@ -28,18 +28,20 @@ const entries = [ 'src/runtime/entry.ts', 'src/integrations/spy.ts', 'src/coverage.ts', + 'src/public/utils.ts', ] -const dtsEntries = [ - 'src/index.ts', - 'src/node.ts', - 'src/environments.ts', - 'src/browser.ts', - 'src/runners.ts', - 'src/suite.ts', - 'src/config.ts', - 'src/coverage.ts', -] +const dtsEntries = { + index: 'src/index.ts', + node: 'src/node.ts', + environments: 'src/environments.ts', + browser: 'src/browser.ts', + runners: 'src/runners.ts', + suite: 'src/suite.ts', + config: 'src/config.ts', + coverage: 'src/coverage.ts', + utils: 'src/public/utils.ts', +} const external = [ ...builtinModules, diff --git a/packages/vitest/src/api/setup.ts b/packages/vitest/src/api/setup.ts index 342be15b6508..735b9ee0826a 100644 --- a/packages/vitest/src/api/setup.ts +++ b/packages/vitest/src/api/setup.ts @@ -52,6 +52,9 @@ export function setup(ctx: Vitest, server?: ViteDevServer) { getPaths() { return ctx.state.getPaths() }, + sendLog(log) { + return ctx.report('onUserConsoleLog', log) + }, resolveSnapshotPath(testPath) { return ctx.snapshot.resolvePath(testPath) }, diff --git a/packages/vitest/src/api/types.ts b/packages/vitest/src/api/types.ts index d56d4cf8fd8f..aa7e1f09cb45 100644 --- a/packages/vitest/src/api/types.ts +++ b/packages/vitest/src/api/types.ts @@ -1,5 +1,5 @@ import type { TransformResult } from 'vite' -import type { File, ModuleGraphData, Reporter, ResolvedConfig, SnapshotResult, TaskResultPack } from '../types' +import type { File, ModuleGraphData, Reporter, ResolvedConfig, SnapshotResult, TaskResultPack, UserConsoleLog } from '../types' export interface TransformResultWithSource extends TransformResult { source?: string @@ -9,6 +9,7 @@ export interface WebSocketHandlers { onCollected(files?: File[]): Promise onTaskUpdate(packs: TaskResultPack[]): void onDone(name: string): void + sendLog(log: UserConsoleLog): void getFiles(): File[] getPaths(): string[] getConfig(): ResolvedConfig diff --git a/packages/vitest/src/public/utils.ts b/packages/vitest/src/public/utils.ts new file mode 100644 index 000000000000..7030fbb320bf --- /dev/null +++ b/packages/vitest/src/public/utils.ts @@ -0,0 +1 @@ +export * from '@vitest/utils' diff --git a/packages/vitest/src/runtime/rpc.ts b/packages/vitest/src/runtime/rpc.ts index 7c4ea63ff253..f0dccd917cae 100644 --- a/packages/vitest/src/runtime/rpc.ts +++ b/packages/vitest/src/runtime/rpc.ts @@ -1,7 +1,7 @@ import { getSafeTimers, } from '@vitest/utils' -import { getWorkerState } from '../utils' +import { getWorkerState } from '../utils/global' const { get } = Reflect const safeRandom = Math.random diff --git a/packages/vitest/utils.d.ts b/packages/vitest/utils.d.ts new file mode 100644 index 000000000000..e3f344e48a8d --- /dev/null +++ b/packages/vitest/utils.d.ts @@ -0,0 +1 @@ +export * from './dist/utils.js' diff --git a/test/browser/package.json b/test/browser/package.json index d9bf628eb1d3..40e29bfb08d3 100644 --- a/test/browser/package.json +++ b/test/browser/package.json @@ -1,8 +1,9 @@ { "name": "@vitest/test-browser", "private": true, + "module": "true", "scripts": { - "test": "node test.mjs", + "test": "node --test specs/", "coverage": "vitest run --coverage" }, "devDependencies": { diff --git a/test/browser/specs/runner.test.mjs b/test/browser/specs/runner.test.mjs new file mode 100644 index 000000000000..8c87292227b2 --- /dev/null +++ b/test/browser/specs/runner.test.mjs @@ -0,0 +1,51 @@ +import assert from 'node:assert' +import { readFile } from 'node:fs/promises' +import test from 'node:test' +import { execa } from 'execa' + +const browser = process.env.BROWSER || 'chrome' + +const { stderr, stdout } = await execa('npx', ['vitest', `--browser=${browser}`], { + env: { + ...process.env, + CI: 'true', + NO_COLOR: 'true', + }, +}) + +test('tests are actually running', async () => { + const browserResult = await readFile('./browser.json', 'utf-8') + const browserResultJson = JSON.parse(browserResult) + + assert.ok(browserResultJson.testResults.length === 5, 'Not all the tests have been run') + + for (const result of browserResultJson.testResults) + assert.ok(result.status === 'passed', `${result.name} has failed`) +}) + +test('logs are redirected to stdout', async () => { + assert.match(stdout, /stdout | test\/logs.test.ts > logging to stdout/) + assert.match(stdout, /hello from console.log/, 'prints console.log') + assert.match(stdout, /hello from console.info/, 'prints console.info') + assert.match(stdout, /hello from console.debug/, 'prints console.debug') + assert.match(stdout, /{ hello: 'from dir' }/, 'prints console.dir') + assert.match(stdout, /{ hello: 'from dirxml' }/, 'prints console.dixml') + assert.match(stdout, /hello from console.trace\s+\w+/, 'prints console.trace') + assert.match(stdout, /dom
/, 'prints dom') + assert.match(stdout, /default: 1/, 'prints first default count') + assert.match(stdout, /default: 2/, 'prints second default count') + assert.match(stdout, /default: 3/, 'prints third default count') + assert.match(stdout, /count: 1/, 'prints first custom count') + assert.match(stdout, /count: 2/, 'prints second custom count') + assert.match(stdout, /count: 3/, 'prints third custom count') + assert.match(stdout, /default: [\d.]+ ms/, 'prints default time') + assert.match(stdout, /time: [\d.]+ ms/, 'prints custom time') +}) + +test('logs are redirected to stderr', async () => { + assert.match(stderr, /stderr | test\/logs.test.ts > logging to stderr/) + assert.match(stderr, /hello from console.error/, 'prints console.log') + assert.match(stderr, /hello from console.warn/, 'prints console.info') + assert.match(stderr, /Timer "invalid timeLog" does not exist/, 'prints errored timeLog') + assert.match(stderr, /Timer "invalid timeEnd" does not exist/, 'prints errored timeEnd') +}) diff --git a/test/browser/test.mjs b/test/browser/test.mjs deleted file mode 100644 index ee9e7d82786d..000000000000 --- a/test/browser/test.mjs +++ /dev/null @@ -1,32 +0,0 @@ -import assert from 'node:assert' -import { readFile } from 'node:fs/promises' -import { execa } from 'execa' - -const browser = process.env.BROWSER || 'chrome' - -let error -await execa('npx', ['vitest', `--browser=${browser}`], { - env: { - ...process.env, - CI: 'true', - NO_COLOR: 'true', - }, - stdout: 'inherit', - stderr: 'inherit', -}) - .catch((e) => { - error = e - }) - -if (error) { - console.error(error) - process.exit(1) -} - -const browserResult = await readFile('./browser.json', 'utf-8') -const browserResultJson = JSON.parse(browserResult) - -assert.ok(browserResultJson.testResults.length === 4, 'Not all the tests have been run') - -for (const result of browserResultJson.testResults) - assert.ok(result.status === 'passed') diff --git a/test/browser/test/logs.test.ts b/test/browser/test/logs.test.ts new file mode 100644 index 000000000000..c1377092b872 --- /dev/null +++ b/test/browser/test/logs.test.ts @@ -0,0 +1,54 @@ +/* eslint-disable no-console */ +import { test } from 'vitest' + +test('logging to stdout', () => { + console.log('hello from console.log') + console.info('hello from console.info') + console.debug('hello from console.debug') + console.dir({ hello: 'from dir' }) + console.dirxml({ hello: 'from dirxml' }) + console.trace('hello from console.trace') +}) + +test('logging to stderr', () => { + console.error('hello from console.error') + console.warn('hello from console.warn') +}) + +test('logging DOM element', () => { + const element = document.createElement('div') + console.log('dom', element) +}) + +test('logging default counter', () => { + console.count() + console.count() + console.count() + console.countReset() + console.count() +}) + +test('logging custom counter', () => { + console.count('count') + console.count('count') + console.count('count') + console.countReset('count') + console.count('count') +}) + +test('logging default time', () => { + console.time() + console.timeLog() + console.timeEnd() +}) + +test('logging custom time', () => { + console.time('time') + console.timeLog('time') + console.timeEnd('time') +}) + +test('logging invalid time', () => { + console.timeLog('invalid timeLog') + console.timeEnd('invalid timeEnd') +}) diff --git a/test/browser/vitest.config.ts b/test/browser/vitest.config.ts index 938b1a1a7885..a1378d68dfb2 100644 --- a/test/browser/vitest.config.ts +++ b/test/browser/vitest.config.ts @@ -4,6 +4,7 @@ const noop = () => {} export default defineConfig({ test: { + include: ['test/**.test.{ts,js}'], browser: { enabled: true, name: 'chrome',