Skip to content

Commit ee6f590

Browse files
authored
feat: show browser console in the terminal (#3048)
1 parent 051bb65 commit ee6f590

File tree

21 files changed

+356
-90
lines changed

21 files changed

+356
-90
lines changed

packages/browser/src/client/logger.ts

+104
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,104 @@
1+
import { rpc } from './rpc'
2+
import { importId } from './utils'
3+
4+
const { Date, console } = globalThis
5+
6+
export const setupConsoleLogSpy = async () => {
7+
const { stringify, format, utilInspect } = await importId('vitest/utils') as typeof import('vitest/utils')
8+
const { log, info, error, dir, dirxml, trace, time, timeEnd, timeLog, warn, debug, count, countReset } = console
9+
const formatInput = (input: unknown) => {
10+
if (input instanceof Node)
11+
return stringify(input)
12+
return format(input)
13+
}
14+
const processLog = (args: unknown[]) => args.map(formatInput).join(' ')
15+
const sendLog = (type: 'stdout' | 'stderr', content: string) => {
16+
if (content.startsWith('[vite]'))
17+
return
18+
const unknownTestId = '__vitest__unknown_test__'
19+
// @ts-expect-error untyped global
20+
const taskId = globalThis.__vitest_worker__?.current?.id ?? unknownTestId
21+
rpc().sendLog({
22+
content,
23+
time: Date.now(),
24+
taskId,
25+
type,
26+
size: content.length,
27+
})
28+
}
29+
const stdout = (base: (...args: unknown[]) => void) => (...args: unknown[]) => {
30+
sendLog('stdout', processLog(args))
31+
return base(...args)
32+
}
33+
const stderr = (base: (...args: unknown[]) => void) => (...args: unknown[]) => {
34+
sendLog('stderr', processLog(args))
35+
return base(...args)
36+
}
37+
console.log = stdout(log)
38+
console.debug = stdout(debug)
39+
console.info = stdout(info)
40+
41+
console.error = stderr(error)
42+
console.warn = stderr(warn)
43+
44+
console.dir = (item, options) => {
45+
sendLog('stdout', utilInspect(item, options))
46+
return dir(item, options)
47+
}
48+
49+
console.dirxml = (...args) => {
50+
sendLog('stdout', processLog(args))
51+
return dirxml(...args)
52+
}
53+
54+
console.trace = (...args: unknown[]) => {
55+
const content = processLog(args)
56+
const error = new Error('Trace')
57+
const stack = (error.stack || '').split('\n').slice(2).join('\n')
58+
sendLog('stdout', `${content}\n${stack}`)
59+
return trace(...args)
60+
}
61+
62+
const timeLabels: Record<string, number> = {}
63+
64+
console.time = (label = 'default') => {
65+
const now = performance.now()
66+
time(label)
67+
timeLabels[label] = now
68+
}
69+
70+
console.timeLog = (label = 'default') => {
71+
timeLog(label)
72+
if (!(label in timeLabels))
73+
sendLog('stderr', `Timer "${label}" does not exist`)
74+
else
75+
sendLog('stdout', `${label}: ${timeLabels[label]} ms`)
76+
}
77+
78+
console.timeEnd = (label = 'default') => {
79+
const end = performance.now()
80+
timeEnd(label)
81+
const start = timeLabels[label]
82+
if (!(label in timeLabels)) {
83+
sendLog('stderr', `Timer "${label}" does not exist`)
84+
}
85+
else if (start) {
86+
const duration = end - start
87+
sendLog('stdout', `${label}: ${duration} ms`)
88+
}
89+
}
90+
91+
const countLabels: Record<string, number> = {}
92+
93+
console.count = (label = 'default') => {
94+
const counter = (countLabels[label] ?? 0) + 1
95+
countLabels[label] = counter
96+
sendLog('stdout', `${label}: ${counter}`)
97+
return count(label)
98+
}
99+
100+
console.countReset = (label = 'default') => {
101+
countLabels[label] = 0
102+
return countReset(label)
103+
}
104+
}

packages/browser/src/client/main.ts

+16-12
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,12 @@
1-
import type { VitestClient } from '@vitest/ws-client'
21
import { createClient } from '@vitest/ws-client'
32
// eslint-disable-next-line no-restricted-imports
43
import type { ResolvedConfig } from 'vitest'
54
import type { VitestRunner } from '@vitest/runner'
65
import { createBrowserRunner } from './runner'
76
import { BrowserSnapshotEnvironment } from './snapshot'
7+
import { importId } from './utils'
8+
import { setupConsoleLogSpy } from './logger'
9+
import { createSafeRpc, rpc, rpcDone } from './rpc'
810

911
// @ts-expect-error mocking some node apis
1012
globalThis.process = { env: {}, argv: [], cwd: () => '/', stdout: { write: () => {} }, nextTick: cb => cb() }
@@ -51,42 +53,43 @@ async function loadConfig() {
5153
ws.addEventListener('open', async () => {
5254
await loadConfig()
5355

56+
const { getSafeTimers } = await importId('vitest/utils') as typeof import('vitest/utils')
57+
const safeRpc = createSafeRpc(client, getSafeTimers)
58+
5459
// @ts-expect-error mocking vitest apis
5560
globalThis.__vitest_worker__ = {
5661
config,
5762
browserHashMap,
5863
moduleCache: new Map(),
5964
rpc: client.rpc,
65+
safeRpc,
6066
}
6167

6268
const paths = getQueryPaths()
6369

6470
const iFrame = document.getElementById('vitest-ui') as HTMLIFrameElement
6571
iFrame.setAttribute('src', '/__vitest__/')
6672

67-
await runTests(paths, config, client)
73+
await setupConsoleLogSpy()
74+
await runTests(paths, config)
6875
})
6976

7077
let hasSnapshot = false
71-
async function runTests(paths: string[], config: any, client: VitestClient) {
78+
async function runTests(paths: string[], config: any) {
7279
// need to import it before any other import, otherwise Vite optimizer will hang
7380
const viteClientPath = '/@vite/client'
7481
await import(viteClientPath)
7582

76-
// we use dynamic import here, because this file is bundled with UI,
77-
// but we need to resolve correct path at runtime
78-
const path = '/__vitest_index__'
79-
const { startTests, setupCommonEnv, setupSnapshotEnvironment } = await import(path) as typeof import('vitest/browser')
83+
const { startTests, setupCommonEnv, setupSnapshotEnvironment } = await importId('vitest/browser') as typeof import('vitest/browser')
8084

8185
if (!runner) {
82-
const runnerPath = '/__vitest_runners__'
83-
const { VitestTestRunner } = await import(runnerPath) as typeof import('vitest/runners')
86+
const { VitestTestRunner } = await importId('vitest/runners') as typeof import('vitest/runners')
8487
const BrowserRunner = createBrowserRunner(VitestTestRunner)
85-
runner = new BrowserRunner({ config, client, browserHashMap })
88+
runner = new BrowserRunner({ config, browserHashMap })
8689
}
8790

8891
if (!hasSnapshot) {
89-
setupSnapshotEnvironment(new BrowserSnapshotEnvironment(client))
92+
setupSnapshotEnvironment(new BrowserSnapshotEnvironment())
9093
hasSnapshot = true
9194
}
9295

@@ -102,6 +105,7 @@ async function runTests(paths: string[], config: any, client: VitestClient) {
102105
await startTests(files, runner)
103106
}
104107
finally {
105-
await client.rpc.onDone(testId)
108+
await rpcDone()
109+
await rpc().onDone(testId)
106110
}
107111
}

packages/browser/src/client/rpc.ts

+74
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,74 @@
1+
import type {
2+
getSafeTimers,
3+
} from '@vitest/utils'
4+
import type { VitestClient } from '@vitest/ws-client'
5+
6+
const { get } = Reflect
7+
const safeRandom = Math.random
8+
9+
function withSafeTimers(getTimers: typeof getSafeTimers, fn: () => void) {
10+
const { setTimeout, clearTimeout, nextTick, setImmediate, clearImmediate } = getTimers()
11+
12+
const currentSetTimeout = globalThis.setTimeout
13+
const currentClearTimeout = globalThis.clearTimeout
14+
const currentRandom = globalThis.Math.random
15+
const currentNextTick = globalThis.process.nextTick
16+
const currentSetImmediate = globalThis.setImmediate
17+
const currentClearImmediate = globalThis.clearImmediate
18+
19+
try {
20+
globalThis.setTimeout = setTimeout
21+
globalThis.clearTimeout = clearTimeout
22+
globalThis.Math.random = safeRandom
23+
globalThis.process.nextTick = nextTick
24+
globalThis.setImmediate = setImmediate
25+
globalThis.clearImmediate = clearImmediate
26+
27+
const result = fn()
28+
return result
29+
}
30+
finally {
31+
globalThis.setTimeout = currentSetTimeout
32+
globalThis.clearTimeout = currentClearTimeout
33+
globalThis.Math.random = currentRandom
34+
globalThis.setImmediate = currentSetImmediate
35+
globalThis.clearImmediate = currentClearImmediate
36+
nextTick(() => {
37+
globalThis.process.nextTick = currentNextTick
38+
})
39+
}
40+
}
41+
42+
const promises = new Set<Promise<unknown>>()
43+
44+
export const rpcDone = async () => {
45+
if (!promises.size)
46+
return
47+
const awaitable = Array.from(promises)
48+
return Promise.all(awaitable)
49+
}
50+
51+
export const createSafeRpc = (client: VitestClient, getTimers: () => any): VitestClient['rpc'] => {
52+
return new Proxy(client.rpc, {
53+
get(target, p, handler) {
54+
const sendCall = get(target, p, handler)
55+
const safeSendCall = (...args: any[]) => withSafeTimers(getTimers, async () => {
56+
const result = sendCall(...args)
57+
promises.add(result)
58+
try {
59+
return await result
60+
}
61+
finally {
62+
promises.delete(result)
63+
}
64+
})
65+
safeSendCall.asEvent = sendCall.asEvent
66+
return safeSendCall
67+
},
68+
})
69+
}
70+
71+
export const rpc = (): VitestClient['rpc'] => {
72+
// @ts-expect-error not typed global
73+
return globalThis.__vitest_worker__.safeRpc
74+
}

packages/browser/src/client/runner.ts

+3-6
Original file line numberDiff line numberDiff line change
@@ -1,24 +1,21 @@
11
import type { File, TaskResult, Test } from '@vitest/runner'
2-
import type { VitestClient } from '@vitest/ws-client'
2+
import { rpc } from './rpc'
33
import type { ResolvedConfig } from '#types'
44

55
interface BrowserRunnerOptions {
66
config: ResolvedConfig
7-
client: VitestClient
87
browserHashMap: Map<string, string>
98
}
109

1110
export function createBrowserRunner(original: any) {
1211
return class BrowserTestRunner extends original {
1312
public config: ResolvedConfig
1413
hashMap = new Map<string, string>()
15-
client: VitestClient
1614

1715
constructor(options: BrowserRunnerOptions) {
1816
super(options.config)
1917
this.config = options.config
2018
this.hashMap = options.browserHashMap
21-
this.client = options.client
2219
}
2320

2421
async onAfterRunTest(task: Test) {
@@ -29,11 +26,11 @@ export function createBrowserRunner(original: any) {
2926
}
3027

3128
onCollected(files: File[]): unknown {
32-
return this.client.rpc.onCollected(files)
29+
return rpc().onCollected(files)
3330
}
3431

3532
onTaskUpdate(task: [string, TaskResult | undefined][]): Promise<void> {
36-
return this.client.rpc.onTaskUpdate(task)
33+
return rpc().onTaskUpdate(task)
3734
}
3835

3936
async importFile(filepath: string) {
+6-8
Original file line numberDiff line numberDiff line change
@@ -1,26 +1,24 @@
1-
import type { VitestClient } from '@vitest/ws-client'
1+
import { rpc } from './rpc'
22
import type { SnapshotEnvironment } from '#types'
33

44
export class BrowserSnapshotEnvironment implements SnapshotEnvironment {
5-
constructor(private client: VitestClient) {}
6-
75
readSnapshotFile(filepath: string): Promise<string | null> {
8-
return this.client.rpc.readFile(filepath)
6+
return rpc().readFile(filepath)
97
}
108

119
saveSnapshotFile(filepath: string, snapshot: string): Promise<void> {
12-
return this.client.rpc.writeFile(filepath, snapshot)
10+
return rpc().writeFile(filepath, snapshot)
1311
}
1412

1513
resolvePath(filepath: string): Promise<string> {
16-
return this.client.rpc.resolveSnapshotPath(filepath)
14+
return rpc().resolveSnapshotPath(filepath)
1715
}
1816

1917
removeSnapshotFile(filepath: string): Promise<void> {
20-
return this.client.rpc.removeFile(filepath)
18+
return rpc().removeFile(filepath)
2119
}
2220

2321
async prepareDirectory(filepath: string): Promise<void> {
24-
await this.client.rpc.createDirectory(filepath)
22+
await rpc().createDirectory(filepath)
2523
}
2624
}

packages/browser/src/client/utils.ts

+4
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
export const importId = (id: string) => {
2+
const name = `/@id/${id}`
3+
return import(name)
4+
}

packages/browser/src/node/index.ts

+9-16
Original file line numberDiff line numberDiff line change
@@ -18,21 +18,6 @@ export default (base = '/'): Plugin[] => {
1818
{
1919
enforce: 'pre',
2020
name: 'vitest:browser',
21-
async resolveId(id) {
22-
if (id === '/__vitest_index__')
23-
return this.resolve('vitest/browser')
24-
25-
if (id === '/__vitest_runners__')
26-
return this.resolve('vitest/runners')
27-
28-
if (id.startsWith('node:'))
29-
id = id.slice(5)
30-
31-
if (polyfills.includes(id))
32-
return polyfillPath(normalizeId(id))
33-
34-
return null
35-
},
3621
async configureServer(server) {
3722
server.middlewares.use(
3823
base,
@@ -45,8 +30,16 @@ export default (base = '/'): Plugin[] => {
4530
},
4631
{
4732
name: 'modern-node-polyfills',
33+
enforce: 'pre',
34+
config() {
35+
return {
36+
optimizeDeps: {
37+
exclude: [...polyfills, ...builtinModules],
38+
},
39+
}
40+
},
4841
async resolveId(id) {
49-
if (!builtinModules.includes(id))
42+
if (!builtinModules.includes(id) && !polyfills.includes(id) && !id.startsWith('node:'))
5043
return
5144

5245
id = normalizeId(id)

packages/runner/src/utils/collect.ts

+2-1
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import type { Suite, TaskBase } from '../types'
2+
import { processError } from './error'
23

34
/**
45
* If any tasks been marked as `only`, mark all other tasks as `skip`.
@@ -65,7 +66,7 @@ function skipAllTasks(suite: Suite) {
6566
function checkAllowOnly(task: TaskBase, allowOnly?: boolean) {
6667
if (allowOnly)
6768
return
68-
const error = new Error('[Vitest] Unexpected .only modifier. Remove it or pass --allowOnly argument to bypass this error')
69+
const error = processError(new Error('[Vitest] Unexpected .only modifier. Remove it or pass --allowOnly argument to bypass this error'))
6970
task.result = {
7071
state: 'fail',
7172
error,

0 commit comments

Comments
 (0)