Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions docs/api/browser/commands.md
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,8 @@ expect(input).toHaveValue('a')

::: warning
CDP session works only with `playwright` provider and only when using `chromium` browser. You can read more about it in playwright's [`CDPSession`](https://playwright.dev/docs/api/class-cdpsession) documentation.

CDP is a privileged debugging API. It is available only when browser API write and exec operations are enabled through [`browser.api.allowWrite`](/config/browser/api#api-allowwrite), [`browser.api.allowExec`](/config/browser/api#api-allowexec), [`api.allowWrite`](/config/api#api-allowwrite), and [`api.allowExec`](/config/api#api-allowexec).
:::

## Custom Commands
Expand Down
2 changes: 2 additions & 0 deletions docs/api/browser/context.md
Original file line number Diff line number Diff line change
Expand Up @@ -211,6 +211,8 @@ The `cdp` export returns the current Chrome DevTools Protocol session. It is mos

::: warning
CDP session works only with `playwright` provider and only when using `chromium` browser. You can read more about it in playwright's [`CDPSession`](https://playwright.dev/docs/api/class-cdpsession) documentation.

CDP is a privileged debugging API. It is available only when browser API write and exec operations are enabled through [`browser.api.allowWrite`](/config/browser/api#api-allowwrite), [`browser.api.allowExec`](/config/browser/api#api-allowexec), [`api.allowWrite`](/config/api#api-allowwrite), and [`api.allowExec`](/config/api#api-allowexec).
:::

```ts
Expand Down
4 changes: 2 additions & 2 deletions docs/config/browser/api.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ Configure options for Vite server that serves code in the browser. Does not affe
- **Type:** `boolean`
- **Default:** `true` if not exposed to the network, `false` otherwise

Vitest saves [annotation attachments](/guide/test-annotations), [artifacts](/api/advanced/artifacts) and [snapshots](/guide/snapshot) by receiving a WebSocket connection from the browser. This allows anyone who can connect to the API write any arbitrary code on your machine within the root of your project (configured by [`fs.allow`](https://vite.dev/config/server-options#server-fs-allow)).
Vitest saves [annotation attachments](/guide/test-annotations), [artifacts](/api/advanced/artifacts) and [snapshots](/guide/snapshot) by receiving a WebSocket connection from the browser. This allows anyone who can connect to the API write any arbitrary code on your machine within the root of your project (configured by [`fs.allow`](https://vite.dev/config/server-options#server-fs-allow)). This option also gates privileged browser APIs that can write files indirectly, such as raw Chrome DevTools Protocol access through [`cdp()`](/api/browser/context#cdp).

If browser server is not exposed to the internet (the host is `localhost`), this should not be a problem, so the default value in that case is `true`. If you override the host, Vitest will set `allowWrite` to `false` by default to prevent potentially harmful writes.

Expand All @@ -25,4 +25,4 @@ If browser server is not exposed to the internet (the host is `localhost`), this
- **Type:** `boolean`
- **Default:** `true` if not exposed to the network, `false` otherwise

Allows running any test file via the UI. This only applies to the interactive elements (and the server code behind them) in the [UI](/guide/ui) that can run the code. If UI is disabled, this has no effect. See [`api.allowExec`](/config/api#api-allowexec) for more information.
Allows running any test file via the UI. This applies to the interactive elements (and the server code behind them) in the [UI](/guide/ui) that can run the code. This option also gates privileged browser APIs that can execute code indirectly, such as raw Chrome DevTools Protocol access through [`cdp()`](/api/browser/context#cdp). See [`api.allowExec`](/config/api#api-allowexec) for more information.
18 changes: 5 additions & 13 deletions packages/browser-playwright/src/playwright.ts
Original file line number Diff line number Diff line change
Expand Up @@ -537,19 +537,11 @@ export class PlaywrightBrowserProvider implements BrowserProvider {
const page = this.getPage(sessionid)
const cdp = await page.context().newCDPSession(page)
return {
send(method, params) {
return cdp.send(method as any, params)
},
on(event, listener) {
return cdp.on(event as any, listener)
},
off(event, listener) {
return cdp.off(event as any, listener)
},
once(event, listener) {
return cdp.once(event as any, listener)
},
}
send: cdp.send.bind(cdp),
on: cdp.on.bind(cdp),
off: cdp.off.bind(cdp),
once: cdp.once.bind(cdp),
} as any // overloaded CDPSession type is too tricky in monorepo
}

async close(): Promise<void> {
Expand Down
16 changes: 16 additions & 0 deletions packages/browser/src/node/commands/coverage.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
import type { BrowserCommand } from 'vitest/node'
import type { BrowserServerCDPHandler } from '../cdp'

export const _startV8Coverage: BrowserCommand<[]> = async (context) => {
const session: BrowserServerCDPHandler = await context.__ensureCDPHandler()
await session.send('Profiler.enable')
await session.send('Profiler.startPreciseCoverage', {
callCount: true,
detailed: true,
})
}

export const _takeV8Coverage: BrowserCommand<[]> = async (context) => {
const session: BrowserServerCDPHandler = await context.__ensureCDPHandler()
return session.send('Profiler.takePreciseCoverage')
}
3 changes: 3 additions & 0 deletions packages/browser/src/node/commands/index.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { _startV8Coverage, _takeV8Coverage } from './coverage'
import {
_fileInfo,
readFile,
Expand All @@ -13,6 +14,8 @@ export default {
removeFile: removeFile as typeof removeFile,
writeFile: writeFile as typeof writeFile,
// private commands
__vitest_startV8Coverage: _startV8Coverage as typeof _startV8Coverage,
__vitest_takeV8Coverage: _takeV8Coverage as typeof _takeV8Coverage,
__vitest_markTrace: _markTrace as typeof _markTrace,
__vitest_groupTraceStart: _groupTraceStart as typeof _groupTraceStart,
__vitest_groupTraceEnd: _groupTraceEnd as typeof _groupTraceEnd,
Expand Down
24 changes: 24 additions & 0 deletions packages/browser/src/node/rpc.ts
Original file line number Diff line number Diff line change
Expand Up @@ -130,6 +130,27 @@ export function setupBrowserRpc(globalServer: ParentBrowserProject, defaultMocke
)
}

function isCdpAllowed(project: TestProject) {
return (
project.config.api.allowExec
&& project.config.browser.api.allowExec
&& project.vitest.config.api.allowExec
&& project.vitest.config.browser.api.allowExec
&& project.config.api.allowWrite
&& project.config.browser.api.allowWrite
&& project.vitest.config.api.allowWrite
&& project.vitest.config.browser.api.allowWrite
)
}

function assertCdpAllowed(project: TestProject) {
if (!isCdpAllowed(project)) {
throw new Error(
`Cannot use CDP because browser API write or exec operations are disabled. See https://vitest.dev/config/browser/api.`,
)
}
}

function setupClient(project: TestProject, rpcId: string, ws: WebSocket) {
const mockResolver = new ServerMockResolver(globalServer.vite, {
moduleDirectories: project.config?.deps?.moduleDirectories,
Expand Down Expand Up @@ -304,6 +325,7 @@ export function setupBrowserRpc(globalServer: ParentBrowserProject, defaultMocke
...args,
)
},
__ensureCDPHandler: () => globalServer.ensureCDPHandler(sessionId, rpcId),
},
provider.getCommandsContext(sessionId),
) as any as BrowserCommandContext
Expand Down Expand Up @@ -381,10 +403,12 @@ export function setupBrowserRpc(globalServer: ParentBrowserProject, defaultMocke

// CDP
async sendCdpEvent(sessionId: string, event: string, payload?: Record<string, unknown>) {
assertCdpAllowed(project)
const cdp = await globalServer.ensureCDPHandler(sessionId, rpcId)
return cdp.send(event, payload)
},
async trackCdpEvent(sessionId: string, type: 'on' | 'once' | 'off', event: string, listenerId: string) {
assertCdpAllowed(project)
const cdp = await globalServer.ensureCDPHandler(sessionId, rpcId)
cdp[type](event, listenerId)
},
Expand Down
18 changes: 8 additions & 10 deletions packages/coverage-v8/src/browser.ts
Original file line number Diff line number Diff line change
@@ -1,13 +1,15 @@
import type { CDPSession } from '@vitest/browser-playwright'
import type { Profiler } from 'node:inspector'
import type { CoverageProviderModule } from 'vitest/node'
import type { V8CoverageProvider } from './provider'
import { cdp } from 'vitest/browser'
import { loadProvider } from './load-provider'

const session = cdp() as CDPSession
let enabled = false

type ScriptCoverage = Awaited<ReturnType<typeof session.send<'Profiler.takePreciseCoverage'>>>
type ScriptCoverage = Profiler.TakePreciseCoverageReturnType

function triggerCommand(command: string, args: any[] = []): Promise<any> {
return (globalThis as any).__vitest_browser_runner__.commands.triggerCommand(command, args)
}

const mod: CoverageProviderModule = {
async startCoverage() {
Expand All @@ -17,15 +19,11 @@ const mod: CoverageProviderModule = {

enabled = true

await session.send('Profiler.enable')
await session.send('Profiler.startPreciseCoverage', {
callCount: true,
detailed: true,
})
await triggerCommand('__vitest_startV8Coverage')
},

async takeCoverage(): Promise<{ result: any[] }> {
const coverage = await session.send('Profiler.takePreciseCoverage')
const coverage: ScriptCoverage = await triggerCommand('__vitest_takeV8Coverage')
const result: typeof coverage.result = []

// Reduce amount of data sent over rpc by doing some early result filtering
Expand Down
12 changes: 12 additions & 0 deletions packages/vitest/src/node/types/browser.ts
Original file line number Diff line number Diff line change
Expand Up @@ -358,6 +358,18 @@ export interface BrowserCommandContext {
name: K,
...args: Parameters<BrowserCommands[K]>
) => ReturnType<BrowserCommands[K]>
/**
* Returns Vitest's cached CDP handler for the current tester RPC connection.
* This works similar to client `cdp()` API.
*
* Unlike `provider.getCDPSession`, this preserves CDP session state across
* multiple command calls from the same browser tester. This matters for
* stateful CDP domains such as `Profiler`, where `startPreciseCoverage` and
* `takePreciseCoverage` must run on the same CDP session.
*
* @internal
*/
__ensureCDPHandler: () => Promise<any> // use `any` since type is messy
}

export interface BrowserServerStateSession {
Expand Down
35 changes: 34 additions & 1 deletion test/browser/specs/errors.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import path from 'pathe'
import { expect, test } from 'vitest'
import { rolldownVersion } from 'vitest/node'
import { buildTestProjectTree } from '../../test-utils'
import { instances, runBrowserTests, runInlineBrowserTests } from './utils'
import { instances, provider, runBrowserTests, runInlineBrowserTests } from './utils'

test('prints correct unhandled error stack', async () => {
const { stderr } = await runBrowserTests({
Expand Down Expand Up @@ -218,3 +218,36 @@ test('prints source-mapped stack for optimized dependency', async () => {
}
}
})

test.runIf(provider.name === 'playwright')('cannot use cdp if write or exec is disabled', async () => {
const result = await runInlineBrowserTests({
'cdp.test.ts': `
import { expect, test } from 'vitest'
import { cdp, server } from 'vitest/browser'

test('cdp throws an error', async () => {
await cdp().send('Runtime.evaluate', { expression: '1 + 1' })
})
`,
}, {
browser: {
instances: [{ browser: 'chromium' }],
screenshotFailures: false,
api: {
allowExec: false,
allowWrite: false,
},
},
})
expect(result.errorTree({ project: true })).toMatchInlineSnapshot(`
{
"chromium": {
"cdp.test.ts": {
"cdp throws an error": [
"Cannot use CDP because browser API write or exec operations are disabled. See https://vitest.dev/config/browser/api.",
],
},
},
}
`)
})
36 changes: 36 additions & 0 deletions test/coverage-test/test/browser-api-permissions.browser.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
import { expect } from 'vitest'
import { readCoverageMap, runVitest, test } from '../utils'

test('browser coverage works when browser api write and exec are disabled', async () => {
await runVitest({
api: {
allowExec: false,
allowWrite: false,
},
browser: {
api: {
allowExec: false,
allowWrite: false,
},
},
include: ['fixtures/test/math.test.ts'],
coverage: {
reporter: 'json',
include: ['fixtures/src/math.ts'],
},
})

const coverageMap = await readCoverageMap()
const fileCoverages = coverageMap.files().map(file => coverageMap.fileCoverageFor(file))

expect(fileCoverages).toMatchInlineSnapshot(`
{
"<process-cwd>/fixtures/src/math.ts": {
"branches": "0/0 (100%)",
"functions": "1/4 (25%)",
"lines": "1/4 (25%)",
"statements": "1/4 (25%)",
},
}
`)
})
Loading