From 32ea024d27a431c528b4beea887fa64fa7128865 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ari=20Perkki=C3=B6?= Date: Sun, 1 Sep 2024 21:24:03 +0300 Subject: [PATCH] feat(browser): support `--inspect-brk` --- docs/guide/debugging.md | 16 ++- packages/browser/src/node/pool.ts | 23 +++- .../browser/src/node/providers/playwright.ts | 3 +- .../vitest/src/node/config/resolveConfig.ts | 3 +- packages/vitest/src/node/types/browser.ts | 2 +- pnpm-lock.yaml | 3 + test/browser/fixtures/inspect/math.test.ts | 5 + .../browser/fixtures/inspect/vitest.config.ts | 12 ++ test/browser/package.json | 3 +- test/browser/specs/inspect.test.ts | 114 ++++++++++++++++++ test/config/test/failures.test.ts | 13 ++ 11 files changed, 185 insertions(+), 12 deletions(-) create mode 100644 test/browser/fixtures/inspect/math.test.ts create mode 100644 test/browser/fixtures/inspect/vitest.config.ts create mode 100644 test/browser/specs/inspect.test.ts diff --git a/docs/guide/debugging.md b/docs/guide/debugging.md index a723018198b4..fb0dba3e9093 100644 --- a/docs/guide/debugging.md +++ b/docs/guide/debugging.md @@ -40,18 +40,19 @@ Then in the debug tab, ensure 'Debug Current Test File' is selected. You can the ### Browser mode -To debug [Vitest Browser Mode](/guide/browser/index.md), pass `--inspect` in CLI or define it in your Vitest configuration: +To debug [Vitest Browser Mode](/guide/browser/index.md), pass `--inspect` or `--inspect-brk` in CLI or define it in your Vitest configuration: ::: code-group ```bash [CLI] -vitest --inspect --browser +vitest --inspect-brk --browser --no-file-parallelism ``` ```ts [vitest.config.js] import { defineConfig } from 'vitest/config' export default defineConfig({ test: { - inspect: true, + inspectBrk: true, + fileParallelism: false, browser: { name: 'chromium', provider: 'playwright', @@ -61,10 +62,10 @@ export default defineConfig({ ``` ::: -By default Vitest will use port `9229` as debugging port. You can overwrite it with by passing value in `inspect`: +By default Vitest will use port `9229` as debugging port. You can overwrite it with by passing value in `--inspect-brk`: ```bash -vitest --inspect=127.0.0.1:3000 --browser +vitest --inspect-brk=127.0.0.1:3000 --browser --no-file-parallelism ``` Use following [VSCode Compound configuration](https://code.visualstudio.com/docs/editor/debugging#_compound-launch-configurations) for launching Vitest and attaching debugger in the browser: @@ -79,7 +80,7 @@ Use following [VSCode Compound configuration](https://code.visualstudio.com/docs "name": "Run Vitest Browser", "program": "${workspaceRoot}/node_modules/vitest/vitest.mjs", "console": "integratedTerminal", - "args": ["--inspect", "--browser"] + "args": ["--inspect-brk", "--browser", "--no-file-parallelism"] }, { "type": "chrome", @@ -120,6 +121,9 @@ vitest --inspect-brk --pool threads --poolOptions.threads.singleThread # To run in a single child process vitest --inspect-brk --pool forks --poolOptions.forks.singleFork + +# To run in browser mode +vitest --inspect-brk --browser --no-file-parallelism ``` If you are using Vitest 1.1 or higher, you can also just provide `--no-file-parallelism` flag: diff --git a/packages/browser/src/node/pool.ts b/packages/browser/src/node/pool.ts index b6369896f512..00870151fbeb 100644 --- a/packages/browser/src/node/pool.ts +++ b/packages/browser/src/node/pool.ts @@ -37,6 +37,23 @@ export function createBrowserPool(ctx: Vitest): ProcessPool { ) } + async function setBreakpoint(contextId: string, file: string) { + if (!project.config.inspector.waitForDebugger) { + return + } + + if (!provider.getCDPSession) { + throw new Error('Unable to set breakpoint, CDP not supported') + } + + const session = await provider.getCDPSession(contextId) + await session.send('Debugger.enable', {}) + await session.send('Debugger.setBreakpointByUrl', { + lineNumber: 0, + urlRegex: escapePathToRegexp(file), + }) + } + const filesPerThread = Math.ceil(files.length / threadsCount) // TODO: make it smarter, @@ -83,7 +100,7 @@ export function createBrowserPool(ctx: Vitest): ProcessPool { const url = new URL('/', origin) url.searchParams.set('contextId', contextId) const page = provider - .openPage(contextId, url.toString()) + .openPage(contextId, url.toString(), () => setBreakpoint(contextId, files[0])) .then(() => waitPromise) promises.push(page) } @@ -145,3 +162,7 @@ export function createBrowserPool(ctx: Vitest): ProcessPool { collectTests: files => runWorkspaceTests('collect', files), } } + +function escapePathToRegexp(path: string): string { + return path.replace(/[/\\.?*()^${}|[\]+]/g, '\\$&') +} diff --git a/packages/browser/src/node/providers/playwright.ts b/packages/browser/src/node/providers/playwright.ts index d037649b8230..cb24179e8479 100644 --- a/packages/browser/src/node/providers/playwright.ts +++ b/packages/browser/src/node/providers/playwright.ts @@ -184,8 +184,9 @@ export class PlaywrightBrowserProvider implements BrowserProvider { return page } - async openPage(contextId: string, url: string) { + async openPage(contextId: string, url: string, beforeNavigate?: () => Promise) { const browserPage = await this.openBrowserPage(contextId) + await beforeNavigate?.() await browserPage.goto(url) } diff --git a/packages/vitest/src/node/config/resolveConfig.ts b/packages/vitest/src/node/config/resolveConfig.ts index 604af0171210..fba6f40e1c6b 100644 --- a/packages/vitest/src/node/config/resolveConfig.ts +++ b/packages/vitest/src/node/config/resolveConfig.ts @@ -213,9 +213,8 @@ export function resolveConfig( && resolved.poolOptions?.threads?.singleThread const isSingleFork = resolved.pool === 'forks' && resolved.poolOptions?.forks?.singleFork - const isBrowser = resolved.browser.enabled - if (resolved.fileParallelism && !isSingleThread && !isSingleFork && !isBrowser) { + if (resolved.fileParallelism && !isSingleThread && !isSingleFork) { const inspectOption = `--inspect${resolved.inspectBrk ? '-brk' : ''}` throw new Error( `You cannot use ${inspectOption} without "--no-file-parallelism", "poolOptions.threads.singleThread" or "poolOptions.forks.singleFork"`, diff --git a/packages/vitest/src/node/types/browser.ts b/packages/vitest/src/node/types/browser.ts index db2bf1e2a6b3..ffaa7080236b 100644 --- a/packages/vitest/src/node/types/browser.ts +++ b/packages/vitest/src/node/types/browser.ts @@ -27,7 +27,7 @@ export interface BrowserProvider { beforeCommand?: (command: string, args: unknown[]) => Awaitable afterCommand?: (command: string, args: unknown[]) => Awaitable getCommandsContext: (contextId: string) => Record - openPage: (contextId: string, url: string) => Promise + openPage: (contextId: string, url: string, beforeNavigate?: () => Promise) => Promise getCDPSession?: (contextId: string) => Promise close: () => Awaitable // eslint-disable-next-line ts/method-signature-style -- we want to allow extended options diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index b080f18cb9f0..4b08663de6c5 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1160,6 +1160,9 @@ importers: webdriverio: specifier: ^8.32.2 version: 8.32.2(typescript@5.5.4) + ws: + specifier: ^8.18.0 + version: 8.18.0 test/cli: devDependencies: diff --git a/test/browser/fixtures/inspect/math.test.ts b/test/browser/fixtures/inspect/math.test.ts new file mode 100644 index 000000000000..0057b5a88156 --- /dev/null +++ b/test/browser/fixtures/inspect/math.test.ts @@ -0,0 +1,5 @@ +import { expect, test } from "vitest"; + +test("sum", () => { + expect(1 + 1).toBe(2) +}) diff --git a/test/browser/fixtures/inspect/vitest.config.ts b/test/browser/fixtures/inspect/vitest.config.ts new file mode 100644 index 000000000000..485b0c32e886 --- /dev/null +++ b/test/browser/fixtures/inspect/vitest.config.ts @@ -0,0 +1,12 @@ +import { defineConfig } from 'vitest/config'; + +export default defineConfig({ + test: { + watch: false, + browser: { + provider: "playwright", + name: "chromium", + headless: true, + }, + }, +}); diff --git a/test/browser/package.json b/test/browser/package.json index 9d7384c67a3b..687da712c534 100644 --- a/test/browser/package.json +++ b/test/browser/package.json @@ -32,6 +32,7 @@ "url": "^0.11.3", "vitest": "workspace:*", "vitest-browser-react": "^0.0.1", - "webdriverio": "^8.32.2" + "webdriverio": "^8.32.2", + "ws": "^8.18.0" } } diff --git a/test/browser/specs/inspect.test.ts b/test/browser/specs/inspect.test.ts new file mode 100644 index 000000000000..d0c6e73caa4a --- /dev/null +++ b/test/browser/specs/inspect.test.ts @@ -0,0 +1,114 @@ +import type { InspectorNotification } from 'node:inspector' +import { expect, test, vi } from 'vitest' +import WebSocket from 'ws' + +import { runVitestCli } from '../../test-utils' + +type Message = Partial> + +const REMOTE_DEBUG_URL = '127.0.0.1:9123' + +test('--inspect-brk stops at test file', async () => { + const { vitest, waitForClose } = await runVitestCli( + '--root', + 'fixtures/inspect', + '--browser', + '--no-file-parallelism', + '--inspect-brk', + REMOTE_DEBUG_URL, + ) + + await vitest.waitForStdout(`Debugger listening on ws:${REMOTE_DEBUG_URL}`) + + const url = await vi.waitFor(() => + fetch(`http://${REMOTE_DEBUG_URL}/json/list`) + .then(response => response.json()) + .then(json => json[0].webSocketDebuggerUrl)) + + const { receive, send } = await createChannel(url) + + const paused = receive('Debugger.paused') + send({ method: 'Debugger.enable' }) + send({ method: 'Runtime.enable' }) + + await receive('Runtime.executionContextCreated') + send({ method: 'Runtime.runIfWaitingForDebugger' }) + + const { params } = await paused + const scriptId = params.callFrames[0].functionLocation.scriptId + + // Verify that debugger paused on test file + const { result } = await send({ method: 'Debugger.getScriptSource', params: { scriptId } }) + + expect(result.scriptSource).toContain('test("sum", () => {') + expect(result.scriptSource).toContain('expect(1 + 1).toBe(2)') + + send({ method: 'Debugger.resume' }) + + await vitest.waitForStdout('Test Files 1 passed (1)') + await waitForClose() +}) + +async function createChannel(url: string) { + const ws = new WebSocket(url) + + let id = 1 + let listeners = [] + + ws.onmessage = (message) => { + const response = JSON.parse(message.data.toString()) + listeners.forEach(listener => listener(response)) + } + + async function receive(methodOrId?: string | { id: number }): Promise { + const { promise, resolve, reject } = withResolvers() + listeners.push(listener) + ws.onerror = reject + + function listener(message) { + const filter = typeof methodOrId === 'string' ? { method: methodOrId } : { id: methodOrId.id } + + const methodMatch = message.method && message.method === filter.method + const idMatch = message.id && message.id === filter.id + + if (methodMatch || idMatch) { + resolve(message) + listeners = listeners.filter(l => l !== listener) + ws.onerror = undefined + } + else if (!filter.id && !filter.method) { + resolve(message) + } + } + + return promise + } + + async function send(message: Message): Promise { + const currentId = id++ + const json = JSON.stringify({ ...message, id: currentId }) + + const receiver = receive({ id: currentId }) + ws.send(json) + + return receiver + } + + await new Promise((resolve, reject) => { + ws.onerror = reject + ws.on('open', resolve) + }) + + return { receive, send } +} + +function withResolvers() { + let reject: (error: unknown) => void + let resolve: (response: Message) => void + + const promise: Promise = new Promise((...args) => { + [resolve, reject] = args + }) + + return { promise, resolve, reject } +} diff --git a/test/config/test/failures.test.ts b/test/config/test/failures.test.ts index 6ff5a7ce8aaa..22a08200dbb1 100644 --- a/test/config/test/failures.test.ts +++ b/test/config/test/failures.test.ts @@ -61,12 +61,24 @@ test('inspect cannot be used with multi-threading', async () => { expect(stderr).toMatch('Error: You cannot use --inspect without "--no-file-parallelism", "poolOptions.threads.singleThread" or "poolOptions.forks.singleFork"') }) +test('inspect in browser mode requires no-file-parallelism', async () => { + const { stderr } = await runVitest({ inspect: true, browser: { enabled: true, name: 'chromium', provider: 'playwright' } }) + + expect(stderr).toMatch('Error: You cannot use --inspect without "--no-file-parallelism", "poolOptions.threads.singleThread" or "poolOptions.forks.singleFork"') +}) + test('inspect-brk cannot be used with multi processing', async () => { const { stderr } = await runVitest({ inspect: true, pool: 'forks', poolOptions: { forks: { singleFork: false } } }) expect(stderr).toMatch('Error: You cannot use --inspect without "--no-file-parallelism", "poolOptions.threads.singleThread" or "poolOptions.forks.singleFork"') }) +test('inspect-brk in browser mode requires no-file-parallelism', async () => { + const { stderr } = await runVitest({ inspectBrk: true, browser: { enabled: true, name: 'chromium', provider: 'playwright' } }) + + expect(stderr).toMatch('Error: You cannot use --inspect-brk without "--no-file-parallelism", "poolOptions.threads.singleThread" or "poolOptions.forks.singleFork"') +}) + test('inspect and --inspect-brk cannot be used when not playwright + chromium', async () => { for (const option of ['inspect', 'inspectBrk']) { const cli = `--inspect${option === 'inspectBrk' ? '-brk' : ''}` @@ -78,6 +90,7 @@ test('inspect and --inspect-brk cannot be used when not playwright + chromium', const { stderr } = await runVitest({ [option]: true, + fileParallelism: false, browser: { enabled: true, provider,