Skip to content

Commit

Permalink
feat: show browser console in the terminal (#3048)
Browse files Browse the repository at this point in the history
  • Loading branch information
sheremet-va committed Mar 22, 2023
1 parent 051bb65 commit ee6f590
Show file tree
Hide file tree
Showing 21 changed files with 356 additions and 90 deletions.
104 changes: 104 additions & 0 deletions 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<string, number> = {}

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<string, number> = {}

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)
}
}
28 changes: 16 additions & 12 deletions 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() }
Expand Down Expand Up @@ -51,42 +53,43 @@ 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()

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
}

Expand All @@ -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)
}
}
74 changes: 74 additions & 0 deletions 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<Promise<unknown>>()

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
}
9 changes: 3 additions & 6 deletions packages/browser/src/client/runner.ts
@@ -1,24 +1,21 @@
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<string, string>
}

export function createBrowserRunner(original: any) {
return class BrowserTestRunner extends original {
public config: ResolvedConfig
hashMap = new Map<string, string>()
client: VitestClient

constructor(options: BrowserRunnerOptions) {
super(options.config)
this.config = options.config
this.hashMap = options.browserHashMap
this.client = options.client
}

async onAfterRunTest(task: Test) {
Expand All @@ -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<void> {
return this.client.rpc.onTaskUpdate(task)
return rpc().onTaskUpdate(task)
}

async importFile(filepath: string) {
Expand Down
14 changes: 6 additions & 8 deletions 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<string | null> {
return this.client.rpc.readFile(filepath)
return rpc().readFile(filepath)
}

saveSnapshotFile(filepath: string, snapshot: string): Promise<void> {
return this.client.rpc.writeFile(filepath, snapshot)
return rpc().writeFile(filepath, snapshot)
}

resolvePath(filepath: string): Promise<string> {
return this.client.rpc.resolveSnapshotPath(filepath)
return rpc().resolveSnapshotPath(filepath)
}

removeSnapshotFile(filepath: string): Promise<void> {
return this.client.rpc.removeFile(filepath)
return rpc().removeFile(filepath)
}

async prepareDirectory(filepath: string): Promise<void> {
await this.client.rpc.createDirectory(filepath)
await rpc().createDirectory(filepath)
}
}
4 changes: 4 additions & 0 deletions packages/browser/src/client/utils.ts
@@ -0,0 +1,4 @@
export const importId = (id: string) => {
const name = `/@id/${id}`
return import(name)
}
25 changes: 9 additions & 16 deletions packages/browser/src/node/index.ts
Expand Up @@ -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,
Expand All @@ -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)
Expand Down
3 changes: 2 additions & 1 deletion 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`.
Expand Down Expand Up @@ -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,
Expand Down

0 comments on commit ee6f590

Please sign in to comment.