From 8d94db14f7d37dc7d8b536a8c8a1608a862fa17b Mon Sep 17 00:00:00 2001 From: Vladimir Sheremet Date: Thu, 6 Jun 2024 19:06:06 +0200 Subject: [PATCH 01/29] feat(browser): attach the context to every page to allow parallelisation --- packages/browser/providers/playwright.d.ts | 4 +- packages/browser/src/client/client.ts | 6 ++- packages/browser/src/client/orchestrator.ts | 4 +- .../src/client/public/esm-client-injector.js | 1 + packages/browser/src/client/utils.ts | 1 + packages/browser/src/node/index.ts | 35 +++++++++++++----- .../browser/src/node/plugins/pluginContext.ts | 2 +- .../browser/src/node/providers/playwright.ts | 19 +++++++--- packages/vitest/src/api/browser.ts | 7 +--- packages/vitest/src/api/types.ts | 3 +- packages/vitest/src/node/pools/browser.ts | 37 +++++++++++-------- packages/vitest/src/node/workspace.ts | 4 +- 12 files changed, 77 insertions(+), 46 deletions(-) diff --git a/packages/browser/providers/playwright.d.ts b/packages/browser/providers/playwright.d.ts index 0c2c99266b27..9d2c5ae41e7d 100644 --- a/packages/browser/providers/playwright.d.ts +++ b/packages/browser/providers/playwright.d.ts @@ -1,8 +1,8 @@ -import type { Browser, LaunchOptions } from 'playwright' +import type { BrowserContextOptions, LaunchOptions } from 'playwright' declare module 'vitest/node' { interface BrowserProviderOptions { launch?: LaunchOptions - page?: Parameters[0] + context?: BrowserContextOptions } } diff --git a/packages/browser/src/client/client.ts b/packages/browser/src/client/client.ts index df2ff58cdd56..b33a127e1a1b 100644 --- a/packages/browser/src/client/client.ts +++ b/packages/browser/src/client/client.ts @@ -9,7 +9,9 @@ const PAGE_TYPE = getBrowserState().type export const PORT = import.meta.hot ? '51204' : location.port export const HOST = [location.hostname, PORT].filter(Boolean).join(':') -export const SESSION_ID = crypto.randomUUID() +export const SESSION_ID = PAGE_TYPE === 'orchestrator' + ? getBrowserState().contextId + : crypto.randomUUID() export const ENTRY_URL = `${ location.protocol === 'https:' ? 'wss:' : 'ws:' }//${HOST}/__vitest_browser_api__?type=${PAGE_TYPE}&sessionId=${SESSION_ID}` @@ -120,4 +122,4 @@ function createClient() { } export const client = createClient() -export const channel = new BroadcastChannel('vitest') +export const channel = new BroadcastChannel(`vitest:${getBrowserState().contextId}`) diff --git a/packages/browser/src/client/orchestrator.ts b/packages/browser/src/client/orchestrator.ts index eb3963db550f..c15c95c7e523 100644 --- a/packages/browser/src/client/orchestrator.ts +++ b/packages/browser/src/client/orchestrator.ts @@ -35,7 +35,7 @@ function createIframe(container: HTMLDivElement, file: string) { const iframe = document.createElement('iframe') iframe.setAttribute('loading', 'eager') - iframe.setAttribute('src', `${url.pathname}__vitest_test__/__test__/${encodeURIComponent(file)}`) + iframe.setAttribute('src', `${url.pathname}__vitest_test__/__test__/${getBrowserState().contextId}/${encodeURIComponent(file)}`) iframe.setAttribute('data-vitest', 'true') iframe.style.display = 'block' @@ -52,7 +52,7 @@ function createIframe(container: HTMLDivElement, file: string) { async function done() { await rpcDone() - await client.rpc.finishBrowserTests() + await client.rpc.finishBrowserTests(getBrowserState().contextId) } interface IframeDoneEvent { diff --git a/packages/browser/src/client/public/esm-client-injector.js b/packages/browser/src/client/public/esm-client-injector.js index d8020791ce01..067b1fe207ea 100644 --- a/packages/browser/src/client/public/esm-client-injector.js +++ b/packages/browser/src/client/public/esm-client-injector.js @@ -21,6 +21,7 @@ window.__vitest_browser_runner__ = { config: { __VITEST_CONFIG__ }, files: { __VITEST_FILES__ }, type: { __VITEST_TYPE__ }, + contextId: { __VITEST_CONTEXT_ID__ }, } const config = __vitest_browser_runner__.config diff --git a/packages/browser/src/client/utils.ts b/packages/browser/src/client/utils.ts index f8fedcc2f517..f27562a187d8 100644 --- a/packages/browser/src/client/utils.ts +++ b/packages/browser/src/client/utils.ts @@ -17,6 +17,7 @@ interface BrowserRunnerState { type: 'tester' | 'orchestrator' wrapModule: (module: () => T) => T iframeId?: string + contextId: string runTests?: (tests: string[]) => Promise createTesters?: (files: string[]) => Promise } diff --git a/packages/browser/src/node/index.ts b/packages/browser/src/node/index.ts index a05e3392c7b0..abf0b7640d8b 100644 --- a/packages/browser/src/node/index.ts +++ b/packages/browser/src/node/index.ts @@ -62,22 +62,29 @@ export default (project: WorkspaceProject, base = '/'): Plugin[] => { res.setHeader('Cache-Control', 'no-cache, max-age=0, must-revalidate') res.setHeader('Content-Type', 'text/html; charset=utf-8') - const files = project.browserState?.files ?? [] - const config = wrapConfig(project.getSerializableConfig()) config.env ??= {} config.env.VITEST_BROWSER_DEBUG = process.env.VITEST_BROWSER_DEBUG || '' - const injector = replacer(await injectorJs, { - __VITEST_CONFIG__: JSON.stringify(config), - __VITEST_FILES__: JSON.stringify(files), - __VITEST_TYPE__: url.pathname === base ? '"orchestrator"' : '"tester"', - }) - // remove custom iframe related headers to allow the iframe to load res.removeHeader('X-Frame-Options') if (url.pathname === base) { + let contextId = url.searchParams.get('contextId') + // it's possible to open the page without a context, + // for now, let's assume it should be the first one + if (!contextId) + contextId = project.browserState.keys().next().value ?? 'none' + + const files = project.browserState.get(contextId!)?.files ?? [] + + const injector = replacer(await injectorJs, { + __VITEST_CONFIG__: JSON.stringify(config), + __VITEST_FILES__: JSON.stringify(files), + __VITEST_TYPE__: url.pathname === base ? '"orchestrator"' : '"tester"', + __VITEST_CONTEXT_ID__: JSON.stringify(contextId), + }) + // disable CSP for the orchestrator as we are the ones controlling it res.removeHeader('Content-Security-Policy') @@ -105,6 +112,7 @@ export default (project: WorkspaceProject, base = '/'): Plugin[] => { __VITEST_TITLE__: 'Vitest Browser Runner', __VITEST_SCRIPTS__: orchestratorScripts, __VITEST_INJECTOR__: injector, + __VITEST_CONTEXT_ID__: JSON.stringify(contextId), }) res.write(html, 'utf-8') res.end() @@ -118,11 +126,20 @@ export default (project: WorkspaceProject, base = '/'): Plugin[] => { res.setHeader('Content-Security-Policy', csp.replace(/frame-ancestors [^;]+/, 'frame-ancestors *')) } - const decodedTestFile = decodeURIComponent(url.pathname.slice(testerPrefix.length)) + const [contextId, testFile] = url.pathname.slice(testerPrefix.length).split('/') + const decodedTestFile = decodeURIComponent(testFile) const testFiles = await project.globTestFiles() // if decoded test file is "__vitest_all__" or not in the list of known files, run all tests const tests = decodedTestFile === '__vitest_all__' || !testFiles.includes(decodedTestFile) ? '__vitest_browser_runner__.files' : JSON.stringify([decodedTestFile]) const iframeId = JSON.stringify(decodedTestFile) + const files = project.browserState.get(contextId)?.files ?? [] + + const injector = replacer(await injectorJs, { + __VITEST_CONFIG__: JSON.stringify(config), + __VITEST_FILES__: JSON.stringify(files), + __VITEST_TYPE__: url.pathname === base ? '"orchestrator"' : '"tester"', + __VITEST_CONTEXT_ID__: JSON.stringify(contextId), + }) if (!testerScripts) testerScripts = await formatScripts(project.config.browser.testerScripts, server) diff --git a/packages/browser/src/node/plugins/pluginContext.ts b/packages/browser/src/node/plugins/pluginContext.ts index 4a13535fcd14..3f2ec80b8209 100644 --- a/packages/browser/src/node/plugins/pluginContext.ts +++ b/packages/browser/src/node/plugins/pluginContext.ts @@ -51,7 +51,7 @@ async function generateContextFile(this: PluginContext, project: WorkspaceProjec ${userEventNonProviderImport} const filepath = () => ${filepathCode} const rpc = () => __vitest_worker__.rpc -const channel = new BroadcastChannel('vitest') +const channel = new BroadcastChannel('vitest:' + __vitest_browser_runner__.contextId) export const server = { platform: ${JSON.stringify(process.platform)}, diff --git a/packages/browser/src/node/providers/playwright.ts b/packages/browser/src/node/providers/playwright.ts index d7c19d724cb9..fc8b8a31772f 100644 --- a/packages/browser/src/node/providers/playwright.ts +++ b/packages/browser/src/node/providers/playwright.ts @@ -1,4 +1,4 @@ -import type { Browser, LaunchOptions, Page } from 'playwright' +import type { Browser, BrowserContext, BrowserContextOptions, LaunchOptions, Page } from 'playwright' import type { BrowserProvider, BrowserProviderInitializationOptions, WorkspaceProject } from 'vitest/node' export const playwrightBrowsers = ['firefox', 'webkit', 'chromium'] as const @@ -13,13 +13,14 @@ export class PlaywrightBrowserProvider implements BrowserProvider { public browser: Browser | null = null public page: Page | null = null + public context: BrowserContext | null = null private browserName!: PlaywrightBrowser private ctx!: WorkspaceProject private options?: { launch?: LaunchOptions - page?: Parameters[0] + context?: BrowserContextOptions } getSupportedBrowsers() { @@ -32,9 +33,9 @@ export class PlaywrightBrowserProvider implements BrowserProvider { this.options = options as any } - private async openBrowserPage() { - if (this.page) - return this.page + private async createContext() { + if (this.context) + return this.context const options = this.ctx.config.browser @@ -45,7 +46,13 @@ export class PlaywrightBrowserProvider implements BrowserProvider { headless: options.headless, }) this.browser = browser - this.page = await browser.newPage(this.options?.page) + this.context = await browser.newContext(this.options?.context) + return this.context + } + + private async openBrowserPage() { + this.context = await this.createContext() + this.page = await this.context.newPage() return this.page } diff --git a/packages/vitest/src/api/browser.ts b/packages/vitest/src/api/browser.ts index 6967c7349238..56b9da8089bd 100644 --- a/packages/vitest/src/api/browser.ts +++ b/packages/vitest/src/api/browser.ts @@ -124,11 +124,8 @@ export function setupBrowserRpc(project: WorkspaceProject, server: ViteDevServer provider: project.browserProvider, }, ...payload) }, - getBrowserFiles() { - return project.browserState?.files ?? [] - }, - finishBrowserTests() { - return project.browserState?.resolve() + finishBrowserTests(contextId: string) { + return project.browserState.get(contextId)?.resolve() }, getProvidedContext() { return 'ctx' in project ? project.getProvidedContext() : ({} as any) diff --git a/packages/vitest/src/api/types.ts b/packages/vitest/src/api/types.ts index d32985f9799d..b378e84b4e71 100644 --- a/packages/vitest/src/api/types.ts +++ b/packages/vitest/src/api/types.ts @@ -37,9 +37,8 @@ export interface WebSocketBrowserHandlers { saveSnapshotFile: (id: string, content: string) => Promise removeSnapshotFile: (id: string) => Promise sendLog: (log: UserConsoleLog) => void - finishBrowserTests: () => void + finishBrowserTests: (contextId: string) => void snapshotSaved: (snapshot: SnapshotResult) => void - getBrowserFiles: () => string[] debug: (...args: string[]) => void resolveId: (id: string, importer?: string) => Promise triggerCommand: (command: string, testPath: string | undefined, payload: unknown[]) => Promise diff --git a/packages/vitest/src/node/pools/browser.ts b/packages/vitest/src/node/pools/browser.ts index 3d0e2b2c1977..12934634fbea 100644 --- a/packages/vitest/src/node/pools/browser.ts +++ b/packages/vitest/src/node/pools/browser.ts @@ -7,17 +7,17 @@ import type { BrowserProvider } from '../../types/browser' export function createBrowserPool(ctx: Vitest): ProcessPool { const providers = new Set() - const waitForTests = async (project: WorkspaceProject, files: string[]) => { + const waitForTests = async (contextId: string, project: WorkspaceProject, files: string[]) => { const defer = createDefer() - project.browserState?.resolve() - project.browserState = { + project.browserState.forEach(state => state.resolve()) + project.browserState.set(contextId, { files, resolve: () => { defer.resolve() - project.browserState = undefined + project.browserState.delete(contextId) }, reject: defer.reject, - } + }) return await defer } @@ -44,17 +44,23 @@ export function createBrowserPool(ctx: Vitest): ProcessPool { if (!origin) throw new Error(`Can't find browser origin URL for project "${project.config.name}"`) - const promise = waitForTests(project, files) + const contextId = crypto.randomUUID() + const promise = waitForTests(contextId, project, files) - const orchestrators = project.browserRpc.orchestrators - if (orchestrators.size) { - orchestrators.forEach((orchestrator) => { - orchestrator.createTesters(files) - }) - } - else { - await provider.openPage(new URL('/', origin).toString()) - } + // const orchestrators = project.browserRpc.orchestrators + // TODO: rerun only opened ones + // expect to have 4 opened pages and distribute tests amongst them + // if there is a UI, have only a sinle page - or just don't support this in watch mode for now? + // if (orchestrators.size) { + // orchestrators.forEach(orchestrator => + // orchestrator.createTesters(files), + // ) + // } + // else { + const url = new URL('/', origin) + url.searchParams.set('contextId', contextId) + await provider.openPage(url.toString()) + // } await promise } @@ -67,6 +73,7 @@ export function createBrowserPool(ctx: Vitest): ProcessPool { groupedFiles.set(project, files) } + // TODO: paralellize tests instead of running them sequentially (based on CPU?) for (const [project, files] of groupedFiles.entries()) await runTests(project, files) } diff --git a/packages/vitest/src/node/workspace.ts b/packages/vitest/src/node/workspace.ts index 2c203a572e6c..a07f93eec82a 100644 --- a/packages/vitest/src/node/workspace.ts +++ b/packages/vitest/src/node/workspace.ts @@ -80,11 +80,11 @@ export class WorkspaceProject { testers: new Map(), } - browserState: { + browserState = new Map void reject: (v: unknown) => void - } | undefined + }>() testFilesList: string[] | null = null From a409387c1e4e0cf50b8bbfd450af76ce6dc07194 Mon Sep 17 00:00:00 2001 From: Vladimir Sheremet Date: Fri, 7 Jun 2024 12:07:40 +0200 Subject: [PATCH 02/29] feat: run playwright tests in parallel --- packages/browser/src/node/commands/click.ts | 8 +- .../browser/src/node/commands/keyboard.ts | 8 +- packages/browser/src/node/commands/utils.ts | 16 +++- packages/browser/src/node/index.ts | 1 + .../browser/src/node/plugins/pluginContext.ts | 7 +- .../browser/src/node/providers/playwright.ts | 83 ++++++++++++----- .../browser/src/node/providers/webdriver.ts | 2 +- packages/vitest/package.json | 2 + packages/vitest/src/api/browser.ts | 11 ++- packages/vitest/src/api/types.ts | 2 +- packages/vitest/src/node/config.ts | 2 +- packages/vitest/src/node/index.ts | 1 + packages/vitest/src/node/pools/browser.ts | 89 ++++++++++++++----- packages/vitest/src/types/browser.ts | 5 +- packages/vitest/src/utils/debugger.ts | 7 ++ pnpm-lock.yaml | 3 + 16 files changed, 185 insertions(+), 62 deletions(-) create mode 100644 packages/vitest/src/utils/debugger.ts diff --git a/packages/browser/src/node/commands/click.ts b/packages/browser/src/node/commands/click.ts index cd36bc83bbd2..34806c0e60d9 100644 --- a/packages/browser/src/node/commands/click.ts +++ b/packages/browser/src/node/commands/click.ts @@ -1,14 +1,14 @@ -import type { Page } from 'playwright' import type { UserEvent } from '../../../context' +import { PlaywrightBrowserProvider } from '../providers/playwright' import type { UserEventCommand } from './utils' export const click: UserEventCommand = async ( - { provider }, + { provider, contextId }, element, options = {}, ) => { - if (provider.name === 'playwright') { - const page = (provider as any).page as Page + if (provider instanceof PlaywrightBrowserProvider) { + const page = provider.getPage(contextId) await page.frameLocator('iframe[data-vitest]').locator(`xpath=${element}`).click(options) return } diff --git a/packages/browser/src/node/commands/keyboard.ts b/packages/browser/src/node/commands/keyboard.ts index 5f9dba3f2a52..ec32cf81f3b3 100644 --- a/packages/browser/src/node/commands/keyboard.ts +++ b/packages/browser/src/node/commands/keyboard.ts @@ -1,6 +1,5 @@ // based on https://github.com/modernweb-dev/web/blob/f7fcf29cb79e82ad5622665d76da3f6b23d0ef43/packages/test-runner-commands/src/sendKeysPlugin.ts -import type { Page } from 'playwright' import type { BrowserCommand } from 'vitest/node' import type { BrowserCommands, @@ -10,6 +9,7 @@ import type { TypePayload, UpPayload, } from '../../../context' +import { PlaywrightBrowserProvider } from '../providers/playwright' function isObject(payload: unknown): payload is Record { return payload != null && typeof payload === 'object' @@ -62,12 +62,12 @@ function isUpPayload(payload: SendKeysPayload): payload is UpPayload { return 'up' in payload } -export const sendKeys: BrowserCommand> = async ({ provider }, payload) => { +export const sendKeys: BrowserCommand> = async ({ provider, contextId }, payload) => { if (!isSendKeysPayload(payload) || !payload) throw new Error('You must provide a `SendKeysPayload` object') - if (provider.name === 'playwright') { - const page = (provider as any).page as Page + if (provider instanceof PlaywrightBrowserProvider) { + const page = provider.getPage(contextId) if (isTypePayload(payload)) await page.keyboard.type(payload.type) else if (isPressPayload(payload)) diff --git a/packages/browser/src/node/commands/utils.ts b/packages/browser/src/node/commands/utils.ts index 3d4013b38037..28428408908e 100644 --- a/packages/browser/src/node/commands/utils.ts +++ b/packages/browser/src/node/commands/utils.ts @@ -1,4 +1,12 @@ -import type { BrowserCommand } from 'vitest/node' +import type { BrowserCommand, BrowserProvider } from 'vitest/node' +import type { PlaywrightBrowserProvider } from '../providers/playwright' +import type { WebdriverBrowserProvider } from '../providers/webdriver' + +declare module 'vitest/node' { + export interface BrowserCommandContext { + provider: PlaywrightBrowserProvider | WebdriverBrowserProvider | BrowserProvider + } +} export type UserEventCommand any> = BrowserCommand< ConvertUserEventParameters> @@ -8,3 +16,9 @@ type ConvertElementToLocator = T extends Element ? string : T type ConvertUserEventParameters = { [K in keyof T]: ConvertElementToLocator } + +export function defineBrowserCommand( + fn: BrowserCommand, +): BrowserCommand { + return fn +} diff --git a/packages/browser/src/node/index.ts b/packages/browser/src/node/index.ts index abf0b7640d8b..af45bff9afb2 100644 --- a/packages/browser/src/node/index.ts +++ b/packages/browser/src/node/index.ts @@ -14,6 +14,7 @@ import BrowserMocker from './plugins/pluginMocker' import DynamicImport from './plugins/pluginDynamicImport' export type { BrowserCommand } from 'vitest/node' +export { defineBrowserCommand } from './commands/utils' export default (project: WorkspaceProject, base = '/'): Plugin[] => { const pkgRoot = resolve(fileURLToPath(import.meta.url), '../..') diff --git a/packages/browser/src/node/plugins/pluginContext.ts b/packages/browser/src/node/plugins/pluginContext.ts index 3f2ec80b8209..200749087c1d 100644 --- a/packages/browser/src/node/plugins/pluginContext.ts +++ b/packages/browser/src/node/plugins/pluginContext.ts @@ -42,7 +42,7 @@ async function generateContextFile(this: PluginContext, project: WorkspaceProjec const provider = project.browserProvider! const commandsCode = commands.map((command) => { - return ` ["${command}"]: (...args) => rpc().triggerCommand("${command}", filepath(), args),` + return ` ["${command}"]: (...args) => rpc().triggerCommand(contextId, "${command}", filepath(), args),` }).join('\n') const userEventNonProviderImport = await getUserEventImport(provider, this.resolve.bind(this)) @@ -51,7 +51,8 @@ async function generateContextFile(this: PluginContext, project: WorkspaceProjec ${userEventNonProviderImport} const filepath = () => ${filepathCode} const rpc = () => __vitest_worker__.rpc -const channel = new BroadcastChannel('vitest:' + __vitest_browser_runner__.contextId) +const contextId = __vitest_browser_runner__.contextId +const channel = new BroadcastChannel('vitest:' + contextId) export const server = { platform: ${JSON.stringify(process.platform)}, @@ -130,7 +131,7 @@ function getUserEventScript(project: WorkspaceProject) { return `{ async click(element, options) { const xpath = convertElementToXPath(element) - return rpc().triggerCommand('__vitest_click', filepath(), options ? [xpath, options] : [xpath]); + return rpc().triggerCommand(contextId, '__vitest_click', filepath(), options ? [xpath, options] : [xpath]); }, }` } diff --git a/packages/browser/src/node/providers/playwright.ts b/packages/browser/src/node/providers/playwright.ts index fc8b8a31772f..a1a01229452d 100644 --- a/packages/browser/src/node/providers/playwright.ts +++ b/packages/browser/src/node/providers/playwright.ts @@ -9,11 +9,9 @@ export interface PlaywrightProviderOptions extends BrowserProviderInitialization } export class PlaywrightBrowserProvider implements BrowserProvider { - public name = 'playwright' + public name = 'playwright' as const public browser: Browser | null = null - public page: Page | null = null - public context: BrowserContext | null = null private browserName!: PlaywrightBrowser private ctx!: WorkspaceProject @@ -23,6 +21,11 @@ export class PlaywrightBrowserProvider implements BrowserProvider { context?: BrowserContextOptions } + public contexts = new Map() + public pages = new Map() + + private browserPromise: Promise | null = null + getSupportedBrowsers() { return playwrightBrowsers } @@ -33,41 +36,73 @@ export class PlaywrightBrowserProvider implements BrowserProvider { this.options = options as any } - private async createContext() { - if (this.context) - return this.context + private async openBrowser() { + if (this.browserPromise) + return this.browserPromise + + if (this.browser) + return this.browser + + this.browserPromise = (async () => { + const options = this.ctx.config.browser + + const playwright = await import('playwright') - const options = this.ctx.config.browser + const browser = await playwright[this.browserName].launch({ + ...this.options?.launch, + headless: options.headless, + }) + this.browser = browser + this.browserPromise = null + return this.browser + })() - const playwright = await import('playwright') + return this.browserPromise + } + + private async createContext(contextId: string) { + if (this.contexts.has(contextId)) + return this.contexts.get(contextId)! - const browser = await playwright[this.browserName].launch({ - ...this.options?.launch, - headless: options.headless, - }) - this.browser = browser - this.context = await browser.newContext(this.options?.context) - return this.context + const browser = await this.openBrowser() + const context = await browser.newContext(this.options?.context) + this.contexts.set(contextId, context) + return context } - private async openBrowserPage() { - this.context = await this.createContext() - this.page = await this.context.newPage() + public getPage(contextId: string) { + const page = this.pages.get(contextId) + if (!page) + throw new Error(`Page "${contextId}" not found`) + return page + } + + private async openBrowserPage(contextId: string) { + if (this.pages.has(contextId)) { + const page = this.pages.get(contextId)! + await page.close() + this.pages.delete(contextId) + } + + const context = await this.createContext(contextId) + const page = await context.newPage() + this.pages.set(contextId, page) - return this.page + return page } - async openPage(url: string) { - const browserPage = await this.openBrowserPage() + async openPage(contextId: string, url: string) { + const browserPage = await this.openBrowserPage(contextId) await browserPage.goto(url) } async close() { - const page = this.page - this.page = null const browser = this.browser this.browser = null - await page?.close() + await Promise.all([...this.pages.values()].map(p => p.close())) + await Promise.all([...this.contexts.values()].map(c => c.close())) + this.contexts.clear() + this.pages.clear() await browser?.close() } } diff --git a/packages/browser/src/node/providers/webdriver.ts b/packages/browser/src/node/providers/webdriver.ts index 3a768430e203..502258d97111 100644 --- a/packages/browser/src/node/providers/webdriver.ts +++ b/packages/browser/src/node/providers/webdriver.ts @@ -9,7 +9,7 @@ interface WebdriverProviderOptions extends BrowserProviderInitializationOptions } export class WebdriverBrowserProvider implements BrowserProvider { - public name = 'webdriverio' + public name = 'webdriverio' as const public browser: WebdriverIO.Browser | null = null diff --git a/packages/vitest/package.json b/packages/vitest/package.json index eed78da42bdc..5748c1baa89d 100644 --- a/packages/vitest/package.json +++ b/packages/vitest/package.json @@ -170,6 +170,7 @@ "@antfu/install-pkg": "0.3.1", "@edge-runtime/vm": "^3.2.0", "@sinonjs/fake-timers": "11.1.0", + "@types/debug": "^4.1.12", "@types/estree": "^1.0.5", "@types/istanbul-lib-coverage": "^2.0.6", "@types/istanbul-reports": "^3.0.4", @@ -183,6 +184,7 @@ "cac": "^6.7.14", "chai-subset": "^1.6.0", "cli-truncate": "^4.0.0", + "debug": "^4.3.4", "expect-type": "^0.19.0", "fast-glob": "^3.3.2", "find-up": "^6.3.0", diff --git a/packages/vitest/src/api/browser.ts b/packages/vitest/src/api/browser.ts index 56b9da8089bd..e7847f4332e7 100644 --- a/packages/vitest/src/api/browser.ts +++ b/packages/vitest/src/api/browser.ts @@ -10,8 +10,11 @@ import type { ViteDevServer } from 'vite' import { BROWSER_API_PATH } from '../constants' import { stringifyReplace } from '../utils' import type { WorkspaceProject } from '../node/workspace' +import { createDebugger } from '../utils/debugger' import type { WebSocketBrowserEvents, WebSocketBrowserHandlers } from './types' +const debug = createDebugger('vitest:browser:api') + export function setupBrowserRpc(project: WorkspaceProject, server: ViteDevServer) { const ctx = project.ctx @@ -36,7 +39,10 @@ export function setupBrowserRpc(project: WorkspaceProject, server: ViteDevServer const clients = type === 'tester' ? rpcs.testers : rpcs.orchestrators clients.set(sessionId, rpc) + debug?.('[%s] Browser API connected to %s', sessionId, type) + ws.on('close', () => { + debug?.('[%s] Browser API disconnected from %s', sessionId, type) clients.delete(sessionId) }) }) @@ -112,7 +118,8 @@ export function setupBrowserRpc(project: WorkspaceProject, server: ViteDevServer getCountOfFailedTests() { return ctx.state.getCountOfFailedTests() }, - triggerCommand(command: string, testPath: string | undefined, payload: unknown[]) { + triggerCommand(contextId: string, command: string, testPath: string | undefined, payload: unknown[]) { + debug?.('[%s] Triggering command "%s"', contextId, command) if (!project.browserProvider) throw new Error('Commands are only available for browser tests.') const commands = project.config.browser?.commands @@ -122,9 +129,11 @@ export function setupBrowserRpc(project: WorkspaceProject, server: ViteDevServer testPath, project, provider: project.browserProvider, + contextId, }, ...payload) }, finishBrowserTests(contextId: string) { + debug?.('[%s] Finishing browser tests for context', contextId) return project.browserState.get(contextId)?.resolve() }, getProvidedContext() { diff --git a/packages/vitest/src/api/types.ts b/packages/vitest/src/api/types.ts index b378e84b4e71..4b0866942f31 100644 --- a/packages/vitest/src/api/types.ts +++ b/packages/vitest/src/api/types.ts @@ -41,7 +41,7 @@ export interface WebSocketBrowserHandlers { snapshotSaved: (snapshot: SnapshotResult) => void debug: (...args: string[]) => void resolveId: (id: string, importer?: string) => Promise - triggerCommand: (command: string, testPath: string | undefined, payload: unknown[]) => Promise + triggerCommand: (contextId: string, command: string, testPath: string | undefined, payload: unknown[]) => Promise queueMock: (id: string, importer: string, hasFactory: boolean) => Promise queueUnmock: (id: string, importer: string) => Promise resolveMock: (id: string, importer: string) => Promise<{ diff --git a/packages/vitest/src/node/config.ts b/packages/vitest/src/node/config.ts index 90e49c7249fd..6beee130d75a 100644 --- a/packages/vitest/src/node/config.ts +++ b/packages/vitest/src/node/config.ts @@ -537,7 +537,7 @@ export function resolveConfig( resolved.browser.headless ??= isCI resolved.browser.isolate ??= true // disable in headless mode by default, and if CI is detected - resolved.browser.ui ??= resolved.browser.headless === false ? true : !isCI + resolved.browser.ui ??= resolved.browser.headless === true ? false : !isCI resolved.browser.viewport ??= {} as any resolved.browser.viewport.width ??= 414 diff --git a/packages/vitest/src/node/index.ts b/packages/vitest/src/node/index.ts index 752c0020e51a..b27b8dd8c06e 100644 --- a/packages/vitest/src/node/index.ts +++ b/packages/vitest/src/node/index.ts @@ -22,6 +22,7 @@ export type { BrowserProviderOptions, BrowserScript, BrowserCommand, + BrowserCommandContext, } from '../types/browser' export type { JsonOptions } from './reporters/json' export type { JUnitOptions } from './reporters/junit' diff --git a/packages/vitest/src/node/pools/browser.ts b/packages/vitest/src/node/pools/browser.ts index 12934634fbea..38cc05f99d05 100644 --- a/packages/vitest/src/node/pools/browser.ts +++ b/packages/vitest/src/node/pools/browser.ts @@ -1,15 +1,19 @@ +import * as nodeos from 'node:os' import { createDefer } from '@vitest/utils' +import { relative } from 'pathe' import type { Vitest } from '../core' import type { ProcessPool } from '../pool' import type { WorkspaceProject } from '../workspace' import type { BrowserProvider } from '../../types/browser' +import { createDebugger } from '../../utils/debugger' + +const debug = createDebugger('vitest:browser:pool') export function createBrowserPool(ctx: Vitest): ProcessPool { const providers = new Set() const waitForTests = async (contextId: string, project: WorkspaceProject, files: string[]) => { const defer = createDefer() - project.browserState.forEach(state => state.resolve()) project.browserState.set(contextId, { files, resolve: () => { @@ -29,6 +33,7 @@ export function createBrowserPool(ctx: Vitest): ProcessPool { }) mocker.mocks.clear() + const threadsCount = getThreadsCount(project) // TODO // let isCancelled = false // project.ctx.onCancel(() => { @@ -44,25 +49,54 @@ export function createBrowserPool(ctx: Vitest): ProcessPool { if (!origin) throw new Error(`Can't find browser origin URL for project "${project.config.name}"`) - const contextId = crypto.randomUUID() - const promise = waitForTests(contextId, project, files) - - // const orchestrators = project.browserRpc.orchestrators - // TODO: rerun only opened ones - // expect to have 4 opened pages and distribute tests amongst them - // if there is a UI, have only a sinle page - or just don't support this in watch mode for now? - // if (orchestrators.size) { - // orchestrators.forEach(orchestrator => - // orchestrator.createTesters(files), - // ) - // } - // else { - const url = new URL('/', origin) - url.searchParams.set('contextId', contextId) - await provider.openPage(url.toString()) - // } - - await promise + const filesPerThread = Math.ceil(files.length / threadsCount) + + const chunks: string[][] = [] + for (let i = 0; i < files.length; i += filesPerThread) { + const chunk = files.slice(i, i + filesPerThread) + chunks.push(chunk) + } + + debug?.( + `[%s] Running %s tests in %s chunks (%s threads)`, + project.getName() || 'core', + files.length, + chunks.length, + threadsCount, + ) + + const orchestrators = [...project.browserRpc.orchestrators.entries()] + + const promises: Promise[] = [] + + chunks.forEach((files, index) => { + if (orchestrators[index]) { + const [contextId, orchestrator] = orchestrators[index] + debug?.( + 'Reusing orchestrator (context %s) for files: %s', + contextId, + [...files.map(f => relative(project.config.root, f))].join(', '), + ) + const promise = waitForTests(contextId, project, files) + promises.push(promise) + orchestrator.createTesters(files) + } + else { + const contextId = crypto.randomUUID() + const waitPromise = waitForTests(contextId, project, files) + debug?.( + 'Opening a new context %s for files: %s', + contextId, + [...files.map(f => relative(project.config.root, f))].join(', '), + ) + const url = new URL('/', origin) + url.searchParams.set('contextId', contextId) + const page = provider.openPage(contextId, url.toString()).then(() => waitPromise) + promises.push(page) + } + }) + + await Promise.all(promises) } const runWorkspaceTests = async (specs: [WorkspaceProject, string][]) => { @@ -78,6 +112,21 @@ export function createBrowserPool(ctx: Vitest): ProcessPool { await runTests(project, files) } + const numCpus + = typeof nodeos.availableParallelism === 'function' + ? nodeos.availableParallelism() + : nodeos.cpus().length + + function getThreadsCount(project: WorkspaceProject) { + const config = project.config.browser + if (!config.headless || config.ui) + return 1 + + return ctx.config.watch + ? Math.max(Math.floor(numCpus / 2), 1) + : Math.max(numCpus - 1, 1) + } + return { name: 'browser', async close() { diff --git a/packages/vitest/src/types/browser.ts b/packages/vitest/src/types/browser.ts index 7b7f58d74095..5e6369376a8d 100644 --- a/packages/vitest/src/types/browser.ts +++ b/packages/vitest/src/types/browser.ts @@ -10,7 +10,7 @@ export interface BrowserProviderInitializationOptions { export interface BrowserProvider { name: string getSupportedBrowsers: () => readonly string[] - openPage: (url: string) => Awaitable + openPage: (contextId: string, url: string) => Promise close: () => Awaitable // eslint-disable-next-line ts/method-signature-style -- we want to allow extended options initialize( @@ -121,8 +121,9 @@ export interface BrowserConfigOptions { export interface BrowserCommandContext { testPath: string | undefined - provider: BrowserProvider + // provider: BrowserProvider project: WorkspaceProject + contextId: string } export interface BrowserCommand { diff --git a/packages/vitest/src/utils/debugger.ts b/packages/vitest/src/utils/debugger.ts new file mode 100644 index 000000000000..c4d8871db775 --- /dev/null +++ b/packages/vitest/src/utils/debugger.ts @@ -0,0 +1,7 @@ +import createDebug from 'debug' + +export function createDebugger(namespace: `vitest:${string}`) { + const debug = createDebug(namespace) + if (debug.enabled) + return debug +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index df29f030c5af..4bce4fa6132c 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -903,6 +903,9 @@ importers: '@sinonjs/fake-timers': specifier: 11.1.0 version: 11.1.0(patch_hash=trok5obk3l5tdlygozv34fknii) + '@types/debug': + specifier: ^4.1.12 + version: 4.1.12 '@types/estree': specifier: ^1.0.5 version: 1.0.5 From d0429ded6d552129f4e5af67bbf775f2ab924f75 Mon Sep 17 00:00:00 2001 From: Vladimir Sheremet Date: Fri, 7 Jun 2024 12:29:31 +0200 Subject: [PATCH 03/29] fix: run preview/webdriverio correctly --- packages/browser/src/node/providers/playwright.ts | 2 +- packages/browser/src/node/providers/preview.ts | 4 ++-- packages/browser/src/node/providers/webdriver.ts | 2 +- packages/vitest/src/node/pools/browser.ts | 1 + 4 files changed, 5 insertions(+), 4 deletions(-) diff --git a/packages/browser/src/node/providers/playwright.ts b/packages/browser/src/node/providers/playwright.ts index a1a01229452d..2707a2d8febd 100644 --- a/packages/browser/src/node/providers/playwright.ts +++ b/packages/browser/src/node/providers/playwright.ts @@ -100,9 +100,9 @@ export class PlaywrightBrowserProvider implements BrowserProvider { const browser = this.browser this.browser = null await Promise.all([...this.pages.values()].map(p => p.close())) + this.pages.clear() await Promise.all([...this.contexts.values()].map(c => c.close())) this.contexts.clear() - this.pages.clear() await browser?.close() } } diff --git a/packages/browser/src/node/providers/preview.ts b/packages/browser/src/node/providers/preview.ts index 4ed5c939656b..aa6b10a0ff55 100644 --- a/packages/browser/src/node/providers/preview.ts +++ b/packages/browser/src/node/providers/preview.ts @@ -21,13 +21,13 @@ export class PreviewBrowserProvider implements BrowserProvider { throw new Error('You\'ve enabled headless mode for "preview" provider but it doesn\'t support it. Use "playwright" or "webdriverio" instead: https://vitest.dev/guide/browser#configuration') } - async openPage(_url: string) { + async openPage(_contextId: string, url: string) { this.open = true if (!this.ctx.browser) throw new Error('Browser is not initialized') const options = this.ctx.browser.config.server const _open = options.open - options.open = _url + options.open = url this.ctx.browser.openBrowser() options.open = _open } diff --git a/packages/browser/src/node/providers/webdriver.ts b/packages/browser/src/node/providers/webdriver.ts index 502258d97111..cda5c1e63dd0 100644 --- a/packages/browser/src/node/providers/webdriver.ts +++ b/packages/browser/src/node/providers/webdriver.ts @@ -75,7 +75,7 @@ export class WebdriverBrowserProvider implements BrowserProvider { return capabilities } - async openPage(url: string) { + async openPage(_contextId: string, url: string) { const browserInstance = await this.openBrowser() await browserInstance.url(url) } diff --git a/packages/vitest/src/node/pools/browser.ts b/packages/vitest/src/node/pools/browser.ts index 38cc05f99d05..e5e025c06097 100644 --- a/packages/vitest/src/node/pools/browser.ts +++ b/packages/vitest/src/node/pools/browser.ts @@ -1,4 +1,5 @@ import * as nodeos from 'node:os' +import crypto from 'node:crypto' import { createDefer } from '@vitest/utils' import { relative } from 'pathe' import type { Vitest } from '../core' From 870415db482dacea9a22e102dfa972b4d215c0b8 Mon Sep 17 00:00:00 2001 From: Vladimir Sheremet Date: Fri, 7 Jun 2024 12:31:56 +0200 Subject: [PATCH 04/29] chore: remove module overwrite --- packages/browser/src/node/commands/utils.ts | 10 +--------- packages/vitest/src/types/browser.ts | 2 +- 2 files changed, 2 insertions(+), 10 deletions(-) diff --git a/packages/browser/src/node/commands/utils.ts b/packages/browser/src/node/commands/utils.ts index 28428408908e..6e4db8456872 100644 --- a/packages/browser/src/node/commands/utils.ts +++ b/packages/browser/src/node/commands/utils.ts @@ -1,12 +1,4 @@ -import type { BrowserCommand, BrowserProvider } from 'vitest/node' -import type { PlaywrightBrowserProvider } from '../providers/playwright' -import type { WebdriverBrowserProvider } from '../providers/webdriver' - -declare module 'vitest/node' { - export interface BrowserCommandContext { - provider: PlaywrightBrowserProvider | WebdriverBrowserProvider | BrowserProvider - } -} +import type { BrowserCommand } from 'vitest/node' export type UserEventCommand any> = BrowserCommand< ConvertUserEventParameters> diff --git a/packages/vitest/src/types/browser.ts b/packages/vitest/src/types/browser.ts index 5e6369376a8d..405f60771e42 100644 --- a/packages/vitest/src/types/browser.ts +++ b/packages/vitest/src/types/browser.ts @@ -121,7 +121,7 @@ export interface BrowserConfigOptions { export interface BrowserCommandContext { testPath: string | undefined - // provider: BrowserProvider + provider: BrowserProvider project: WorkspaceProject contextId: string } From 665f4509cfcdd864416c567b67b03e57bda3cab2 Mon Sep 17 00:00:00 2001 From: Vladimir Sheremet Date: Fri, 7 Jun 2024 14:18:52 +0200 Subject: [PATCH 05/29] feat: add fileParallelism field to config, supportsParallelism to providers --- packages/browser/src/node/providers/playwright.ts | 1 + packages/browser/src/node/providers/preview.ts | 3 ++- packages/browser/src/node/providers/webdriver.ts | 1 + packages/vitest/src/node/config.ts | 1 + packages/vitest/src/node/pools/browser.ts | 5 ++++- packages/vitest/src/types/browser.ts | 13 +++++++++++++ 6 files changed, 22 insertions(+), 2 deletions(-) diff --git a/packages/browser/src/node/providers/playwright.ts b/packages/browser/src/node/providers/playwright.ts index 2707a2d8febd..bcd785d1aad3 100644 --- a/packages/browser/src/node/providers/playwright.ts +++ b/packages/browser/src/node/providers/playwright.ts @@ -10,6 +10,7 @@ export interface PlaywrightProviderOptions extends BrowserProviderInitialization export class PlaywrightBrowserProvider implements BrowserProvider { public name = 'playwright' as const + public supportsParallelism: boolean = true public browser: Browser | null = null diff --git a/packages/browser/src/node/providers/preview.ts b/packages/browser/src/node/providers/preview.ts index aa6b10a0ff55..341d631a8154 100644 --- a/packages/browser/src/node/providers/preview.ts +++ b/packages/browser/src/node/providers/preview.ts @@ -1,7 +1,8 @@ import type { BrowserProvider, WorkspaceProject } from 'vitest/node' export class PreviewBrowserProvider implements BrowserProvider { - public name = 'preview' + public name = 'preview' as const + public supportsParallelism: boolean = false private ctx!: WorkspaceProject private open = false diff --git a/packages/browser/src/node/providers/webdriver.ts b/packages/browser/src/node/providers/webdriver.ts index cda5c1e63dd0..55cf64200014 100644 --- a/packages/browser/src/node/providers/webdriver.ts +++ b/packages/browser/src/node/providers/webdriver.ts @@ -10,6 +10,7 @@ interface WebdriverProviderOptions extends BrowserProviderInitializationOptions export class WebdriverBrowserProvider implements BrowserProvider { public name = 'webdriverio' as const + public supportsParallelism: boolean = false public browser: WebdriverIO.Browser | null = null diff --git a/packages/vitest/src/node/config.ts b/packages/vitest/src/node/config.ts index 6beee130d75a..8102a890987d 100644 --- a/packages/vitest/src/node/config.ts +++ b/packages/vitest/src/node/config.ts @@ -536,6 +536,7 @@ export function resolveConfig( resolved.browser.enabled ??= false resolved.browser.headless ??= isCI resolved.browser.isolate ??= true + resolved.browser.fileParallelism ??= options.fileParallelism ?? mode !== 'benchmark' // disable in headless mode by default, and if CI is detected resolved.browser.ui ??= resolved.browser.headless === true ? false : !isCI diff --git a/packages/vitest/src/node/pools/browser.ts b/packages/vitest/src/node/pools/browser.ts index e5e025c06097..77710b7703f1 100644 --- a/packages/vitest/src/node/pools/browser.ts +++ b/packages/vitest/src/node/pools/browser.ts @@ -120,7 +120,10 @@ export function createBrowserPool(ctx: Vitest): ProcessPool { function getThreadsCount(project: WorkspaceProject) { const config = project.config.browser - if (!config.headless || config.ui) + if (!config.headless || !project.browserProvider!.supportsParallelism) + return 1 + + if (!config.fileParallelism) return 1 return ctx.config.watch diff --git a/packages/vitest/src/types/browser.ts b/packages/vitest/src/types/browser.ts index 405f60771e42..81e4154a7d6f 100644 --- a/packages/vitest/src/types/browser.ts +++ b/packages/vitest/src/types/browser.ts @@ -9,6 +9,10 @@ export interface BrowserProviderInitializationOptions { export interface BrowserProvider { name: string + /** + * @experimental opt-in into file parallelisation + */ + supportsParallelism: boolean getSupportedBrowsers: () => readonly string[] openPage: (contextId: string, url: string) => Promise close: () => Awaitable @@ -78,6 +82,14 @@ export interface BrowserConfigOptions { */ isolate?: boolean + /** + * Run test files in parallel if provider supports this option + * This option only has effect in headless mode (enabled in CI by default) + * + * @default // Same as "test.fileParallelism" + */ + fileParallelism?: boolean + /** * Show Vitest UI * @@ -163,6 +175,7 @@ export interface ResolvedBrowserOptions extends BrowserConfigOptions { enabled: boolean headless: boolean isolate: boolean + fileParallelism: boolean api: ApiConfig ui: boolean viewport: { From 5b8bfdd817f70b4463d85fdf877beeac783c7310 Mon Sep 17 00:00:00 2001 From: Vladimir Sheremet Date: Fri, 7 Jun 2024 14:26:36 +0200 Subject: [PATCH 06/29] chore: add browser.fileParallelism to CLI --- packages/vitest/src/node/cli/cli-config.ts | 3 +++ 1 file changed, 3 insertions(+) diff --git a/packages/vitest/src/node/cli/cli-config.ts b/packages/vitest/src/node/cli/cli-config.ts index f858ea1ecab3..6911ba6fcd26 100644 --- a/packages/vitest/src/node/cli/cli-config.ts +++ b/packages/vitest/src/node/cli/cli-config.ts @@ -347,6 +347,9 @@ export const cliOptionsConfig: VitestCLIOptions = { ui: { description: 'Show Vitest UI when running tests (default: `!process.env.CI`)', }, + fileParallelism: { + description: 'Should browser test files run in parallel. Use `--browser.fileParallelism=false` to disable (default: `true`)', + }, orchestratorScripts: null, testerScripts: null, commands: null, From f47b9e35ce63a2328cc2302b11e4f6ca48c2c6cf Mon Sep 17 00:00:00 2001 From: Vladimir Sheremet Date: Fri, 7 Jun 2024 14:46:56 +0200 Subject: [PATCH 07/29] fix: ignore https errors in playwright by default --- packages/browser/src/node/providers/playwright.ts | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/packages/browser/src/node/providers/playwright.ts b/packages/browser/src/node/providers/playwright.ts index bcd785d1aad3..82333e17415b 100644 --- a/packages/browser/src/node/providers/playwright.ts +++ b/packages/browser/src/node/providers/playwright.ts @@ -66,7 +66,10 @@ export class PlaywrightBrowserProvider implements BrowserProvider { return this.contexts.get(contextId)! const browser = await this.openBrowser() - const context = await browser.newContext(this.options?.context) + const context = await browser.newContext({ + ignoreHTTPSErrors: true, + ...this.options?.context, + }) this.contexts.set(contextId, context) return context } From 99c6322571b5bccf776dfe16ea479fb1a2bb362b Mon Sep 17 00:00:00 2001 From: Vladimir Sheremet Date: Fri, 7 Jun 2024 18:44:59 +0200 Subject: [PATCH 08/29] feat: use msw for mocking --- eslint.config.js | 1 + packages/browser/msw/sw.js | 284 +++++++++++++++++ packages/browser/package.json | 3 +- packages/browser/rollup.config.js | 6 + packages/browser/src/client/mocker.ts | 99 +++++- .../src/client/public/esm-client-injector.js | 1 + packages/browser/src/client/tester.ts | 7 + packages/browser/src/client/utils.ts | 3 + packages/browser/src/client/vite.config.ts | 9 + packages/browser/src/node/index.ts | 29 ++ .../browser/src/node/plugins/pluginMocker.ts | 1 + .../browser/src/node/providers/playwright.ts | 2 +- packages/utils/src/source-map.ts | 1 + packages/vitest/src/api/browser.ts | 20 +- packages/vitest/src/api/types.ts | 6 +- .../vitest/src/integrations/browser/mocker.ts | 10 +- packages/vitest/src/node/automockBrowser.ts | 148 +++++++++ pnpm-lock.yaml | 300 ++++++++++++++++-- 18 files changed, 897 insertions(+), 33 deletions(-) create mode 100644 packages/browser/msw/sw.js create mode 100644 packages/vitest/src/node/automockBrowser.ts diff --git a/eslint.config.js b/eslint.config.js index 08f726ec8e9f..6d1d64076e75 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -44,6 +44,7 @@ export default antfu( // let TypeScript handle this 'no-undef': 'off', 'ts/no-invalid-this': 'off', + 'eslint-comments/no-unlimited-disable': 'off', // TODO: migrate and turn it back on 'ts/ban-types': 'off', diff --git a/packages/browser/msw/sw.js b/packages/browser/msw/sw.js new file mode 100644 index 000000000000..24fe3a25f00f --- /dev/null +++ b/packages/browser/msw/sw.js @@ -0,0 +1,284 @@ +/* eslint-disable */ +/* tslint:disable */ + +/** + * Mock Service Worker. + * @see https://github.com/mswjs/msw + * - Please do NOT modify this file. + * - Please do NOT serve this file on production. + */ + +const PACKAGE_VERSION = '2.3.1' +const INTEGRITY_CHECKSUM = '26357c79639bfa20d64c0efca2a87423' +const IS_MOCKED_RESPONSE = Symbol('isMockedResponse') +const activeClientIds = new Set() + +self.addEventListener('install', function () { + self.skipWaiting() +}) + +self.addEventListener('activate', function (event) { + event.waitUntil(self.clients.claim()) +}) + +self.addEventListener('message', async function (event) { + const clientId = event.source.id + + if (!clientId || !self.clients) { + return + } + + const client = await self.clients.get(clientId) + + if (!client) { + return + } + + const allClients = await self.clients.matchAll({ + type: 'window', + }) + + switch (event.data) { + case 'KEEPALIVE_REQUEST': { + sendToClient(client, { + type: 'KEEPALIVE_RESPONSE', + }) + break + } + + case 'INTEGRITY_CHECK_REQUEST': { + sendToClient(client, { + type: 'INTEGRITY_CHECK_RESPONSE', + payload: { + packageVersion: PACKAGE_VERSION, + checksum: INTEGRITY_CHECKSUM, + }, + }) + break + } + + case 'MOCK_ACTIVATE': { + activeClientIds.add(clientId) + + sendToClient(client, { + type: 'MOCKING_ENABLED', + payload: true, + }) + break + } + + case 'MOCK_DEACTIVATE': { + activeClientIds.delete(clientId) + break + } + + case 'CLIENT_CLOSED': { + activeClientIds.delete(clientId) + + const remainingClients = allClients.filter((client) => { + return client.id !== clientId + }) + + // Unregister itself when there are no more clients + if (remainingClients.length === 0) { + self.registration.unregister() + } + + break + } + } +}) + +self.addEventListener('fetch', function (event) { + const { request } = event + + // Bypass navigation requests. + if (request.mode === 'navigate') { + return + } + + // Opening the DevTools triggers the "only-if-cached" request + // that cannot be handled by the worker. Bypass such requests. + if (request.cache === 'only-if-cached' && request.mode !== 'same-origin') { + return + } + + // Bypass all requests when there are no active clients. + // Prevents the self-unregistered worked from handling requests + // after it's been deleted (still remains active until the next reload). + if (activeClientIds.size === 0) { + return + } + + // Generate unique request ID. + const requestId = crypto.randomUUID() + event.respondWith(handleRequest(event, requestId)) +}) + +async function handleRequest(event, requestId) { + const client = await resolveMainClient(event) + const response = await getResponse(event, client, requestId) + + // Send back the response clone for the "response:*" life-cycle events. + // Ensure MSW is active and ready to handle the message, otherwise + // this message will pend indefinitely. + if (client && activeClientIds.has(client.id)) { + ;(async function () { + const responseClone = response.clone() + + sendToClient( + client, + { + type: 'RESPONSE', + payload: { + requestId, + isMockedResponse: IS_MOCKED_RESPONSE in response, + type: responseClone.type, + status: responseClone.status, + statusText: responseClone.statusText, + body: responseClone.body, + headers: Object.fromEntries(responseClone.headers.entries()), + }, + }, + [responseClone.body], + ) + })() + } + + return response +} + +// Resolve the main client for the given event. +// Client that issues a request doesn't necessarily equal the client +// that registered the worker. It's with the latter the worker should +// communicate with during the response resolving phase. +async function resolveMainClient(event) { + const client = await self.clients.get(event.clientId) + + if (client?.frameType === 'top-level') { + return client + } + + const allClients = await self.clients.matchAll({ + type: 'window', + }) + + return allClients + .filter((client) => { + // Get only those clients that are currently visible. + return client.visibilityState === 'visible' + }) + .find((client) => { + // Find the client ID that's recorded in the + // set of clients that have registered the worker. + return activeClientIds.has(client.id) + }) +} + +async function getResponse(event, client, requestId) { + const { request } = event + + // Clone the request because it might've been already used + // (i.e. its body has been read and sent to the client). + const requestClone = request.clone() + + function passthrough() { + const headers = Object.fromEntries(requestClone.headers.entries()) + + // Remove internal MSW request header so the passthrough request + // complies with any potential CORS preflight checks on the server. + // Some servers forbid unknown request headers. + delete headers['x-msw-intention'] + + return fetch(requestClone, { headers }) + } + + // Bypass mocking when the client is not active. + if (!client) { + return passthrough() + } + + // Bypass initial page load requests (i.e. static assets). + // The absence of the immediate/parent client in the map of the active clients + // means that MSW hasn't dispatched the "MOCK_ACTIVATE" event yet + // and is not ready to handle requests. + if (!activeClientIds.has(client.id)) { + return passthrough() + } + + // Notify the client that a request has been intercepted. + const requestBuffer = await request.arrayBuffer() + const clientMessage = await sendToClient( + client, + { + type: 'REQUEST', + payload: { + id: requestId, + url: request.url, + mode: request.mode, + method: request.method, + headers: Object.fromEntries(request.headers.entries()), + cache: request.cache, + credentials: request.credentials, + destination: request.destination, + integrity: request.integrity, + redirect: request.redirect, + referrer: request.referrer, + referrerPolicy: request.referrerPolicy, + body: requestBuffer, + keepalive: request.keepalive, + }, + }, + [requestBuffer], + ) + + switch (clientMessage.type) { + case 'MOCK_RESPONSE': { + return respondWithMock(clientMessage.data) + } + + case 'PASSTHROUGH': { + return passthrough() + } + } + + return passthrough() +} + +function sendToClient(client, message, transferrables = []) { + return new Promise((resolve, reject) => { + const channel = new MessageChannel() + + channel.port1.onmessage = (event) => { + if (event.data && event.data.error) { + return reject(event.data.error) + } + + resolve(event.data) + } + + client.postMessage( + message, + [channel.port2].concat(transferrables.filter(Boolean)), + ) + }) +} + +async function respondWithMock(response) { + // Setting response status code to 0 is a no-op. + // However, when responding with a "Response.error()", the produced Response + // instance will have status code set to 0. Since it's not possible to create + // a Response instance with status code 0, handle that use-case separately. + if (response.status === 0) { + return Response.error() + } + + const mockedResponse = new Response(response.body, response) + + Reflect.defineProperty(mockedResponse, IS_MOCKED_RESPONSE, { + value: true, + enumerable: true, + }) + + return mockedResponse +} diff --git a/packages/browser/package.json b/packages/browser/package.json index 90915c560ebc..1bd5b372db39 100644 --- a/packages/browser/package.json +++ b/packages/browser/package.json @@ -73,6 +73,7 @@ "@testing-library/user-event": "^14.5.2", "@vitest/utils": "workspace:*", "magic-string": "^0.30.10", + "msw": "^2.3.1", "sirv": "^2.0.4" }, "devDependencies": { @@ -90,4 +91,4 @@ "vitest": "workspace:*", "webdriverio": "^8.38.2" } -} +} \ No newline at end of file diff --git a/packages/browser/rollup.config.js b/packages/browser/rollup.config.js index 910c332a6996..362c552c97aa 100644 --- a/packages/browser/rollup.config.js +++ b/packages/browser/rollup.config.js @@ -4,6 +4,7 @@ import dts from 'rollup-plugin-dts' import resolve from '@rollup/plugin-node-resolve' import commonjs from '@rollup/plugin-commonjs' import json from '@rollup/plugin-json' +import copy from 'rollup-plugin-copy' const require = createRequire(import.meta.url) const pkg = require('./package.json') @@ -25,6 +26,11 @@ const plugins = [ esbuild({ target: 'node18', }), + copy({ + targets: [ + { src: './msw/sw.js', dest: 'dist', rename: 'mocker-worker.js' }, + ], + }), ] const input = { diff --git a/packages/browser/src/client/mocker.ts b/packages/browser/src/client/mocker.ts index c76c5c218b18..f0257611d055 100644 --- a/packages/browser/src/client/mocker.ts +++ b/packages/browser/src/client/mocker.ts @@ -1,5 +1,7 @@ import { getType } from '@vitest/utils' -import { extname } from 'pathe' +import { extname, join } from 'pathe' +import { setupWorker } from 'msw/browser' +import { HttpResponse, http, passthrough } from 'msw' import { rpc } from './rpc' import { getBrowserState } from './utils' @@ -9,13 +11,66 @@ interface SpyModule { spyOn: typeof import('vitest').vi['spyOn'] } +const timestampRegexp = /(\?|&)t=\d{13}/ + +function cleanTimestamp(url: string) { + return url.replace(timestampRegexp, '') +} + export class VitestBrowserClientMocker { private queue = new Set>() - private mocks: Record = {} + private mocks: Record = {} + private mockObjects: Record = {} private factories: Record any> = {} private spyModule!: SpyModule + startWorker() { + const worker = setupWorker( + http.get(/.+/, async ({ request }) => { + const path = cleanTimestamp(request.url.slice(location.origin.length)) + if (!(path in this.mocks)) + return passthrough() + + const mock = this.mocks[path] + + // using a factory + if (mock === undefined) { + const exportsModule = await this.resolve(path) + const exports = Object.keys(exportsModule) + const module = `const module = __vitest_mocker__.get('${path}');` + const keys = exports.map((name) => { + if (name === 'default') + return `export default module['default'];` + return `export const ${name} = module['${name}'];` + }).join('\n') + const text = `${module}\n${keys}` + return new Response(text, { + headers: { + 'Content-Type': 'application/javascript', + }, + }) + } + + if (typeof mock === 'string') + return HttpResponse.redirect(mock) + + const content = await rpc().automock(path) + return new Response(content, { + headers: { + 'Content-Type': 'application/javascript', + }, + }) + }), + ) + return worker.start({ + serviceWorker: { + url: '/__virtual_vitest__:mocker-worker.js', + }, + quiet: true, + }) + } + public setSpyModule(mod: SpyModule) { this.spyModule = mod } @@ -61,7 +116,7 @@ export class VitestBrowserClientMocker { } public get(id: string) { - return this.mocks[id] + return this.mockObjects[id] } public async resolve(id: string) { @@ -69,8 +124,8 @@ export class VitestBrowserClientMocker { if (!factory) throw new Error(`Cannot resolve ${id} mock: no factory provided`) try { - this.mocks[id] = await factory() - return this.mocks[id] + this.mockObjects[id] = await factory() + return this.mockObjects[id] } catch (err) { const vitestError = new Error( @@ -85,8 +140,15 @@ export class VitestBrowserClientMocker { public queueMock(id: string, importer: string, factory?: () => any) { const promise = rpc().queueMock(id, importer, !!factory) - .then((id) => { - this.factories[id] = factory! + .then(({ id, mock }) => { + const urlPaths = resolveMockPaths(id) + const resolvedMock = typeof mock === 'string' + ? new URL(resolvedMockedPath(mock), location.href).toString() + : mock + urlPaths.forEach((url) => { + this.mocks[url] = resolvedMock + this.factories[url] = factory! + }) }).finally(() => { this.queue.delete(promise) }) @@ -284,3 +346,26 @@ function collectOwnProperties(obj: any, collector: Set | ((key: Object.getOwnPropertyNames(obj).forEach(collect) Object.getOwnPropertySymbols(obj).forEach(collect) } + +function resolvedMockedPath(path: string) { + const config = getBrowserState().viteConfig + if (path.startsWith(config.root)) + return path.slice(config.root.length) + return path +} + +// TODO: check _base_ path +function resolveMockPaths(path: string) { + const config = getBrowserState().viteConfig + const fsRoot = join('/@fs/', config.root) + const paths = [path, join('/@fs/', path)] + + // URL can be /file/path.js, but path is resolved to /file/path + if (path.startsWith(config.root)) + paths.push(path.slice(config.root.length)) + + if (path.startsWith(fsRoot)) + paths.push(path.slice(fsRoot.length)) + + return paths +} diff --git a/packages/browser/src/client/public/esm-client-injector.js b/packages/browser/src/client/public/esm-client-injector.js index 067b1fe207ea..a1365fc91d87 100644 --- a/packages/browser/src/client/public/esm-client-injector.js +++ b/packages/browser/src/client/public/esm-client-injector.js @@ -19,6 +19,7 @@ window.__vitest_browser_runner__ = { wrapModule, moduleCache, config: { __VITEST_CONFIG__ }, + viteConfig: { __VITEST_VITE_CONFIG__ }, files: { __VITEST_FILES__ }, type: { __VITEST_TYPE__ }, contextId: { __VITEST_CONTEXT_ID__ }, diff --git a/packages/browser/src/client/tester.ts b/packages/browser/src/client/tester.ts index a2486a4df13d..13039f5a98ea 100644 --- a/packages/browser/src/client/tester.ts +++ b/packages/browser/src/client/tester.ts @@ -132,6 +132,13 @@ async function prepareTestEnvironment(files: string[]) { stopErrorHandler() registerUnexpectedErrors(rpc) + try { + await mocker.startWorker() + } + catch (err) { + console.error(err) + } + return { runner, config, diff --git a/packages/browser/src/client/utils.ts b/packages/browser/src/client/utils.ts index f27562a187d8..836f48a2d32b 100644 --- a/packages/browser/src/client/utils.ts +++ b/packages/browser/src/client/utils.ts @@ -14,6 +14,9 @@ interface BrowserRunnerState { runningFiles: string[] moduleCache: WorkerGlobalState['moduleCache'] config: ResolvedConfig + viteConfig: { + root: string + } type: 'tester' | 'orchestrator' wrapModule: (module: () => T) => T iframeId?: string diff --git a/packages/browser/src/client/vite.config.ts b/packages/browser/src/client/vite.config.ts index 71f17b7dcbd4..dfa8fe446607 100644 --- a/packages/browser/src/client/vite.config.ts +++ b/packages/browser/src/client/vite.config.ts @@ -19,9 +19,18 @@ export default defineConfig({ orchestrator: resolve(__dirname, './orchestrator.html'), tester: resolve(__dirname, './tester.html'), }, + external: [/__virtual_vitest__/], }, }, plugins: [ + { + name: 'virtual:msw', + enforce: 'pre', + resolveId(id) { + if (id.startsWith('msw')) + return `/__virtual_vitest__:${id}` + }, + }, { name: 'copy-ui-plugin', /* eslint-disable no-console */ diff --git a/packages/browser/src/node/index.ts b/packages/browser/src/node/index.ts index af45bff9afb2..a8f8e546c071 100644 --- a/packages/browser/src/node/index.ts +++ b/packages/browser/src/node/index.ts @@ -81,6 +81,9 @@ export default (project: WorkspaceProject, base = '/'): Plugin[] => { const injector = replacer(await injectorJs, { __VITEST_CONFIG__: JSON.stringify(config), + __VITEST_VITE_CONFIG__: JSON.stringify({ + root: project.browser!.config.root, + }), __VITEST_FILES__: JSON.stringify(files), __VITEST_TYPE__: url.pathname === base ? '"orchestrator"' : '"tester"', __VITEST_CONTEXT_ID__: JSON.stringify(contextId), @@ -138,6 +141,9 @@ export default (project: WorkspaceProject, base = '/'): Plugin[] => { const injector = replacer(await injectorJs, { __VITEST_CONFIG__: JSON.stringify(config), __VITEST_FILES__: JSON.stringify(files), + __VITEST_VITE_CONFIG__: JSON.stringify({ + root: project.browser!.config.root, + }), __VITEST_TYPE__: url.pathname === base ? '"orchestrator"' : '"tester"', __VITEST_CONTEXT_ID__: JSON.stringify(contextId), }) @@ -218,6 +224,8 @@ export default (project: WorkspaceProject, base = '/'): Plugin[] => { 'tinybench', 'tinyspy', 'pathe', + 'msw', + 'msw/browser', ], include: [ 'vitest > @vitest/utils > pretty-format', @@ -250,6 +258,27 @@ export default (project: WorkspaceProject, base = '/'): Plugin[] => { return useId }, }, + { + name: 'vitest:browser:resolve-virtual', + async resolveId(rawId) { + if (rawId.startsWith('/__virtual_vitest__:')) { + const id = rawId.slice('/__virtual_vitest__:'.length) + // TODO: don't hardcode + if (id === 'mocker-worker.js') { + const path = resolve(distRoot, './mocker-worker.js') + return path + } + const resolved = await this.resolve( + id, + distRoot, + { + skipSelf: true, + }, + ) + return resolved + } + }, + }, BrowserContext(project), DynamicImport(), // TODO: remove this when @testing-library/vue supports ESM diff --git a/packages/browser/src/node/plugins/pluginMocker.ts b/packages/browser/src/node/plugins/pluginMocker.ts index 1b2d269a6b3a..920ded367702 100644 --- a/packages/browser/src/node/plugins/pluginMocker.ts +++ b/packages/browser/src/node/plugins/pluginMocker.ts @@ -4,6 +4,7 @@ import type { WorkspaceProject } from 'vitest/node' import { automockModule } from '../automocker' export default (project: WorkspaceProject): Plugin[] => { + return [] return [ { name: 'vitest:browser:mocker', diff --git a/packages/browser/src/node/providers/playwright.ts b/packages/browser/src/node/providers/playwright.ts index 82333e17415b..187fb3f7023b 100644 --- a/packages/browser/src/node/providers/playwright.ts +++ b/packages/browser/src/node/providers/playwright.ts @@ -10,7 +10,7 @@ export interface PlaywrightProviderOptions extends BrowserProviderInitialization export class PlaywrightBrowserProvider implements BrowserProvider { public name = 'playwright' as const - public supportsParallelism: boolean = true + public supportsParallelism = true public browser: Browser | null = null diff --git a/packages/utils/src/source-map.ts b/packages/utils/src/source-map.ts index e6e667fb253e..701e42a59c35 100644 --- a/packages/utils/src/source-map.ts +++ b/packages/utils/src/source-map.ts @@ -34,6 +34,7 @@ const stackIgnorePatterns = [ /node:\w+/, /__vitest_test__/, /__vitest_browser__/, + /\/deps\/vitest_/, ] function extractLocation(urlLike: string) { diff --git a/packages/vitest/src/api/browser.ts b/packages/vitest/src/api/browser.ts index e7847f4332e7..ed5526c9aea8 100644 --- a/packages/vitest/src/api/browser.ts +++ b/packages/vitest/src/api/browser.ts @@ -5,12 +5,15 @@ import { createBirpc } from 'birpc' import { parse, stringify } from 'flatted' import type { WebSocket } from 'ws' import { WebSocketServer } from 'ws' -import { isFileServingAllowed } from 'vite' +import { isFileServingAllowed, parseAst } from 'vite' import type { ViteDevServer } from 'vite' +import type { EncodedSourceMap } from '@ampproject/remapping' +import remapping from '@ampproject/remapping' import { BROWSER_API_PATH } from '../constants' import { stringifyReplace } from '../utils' import type { WorkspaceProject } from '../node/workspace' import { createDebugger } from '../utils/debugger' +import { automockModule } from '../node/automockBrowser' import type { WebSocketBrowserEvents, WebSocketBrowserHandlers } from './types' const debug = createDebugger('vitest:browser:api') @@ -139,6 +142,21 @@ export function setupBrowserRpc(project: WorkspaceProject, server: ViteDevServer getProvidedContext() { return 'ctx' in project ? project.getProvidedContext() : ({} as any) }, + async automock(id: string) { + const request = await project.browser!.transformRequest(id) + if (!request) + throw new Error(`Module "${id}" not found.`) + const ms = automockModule(request.code, parseAst) + const code = ms.toString() + const sourcemap = ms.generateMap({ hires: 'boundary', source: id }) + const combinedMap = request.map && request.map.mappings + ? remapping( + [{ ...sourcemap, version: 3 }, request.map as EncodedSourceMap], + () => null, + ) + : sourcemap + return `${code}\n//# sourceMappingURL=data:application/json;charset=utf-8;base64,${Buffer.from(JSON.stringify(combinedMap)).toString('base64')}` + }, async queueMock(id: string, importer: string, hasFactory: boolean) { return project.browserMocker.mock(sessionId, id, importer, hasFactory) }, diff --git a/packages/vitest/src/api/types.ts b/packages/vitest/src/api/types.ts index 4b0866942f31..1cc474eec8c0 100644 --- a/packages/vitest/src/api/types.ts +++ b/packages/vitest/src/api/types.ts @@ -42,13 +42,17 @@ export interface WebSocketBrowserHandlers { debug: (...args: string[]) => void resolveId: (id: string, importer?: string) => Promise triggerCommand: (contextId: string, command: string, testPath: string | undefined, payload: unknown[]) => Promise - queueMock: (id: string, importer: string, hasFactory: boolean) => Promise + queueMock: (id: string, importer: string, hasFactory: boolean) => Promise<{ + id: string + mock: string | undefined | null + }> queueUnmock: (id: string, importer: string) => Promise resolveMock: (id: string, importer: string) => Promise<{ type: 'factory' | 'redirect' | 'automock' mockPath?: string | null resolvedId: string }> + automock: (id: string) => Promise invalidateMocks: () => void getBrowserFileSourceMap: (id: string) => Promise getProvidedContext: () => ProvidedContext diff --git a/packages/vitest/src/integrations/browser/mocker.ts b/packages/vitest/src/integrations/browser/mocker.ts index 23d2da272b4c..fbd4fa95db8b 100644 --- a/packages/vitest/src/integrations/browser/mocker.ts +++ b/packages/vitest/src/integrations/browser/mocker.ts @@ -24,12 +24,18 @@ export class VitestBrowserServerMocker { if (type === 'factory') { this.mocks.set(resolvedId, { sessionId, mock: undefined }) - return resolvedId + return { + id: resolvedId, + mock: undefined, + } } this.mocks.set(resolvedId, { sessionId, mock: mockPath }) - return resolvedId + return { + id: resolvedId, + mock: mockPath, + } } async unmock(rawId: string, importer: string) { diff --git a/packages/vitest/src/node/automockBrowser.ts b/packages/vitest/src/node/automockBrowser.ts new file mode 100644 index 000000000000..fc6bb550d4b1 --- /dev/null +++ b/packages/vitest/src/node/automockBrowser.ts @@ -0,0 +1,148 @@ +import type { Declaration, ExportDefaultDeclaration, ExportNamedDeclaration, Expression, Identifier, Literal, Pattern, Positioned, Program } from '@vitest/utils/ast' +import MagicString from 'magic-string' + +// TODO: better source map replacement +export function automockModule(code: string, parse: (code: string) => Program) { + const ast = parse(code) + + const m = new MagicString(code) + + const allSpecifiers: { name: string; alias?: string }[] = [] + let importIndex = 0 + for (const _node of ast.body) { + if (_node.type === 'ExportAllDeclaration') { + throw new Error( + `automocking files with \`export *\` is not supported in browser mode because it cannot be statically analysed`, + ) + } + + if (_node.type === 'ExportNamedDeclaration') { + const node = _node as Positioned + const declaration = node.declaration // export const name + + function traversePattern(expression: Pattern) { + // export const test = '1' + if (expression.type === 'Identifier') { + allSpecifiers.push({ name: expression.name }) + } + // export const [test, ...rest] = [1, 2, 3] + else if (expression.type === 'ArrayPattern') { + expression.elements.forEach((element) => { + if (!element) + return + traversePattern(element) + }) + } + else if (expression.type === 'ObjectPattern') { + expression.properties.forEach((property) => { + // export const { ...rest } = {} + if (property.type === 'RestElement') + traversePattern(property) + // export const { test, test2: alias } = {} + else if (property.type === 'Property') + traversePattern(property.value) + else + property satisfies never + }) + } + else if (expression.type === 'RestElement') { + traversePattern(expression.argument) + } + // const [name[1], name[2]] = [] + // cannot be used in export + else if (expression.type === 'AssignmentPattern') { + throw new Error(`AssignmentPattern is not supported. Please open a new bug report.`) + } + // const test = thing.func() + // cannot be used in export + else if (expression.type === 'MemberExpression') { + throw new Error(`MemberExpression is not supported. Please open a new bug report.`) + } + else { + expression satisfies never + } + } + + if (declaration) { + if (declaration.type === 'FunctionDeclaration') { + allSpecifiers.push({ name: declaration.id.name }) + } + else if (declaration.type === 'VariableDeclaration') { + declaration.declarations.forEach((declaration) => { + traversePattern(declaration.id) + }) + } + else if (declaration.type === 'ClassDeclaration') { + allSpecifiers.push({ name: declaration.id.name }) + } + else { + declaration satisfies never + } + m.remove(node.start, (declaration as Positioned).start) + } + + const specifiers = node.specifiers || [] + const source = node.source + + if (!source && specifiers.length) { + specifiers.forEach((specifier) => { + const exported = specifier.exported as Literal | Identifier + + allSpecifiers.push({ + alias: exported.type === 'Literal' + ? exported.raw! + : exported.name, + name: specifier.local.name, + }) + }) + m.remove(node.start, node.end) + } + else if (source && specifiers.length) { + const importNames: [string, string][] = [] + + specifiers.forEach((specifier) => { + const importedName = `__vitest_imported_${importIndex++}__` + const exported = specifier.exported as Literal | Identifier + importNames.push([specifier.local.name, importedName]) + allSpecifiers.push({ + name: importedName, + alias: exported.type === 'Literal' + ? exported.raw! + : exported.name, + }) + }) + + const importString = `import { ${importNames.map(([name, alias]) => `${name} as ${alias}`).join(', ')} } from '${source.value}'` + + m.overwrite(node.start, node.end, importString) + } + } + if (_node.type === 'ExportDefaultDeclaration') { + const node = _node as Positioned + const declaration = node.declaration as Positioned + allSpecifiers.push({ name: '__vitest_default', alias: 'default' }) + m.overwrite(node.start, declaration.start, `const __vitest_default = `) + } + } + const moduleObject = ` +const __vitest_es_current_module__ = { + __esModule: true, + ${allSpecifiers.map(({ name }) => `["${name}"]: ${name},`).join('\n ')} +} +const __vitest_mocked_module__ = __vitest_mocker__.mockObject(__vitest_es_current_module__) +` + const assigning = allSpecifiers.map(({ name }, index) => { + return `const __vitest_mocked_${index}__ = __vitest_mocked_module__["${name}"]` + }).join('\n') + + const redeclarations = allSpecifiers.map(({ name, alias }, index) => { + return ` __vitest_mocked_${index}__ as ${alias || name},` + }).join('\n') + const specifiersExports = ` +export { +${redeclarations} +} +` + m.append(moduleObject + assigning + specifiersExports) + return m +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 4bce4fa6132c..c7b5e9dc0313 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -438,6 +438,9 @@ importers: magic-string: specifier: ^0.30.10 version: 0.30.10 + msw: + specifier: ^2.3.1 + version: 2.3.1(typescript@5.4.5) sirv: specifier: ^2.0.4 version: 2.0.4 @@ -470,8 +473,11 @@ importers: specifier: ^1.44.1 version: 1.44.1 playwright-core: - specifier: ^1.44.1 - version: 1.44.1 + specifier: ^1.44.0 + version: 1.44.0 + rollup-plugin-copy: + specifier: ^3.5.0 + version: 3.5.0 safaridriver: specifier: ^0.1.2 version: 0.1.2 @@ -3468,6 +3474,18 @@ packages: resolution: {integrity: sha512-0hYQ8SB4Db5zvZB4axdMHGwEaQjkZzFjQiN9LVYvIFB2nSUHW9tYpxWriPrWDASIxiaXax83REcLxuSdnGPZtw==} dev: false + /@bundled-es-modules/cookie@2.0.0: + resolution: {integrity: sha512-Or6YHg/kamKHpxULAdSqhGqnWFneIXu1NKvvfBBzKGwpVsYuFIQ5aBPHDnnoR3ghW1nvSkALd+EF9iMtY7Vjxw==} + dependencies: + cookie: 0.5.0 + dev: false + + /@bundled-es-modules/statuses@1.0.1: + resolution: {integrity: sha512-yn7BklA5acgcBr+7w064fGV+SGIFySjCKpqjcWgBAIfrAkY+4GQTJJHQMeT3V/sgz23VTEVV8TtOmkvJAhFVfg==} + dependencies: + statuses: 2.0.1 + dev: false + /@canvas/image-data@1.0.0: resolution: {integrity: sha512-BxOqI5LgsIQP1odU5KMwV9yoijleOPzHL18/YvNqF9KFSGF2K/DLlYAbDQsWqd/1nbaFuSkYD/191dpMtNh4vw==} dev: true @@ -4321,6 +4339,43 @@ packages: - supports-color dev: true + /@inquirer/confirm@3.1.9: + resolution: {integrity: sha512-UF09aejxCi4Xqm6N/jJAiFXArXfi9al52AFaSD+2uIHnhZGtd1d6lIGTRMPouVSJxbGEi+HkOWSYaiEY/+szUw==} + engines: {node: '>=18'} + dependencies: + '@inquirer/core': 8.2.2 + '@inquirer/type': 1.3.3 + dev: false + + /@inquirer/core@8.2.2: + resolution: {integrity: sha512-K8SuNX45jEFlX3EBJpu9B+S2TISzMPGXZIuJ9ME924SqbdW6Pt6fIkKvXg7mOEOKJ4WxpQsxj0UTfcL/A434Ww==} + engines: {node: '>=18'} + dependencies: + '@inquirer/figures': 1.0.3 + '@inquirer/type': 1.3.3 + '@types/mute-stream': 0.0.4 + '@types/node': 20.14.2 + '@types/wrap-ansi': 3.0.0 + ansi-escapes: 4.3.2 + chalk: 4.1.2 + cli-spinners: 2.9.2 + cli-width: 4.1.0 + mute-stream: 1.0.0 + signal-exit: 4.1.0 + strip-ansi: 6.0.1 + wrap-ansi: 6.2.0 + dev: false + + /@inquirer/figures@1.0.3: + resolution: {integrity: sha512-ErXXzENMH5pJt5/ssXV0DfWUZqly8nGzf0UcBV9xTnP+KyffE2mqyxIMBrZ8ijQck2nU0TQm40EQB53YreyWHw==} + engines: {node: '>=18'} + dev: false + + /@inquirer/type@1.3.3: + resolution: {integrity: sha512-xTUt0NulylX27/zMx04ZYar/kr1raaiFTVvQ5feljQsiAgdm0WPj4S73/ye0fbslh+15QrIuDvfCXTek7pMY5A==} + engines: {node: '>=18'} + dev: false + /@isaacs/cliui@8.0.2: resolution: {integrity: sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA==} engines: {node: '>=12'} @@ -4445,6 +4500,112 @@ packages: '@lit-labs/ssr-dom-shim': 1.1.2 dev: false + /@luxass/strip-json-comments@1.2.0: + resolution: {integrity: sha512-JNz1ha89f+pJDxSoFwSnIX3MdBpFbxNlILfoN0SHBZpsw4BjEKWmYyV1mFfBTbfDQqZQJELFlmAcoM54dvVfzw==} + engines: {node: '>=18'} + dev: true + + /@marko/babel-utils@6.4.3: + resolution: {integrity: sha512-csLxUFdRCHxDeKvadkme09JJym33s8V1er4Vp/0LoKQTN8D2lcpv/ZJh6dQj/9vaNSSDlWaV56XSGF0sLEJEnA==} + dependencies: + '@babel/runtime': 7.24.4 + jsesc: 3.0.2 + relative-import-path: 1.0.0 + dev: true + + /@marko/compiler@5.36.1: + resolution: {integrity: sha512-a+L44BPOChVYrS4t5kG4t9EVVeCFhQxoNLfSxEHBdyXEzC3auntOG0yEcURkSVClT3/do2GCkFHZ6T8ILO90kQ==} + dependencies: + '@babel/code-frame': 7.24.2 + '@babel/core': 7.24.4 + '@babel/generator': 7.24.4 + '@babel/parser': 7.24.4 + '@babel/plugin-syntax-typescript': 7.24.1(@babel/core@7.24.4) + '@babel/plugin-transform-modules-commonjs': 7.24.1(@babel/core@7.24.4) + '@babel/plugin-transform-typescript': 7.24.4(@babel/core@7.24.4) + '@babel/runtime': 7.24.4 + '@babel/traverse': 7.24.1 + '@babel/types': 7.24.0 + '@luxass/strip-json-comments': 1.2.0 + '@marko/babel-utils': 6.4.3 + complain: 1.6.0 + he: 1.2.0 + htmljs-parser: 5.5.2 + jsesc: 3.0.2 + kleur: 4.1.5 + lasso-package-root: 1.0.1 + raptor-regexp: 1.0.1 + raptor-util: 3.2.0 + resolve-from: 5.0.0 + self-closing-tags: 1.0.1 + source-map-support: 0.5.21 + transitivePeerDependencies: + - supports-color + dev: true + + /@marko/testing-library@6.2.0(marko@5.34.2): + resolution: {integrity: sha512-09R7PuyoBmDF+Q1JvDP8xsVU27hpP533SXy3ILzwzJyQMucvUddjT5VfF3FlSZmFy6RXj4jP6yUCpokEB0RNkg==} + peerDependencies: + marko: ^3 || ^4 || ^5 + dependencies: + '@testing-library/dom': 9.3.3 + jsdom: 23.2.0 + marko: 5.34.2 + transitivePeerDependencies: + - bufferutil + - canvas + - supports-color + - utf-8-validate + dev: true + + /@marko/translator-default@6.0.0(@marko/compiler@5.36.1)(marko@5.34.2): + resolution: {integrity: sha512-ldx7s2oYkWvjclLzDbGQyKTC5M7Of/eeFtodDD4WQdTUgxuEKOVTx065V1hKGm5wqHMW4pCKSD3Z+35jrsYYlA==} + peerDependencies: + '@marko/compiler': ^5.16.1 + marko: ^5.17.2 + dependencies: + '@babel/runtime': 7.24.4 + '@marko/babel-utils': 6.4.3 + '@marko/compiler': 5.36.1 + magic-string: 0.30.10 + marko: 5.34.2 + self-closing-tags: 1.0.1 + dev: true + + /@marko/vite@4.1.10(@marko/compiler@5.36.1)(vite@5.2.6): + resolution: {integrity: sha512-D6rY/P8CgO83kNKQOpZMqWNr9xXeUdrbfkdTgxyKHMCpGigr2ywASaUIh19E9Nz9jp0aRI9w2U0NnXK+gfLCKA==} + peerDependencies: + '@marko/compiler': ^5 + vite: ^5.2.6 + dependencies: + '@chialab/cjs-to-esm': 0.18.0 + '@marko/compiler': 5.36.1 + anymatch: 3.1.3 + domelementtype: 2.3.0 + domhandler: 5.0.3 + htmlparser2: 9.1.0 + resolve: 1.22.8 + resolve.exports: 2.0.2 + vite: 5.2.6(@types/node@20.12.11) + dev: true + + /@mswjs/cookies@1.1.0: + resolution: {integrity: sha512-0ZcCVQxifZmhwNBoQIrystCb+2sWBY2Zw8lpfJBPCHGCA/HWqehITeCRVIv4VMy8MPlaHo2w2pTHFV2pFfqKPw==} + engines: {node: '>=18'} + dev: false + + /@mswjs/interceptors@0.29.1: + resolution: {integrity: sha512-3rDakgJZ77+RiQUuSK69t1F0m8BQKA8Vh5DCS5V0DWvNY67zob2JhhQrhCO0AKLGINTRSFd1tBaHcJTkhefoSw==} + engines: {node: '>=18'} + dependencies: + '@open-draft/deferred-promise': 2.2.0 + '@open-draft/logger': 0.3.0 + '@open-draft/until': 2.1.0 + is-node-process: 1.2.0 + outvariant: 1.4.2 + strict-event-emitter: 0.5.1 + dev: false + /@nodelib/fs.scandir@2.1.5: resolution: {integrity: sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==} engines: {node: '>= 8'} @@ -4467,6 +4628,28 @@ packages: resolution: {integrity: sha512-XuySG1E38YScSJoMlqovLru4KTUNSjgVTIjyh7qMX6aNN5HY5Ct5LhRJdxO79JtTzKfzV/bnWpz+zquYrISsvw==} dev: true + /@open-draft/deferred-promise@2.2.0: + resolution: {integrity: sha512-CecwLWx3rhxVQF6V4bAgPS5t+So2sTbPgAzafKkVizyi7tlwpcFpdFqq+wqF2OwNBmqFuu6tOyouTuxgpMfzmA==} + dev: false + + /@open-draft/logger@0.3.0: + resolution: {integrity: sha512-X2g45fzhxH238HKO4xbSr7+wBS8Fvw6ixhTDuvLd5mqh6bJJCFAPwU9mPDxbcrRtfxv4u5IHCEH77BmxvXmmxQ==} + dependencies: + is-node-process: 1.2.0 + outvariant: 1.4.2 + dev: false + + /@open-draft/until@2.1.0: + resolution: {integrity: sha512-U69T3ItWHvLwGg5eJ0n3I62nWuE6ilHlmz7zM0npLBRvPRd7e6NYmg54vvRtP5mZG7kZqZCFVdsTWo7BPtBujg==} + dev: false + + /@parcel/source-map@2.1.1: + resolution: {integrity: sha512-Ejx1P/mj+kMjQb8/y5XxDUn4reGdr+WyKYloBljpppUy8gs42T+BNoEOuRYqDVdgPc6NxduzIDoJS9pOFfV5Ew==} + engines: {node: ^12.18.3 || >=14} + dependencies: + detect-libc: 1.0.3 + dev: true + /@pkgjs/parseargs@0.11.0: resolution: {integrity: sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==} engines: {node: '>=14'} @@ -5425,6 +5608,10 @@ packages: resolution: {integrity: sha512-COUnqfB2+ckwXXSFInsFdOAWQzCCx+a5hq2ruyj+Vjund94RJQd4LG2u9hnvJrTgunKAaax7ancBYlDrNYxA0g==} dev: true + /@types/cookie@0.6.0: + resolution: {integrity: sha512-4Kh9a6B2bQciAhf7FSuMRRkUWecJgJu9nPnx3yzpsfXX/c50REIqpHY4C82bXP90qrLtXtkDxTZosYO3UpOwlA==} + dev: false + /@types/d3-force@3.0.9: resolution: {integrity: sha512-IKtvyFdb4Q0LWna6ymywQsEYjK/94SGhPrMfEr1TIc5OBeziTi+1jcCvttts8e0UWZIxpasjnQk9MNk/3iS+kA==} dev: true @@ -5591,6 +5778,12 @@ packages: resolution: {integrity: sha512-iiUgKzV9AuaEkZqkOLDIvlQiL6ltuZd9tGcW3gwpnX8JbuiuhFlEGmmFXEXkN50Cvq7Os88IY2v0dkDqXYWVgA==} dev: true + /@types/mute-stream@0.0.4: + resolution: {integrity: sha512-CPM9nzrCPPJHQNA9keH9CVkVI+WR5kMa+7XEs5jcGQ0VoAGnLv242w8lIVgwAEfmE4oufJRaTc9PNLQl0ioAow==} + dependencies: + '@types/node': 20.14.2 + dev: false + /@types/natural-compare@1.4.3: resolution: {integrity: sha512-XCAxy+Gg6+S6VagwzcknnvCKujj/bVv1q+GFuCrFEelqaZPqJoC+FeXLwc2dp+oLP7qDZQ4ZfQiTJQ9sIUmlLw==} dev: true @@ -5625,6 +5818,11 @@ packages: dependencies: undici-types: 5.26.5 + /@types/node@20.14.2: + resolution: {integrity: sha512-xyu6WAMVwv6AKFLB+e/7ySZVr/0zLCzOa7rSpq6jNwpqOrUbcACDWC+53d4n2QHOnDou0fbIsg8wZu/sxrnI4Q==} + dependencies: + undici-types: 5.26.5 + /@types/normalize-package-data@2.4.1: resolution: {integrity: sha512-Gj7cI7z+98M282Tqmp2K5EIsoouUEzbBJhQQzDE3jSIRk6r9gsz0oUokqIUR4u1R3dMHo0pDHM7sNOHyhulypw==} dev: true @@ -5670,6 +5868,10 @@ packages: resolution: {integrity: sha512-Hl219/BT5fLAaz6NDkSuhzasy49dwQS/DSdu4MdggFB8zcXv7vflBI3xp7FEmkmdDkBUI2bPUNeMttp2knYdxw==} dev: true + /@types/statuses@2.0.5: + resolution: {integrity: sha512-jmIUGWrAiwu3dZpxntxieC+1n/5c3mjrImkmOSQ2NC5uP6cYO4aAZDdSmRcI5C1oiTmqlZGHC+/NmJrKogbP5A==} + dev: false + /@types/tern@0.23.4: resolution: {integrity: sha512-JAUw1iXGO1qaWwEOzxTKJZ/5JxVeON9kvGZ/osgZaJImBnyjyn0cjovPsf6FNLmyGY8Vw9DoXZCMlfMkMwHRWg==} dependencies: @@ -5714,6 +5916,10 @@ packages: resolution: {integrity: sha512-113D3mDkZDjo+EeUEHCFy0qniNc1ZpecGiAU7WSo7YDoSzolZIQKpYFHrPpjkB2nuyahcKfrmLXeQlh7gqJYdw==} dev: true + /@types/wrap-ansi@3.0.0: + resolution: {integrity: sha512-ltIpx+kM7g/MLRZfkbL7EsCEjfzCcScLpkg37eXEtx5kmrAKBkTJwd1GIAjDSL8wTpM6Hzn5YO4pSb91BEwu1g==} + dev: false + /@types/ws@8.5.10: resolution: {integrity: sha512-vmQSUcfalpIq0R9q7uTo2lXs6eGIpt9wtnLdMv9LVpIjCA/+ufZRozlVoVelIYixx1ugCBKDhn89vnsEGOCx9A==} dependencies: @@ -7074,7 +7280,6 @@ packages: engines: {node: '>=8'} dependencies: type-fest: 0.21.3 - dev: true /ansi-escapes@5.0.0: resolution: {integrity: sha512-5GFMVX8HqE/TB+FuBJGuO5XG0WrsA6ptUqoODaT/n9mmUaZFkqnBueB4leqGBCmrUHnCnC4PCZTCd0E7QQ83bA==} @@ -7854,6 +8059,11 @@ packages: engines: {node: '>=6'} dev: true + /cli-spinners@2.9.2: + resolution: {integrity: sha512-ywqV+5MmyL4E7ybXgKys4DugZbX0FC6LnwrhjuykIjnK9k8OQacQ7axGKnjDXWNhns0xot3bZI5h55H8yo9cJg==} + engines: {node: '>=6'} + dev: false + /cli-truncate@4.0.0: resolution: {integrity: sha512-nPdaFdQ0h/GEigbPClz11D0v/ZJEwxmeVZGeMo3Z5StPtUTkA9o1lD6QwoirYiSDzbcwn2XcjwmCp68W1IS4TA==} engines: {node: '>=18'} @@ -7867,6 +8077,11 @@ packages: engines: {node: '>= 12'} dev: true + /cli-width@4.1.0: + resolution: {integrity: sha512-ouuZd4/dm2Sw5Gmqy6bGyNNNe1qt9RpmxveLSO7KcgsTnU7RXfsw+/bukWGo1abgBiMAic068rclZsO4IWmmxQ==} + engines: {node: '>= 12'} + dev: false + /cliui@8.0.1: resolution: {integrity: sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==} engines: {node: '>=12'} @@ -7874,7 +8089,6 @@ packages: string-width: 4.2.3 strip-ansi: 6.0.1 wrap-ansi: 7.0.0 - dev: true /clone@1.0.4: resolution: {integrity: sha512-JQHZ2QMW6l3aH/j6xCqQThY/9OH4D/9ls34cgkUBiEeocRTU04tHfKPBsUK1PqZCUQM7GiA0IIXJSuXHI64Kbg==} @@ -8077,7 +8291,6 @@ packages: /cookie@0.5.0: resolution: {integrity: sha512-YZ3GUyn/o8gfKJlnlX7g7xq4gyO6OSuhGPKaaGssGB2qgDUS0gPgtTvoyZLTt9Ab6dC4hfc9dV5arkvc/OCmrw==} engines: {node: '>= 0.6'} - dev: true /cookie@0.6.0: resolution: {integrity: sha512-U71cyTamuh1CRNCfpGY6to28lxvNwPG4Guz/EVjgf3Jmzv0vlDp1atT9eS5dDjMYHucpHbWns6Lwf3BKz6svdw==} @@ -8765,7 +8978,6 @@ packages: /emoji-regex@8.0.0: resolution: {integrity: sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==} - dev: true /emoji-regex@9.2.2: resolution: {integrity: sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==} @@ -10127,7 +10339,6 @@ packages: /get-caller-file@2.0.5: resolution: {integrity: sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==} engines: {node: 6.* || 8.* || >= 10.*} - dev: true /get-east-asian-width@1.2.0: resolution: {integrity: sha512-2nk+7SIVb14QrgXFHcm84tD4bKQz0RxPuMT8Ag5KPOq7J5fEmAg0UbXdTOSHqNuHSU28k55qnceesxXRZGzKWA==} @@ -10417,6 +10628,11 @@ packages: resolution: {integrity: sha512-EtKwoO6kxCL9WO5xipiHTZlSzBm7WLT627TqC/uVRd0HKmq8NXyebnNYxDoBi7wt8eTWrUrKXCOVaFq9x1kgag==} dev: true + /graphql@16.8.1: + resolution: {integrity: sha512-59LZHPdGZVh695Ud9lRzPBVTtlX9ZCV150Er2W43ro37wVof0ctenSaskPPjN7lVTIN8mSZt8PHUNKZuNQUuxw==} + engines: {node: ^12.22.0 || ^14.16.0 || ^16.0.0 || >=17.0.0} + dev: false + /gtoken@7.0.1: resolution: {integrity: sha512-KcFVtoP1CVFtQu0aSk3AyAt2og66PFhZAlkUOuWKwzMLoulHXG5W5wE5xAnHb+yl3/wEFoqGW7/cDGMU8igDZQ==} engines: {node: '>=14.0.0'} @@ -10508,6 +10724,10 @@ packages: hasBin: true dev: true + /headers-polyfill@4.0.3: + resolution: {integrity: sha512-IScLbePpkvO846sIwOtOTDjutRMWdXdJmXdMvk6gCBHxFO8d+QKOQedyZSxFTTFYRSmlgSTDtXqqq4pcenBXLQ==} + dev: false + /hexoid@1.0.0: resolution: {integrity: sha512-QFLV0taWQOZtvIRIAdBChesmogZrtuXvVWsFHZTk2SU+anspqZ2vMnoLg7IE1+Uk16N19APic1BuF8bC8c2m5g==} engines: {node: '>=8'} @@ -10879,7 +11099,6 @@ packages: /is-fullwidth-code-point@3.0.0: resolution: {integrity: sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==} engines: {node: '>=8'} - dev: true /is-fullwidth-code-point@4.0.0: resolution: {integrity: sha512-O4L094N2/dZ7xqVdrXhh9r1KODPJpFms8B5sGdJLPy664AgvXsreZUyCQQNItZRDlYug4xStLjNp/sz3HvBowQ==} @@ -10920,6 +11139,10 @@ packages: engines: {node: '>= 0.4'} dev: true + /is-node-process@1.2.0: + resolution: {integrity: sha512-Vg4o6/fqPxIjtxgUH5QLJhwZ7gW5diGCVlXpuUfELC62CuxM1iHcRe51f2W1FDy04Ai4KJkagKjx3XaqyfRKXw==} + dev: false + /is-number-object@1.0.7: resolution: {integrity: sha512-k1U0IRzLMo7ZlYIfzRu23Oh6MiIFasgpb9X76eqfFZAqwH44UI4KTBvBYIZ1dSL9ZzChTB9ShHfLkR4pdW5krQ==} engines: {node: '>= 0.4'} @@ -12413,6 +12636,37 @@ packages: resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==} dev: true + /msw@2.3.1(typescript@5.4.5): + resolution: {integrity: sha512-ocgvBCLn/5l3jpl1lssIb3cniuACJLoOfZu01e3n5dbJrpA5PeeWn28jCLgQDNt6d7QT8tF2fYRzm9JoEHtiig==} + engines: {node: '>=18'} + hasBin: true + requiresBuild: true + peerDependencies: + typescript: '>= 4.7.x' + peerDependenciesMeta: + typescript: + optional: true + dependencies: + '@bundled-es-modules/cookie': 2.0.0 + '@bundled-es-modules/statuses': 1.0.1 + '@inquirer/confirm': 3.1.9 + '@mswjs/cookies': 1.1.0 + '@mswjs/interceptors': 0.29.1 + '@open-draft/until': 2.1.0 + '@types/cookie': 0.6.0 + '@types/statuses': 2.0.5 + chalk: 4.1.2 + graphql: 16.8.1 + headers-polyfill: 4.0.3 + is-node-process: 1.2.0 + outvariant: 1.4.2 + path-to-regexp: 6.2.2 + strict-event-emitter: 0.5.1 + type-fest: 4.19.0 + typescript: 5.4.5 + yargs: 17.7.2 + dev: false + /muggle-string@0.3.1: resolution: {integrity: sha512-ckmWDJjphvd/FvZawgygcUeQCxzvohjFO5RxTjj4eq8kw359gFF3E1brjfI+viLMxss5JrHTDRHZvu2/tuy0Qg==} dev: true @@ -12424,7 +12678,6 @@ packages: /mute-stream@1.0.0: resolution: {integrity: sha512-avsJQhyd+680gKXyG/sQc0nXaC6rBkPOfyHYcFb9+hdkqQkR9bdnkJ0AMZhke0oesPqIO+mFFJ+IdBc7mst4IA==} engines: {node: ^14.17.0 || ^16.13.0 || >=18.0.0} - dev: true /n12@0.4.0: resolution: {integrity: sha512-p/hj4zQ8d3pbbFLQuN1K9honUxiDDhueOWyFLw/XgBv+wZCE44bcLH4CIcsolOceJQduh4Jf7m/LfaTxyGmGtQ==} @@ -12721,6 +12974,10 @@ packages: engines: {node: '>=0.10.0'} dev: true + /outvariant@1.4.2: + resolution: {integrity: sha512-Ou3dJ6bA/UJ5GVHxah4LnqDwZRwAmWxrG3wtrHrbGnP4RnLCtA64A4F+ae7Y8ww660JaddSoArUR5HjipWSHAQ==} + dev: false + /p-cancelable@3.0.0: resolution: {integrity: sha512-mlVgR3PGuzlo0MmTdk4cXqXWlwQDLnONTAg6sm62XkMJEiRxN3GL3SffkYvqwonbkJBcrI7Uvv5Zh9yjvn2iUw==} engines: {node: '>=12.20'} @@ -12910,6 +13167,10 @@ packages: resolution: {integrity: sha512-5DFkuoqlv1uYQKxy8omFBeJPQcdoE07Kv2sferDCrAq1ohOU+MSDswDIbnx3YAM60qIOnYa53wBhXW0EbMonrQ==} dev: true + /path-to-regexp@6.2.2: + resolution: {integrity: sha512-GQX3SSMokngb36+whdpRXE+3f9V8UzyAorlYvOGx87ufGHehNTn5lCxrKtLyZ4Yl/wEKnNnr98ZzOwwDZV5ogw==} + dev: false + /path-type@4.0.0: resolution: {integrity: sha512-gDKb8aZMDeD/tZWs9P6+q0J9Mwkdl6xMV8TjnGP3qJVJ06bdMgkbBlLU8IdfOsIsFz2BW1rNVT3XuNEl8zPAvw==} engines: {node: '>=8'} @@ -13569,7 +13830,6 @@ packages: /require-directory@2.1.1: resolution: {integrity: sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==} engines: {node: '>=0.10.0'} - dev: true /require-from-string@2.0.2: resolution: {integrity: sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==} @@ -14338,7 +14598,6 @@ packages: /statuses@2.0.1: resolution: {integrity: sha512-RwNA9Z/7PrK06rYLIzFMlaF+l73iwpzsqRIFgbMLbTcLD6cOao82TaWefPXQvB2fOC4AjuYSEndS7N/mTCbkdQ==} engines: {node: '>= 0.8'} - dev: true /std-env@3.7.0: resolution: {integrity: sha512-JPbdCEQLj1w5GilpiHAx3qJvFndqybBysA3qUOnznweH4QbNYUsW/ea8QzSrnh0vNsezMMw5bcVool8lM0gwzg==} @@ -14362,6 +14621,10 @@ packages: queue-tick: 1.0.1 dev: true + /strict-event-emitter@0.5.1: + resolution: {integrity: sha512-vMgjE/GGEPEFnhFub6pa4FmJBRBVOLpIII2hvCZ8Kzb7K0hlHo7mQv6xYrBvCL2LtAIBwFUK8wvuJgTVSQ5MFQ==} + dev: false + /string-argv@0.3.2: resolution: {integrity: sha512-aqD2Q0144Z+/RqG52NeHEkZauTAUWJO8c6yTftGJKO3Tja5tUgIfmIl6kExvhtxSDP7fXB6DvzkfMpCd/F3G+Q==} engines: {node: '>=0.6.19'} @@ -14374,7 +14637,6 @@ packages: emoji-regex: 8.0.0 is-fullwidth-code-point: 3.0.0 strip-ansi: 6.0.1 - dev: true /string-width@5.1.2: resolution: {integrity: sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA==} @@ -14459,7 +14721,6 @@ packages: engines: {node: '>=8'} dependencies: ansi-regex: 5.0.1 - dev: true /strip-ansi@7.0.1: resolution: {integrity: sha512-cXNxvT8dFNRVfhVME3JAe98mkXDYN2O1l7jmcwMnOslDeESg1rF/OZMtK0nRAhiari1unG5cD4jG3rapUAkLbw==} @@ -14997,7 +15258,6 @@ packages: /type-fest@0.21.3: resolution: {integrity: sha512-t0rzBq87m3fVcduHDUFhKmyyX+9eo6WQjZvf51Ea/M0Q7+T374Jp1aUiyUl0GKxp8M/OETVHSDvmkyPgvX+X2w==} engines: {node: '>=10'} - dev: true /type-fest@0.6.0: resolution: {integrity: sha512-q+MB8nYR1KDLrgr4G5yemftpMC7/QLqVndBmEEdqzmNj5dcFOO4Oo8qlwZE3ULT3+Zim1F8Kq4cBnikNhlCMlg==} @@ -15024,6 +15284,11 @@ packages: engines: {node: '>=12.20'} dev: true + /type-fest@4.19.0: + resolution: {integrity: sha512-CN2l+hWACRiejlnr68vY0/7734Kzu+9+TOslUXbSCQ1ruY9XIHDBSceVXCcHm/oXrdzhtLMMdJEKfemf1yXiZQ==} + engines: {node: '>=16'} + dev: false + /type-is@1.6.18: resolution: {integrity: sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g==} engines: {node: '>= 0.6'} @@ -15405,7 +15670,7 @@ packages: dependencies: browserslist: 4.23.0 escalade: 3.1.2 - picocolors: 1.0.1 + picocolors: 1.0.0 /uri-js@4.4.1: resolution: {integrity: sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==} @@ -16423,7 +16688,6 @@ packages: ansi-styles: 4.3.0 string-width: 4.2.3 strip-ansi: 6.0.1 - dev: true /wrap-ansi@7.0.0: resolution: {integrity: sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==} @@ -16432,7 +16696,6 @@ packages: ansi-styles: 4.3.0 string-width: 4.2.3 strip-ansi: 6.0.1 - dev: true /wrap-ansi@8.1.0: resolution: {integrity: sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ==} @@ -16523,7 +16786,6 @@ packages: /y18n@5.0.8: resolution: {integrity: sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==} engines: {node: '>=10'} - dev: true /yallist@2.1.2: resolution: {integrity: sha512-ncTzHV7NvsQZkYe1DW7cbDLm0YpzHmZF5r/iyP3ZnQtMiJ+pjzisCiMNI+Sj+xQF5pXhSHxSB3uDbsBTzY/c2A==} @@ -16553,7 +16815,6 @@ packages: /yargs-parser@21.1.1: resolution: {integrity: sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==} engines: {node: '>=12'} - dev: true /yargs@17.7.1: resolution: {integrity: sha512-cwiTb08Xuv5fqF4AovYacTFNxk62th7LKJ6BL9IGUpTJrWoU7/7WdQGTP2SjKf1dUNBGzDd28p/Yfs/GI6JrLw==} @@ -16579,7 +16840,6 @@ packages: string-width: 4.2.3 y18n: 5.0.8 yargs-parser: 21.1.1 - dev: true /yauzl@2.10.0: resolution: {integrity: sha512-p4a9I6X6nu6IhoGmBqAcbJy1mlC4j27vEPZX9F4L4/vZT3Lyq1VkFHw/V/PUcB9Buo+DG3iHkT0x3Qya58zc3g==} From 2f1707a79160ce3231ca24bca6e31adcc8b82148 Mon Sep 17 00:00:00 2001 From: Vladimir Sheremet Date: Fri, 7 Jun 2024 18:53:53 +0200 Subject: [PATCH 09/29] refactor: define dependencies correctly --- packages/browser/package.json | 2 +- packages/browser/src/client/mocker.ts | 27 +++++++++++++++++++++++---- packages/browser/src/client/tester.ts | 2 +- packages/vitest/package.json | 3 +-- pnpm-lock.yaml | 10 +++++----- 5 files changed, 31 insertions(+), 13 deletions(-) diff --git a/packages/browser/package.json b/packages/browser/package.json index 1bd5b372db39..226a8ca8e5b6 100644 --- a/packages/browser/package.json +++ b/packages/browser/package.json @@ -91,4 +91,4 @@ "vitest": "workspace:*", "webdriverio": "^8.38.2" } -} \ No newline at end of file +} diff --git a/packages/browser/src/client/mocker.ts b/packages/browser/src/client/mocker.ts index f0257611d055..3f7558670a09 100644 --- a/packages/browser/src/client/mocker.ts +++ b/packages/browser/src/client/mocker.ts @@ -1,5 +1,6 @@ import { getType } from '@vitest/utils' import { extname, join } from 'pathe' +import type { SetupWorker } from 'msw/browser' import { setupWorker } from 'msw/browser' import { HttpResponse, http, passthrough } from 'msw' import { rpc } from './rpc' @@ -24,9 +25,13 @@ export class VitestBrowserClientMocker { private factories: Record any> = {} private spyModule!: SpyModule + private mswWorker!: SetupWorker - startWorker() { - const worker = setupWorker( + private _mswStarted = false + private _mswPromise: Promise | null = null + + setupWorker() { + this.mswWorker = setupWorker( http.get(/.+/, async ({ request }) => { const path = cleanTimestamp(request.url.slice(location.origin.length)) if (!(path in this.mocks)) @@ -63,12 +68,23 @@ export class VitestBrowserClientMocker { }) }), ) - return worker.start({ + } + + public async startMSW() { + if (this._mswStarted) + return + if (this._mswPromise) + return this._mswPromise + this._mswPromise = this.mswWorker.start({ serviceWorker: { url: '/__virtual_vitest__:mocker-worker.js', }, quiet: true, + }).then(() => { + this._mswStarted = true + this._mswPromise = null }) + await this._mswPromise } public setSpyModule(mod: SpyModule) { @@ -168,7 +184,10 @@ export class VitestBrowserClientMocker { public async prepare() { if (!this.queue.size) return - await Promise.all([...this.queue.values()]) + await Promise.all([ + this.startMSW(), + ...this.queue.values(), + ]) } // TODO: move this logic into a util(?) diff --git a/packages/browser/src/client/tester.ts b/packages/browser/src/client/tester.ts index 13039f5a98ea..a2fce5a3b06a 100644 --- a/packages/browser/src/client/tester.ts +++ b/packages/browser/src/client/tester.ts @@ -133,7 +133,7 @@ async function prepareTestEnvironment(files: string[]) { registerUnexpectedErrors(rpc) try { - await mocker.startWorker() + await mocker.setupWorker() } catch (err) { console.error(err) diff --git a/packages/vitest/package.json b/packages/vitest/package.json index 5748c1baa89d..c1707c5196c8 100644 --- a/packages/vitest/package.json +++ b/packages/vitest/package.json @@ -147,6 +147,7 @@ } }, "dependencies": { + "@ampproject/remapping": "^2.3.0", "@vitest/expect": "workspace:*", "@vitest/runner": "workspace:*", "@vitest/snapshot": "workspace:*", @@ -166,7 +167,6 @@ "why-is-node-running": "^2.2.2" }, "devDependencies": { - "@ampproject/remapping": "^2.3.0", "@antfu/install-pkg": "0.3.1", "@edge-runtime/vm": "^3.2.0", "@sinonjs/fake-timers": "11.1.0", @@ -184,7 +184,6 @@ "cac": "^6.7.14", "chai-subset": "^1.6.0", "cli-truncate": "^4.0.0", - "debug": "^4.3.4", "expect-type": "^0.19.0", "fast-glob": "^3.3.2", "find-up": "^6.3.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index c7b5e9dc0313..f86baf70365a 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -839,6 +839,9 @@ importers: packages/vitest: dependencies: + '@ampproject/remapping': + specifier: ^2.3.0 + version: 2.3.0 '@vitest/browser': specifier: workspace:* version: link:../browser @@ -864,8 +867,8 @@ importers: specifier: ^5.1.1 version: 5.1.1 debug: - specifier: ^4.3.5 - version: 4.3.5 + specifier: ^4.3.4 + version: 4.3.4 execa: specifier: ^8.0.1 version: 8.0.1 @@ -897,9 +900,6 @@ importers: specifier: ^2.2.2 version: 2.2.2 devDependencies: - '@ampproject/remapping': - specifier: ^2.3.0 - version: 2.3.0 '@antfu/install-pkg': specifier: 0.3.1 version: 0.3.1 From ecb838ae03a157cdd06aa87c0e37c3e5b12719e9 Mon Sep 17 00:00:00 2001 From: Vladimir Sheremet Date: Fri, 7 Jun 2024 22:03:03 +0200 Subject: [PATCH 10/29] chore: cleanup --- packages/browser/src/client/mocker.ts | 62 ++++++++++++++----- packages/browser/src/client/runner.ts | 14 +++-- packages/browser/src/client/tester.ts | 10 +-- packages/browser/src/node/index.ts | 11 ++-- .../browser/src/node/plugins/pluginMocker.ts | 60 ------------------ packages/vitest/src/api/browser.ts | 25 +++----- packages/vitest/src/api/setup.ts | 2 +- packages/vitest/src/api/types.ts | 9 +-- .../vitest/src/integrations/browser/mocker.ts | 36 ----------- .../vitest/src/integrations/browser/server.ts | 1 + 10 files changed, 76 insertions(+), 154 deletions(-) delete mode 100644 packages/browser/src/node/plugins/pluginMocker.ts diff --git a/packages/browser/src/client/mocker.ts b/packages/browser/src/client/mocker.ts index 3f7558670a09..35f43cd5b1d2 100644 --- a/packages/browser/src/client/mocker.ts +++ b/packages/browser/src/client/mocker.ts @@ -2,7 +2,7 @@ import { getType } from '@vitest/utils' import { extname, join } from 'pathe' import type { SetupWorker } from 'msw/browser' import { setupWorker } from 'msw/browser' -import { HttpResponse, http, passthrough } from 'msw' +import { http } from 'msw/core/http' import { rpc } from './rpc' import { getBrowserState } from './utils' @@ -14,7 +14,7 @@ interface SpyModule { const timestampRegexp = /(\?|&)t=\d{13}/ -function cleanTimestamp(url: string) { +function removeTimestamp(url: string) { return url.replace(timestampRegexp, '') } @@ -23,6 +23,7 @@ export class VitestBrowserClientMocker { private mocks: Record = {} private mockObjects: Record = {} private factories: Record any> = {} + private ids = new Set() private spyModule!: SpyModule private mswWorker!: SetupWorker @@ -33,7 +34,7 @@ export class VitestBrowserClientMocker { setupWorker() { this.mswWorker = setupWorker( http.get(/.+/, async ({ request }) => { - const path = cleanTimestamp(request.url.slice(location.origin.length)) + const path = removeTimestamp(request.url.slice(location.origin.length)) if (!(path in this.mocks)) return passthrough() @@ -58,7 +59,7 @@ export class VitestBrowserClientMocker { } if (typeof mock === 'string') - return HttpResponse.redirect(mock) + return Response.redirect(mock) const content = await rpc().automock(path) return new Response(content, { @@ -107,7 +108,7 @@ export class VitestBrowserClientMocker { public async importMock(rawId: string, importer: string) { await this.prepare() - const { resolvedId, type, mockPath } = await rpc().resolveMock(rawId, importer) + const { resolvedId, type, mockPath } = await rpc().resolveMock(rawId, importer, false) const factoryReturn = this.get(resolvedId) if (factoryReturn) @@ -135,6 +136,17 @@ export class VitestBrowserClientMocker { return this.mockObjects[id] } + public async invalidate() { + const ids = Array.from(this.ids) + if (!ids.length) + return + await rpc().invalidate(ids) + this.ids.clear() + this.mocks = {} + this.mockObjects = {} + this.factories = {} + } + public async resolve(id: string) { const factory = this.factories[id] if (!factory) @@ -155,12 +167,13 @@ export class VitestBrowserClientMocker { } public queueMock(id: string, importer: string, factory?: () => any) { - const promise = rpc().queueMock(id, importer, !!factory) - .then(({ id, mock }) => { - const urlPaths = resolveMockPaths(id) - const resolvedMock = typeof mock === 'string' - ? new URL(resolvedMockedPath(mock), location.href).toString() - : mock + const promise = rpc().resolveMock(id, importer, !!factory) + .then(({ mockPath, resolvedId }) => { + this.ids.add(resolvedId) + const urlPaths = resolveMockPaths(resolvedId) + const resolvedMock = typeof mockPath === 'string' + ? new URL(resolvedMockedPath(mockPath), location.href).toString() + : mockPath urlPaths.forEach((url) => { this.mocks[url] = resolvedMock this.factories[url] = factory! @@ -172,10 +185,19 @@ export class VitestBrowserClientMocker { } public queueUnmock(id: string, importer: string) { - const promise = rpc().queueUnmock(id, importer) - .then((id) => { - delete this.factories[id] - }).finally(() => { + const promise = rpc().resolveId(id, importer) + .then((resolved) => { + if (!resolved) + return + this.ids.delete(resolved.id) + const urlPaths = resolveMockPaths(resolved.id) + urlPaths.forEach((url) => { + delete this.mocks[url] + delete this.factories[url] + delete this.mockObjects[url] + }) + }) + .finally(() => { this.queue.delete(promise) }) this.queue.add(promise) @@ -388,3 +410,13 @@ function resolveMockPaths(path: string) { return paths } + +function passthrough() { + return new Response(null, { + status: 302, + statusText: 'Passthrough', + headers: { + 'x-msw-intention': 'passthrough', + }, + }) +} diff --git a/packages/browser/src/client/runner.ts b/packages/browser/src/client/runner.ts index 0b3f5fde90a2..bc7177be2dfb 100644 --- a/packages/browser/src/client/runner.ts +++ b/packages/browser/src/client/runner.ts @@ -4,6 +4,7 @@ import type { VitestExecutor } from 'vitest/execute' import { rpc } from './rpc' import { importId } from './utils' import { VitestBrowserSnapshotEnvironment } from './snapshot' +import type { VitestBrowserClientMocker } from './mocker' interface BrowserRunnerOptions { config: ResolvedConfig @@ -17,6 +18,7 @@ interface CoverageHandler { export function createBrowserRunner( runnerClass: { new(config: ResolvedConfig): VitestRunner }, + mocker: VitestBrowserClientMocker, state: WorkerGlobalState, coverageModule: CoverageHandler | null, ): { new(options: BrowserRunnerOptions): VitestRunner } { @@ -44,9 +46,11 @@ export function createBrowserRunner( } onAfterRunFiles = async (files: File[]) => { - await rpc().invalidateMocks() - await super.onAfterRunFiles?.(files) - const coverage = await coverageModule?.takeCoverage?.() + const [coverage] = await Promise.all([ + coverageModule?.takeCoverage?.(), + mocker.invalidate(), + super.onAfterRunFiles?.(files), + ]) if (coverage) { await rpc().onAfterSuiteRun({ @@ -98,7 +102,7 @@ export function createBrowserRunner( let cachedRunner: VitestRunner | null = null -export async function initiateRunner(state: WorkerGlobalState, config: ResolvedConfig) { +export async function initiateRunner(state: WorkerGlobalState, mocker: VitestBrowserClientMocker, config: ResolvedConfig) { if (cachedRunner) return cachedRunner const [ @@ -109,7 +113,7 @@ export async function initiateRunner(state: WorkerGlobalState, config: ResolvedC importId('vitest/browser') as Promise, ]) const runnerClass = config.mode === 'test' ? VitestTestRunner : NodeBenchmarkRunner - const BrowserRunner = createBrowserRunner(runnerClass, state, { + const BrowserRunner = createBrowserRunner(runnerClass, mocker, state, { takeCoverage: () => takeCoverageInsideWorker(config.coverage, { executeId: importId }), }) if (!config.snapshotOptions.snapshotEnvironment) diff --git a/packages/browser/src/client/tester.ts b/packages/browser/src/client/tester.ts index a2fce5a3b06a..b7b5f68a6976 100644 --- a/packages/browser/src/client/tester.ts +++ b/packages/browser/src/client/tester.ts @@ -119,11 +119,12 @@ async function prepareTestEnvironment(files: string[]) { }) const [runner, { startTests, setupCommonEnv, Spy }] = await Promise.all([ - initiateRunner(state, config), + initiateRunner(state, mocker, config), importId('vitest/browser') as Promise, ]) mocker.setSpyModule(Spy) + mocker.setupWorker() onCancel.then((reason) => { runner.onCancel?.(reason) @@ -132,13 +133,6 @@ async function prepareTestEnvironment(files: string[]) { stopErrorHandler() registerUnexpectedErrors(rpc) - try { - await mocker.setupWorker() - } - catch (err) { - console.error(err) - } - return { runner, config, diff --git a/packages/browser/src/node/index.ts b/packages/browser/src/node/index.ts index a8f8e546c071..78b4e9f6af6b 100644 --- a/packages/browser/src/node/index.ts +++ b/packages/browser/src/node/index.ts @@ -10,7 +10,6 @@ import { getFilePoolName, distDir as vitestDist } from 'vitest/node' import { type Plugin, coverageConfigDefaults } from 'vitest/config' import { slash, toArray } from '@vitest/utils' import BrowserContext from './plugins/pluginContext' -import BrowserMocker from './plugins/pluginMocker' import DynamicImport from './plugins/pluginDynamicImport' export type { BrowserCommand } from 'vitest/node' @@ -21,7 +20,6 @@ export default (project: WorkspaceProject, base = '/'): Plugin[] => { const distRoot = resolve(pkgRoot, 'dist') return [ - ...BrowserMocker(project), { enforce: 'pre', name: 'vitest:browser', @@ -262,12 +260,11 @@ export default (project: WorkspaceProject, base = '/'): Plugin[] => { name: 'vitest:browser:resolve-virtual', async resolveId(rawId) { if (rawId.startsWith('/__virtual_vitest__:')) { - const id = rawId.slice('/__virtual_vitest__:'.length) + let id = rawId.slice('/__virtual_vitest__:'.length) // TODO: don't hardcode - if (id === 'mocker-worker.js') { - const path = resolve(distRoot, './mocker-worker.js') - return path - } + if (id === 'mocker-worker.js') + id = 'msw/mockServiceWorker.js' + const resolved = await this.resolve( id, distRoot, diff --git a/packages/browser/src/node/plugins/pluginMocker.ts b/packages/browser/src/node/plugins/pluginMocker.ts deleted file mode 100644 index 920ded367702..000000000000 --- a/packages/browser/src/node/plugins/pluginMocker.ts +++ /dev/null @@ -1,60 +0,0 @@ -import { readFile } from 'node:fs/promises' -import type { Plugin } from 'vitest/config' -import type { WorkspaceProject } from 'vitest/node' -import { automockModule } from '../automocker' - -export default (project: WorkspaceProject): Plugin[] => { - return [] - return [ - { - name: 'vitest:browser:mocker', - enforce: 'pre', - async load(id) { - const data = project.browserMocker.mocks.get(id) - if (!data) - return - const { mock, sessionId } = data - // undefined mock means there is a factory in the browser - if (mock === undefined) { - const rpc = project.browserRpc.testers.get(sessionId) - - if (!rpc) - throw new Error(`WebSocket rpc was destroyed for session ${sessionId}`) - - const exports = await rpc.startMocking(id) - const module = `const module = __vitest_mocker__.get('${id}');` - const keys = exports.map((name) => { - if (name === 'default') - return `export default module['default'];` - return `export const ${name} = module['${name}'];` - }).join('\n') - return `${module}\n${keys}` - } - - // should import the same module and automock all exports - if (mock === null) - return - - // file is inside __mocks__ - return readFile(mock, 'utf-8') - }, - }, - { - name: 'vitest:browser:automocker', - enforce: 'post', - transform(code, id) { - const data = project.browserMocker.mocks.get(id) - if (!data) - return - if (data.mock === null) { - const m = automockModule(code, this.parse) - - return { - code: m.toString(), - map: m.generateMap({ hires: 'boundary', source: id }), - } - } - }, - }, - ] -} diff --git a/packages/vitest/src/api/browser.ts b/packages/vitest/src/api/browser.ts index ed5526c9aea8..7dbdf1819ce3 100644 --- a/packages/vitest/src/api/browser.ts +++ b/packages/vitest/src/api/browser.ts @@ -121,7 +121,7 @@ export function setupBrowserRpc(project: WorkspaceProject, server: ViteDevServer getCountOfFailedTests() { return ctx.state.getCountOfFailedTests() }, - triggerCommand(contextId: string, command: string, testPath: string | undefined, payload: unknown[]) { + triggerCommand(contextId, command, testPath, payload) { debug?.('[%s] Triggering command "%s"', contextId, command) if (!project.browserProvider) throw new Error('Commands are only available for browser tests.') @@ -142,7 +142,7 @@ export function setupBrowserRpc(project: WorkspaceProject, server: ViteDevServer getProvidedContext() { return 'ctx' in project ? project.getProvidedContext() : ({} as any) }, - async automock(id: string) { + async automock(id) { const request = await project.browser!.transformRequest(id) if (!request) throw new Error(`Module "${id}" not found.`) @@ -157,21 +157,16 @@ export function setupBrowserRpc(project: WorkspaceProject, server: ViteDevServer : sourcemap return `${code}\n//# sourceMappingURL=data:application/json;charset=utf-8;base64,${Buffer.from(JSON.stringify(combinedMap)).toString('base64')}` }, - async queueMock(id: string, importer: string, hasFactory: boolean) { - return project.browserMocker.mock(sessionId, id, importer, hasFactory) + resolveMock(rawId, importer, hasFactory) { + return project.browserMocker.resolveMock(rawId, importer, hasFactory) }, - async queueUnmock(id: string, importer: string) { - return project.browserMocker.unmock(id, importer) - }, - resolveMock(rawId: string, importer: string) { - return project.browserMocker.resolveMock(rawId, importer, false) - }, - invalidateMocks() { - const mocker = project.browserMocker - mocker.mocks.forEach((_, id) => { - mocker.invalidateModuleById(id) + invalidate(ids) { + ids.forEach((id) => { + const moduleGraph = project.browser!.moduleGraph + const module = moduleGraph.getModuleById(id) + if (module) + moduleGraph.invalidateModule(module, new Set(), Date.now(), true) }) - mocker.mocks.clear() }, }, { diff --git a/packages/vitest/src/api/setup.ts b/packages/vitest/src/api/setup.ts index e3ec7a90357e..c92ebf77f9db 100644 --- a/packages/vitest/src/api/setup.ts +++ b/packages/vitest/src/api/setup.ts @@ -81,7 +81,7 @@ export function setup(ctx: Vitest, _server?: ViteDevServer) { return result } }, - async getModuleGraph(project: string, id: string, browser?: boolean): Promise { + async getModuleGraph(project, id, browser): Promise { return getModuleGraph(ctx, project, id, browser) }, updateSnapshot(file?: File) { diff --git a/packages/vitest/src/api/types.ts b/packages/vitest/src/api/types.ts index 1cc474eec8c0..5659935c7c20 100644 --- a/packages/vitest/src/api/types.ts +++ b/packages/vitest/src/api/types.ts @@ -42,18 +42,13 @@ export interface WebSocketBrowserHandlers { debug: (...args: string[]) => void resolveId: (id: string, importer?: string) => Promise triggerCommand: (contextId: string, command: string, testPath: string | undefined, payload: unknown[]) => Promise - queueMock: (id: string, importer: string, hasFactory: boolean) => Promise<{ - id: string - mock: string | undefined | null - }> - queueUnmock: (id: string, importer: string) => Promise - resolveMock: (id: string, importer: string) => Promise<{ + resolveMock: (id: string, importer: string, hasFactory: boolean) => Promise<{ type: 'factory' | 'redirect' | 'automock' mockPath?: string | null resolvedId: string }> automock: (id: string) => Promise - invalidateMocks: () => void + invalidate: (ids: string[]) => void getBrowserFileSourceMap: (id: string) => Promise getProvidedContext: () => ProvidedContext } diff --git a/packages/vitest/src/integrations/browser/mocker.ts b/packages/vitest/src/integrations/browser/mocker.ts index fbd4fa95db8b..3639d7176392 100644 --- a/packages/vitest/src/integrations/browser/mocker.ts +++ b/packages/vitest/src/integrations/browser/mocker.ts @@ -17,35 +17,6 @@ export class VitestBrowserServerMocker { this.#project = project } - async mock(sessionId: string, rawId: string, importer: string, hasFactory: boolean) { - const { type, mockPath, resolvedId } = await this.resolveMock(rawId, importer, hasFactory) - - this.invalidateModuleById(resolvedId) - - if (type === 'factory') { - this.mocks.set(resolvedId, { sessionId, mock: undefined }) - return { - id: resolvedId, - mock: undefined, - } - } - - this.mocks.set(resolvedId, { sessionId, mock: mockPath }) - - return { - id: resolvedId, - mock: mockPath, - } - } - - async unmock(rawId: string, importer: string) { - const { id } = await this.resolveId(rawId, importer) - - this.invalidateModuleById(id) - this.mocks.delete(id) - return id - } - public async resolveMock(rawId: string, importer: string, hasFactory: boolean) { const { id, fsPath, external } = await this.resolveId(rawId, importer) @@ -61,13 +32,6 @@ export class VitestBrowserServerMocker { } } - public invalidateModuleById(id: string) { - const moduleGraph = this.#project.browser!.moduleGraph - const module = moduleGraph.getModuleById(id) - if (module) - moduleGraph.invalidateModule(module, new Set(), Date.now(), true) - } - private async resolveId(rawId: string, importer: string) { const resolved = await this.#project.browser!.pluginContainer.resolveId(rawId, importer, { ssr: false, diff --git a/packages/vitest/src/integrations/browser/server.ts b/packages/vitest/src/integrations/browser/server.ts index bc93ce8433e1..75554ff5b5e2 100644 --- a/packages/vitest/src/integrations/browser/server.ts +++ b/packages/vitest/src/integrations/browser/server.ts @@ -17,6 +17,7 @@ export async function createBrowserServer(project: WorkspaceProject, configFile: const server = await createServer({ ...project.options, // spread project config inlined in root workspace config + base: '/', logLevel: 'error', mode: project.config.mode, configFile: configPath, From eadea7a5548af38e90fc3efc62a8a0c824fbf9bf Mon Sep 17 00:00:00 2001 From: Vladimir Sheremet Date: Tue, 11 Jun 2024 16:16:51 +0200 Subject: [PATCH 11/29] chore: experiment --- packages/browser/src/client/channel.ts | 79 +++++++++++++++ packages/browser/src/client/client.ts | 3 +- packages/browser/src/client/mocker.ts | 101 +++++--------------- packages/browser/src/client/msw.ts | 98 +++++++++++++++++++ packages/browser/src/client/orchestrator.ts | 37 ++----- packages/vitest/src/node/pools/browser.ts | 13 ++- 6 files changed, 219 insertions(+), 112 deletions(-) create mode 100644 packages/browser/src/client/channel.ts create mode 100644 packages/browser/src/client/msw.ts diff --git a/packages/browser/src/client/channel.ts b/packages/browser/src/client/channel.ts new file mode 100644 index 000000000000..5af1296e4bf7 --- /dev/null +++ b/packages/browser/src/client/channel.ts @@ -0,0 +1,79 @@ +import { getBrowserState } from './utils' + +export interface IframeDoneEvent { + type: 'done' + filenames: string[] + id: string +} + +export interface IframeErrorEvent { + type: 'error' + error: any + errorType: string + files: string[] + id: string +} + +export interface IframeViewportEvent { + type: 'viewport' + width: number + height: number + id: string +} + +export interface IframeMockEvent { + type: 'mock' + paths: string[] + mock: string | undefined | null +} + +export interface IframeUnmockEvent { + type: 'unmock' + paths: string[] +} + +interface IframeMockingDoneEvent { + type: 'mock:done' | 'unmock:done' +} + +export interface IframeMockFactoryRequestEvent { + type: 'mock-factory:request' + id: string +} + +export interface IframeMockFactoryResponseEvent { + type: 'mock-factory:response' + exports: string[] +} + +export interface IframeViewportChannelEvent { + type: 'viewport:done' | 'viewport:fail' +} + +export type IframeChannelIncomingEvent = + | IframeViewportEvent + | IframeErrorEvent + | IframeDoneEvent + | IframeMockEvent + | IframeUnmockEvent + | IframeMockFactoryResponseEvent + +export type IframeChannelOutgoingEvent = + | IframeMockFactoryRequestEvent + | IframeViewportChannelEvent + | IframeMockingDoneEvent + +export type IframeChannelEvent = + | IframeChannelIncomingEvent + | IframeChannelOutgoingEvent + +export const channel = new BroadcastChannel(`vitest:${getBrowserState().contextId}`) + +export function waitForChannel(event: IframeChannelOutgoingEvent['type']) { + return new Promise((resolve) => { + channel.addEventListener('message', (e) => { + if (e.data?.type === event) + resolve() + }, { once: true }) + }) +} diff --git a/packages/browser/src/client/client.ts b/packages/browser/src/client/client.ts index b33a127e1a1b..024ccb9e872e 100644 --- a/packages/browser/src/client/client.ts +++ b/packages/browser/src/client/client.ts @@ -122,4 +122,5 @@ function createClient() { } export const client = createClient() -export const channel = new BroadcastChannel(`vitest:${getBrowserState().contextId}`) + +export { channel, waitForChannel } from './channel' diff --git a/packages/browser/src/client/mocker.ts b/packages/browser/src/client/mocker.ts index 35f43cd5b1d2..d90cd53e03e3 100644 --- a/packages/browser/src/client/mocker.ts +++ b/packages/browser/src/client/mocker.ts @@ -1,10 +1,9 @@ import { getType } from '@vitest/utils' import { extname, join } from 'pathe' -import type { SetupWorker } from 'msw/browser' -import { setupWorker } from 'msw/browser' -import { http } from 'msw/core/http' import { rpc } from './rpc' import { getBrowserState } from './utils' +import { channel, waitForChannel } from './client' +import type { IframeChannelOutgoingEvent } from './channel' const now = Date.now @@ -12,12 +11,6 @@ interface SpyModule { spyOn: typeof import('vitest').vi['spyOn'] } -const timestampRegexp = /(\?|&)t=\d{13}/ - -function removeTimestamp(url: string) { - return url.replace(timestampRegexp, '') -} - export class VitestBrowserClientMocker { private queue = new Set>() private mocks: Record = {} @@ -26,66 +19,18 @@ export class VitestBrowserClientMocker { private ids = new Set() private spyModule!: SpyModule - private mswWorker!: SetupWorker - - private _mswStarted = false - private _mswPromise: Promise | null = null setupWorker() { - this.mswWorker = setupWorker( - http.get(/.+/, async ({ request }) => { - const path = removeTimestamp(request.url.slice(location.origin.length)) - if (!(path in this.mocks)) - return passthrough() - - const mock = this.mocks[path] - - // using a factory - if (mock === undefined) { - const exportsModule = await this.resolve(path) - const exports = Object.keys(exportsModule) - const module = `const module = __vitest_mocker__.get('${path}');` - const keys = exports.map((name) => { - if (name === 'default') - return `export default module['default'];` - return `export const ${name} = module['${name}'];` - }).join('\n') - const text = `${module}\n${keys}` - return new Response(text, { - headers: { - 'Content-Type': 'application/javascript', - }, - }) - } - - if (typeof mock === 'string') - return Response.redirect(mock) - - const content = await rpc().automock(path) - return new Response(content, { - headers: { - 'Content-Type': 'application/javascript', - }, + channel.addEventListener('message', async (e: MessageEvent) => { + if (e.data.type === 'mock-factory:request') { + const module = await this.resolve(e.data.id) + const exports = Object.keys(module) + channel.postMessage({ + type: 'mock-factory:response', + exports, }) - }), - ) - } - - public async startMSW() { - if (this._mswStarted) - return - if (this._mswPromise) - return this._mswPromise - this._mswPromise = this.mswWorker.start({ - serviceWorker: { - url: '/__virtual_vitest__:mocker-worker.js', - }, - quiet: true, - }).then(() => { - this._mswStarted = true - this._mswPromise = null + } }) - await this._mswPromise } public setSpyModule(mod: SpyModule) { @@ -168,7 +113,7 @@ export class VitestBrowserClientMocker { public queueMock(id: string, importer: string, factory?: () => any) { const promise = rpc().resolveMock(id, importer, !!factory) - .then(({ mockPath, resolvedId }) => { + .then(async ({ mockPath, resolvedId }) => { this.ids.add(resolvedId) const urlPaths = resolveMockPaths(resolvedId) const resolvedMock = typeof mockPath === 'string' @@ -178,6 +123,12 @@ export class VitestBrowserClientMocker { this.mocks[url] = resolvedMock this.factories[url] = factory! }) + channel.postMessage({ + type: 'mock', + paths: urlPaths, + mock: resolvedMock, + }) + await waitForChannel('mock:done') }).finally(() => { this.queue.delete(promise) }) @@ -186,7 +137,7 @@ export class VitestBrowserClientMocker { public queueUnmock(id: string, importer: string) { const promise = rpc().resolveId(id, importer) - .then((resolved) => { + .then(async (resolved) => { if (!resolved) return this.ids.delete(resolved.id) @@ -196,6 +147,11 @@ export class VitestBrowserClientMocker { delete this.factories[url] delete this.mockObjects[url] }) + channel.postMessage({ + type: 'unmock', + paths: urlPaths, + }) + await waitForChannel('unmock:done') }) .finally(() => { this.queue.delete(promise) @@ -207,7 +163,6 @@ export class VitestBrowserClientMocker { if (!this.queue.size) return await Promise.all([ - this.startMSW(), ...this.queue.values(), ]) } @@ -410,13 +365,3 @@ function resolveMockPaths(path: string) { return paths } - -function passthrough() { - return new Response(null, { - status: 302, - statusText: 'Passthrough', - headers: { - 'x-msw-intention': 'passthrough', - }, - }) -} diff --git a/packages/browser/src/client/msw.ts b/packages/browser/src/client/msw.ts new file mode 100644 index 000000000000..2e66f1ff1c5d --- /dev/null +++ b/packages/browser/src/client/msw.ts @@ -0,0 +1,98 @@ +import { http } from 'msw/core/http' +import { setupWorker } from 'msw/browser' +import { rpc } from './rpc' +import type { IframeChannelEvent, IframeChannelIncomingEvent } from './channel' +import { channel } from './channel' + +export async function createModuleMocker() { + const mocks: Record = {} + + channel.addEventListener('message', (e: MessageEvent) => { + switch (e.data.type) { + case 'mock': + for (const path of e.data.paths) + mocks[path] = e.data.mock + break + case 'unmock': + for (const path of e.data.paths) + delete mocks[path] + break + } + }) + + const worker = setupWorker( + http.get(/.+/, async ({ request }) => { + const path = removeTimestamp(request.url.slice(location.origin.length)) + if (!(path in mocks)) + return passthrough() + + const mock = mocks[path] + + // using a factory + if (mock === undefined) { + const exportsModule = await getFactoryExports(path) + const exports = Object.keys(exportsModule) + const module = `const module = __vitest_mocker__.get('${path}');` + const keys = exports.map((name) => { + if (name === 'default') + return `export default module['default'];` + return `export const ${name} = module['${name}'];` + }).join('\n') + const text = `${module}\n${keys}` + return new Response(text, { + headers: { + 'Content-Type': 'application/javascript', + }, + }) + } + + if (typeof mock === 'string') + return Response.redirect(mock) + + const content = await rpc().automock(path) + return new Response(content, { + headers: { + 'Content-Type': 'application/javascript', + }, + }) + }), + ) + + await worker.start({ + serviceWorker: { + url: '/__virtual_vitest__:mocker-worker.js', + }, + quiet: true, + }) +} + +function getFactoryExports(id: string) { + channel.postMessage({ + type: 'mock-factory:request', + id, + }) + return new Promise((resolve) => { + channel.addEventListener('message', function onMessage(e: MessageEvent) { + if (e.data.type === 'mock-factory:response') { + resolve(e.data.exports) + channel.removeEventListener('message', onMessage) + } + }) + }) +} + +const timestampRegexp = /(\?|&)t=\d{13}/ + +function removeTimestamp(url: string) { + return url.replace(timestampRegexp, '') +} + +function passthrough() { + return new Response(null, { + status: 302, + statusText: 'Passthrough', + headers: { + 'x-msw-intention': 'passthrough', + }, + }) +} diff --git a/packages/browser/src/client/orchestrator.ts b/packages/browser/src/client/orchestrator.ts index c15c95c7e523..842535dc9588 100644 --- a/packages/browser/src/client/orchestrator.ts +++ b/packages/browser/src/client/orchestrator.ts @@ -5,6 +5,7 @@ import { channel, client } from './client' import { rpcDone } from './rpc' import { getBrowserState, getConfig } from './utils' import { getUiAPI } from './ui' +import type { IframeChannelEvent, IframeChannelIncomingEvent } from './channel' const url = new URL(location.href) @@ -55,33 +56,6 @@ async function done() { await client.rpc.finishBrowserTests(getBrowserState().contextId) } -interface IframeDoneEvent { - type: 'done' - filenames: string[] - id: string -} - -interface IframeErrorEvent { - type: 'error' - error: any - errorType: string - files: string[] - id: string -} - -interface IframeViewportEvent { - type: 'viewport' - width: number - height: number - id: string -} - -interface IframeViewportChannelEvent { - type: 'viewport:done' | 'viewport:fail' -} - -type IframeChannelEvent = IframeDoneEvent | IframeErrorEvent | IframeViewportEvent | IframeViewportChannelEvent - async function getContainer(config: ResolvedConfig): Promise { if (config.browser.ui) { const element = document.querySelector('#tester-ui') @@ -107,7 +81,7 @@ client.ws.addEventListener('open', async () => { runningFiles.clear() testFiles.forEach(file => runningFiles.add(file)) - channel.addEventListener('message', async (e: MessageEvent): Promise => { + channel.addEventListener('message', async (e: MessageEvent): Promise => { debug('channel event', JSON.stringify(e.data)) switch (e.data.type) { case 'viewport': { @@ -161,7 +135,14 @@ client.ws.addEventListener('open', async () => { await done() break } + case 'mock-factory:response': + case 'unmock': + case 'mock': + // ignore, it is processed by the mocker + break default: { + e.data satisfies never + await client.rpc.onUnhandledError({ name: 'Unexpected Event', message: `Unexpected event: ${(e.data as any).type}`, diff --git a/packages/vitest/src/node/pools/browser.ts b/packages/vitest/src/node/pools/browser.ts index 77710b7703f1..7daa5ad64ed3 100644 --- a/packages/vitest/src/node/pools/browser.ts +++ b/packages/vitest/src/node/pools/browser.ts @@ -28,11 +28,11 @@ export function createBrowserPool(ctx: Vitest): ProcessPool { const runTests = async (project: WorkspaceProject, files: string[]) => { ctx.state.clearFiles(project, files) - const mocker = project.browserMocker - mocker.mocks.forEach((_, id) => { - mocker.invalidateModuleById(id) - }) - mocker.mocks.clear() + // const mocker = project.browserMocker + // mocker.mocks.forEach((_, id) => { + // mocker.invalidateModuleById(id) + // }) + // mocker.mocks.clear() const threadsCount = getThreadsCount(project) // TODO @@ -52,6 +52,9 @@ export function createBrowserPool(ctx: Vitest): ProcessPool { const filesPerThread = Math.ceil(files.length / threadsCount) + // TODO: make it smarter, + // Currently if we run 4/4/4/4 tests, and one of the chunks ends, + // but there are pending tests in another chunks, we can't redistribute them const chunks: string[][] = [] for (let i = 0; i < files.length; i += filesPerThread) { const chunk = files.slice(i, i + filesPerThread) From bf3225fc21f88ac8f116ab7687f1cff1cea7f112 Mon Sep 17 00:00:00 2001 From: Vladimir Sheremet Date: Wed, 12 Jun 2024 09:00:03 +0200 Subject: [PATCH 12/29] feat: support page.screenshot for playwright --- packages/browser/context.d.ts | 17 ++++++- packages/browser/package.json | 1 + packages/browser/providers/playwright.d.ts | 16 ++++++- packages/browser/src/node/commands/index.ts | 2 + .../browser/src/node/commands/screenshot.ts | 44 ++++++++++++++++++ packages/browser/src/node/commands/utils.ts | 11 ++++- .../browser/src/node/plugins/pluginContext.ts | 24 ++++++++++ .../browser/src/node/providers/playwright.ts | 15 +++++- .../browser/src/node/providers/preview.ts | 4 ++ .../browser/src/node/providers/webdriver.ts | 4 ++ packages/vitest/src/api/browser.ts | 5 +- packages/vitest/src/node/config.ts | 2 + packages/vitest/src/types/browser.ts | 10 +++- pnpm-lock.yaml | 3 ++ .../dom-related-activity-renders-div-1.png | Bin 0 -> 5597 bytes test/browser/test/dom.test.ts | 23 +++++---- 16 files changed, 163 insertions(+), 18 deletions(-) create mode 100644 packages/browser/src/node/commands/screenshot.ts create mode 100644 test/browser/test/__screenshots__/dom.test.ts/dom-related-activity-renders-div-1.png diff --git a/packages/browser/context.d.ts b/packages/browser/context.d.ts index 44c9951d75d3..fcd268255528 100644 --- a/packages/browser/context.d.ts +++ b/packages/browser/context.d.ts @@ -26,6 +26,14 @@ export interface UpPayload { up: string } export type SendKeysPayload = TypePayload | PressPayload | DownPayload | UpPayload +export interface ScreenshotOptions { + element?: Element + /** + * Path relative to the `screenshotDirectory` in the test config. + */ + path?: string +} + export interface BrowserCommands { readFile: (path: string, options?: BufferEncoding | FsOptions) => Promise writeFile: (path: string, content: string, options?: BufferEncoding | FsOptions & { mode?: number | string }) => Promise @@ -100,7 +108,7 @@ export const userEvent: UserEvent */ export const commands: BrowserCommands -export const page: { +export interface BrowserPage { /** * Serialized test config. */ @@ -109,4 +117,11 @@ export const page: { * Change the size of iframe's viewport. */ viewport: (width: number, height: number) => Promise + /** + * Make a screenshot of the test iframe or a specific element. + * @returns Path to the screenshot file. + */ + screenshot: (options?: ScreenshotOptions) => Promise } + +export const page: BrowserPage diff --git a/packages/browser/package.json b/packages/browser/package.json index 226a8ca8e5b6..90f254f51f6a 100644 --- a/packages/browser/package.json +++ b/packages/browser/package.json @@ -84,6 +84,7 @@ "@wdio/protocols": "^8.38.0", "birpc": "0.2.17", "flatted": "^3.3.1", + "pathe": "^1.1.2", "periscopic": "^4.0.2", "playwright": "^1.44.1", "playwright-core": "^1.44.1", diff --git a/packages/browser/providers/playwright.d.ts b/packages/browser/providers/playwright.d.ts index 9d2c5ae41e7d..c74a5841cc99 100644 --- a/packages/browser/providers/playwright.d.ts +++ b/packages/browser/providers/playwright.d.ts @@ -1,8 +1,20 @@ -import type { BrowserContextOptions, LaunchOptions } from 'playwright' +import type { + BrowserContextOptions, + FrameLocator, + LaunchOptions, + Locator, + Page, +} from 'playwright' declare module 'vitest/node' { interface BrowserProviderOptions { launch?: LaunchOptions - context?: BrowserContextOptions + context?: Omit + } + + export interface BrowserCommandContext { + page: Page + tester: FrameLocator + body: Locator } } diff --git a/packages/browser/src/node/commands/index.ts b/packages/browser/src/node/commands/index.ts index 5314fc3fc073..0f6946e20dae 100644 --- a/packages/browser/src/node/commands/index.ts +++ b/packages/browser/src/node/commands/index.ts @@ -5,6 +5,7 @@ import { writeFile, } from './fs' import { sendKeys } from './keyboard' +import { screenshot } from './screenshot' export default { readFile, @@ -12,4 +13,5 @@ export default { writeFile, sendKeys, __vitest_click: click, + __vitest_screenshot: screenshot, } diff --git a/packages/browser/src/node/commands/screenshot.ts b/packages/browser/src/node/commands/screenshot.ts new file mode 100644 index 000000000000..81c63c131764 --- /dev/null +++ b/packages/browser/src/node/commands/screenshot.ts @@ -0,0 +1,44 @@ +import { mkdir } from 'node:fs/promises' +import type { BrowserCommand } from 'vitest/node' +import { basename, dirname, relative, resolve } from 'pathe' +import type { ResolvedConfig } from 'vitest' +import type { ScreenshotOptions } from '../../../context' +import { PlaywrightBrowserProvider } from '../providers/playwright' + +// TODO: expose provider specific options in types +export const screenshot: BrowserCommand<[string, ScreenshotOptions]> = async (context, name: string, options = {}) => { + if (!context.testPath) + throw new Error(`Cannot take a screenshot without a test path`) + + const path = resolveScreenshotPath(context.testPath, name, context.project.config) + await mkdir(dirname(path), { recursive: true }) + + if (context.provider instanceof PlaywrightBrowserProvider) { + if (options.element) { + const { element: elementXpath, ...config } = options + const iframe = context.tester + const element = iframe.locator(`xpath=${elementXpath}`) + await element.screenshot({ ...config, path }) + } + else { + await context.body.screenshot({ ...options, path }) + } + return path + } + + throw new Error(`Provider "${context.provider.name}" does not support screenshots`) +} + +function resolveScreenshotPath(testPath: string, name: string, config: ResolvedConfig) { + const dir = dirname(testPath) + const base = basename(testPath) + if (config.browser.screenshotDirectory) { + return resolve( + config.browser.screenshotDirectory, + relative(config.root, dir), + base, + name, + ) + } + return resolve(dir, '__screenshots__', base, name) +} diff --git a/packages/browser/src/node/commands/utils.ts b/packages/browser/src/node/commands/utils.ts index 6e4db8456872..18d20a63eba3 100644 --- a/packages/browser/src/node/commands/utils.ts +++ b/packages/browser/src/node/commands/utils.ts @@ -1,4 +1,13 @@ -import type { BrowserCommand } from 'vitest/node' +import type { BrowserCommand, BrowserProvider } from 'vitest/node' +import type { PreviewBrowserProvider } from '../providers/preview' +import type { WebdriverBrowserProvider } from '../providers/webdriver' +import type { PlaywrightBrowserProvider } from '../providers/playwright' + +declare module 'vitest/node' { + export interface BrowserCommandContext { + provider: PlaywrightBrowserProvider | WebdriverBrowserProvider | BrowserProvider | PreviewBrowserProvider + } +} export type UserEventCommand any> = BrowserCommand< ConvertUserEventParameters> diff --git a/packages/browser/src/node/plugins/pluginContext.ts b/packages/browser/src/node/plugins/pluginContext.ts index 200749087c1d..f367011cd7fd 100644 --- a/packages/browser/src/node/plugins/pluginContext.ts +++ b/packages/browser/src/node/plugins/pluginContext.ts @@ -64,6 +64,8 @@ export const server = { } } export const commands = server.commands + +const screenshotIds = {} export const page = { get config() { return __vitest_browser_runner__.config @@ -84,10 +86,32 @@ export const page = { }) }) }, + screenshot(options) { + const currentTest = __vitest_worker__.current + if (!currentTest) { + throw new Error('Cannot take a screenshot outside of a test') + } + if (currentTest.concurrent) { + throw new Error('Cannot take a screenshot in a concurrent test') + } + const repeatCount = currentTest.result.repeatCount ?? 0 + const taskName = getTaskFullName(currentTest) + const number = screenshotIds[repeatCount]?.[taskName] ?? 1 + + screenshotIds[repeatCount] ??= {} + screenshotIds[repeatCount][taskName] = number + 1 + + const name = \`\${taskName.replace(/[^a-z0-9]/g, '-')}-\${number}.png\` + return rpc().triggerCommand(contextId, '__vitest_screenshot', currentTest.file.filepath, options ? [name, options] : [name]) + } } export const userEvent = ${getUserEventScript(project)} +function getTaskFullName(task) { + return task.suite ? getTaskFullName(task.suite) + ' ' + task.name : task.name +} + function convertElementToXPath(element) { if (!element || !(element instanceof Element)) { // TODO: better error message diff --git a/packages/browser/src/node/providers/playwright.ts b/packages/browser/src/node/providers/playwright.ts index 187fb3f7023b..cfb7b5261303 100644 --- a/packages/browser/src/node/providers/playwright.ts +++ b/packages/browser/src/node/providers/playwright.ts @@ -67,8 +67,9 @@ export class PlaywrightBrowserProvider implements BrowserProvider { const browser = await this.openBrowser() const context = await browser.newContext({ - ignoreHTTPSErrors: true, ...this.options?.context, + ignoreHTTPSErrors: true, + serviceWorkers: 'allow', }) this.contexts.set(contextId, context) return context @@ -81,6 +82,18 @@ export class PlaywrightBrowserProvider implements BrowserProvider { return page } + public getCommandsContext(contextId: string) { + const page = this.getPage(contextId) + const tester = page.frameLocator('iframe[data-vitest]') + return { + page, + tester, + get body() { + return page.frameLocator('iframe[data-vitest]').locator('body') + }, + } + } + private async openBrowserPage(contextId: string) { if (this.pages.has(contextId)) { const page = this.pages.get(contextId)! diff --git a/packages/browser/src/node/providers/preview.ts b/packages/browser/src/node/providers/preview.ts index 341d631a8154..45bfde31576a 100644 --- a/packages/browser/src/node/providers/preview.ts +++ b/packages/browser/src/node/providers/preview.ts @@ -15,6 +15,10 @@ export class PreviewBrowserProvider implements BrowserProvider { return this.open } + getCommandsContext() { + return {} + } + async initialize(ctx: WorkspaceProject) { this.ctx = ctx this.open = false diff --git a/packages/browser/src/node/providers/webdriver.ts b/packages/browser/src/node/providers/webdriver.ts index 55cf64200014..03976b8e62c4 100644 --- a/packages/browser/src/node/providers/webdriver.ts +++ b/packages/browser/src/node/providers/webdriver.ts @@ -29,6 +29,10 @@ export class WebdriverBrowserProvider implements BrowserProvider { this.options = options as RemoteOptions } + getCommandsContext() { + return {} + } + async openBrowser() { if (this.browser) return this.browser diff --git a/packages/vitest/src/api/browser.ts b/packages/vitest/src/api/browser.ts index 7dbdf1819ce3..0631faf6f37b 100644 --- a/packages/vitest/src/api/browser.ts +++ b/packages/vitest/src/api/browser.ts @@ -128,12 +128,13 @@ export function setupBrowserRpc(project: WorkspaceProject, server: ViteDevServer const commands = project.config.browser?.commands if (!commands || !commands[command]) throw new Error(`Unknown command "${command}".`) - return commands[command]({ + const context = Object.assign({ testPath, project, provider: project.browserProvider, contextId, - }, ...payload) + }, project.browserProvider.getCommandsContext(contextId)) + return commands[command](context, ...payload) }, finishBrowserTests(contextId: string) { debug?.('[%s] Finishing browser tests for context', contextId) diff --git a/packages/vitest/src/node/config.ts b/packages/vitest/src/node/config.ts index 8102a890987d..f9ab1db2d7dd 100644 --- a/packages/vitest/src/node/config.ts +++ b/packages/vitest/src/node/config.ts @@ -539,6 +539,8 @@ export function resolveConfig( resolved.browser.fileParallelism ??= options.fileParallelism ?? mode !== 'benchmark' // disable in headless mode by default, and if CI is detected resolved.browser.ui ??= resolved.browser.headless === true ? false : !isCI + if (resolved.browser.screenshotDirectory) + resolved.browser.screenshotDirectory = resolve(resolved.root, resolved.browser.screenshotDirectory) resolved.browser.viewport ??= {} as any resolved.browser.viewport.width ??= 414 diff --git a/packages/vitest/src/types/browser.ts b/packages/vitest/src/types/browser.ts index 81e4154a7d6f..e7d2fe1af140 100644 --- a/packages/vitest/src/types/browser.ts +++ b/packages/vitest/src/types/browser.ts @@ -14,6 +14,7 @@ export interface BrowserProvider { */ supportsParallelism: boolean getSupportedBrowsers: () => readonly string[] + getCommandsContext: (contextId: string) => Record openPage: (contextId: string, url: string) => Promise close: () => Awaitable // eslint-disable-next-line ts/method-signature-style -- we want to allow extended options @@ -113,6 +114,13 @@ export interface BrowserConfigOptions { height: number } + /** + * Directory where screenshots will be saved when page.screenshot() is called + * If not set, all screenshots are saved to __screenshots__ directory in the same folder as the test file. + * If this is set, it will be resolved relative to the project root. + * @default __screenshots__ + */ + screenshotDirectory?: string /** * Scripts injected into the tester iframe. */ @@ -133,7 +141,7 @@ export interface BrowserConfigOptions { export interface BrowserCommandContext { testPath: string | undefined - provider: BrowserProvider + // provider: BrowserProvider project: WorkspaceProject contextId: string } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index f86baf70365a..391cc8623120 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -466,6 +466,9 @@ importers: flatted: specifier: ^3.3.1 version: 3.3.1 + pathe: + specifier: ^1.1.2 + version: 1.1.2 periscopic: specifier: ^4.0.2 version: 4.0.2 diff --git a/test/browser/test/__screenshots__/dom.test.ts/dom-related-activity-renders-div-1.png b/test/browser/test/__screenshots__/dom.test.ts/dom-related-activity-renders-div-1.png new file mode 100644 index 0000000000000000000000000000000000000000..4d5679084b3a905357c4c16bacc41f004df64b19 GIT binary patch literal 5597 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 FzW`DJ!|(tA literal 0 HcmV?d00001 diff --git a/test/browser/test/dom.test.ts b/test/browser/test/dom.test.ts index 034f3a32ae2a..875b31dfa730 100644 --- a/test/browser/test/dom.test.ts +++ b/test/browser/test/dom.test.ts @@ -1,15 +1,18 @@ -import { expect, test } from 'vitest' +import { describe, expect, test } from 'vitest' import { page } from '@vitest/browser/context' import { createNode } from '#src/createNode' import '../src/button.css' -test('renders div', async () => { - await page.viewport(1500, 600) - document.body.style.background = '#f3f3f3' - const wrapper = document.createElement('div') - wrapper.className = 'wrapper' - document.body.appendChild(wrapper) - const div = createNode() - wrapper.appendChild(div) - expect(div.textContent).toBe('Hello World!') +describe('dom related activity', () => { + test('renders div', async () => { + document.body.style.background = '#f3f3f3' + const wrapper = document.createElement('div') + wrapper.className = 'wrapper' + document.body.appendChild(wrapper) + const div = createNode() + wrapper.appendChild(div) + expect(div.textContent).toBe('Hello World!') + const screenshotPath = await page.screenshot() + expect(screenshotPath).toMatch(/__screenshots__\/dom.test.ts\/dom-related-activity-renders-div-1.png/) + }) }) From f75c5c557ad2fcb9ffcc0362073e774276851271 Mon Sep 17 00:00:00 2001 From: Vladimir Sheremet Date: Wed, 12 Jun 2024 09:01:43 +0200 Subject: [PATCH 13/29] chore: remove msw file --- packages/browser/msw/sw.js | 284 ------------------------------ packages/browser/rollup.config.js | 6 - 2 files changed, 290 deletions(-) delete mode 100644 packages/browser/msw/sw.js diff --git a/packages/browser/msw/sw.js b/packages/browser/msw/sw.js deleted file mode 100644 index 24fe3a25f00f..000000000000 --- a/packages/browser/msw/sw.js +++ /dev/null @@ -1,284 +0,0 @@ -/* eslint-disable */ -/* tslint:disable */ - -/** - * Mock Service Worker. - * @see https://github.com/mswjs/msw - * - Please do NOT modify this file. - * - Please do NOT serve this file on production. - */ - -const PACKAGE_VERSION = '2.3.1' -const INTEGRITY_CHECKSUM = '26357c79639bfa20d64c0efca2a87423' -const IS_MOCKED_RESPONSE = Symbol('isMockedResponse') -const activeClientIds = new Set() - -self.addEventListener('install', function () { - self.skipWaiting() -}) - -self.addEventListener('activate', function (event) { - event.waitUntil(self.clients.claim()) -}) - -self.addEventListener('message', async function (event) { - const clientId = event.source.id - - if (!clientId || !self.clients) { - return - } - - const client = await self.clients.get(clientId) - - if (!client) { - return - } - - const allClients = await self.clients.matchAll({ - type: 'window', - }) - - switch (event.data) { - case 'KEEPALIVE_REQUEST': { - sendToClient(client, { - type: 'KEEPALIVE_RESPONSE', - }) - break - } - - case 'INTEGRITY_CHECK_REQUEST': { - sendToClient(client, { - type: 'INTEGRITY_CHECK_RESPONSE', - payload: { - packageVersion: PACKAGE_VERSION, - checksum: INTEGRITY_CHECKSUM, - }, - }) - break - } - - case 'MOCK_ACTIVATE': { - activeClientIds.add(clientId) - - sendToClient(client, { - type: 'MOCKING_ENABLED', - payload: true, - }) - break - } - - case 'MOCK_DEACTIVATE': { - activeClientIds.delete(clientId) - break - } - - case 'CLIENT_CLOSED': { - activeClientIds.delete(clientId) - - const remainingClients = allClients.filter((client) => { - return client.id !== clientId - }) - - // Unregister itself when there are no more clients - if (remainingClients.length === 0) { - self.registration.unregister() - } - - break - } - } -}) - -self.addEventListener('fetch', function (event) { - const { request } = event - - // Bypass navigation requests. - if (request.mode === 'navigate') { - return - } - - // Opening the DevTools triggers the "only-if-cached" request - // that cannot be handled by the worker. Bypass such requests. - if (request.cache === 'only-if-cached' && request.mode !== 'same-origin') { - return - } - - // Bypass all requests when there are no active clients. - // Prevents the self-unregistered worked from handling requests - // after it's been deleted (still remains active until the next reload). - if (activeClientIds.size === 0) { - return - } - - // Generate unique request ID. - const requestId = crypto.randomUUID() - event.respondWith(handleRequest(event, requestId)) -}) - -async function handleRequest(event, requestId) { - const client = await resolveMainClient(event) - const response = await getResponse(event, client, requestId) - - // Send back the response clone for the "response:*" life-cycle events. - // Ensure MSW is active and ready to handle the message, otherwise - // this message will pend indefinitely. - if (client && activeClientIds.has(client.id)) { - ;(async function () { - const responseClone = response.clone() - - sendToClient( - client, - { - type: 'RESPONSE', - payload: { - requestId, - isMockedResponse: IS_MOCKED_RESPONSE in response, - type: responseClone.type, - status: responseClone.status, - statusText: responseClone.statusText, - body: responseClone.body, - headers: Object.fromEntries(responseClone.headers.entries()), - }, - }, - [responseClone.body], - ) - })() - } - - return response -} - -// Resolve the main client for the given event. -// Client that issues a request doesn't necessarily equal the client -// that registered the worker. It's with the latter the worker should -// communicate with during the response resolving phase. -async function resolveMainClient(event) { - const client = await self.clients.get(event.clientId) - - if (client?.frameType === 'top-level') { - return client - } - - const allClients = await self.clients.matchAll({ - type: 'window', - }) - - return allClients - .filter((client) => { - // Get only those clients that are currently visible. - return client.visibilityState === 'visible' - }) - .find((client) => { - // Find the client ID that's recorded in the - // set of clients that have registered the worker. - return activeClientIds.has(client.id) - }) -} - -async function getResponse(event, client, requestId) { - const { request } = event - - // Clone the request because it might've been already used - // (i.e. its body has been read and sent to the client). - const requestClone = request.clone() - - function passthrough() { - const headers = Object.fromEntries(requestClone.headers.entries()) - - // Remove internal MSW request header so the passthrough request - // complies with any potential CORS preflight checks on the server. - // Some servers forbid unknown request headers. - delete headers['x-msw-intention'] - - return fetch(requestClone, { headers }) - } - - // Bypass mocking when the client is not active. - if (!client) { - return passthrough() - } - - // Bypass initial page load requests (i.e. static assets). - // The absence of the immediate/parent client in the map of the active clients - // means that MSW hasn't dispatched the "MOCK_ACTIVATE" event yet - // and is not ready to handle requests. - if (!activeClientIds.has(client.id)) { - return passthrough() - } - - // Notify the client that a request has been intercepted. - const requestBuffer = await request.arrayBuffer() - const clientMessage = await sendToClient( - client, - { - type: 'REQUEST', - payload: { - id: requestId, - url: request.url, - mode: request.mode, - method: request.method, - headers: Object.fromEntries(request.headers.entries()), - cache: request.cache, - credentials: request.credentials, - destination: request.destination, - integrity: request.integrity, - redirect: request.redirect, - referrer: request.referrer, - referrerPolicy: request.referrerPolicy, - body: requestBuffer, - keepalive: request.keepalive, - }, - }, - [requestBuffer], - ) - - switch (clientMessage.type) { - case 'MOCK_RESPONSE': { - return respondWithMock(clientMessage.data) - } - - case 'PASSTHROUGH': { - return passthrough() - } - } - - return passthrough() -} - -function sendToClient(client, message, transferrables = []) { - return new Promise((resolve, reject) => { - const channel = new MessageChannel() - - channel.port1.onmessage = (event) => { - if (event.data && event.data.error) { - return reject(event.data.error) - } - - resolve(event.data) - } - - client.postMessage( - message, - [channel.port2].concat(transferrables.filter(Boolean)), - ) - }) -} - -async function respondWithMock(response) { - // Setting response status code to 0 is a no-op. - // However, when responding with a "Response.error()", the produced Response - // instance will have status code set to 0. Since it's not possible to create - // a Response instance with status code 0, handle that use-case separately. - if (response.status === 0) { - return Response.error() - } - - const mockedResponse = new Response(response.body, response) - - Reflect.defineProperty(mockedResponse, IS_MOCKED_RESPONSE, { - value: true, - enumerable: true, - }) - - return mockedResponse -} diff --git a/packages/browser/rollup.config.js b/packages/browser/rollup.config.js index 362c552c97aa..910c332a6996 100644 --- a/packages/browser/rollup.config.js +++ b/packages/browser/rollup.config.js @@ -4,7 +4,6 @@ import dts from 'rollup-plugin-dts' import resolve from '@rollup/plugin-node-resolve' import commonjs from '@rollup/plugin-commonjs' import json from '@rollup/plugin-json' -import copy from 'rollup-plugin-copy' const require = createRequire(import.meta.url) const pkg = require('./package.json') @@ -26,11 +25,6 @@ const plugins = [ esbuild({ target: 'node18', }), - copy({ - targets: [ - { src: './msw/sw.js', dest: 'dist', rename: 'mocker-worker.js' }, - ], - }), ] const input = { From eedccc94ec482e6edfde844b8be996d7fa13daf9 Mon Sep 17 00:00:00 2001 From: Vladimir Sheremet Date: Wed, 12 Jun 2024 09:03:17 +0200 Subject: [PATCH 14/29] chore: cleanup --- pnpm-lock.yaml | 3 --- 1 file changed, 3 deletions(-) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 391cc8623120..4890d5ae6eba 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -478,9 +478,6 @@ importers: playwright-core: specifier: ^1.44.0 version: 1.44.0 - rollup-plugin-copy: - specifier: ^3.5.0 - version: 3.5.0 safaridriver: specifier: ^0.1.2 version: 0.1.2 From c76a02804973e2c70a5add2ec886b57e43ba270b Mon Sep 17 00:00:00 2001 From: Vladimir Sheremet Date: Wed, 12 Jun 2024 09:06:13 +0200 Subject: [PATCH 15/29] chore: lockfile --- pnpm-lock.yaml | 117 ++++--------------------------------------------- 1 file changed, 8 insertions(+), 109 deletions(-) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 4890d5ae6eba..84b2a03bf0c7 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -476,8 +476,8 @@ importers: specifier: ^1.44.1 version: 1.44.1 playwright-core: - specifier: ^1.44.0 - version: 1.44.0 + specifier: ^1.44.1 + version: 1.44.1 safaridriver: specifier: ^0.1.2 version: 0.1.2 @@ -867,8 +867,8 @@ importers: specifier: ^5.1.1 version: 5.1.1 debug: - specifier: ^4.3.4 - version: 4.3.4 + specifier: ^4.3.5 + version: 4.3.5 execa: specifier: ^8.0.1 version: 8.0.1 @@ -4500,95 +4500,6 @@ packages: '@lit-labs/ssr-dom-shim': 1.1.2 dev: false - /@luxass/strip-json-comments@1.2.0: - resolution: {integrity: sha512-JNz1ha89f+pJDxSoFwSnIX3MdBpFbxNlILfoN0SHBZpsw4BjEKWmYyV1mFfBTbfDQqZQJELFlmAcoM54dvVfzw==} - engines: {node: '>=18'} - dev: true - - /@marko/babel-utils@6.4.3: - resolution: {integrity: sha512-csLxUFdRCHxDeKvadkme09JJym33s8V1er4Vp/0LoKQTN8D2lcpv/ZJh6dQj/9vaNSSDlWaV56XSGF0sLEJEnA==} - dependencies: - '@babel/runtime': 7.24.4 - jsesc: 3.0.2 - relative-import-path: 1.0.0 - dev: true - - /@marko/compiler@5.36.1: - resolution: {integrity: sha512-a+L44BPOChVYrS4t5kG4t9EVVeCFhQxoNLfSxEHBdyXEzC3auntOG0yEcURkSVClT3/do2GCkFHZ6T8ILO90kQ==} - dependencies: - '@babel/code-frame': 7.24.2 - '@babel/core': 7.24.4 - '@babel/generator': 7.24.4 - '@babel/parser': 7.24.4 - '@babel/plugin-syntax-typescript': 7.24.1(@babel/core@7.24.4) - '@babel/plugin-transform-modules-commonjs': 7.24.1(@babel/core@7.24.4) - '@babel/plugin-transform-typescript': 7.24.4(@babel/core@7.24.4) - '@babel/runtime': 7.24.4 - '@babel/traverse': 7.24.1 - '@babel/types': 7.24.0 - '@luxass/strip-json-comments': 1.2.0 - '@marko/babel-utils': 6.4.3 - complain: 1.6.0 - he: 1.2.0 - htmljs-parser: 5.5.2 - jsesc: 3.0.2 - kleur: 4.1.5 - lasso-package-root: 1.0.1 - raptor-regexp: 1.0.1 - raptor-util: 3.2.0 - resolve-from: 5.0.0 - self-closing-tags: 1.0.1 - source-map-support: 0.5.21 - transitivePeerDependencies: - - supports-color - dev: true - - /@marko/testing-library@6.2.0(marko@5.34.2): - resolution: {integrity: sha512-09R7PuyoBmDF+Q1JvDP8xsVU27hpP533SXy3ILzwzJyQMucvUddjT5VfF3FlSZmFy6RXj4jP6yUCpokEB0RNkg==} - peerDependencies: - marko: ^3 || ^4 || ^5 - dependencies: - '@testing-library/dom': 9.3.3 - jsdom: 23.2.0 - marko: 5.34.2 - transitivePeerDependencies: - - bufferutil - - canvas - - supports-color - - utf-8-validate - dev: true - - /@marko/translator-default@6.0.0(@marko/compiler@5.36.1)(marko@5.34.2): - resolution: {integrity: sha512-ldx7s2oYkWvjclLzDbGQyKTC5M7Of/eeFtodDD4WQdTUgxuEKOVTx065V1hKGm5wqHMW4pCKSD3Z+35jrsYYlA==} - peerDependencies: - '@marko/compiler': ^5.16.1 - marko: ^5.17.2 - dependencies: - '@babel/runtime': 7.24.4 - '@marko/babel-utils': 6.4.3 - '@marko/compiler': 5.36.1 - magic-string: 0.30.10 - marko: 5.34.2 - self-closing-tags: 1.0.1 - dev: true - - /@marko/vite@4.1.10(@marko/compiler@5.36.1)(vite@5.2.6): - resolution: {integrity: sha512-D6rY/P8CgO83kNKQOpZMqWNr9xXeUdrbfkdTgxyKHMCpGigr2ywASaUIh19E9Nz9jp0aRI9w2U0NnXK+gfLCKA==} - peerDependencies: - '@marko/compiler': ^5 - vite: ^5.2.6 - dependencies: - '@chialab/cjs-to-esm': 0.18.0 - '@marko/compiler': 5.36.1 - anymatch: 3.1.3 - domelementtype: 2.3.0 - domhandler: 5.0.3 - htmlparser2: 9.1.0 - resolve: 1.22.8 - resolve.exports: 2.0.2 - vite: 5.2.6(@types/node@20.12.11) - dev: true - /@mswjs/cookies@1.1.0: resolution: {integrity: sha512-0ZcCVQxifZmhwNBoQIrystCb+2sWBY2Zw8lpfJBPCHGCA/HWqehITeCRVIv4VMy8MPlaHo2w2pTHFV2pFfqKPw==} engines: {node: '>=18'} @@ -4643,13 +4554,6 @@ packages: resolution: {integrity: sha512-U69T3ItWHvLwGg5eJ0n3I62nWuE6ilHlmz7zM0npLBRvPRd7e6NYmg54vvRtP5mZG7kZqZCFVdsTWo7BPtBujg==} dev: false - /@parcel/source-map@2.1.1: - resolution: {integrity: sha512-Ejx1P/mj+kMjQb8/y5XxDUn4reGdr+WyKYloBljpppUy8gs42T+BNoEOuRYqDVdgPc6NxduzIDoJS9pOFfV5Ew==} - engines: {node: ^12.18.3 || >=14} - dependencies: - detect-libc: 1.0.3 - dev: true - /@pkgjs/parseargs@0.11.0: resolution: {integrity: sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==} engines: {node: '>=14'} @@ -5818,11 +5722,6 @@ packages: dependencies: undici-types: 5.26.5 - /@types/node@20.14.2: - resolution: {integrity: sha512-xyu6WAMVwv6AKFLB+e/7ySZVr/0zLCzOa7rSpq6jNwpqOrUbcACDWC+53d4n2QHOnDou0fbIsg8wZu/sxrnI4Q==} - dependencies: - undici-types: 5.26.5 - /@types/normalize-package-data@2.4.1: resolution: {integrity: sha512-Gj7cI7z+98M282Tqmp2K5EIsoouUEzbBJhQQzDE3jSIRk6r9gsz0oUokqIUR4u1R3dMHo0pDHM7sNOHyhulypw==} dev: true @@ -12662,7 +12561,7 @@ packages: outvariant: 1.4.2 path-to-regexp: 6.2.2 strict-event-emitter: 0.5.1 - type-fest: 4.19.0 + type-fest: 4.20.0 typescript: 5.4.5 yargs: 17.7.2 dev: false @@ -15284,8 +15183,8 @@ packages: engines: {node: '>=12.20'} dev: true - /type-fest@4.19.0: - resolution: {integrity: sha512-CN2l+hWACRiejlnr68vY0/7734Kzu+9+TOslUXbSCQ1ruY9XIHDBSceVXCcHm/oXrdzhtLMMdJEKfemf1yXiZQ==} + /type-fest@4.20.0: + resolution: {integrity: sha512-MBh+PHUHHisjXf4tlx0CFWoMdjx8zCMLJHOjnV1prABYZFHqtFOyauCIK2/7w4oIfwkF8iNhLtnJEfVY2vn3iw==} engines: {node: '>=16'} dev: false @@ -15670,7 +15569,7 @@ packages: dependencies: browserslist: 4.23.0 escalade: 3.1.2 - picocolors: 1.0.0 + picocolors: 1.0.1 /uri-js@4.4.1: resolution: {integrity: sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==} From 661dad00886eb8e00555643847bd44c9bd2b1a51 Mon Sep 17 00:00:00 2001 From: Vladimir Sheremet Date: Wed, 12 Jun 2024 10:43:41 +0200 Subject: [PATCH 16/29] refactor: define context in a separate file, move mocker --- packages/browser/rollup.config.js | 17 ++- packages/browser/src/client/channel.ts | 13 ++- packages/browser/src/client/client.ts | 2 +- packages/browser/src/client/context.ts | 110 ++++++++++++++++++ packages/browser/src/client/mocker.ts | 24 ++-- packages/browser/src/client/msw.ts | 72 +++++++----- packages/browser/src/client/orchestrator.ts | 15 ++- packages/browser/src/client/tester.ts | 4 +- packages/browser/src/client/utils.ts | 2 +- .../browser/src/node/plugins/pluginContext.ts | 93 +-------------- packages/vitest/src/api/browser.ts | 11 +- packages/vitest/src/api/types.ts | 2 +- packages/vitest/src/browser.ts | 2 +- .../dom-related-activity-renders-div-1.png | Bin 5597 -> 5543 bytes 14 files changed, 231 insertions(+), 136 deletions(-) create mode 100644 packages/browser/src/client/context.ts diff --git a/packages/browser/rollup.config.js b/packages/browser/rollup.config.js index 910c332a6996..f6327813e599 100644 --- a/packages/browser/rollup.config.js +++ b/packages/browser/rollup.config.js @@ -4,6 +4,7 @@ import dts from 'rollup-plugin-dts' import resolve from '@rollup/plugin-node-resolve' import commonjs from '@rollup/plugin-commonjs' import json from '@rollup/plugin-json' +import { defineConfig } from 'rollup' const require = createRequire(import.meta.url) const pkg = require('./package.json') @@ -32,7 +33,7 @@ const input = { providers: './src/node/providers/index.ts', } -export default () => [ +export default () => defineConfig([ { input, output: { @@ -42,6 +43,18 @@ export default () => [ external, plugins, }, + { + input: './src/client/context.ts', + output: { + file: 'dist/context.js', + format: 'esm', + }, + plugins: [ + esbuild({ + target: 'node18', + }), + ], + }, { input: input.index, output: { @@ -51,4 +64,4 @@ export default () => [ external, plugins: [dts()], }, -] +]) diff --git a/packages/browser/src/client/channel.ts b/packages/browser/src/client/channel.ts index 5af1296e4bf7..b2d894248006 100644 --- a/packages/browser/src/client/channel.ts +++ b/packages/browser/src/client/channel.ts @@ -32,7 +32,7 @@ export interface IframeUnmockEvent { paths: string[] } -interface IframeMockingDoneEvent { +export interface IframeMockingDoneEvent { type: 'mock:done' | 'unmock:done' } @@ -46,10 +46,19 @@ export interface IframeMockFactoryResponseEvent { exports: string[] } +export interface IframeMockFactoryErrorEvent { + type: 'mock-factory:error' + error: any +} + export interface IframeViewportChannelEvent { type: 'viewport:done' | 'viewport:fail' } +export interface IframeMockInvalidateEvent { + type: 'mock:invalidate' +} + export type IframeChannelIncomingEvent = | IframeViewportEvent | IframeErrorEvent @@ -57,6 +66,8 @@ export type IframeChannelIncomingEvent = | IframeMockEvent | IframeUnmockEvent | IframeMockFactoryResponseEvent + | IframeMockFactoryErrorEvent + | IframeMockInvalidateEvent export type IframeChannelOutgoingEvent = | IframeMockFactoryRequestEvent diff --git a/packages/browser/src/client/client.ts b/packages/browser/src/client/client.ts index 024ccb9e872e..12f19d673ef7 100644 --- a/packages/browser/src/client/client.ts +++ b/packages/browser/src/client/client.ts @@ -27,7 +27,7 @@ export interface VitestBrowserClient { waitForConnection: () => Promise } -type BrowserRPC = BirpcReturn +export type BrowserRPC = BirpcReturn function createClient() { const autoReconnect = true diff --git a/packages/browser/src/client/context.ts b/packages/browser/src/client/context.ts new file mode 100644 index 000000000000..860369d842aa --- /dev/null +++ b/packages/browser/src/client/context.ts @@ -0,0 +1,110 @@ +import type { Task, WorkerGlobalState } from 'vitest' +import type { BrowserPage, UserEvent, UserEventClickOptions } from '../../context' +import type { BrowserRPC } from './client' +import type { BrowserRunnerState } from './utils' + +// this file should not import anything directly, only types + +function convertElementToXPath(element: Element) { + if (!element || !(element instanceof Element)) + throw new Error(`Expected DOM element to be an instance of Element, received ${typeof element}`) + + return getPathTo(element) +} + +function getPathTo(element: Element): string { + if (element.id !== '') + return `id("${element.id}")` + + if (!element.parentNode || element === document.documentElement) + return element.tagName + + let ix = 0 + const siblings = element.parentNode.childNodes + for (let i = 0; i < siblings.length; i++) { + const sibling = siblings[i] + if (sibling === element) + return `${getPathTo(element.parentNode as Element)}/${element.tagName}[${ix + 1}]` + if (sibling.nodeType === 1 && (sibling as Element).tagName === element.tagName) + ix++ + } + return 'invalid xpath' +} + +// @ts-expect-error not typed global +const state = (): WorkerGlobalState => __vitest_worker__ +// @ts-expect-error not typed global +const runner = (): BrowserRunnerState => __vitest_browser_runner__ +const filepath = () => state().filepath || state().current?.file?.filepath || undefined +const rpc = () => state().rpc as any as BrowserRPC +const contextId = runner().contextId +const channel = new BroadcastChannel(`vitest:${contextId}`) + +function triggerCommand(command: string, ...args: any[]) { + return rpc().triggerCommand(contextId, command, filepath(), args) +} + +export const userEvent: UserEvent = { + click(element: Element, options: UserEventClickOptions = {}) { + const xpath = convertElementToXPath(element) + return triggerCommand('__vitest_click', xpath, options) + }, +} + +const screenshotIds: Record> = {} +export const page: BrowserPage = { + get config() { + return runner().config + }, + viewport(width, height) { + const id = runner().iframeId + channel.postMessage({ type: 'viewport', width, height, id }) + return new Promise((resolve, reject) => { + channel.addEventListener('message', function handler(e) { + if (e.data.type === 'viewport:done' && e.data.id === id) { + channel.removeEventListener('message', handler) + resolve() + } + if (e.data.type === 'viewport:fail' && e.data.id === id) { + channel.removeEventListener('message', handler) + reject(new Error(e.data.error)) + } + }) + }) + }, + async screenshot(options = {}) { + const currentTest = state().current + if (!currentTest) + throw new Error('Cannot take a screenshot outside of a test.') + + if (currentTest.concurrent) { + throw new Error( + 'Cannot take a screenshot in a concurrent test because ' + + 'concurrent tests run at the same time in the same iframe and affect each other\'s environment. ' + + 'Use a non-concurrent test to take a screenshot.', + ) + } + + const repeatCount = currentTest.result?.repeatCount ?? 0 + const taskName = getTaskFullName(currentTest) + const number = screenshotIds[repeatCount]?.[taskName] ?? 1 + + screenshotIds[repeatCount] ??= {} + screenshotIds[repeatCount][taskName] = number + 1 + + const name = `${taskName.replace(/[^a-z0-9]/g, '-')}-${number}.png` + + return triggerCommand( + '__vitest_screenshot', + name, + { + ...options, + element: options.element ? convertElementToXPath(options.element) : undefined, + }, + ) + }, +} + +function getTaskFullName(task: Task): string { + return task.suite ? `${getTaskFullName(task.suite)} ${task.name}` : task.name +} diff --git a/packages/browser/src/client/mocker.ts b/packages/browser/src/client/mocker.ts index d90cd53e03e3..ecbc46b872d3 100644 --- a/packages/browser/src/client/mocker.ts +++ b/packages/browser/src/client/mocker.ts @@ -1,7 +1,7 @@ import { getType } from '@vitest/utils' import { extname, join } from 'pathe' import { rpc } from './rpc' -import { getBrowserState } from './utils' +import { getBrowserState, importId } from './utils' import { channel, waitForChannel } from './client' import type { IframeChannelOutgoingEvent } from './channel' @@ -23,12 +23,21 @@ export class VitestBrowserClientMocker { setupWorker() { channel.addEventListener('message', async (e: MessageEvent) => { if (e.data.type === 'mock-factory:request') { - const module = await this.resolve(e.data.id) - const exports = Object.keys(module) - channel.postMessage({ - type: 'mock-factory:response', - exports, - }) + try { + const module = await this.resolve(e.data.id) + const exports = Object.keys(module) + channel.postMessage({ + type: 'mock-factory:response', + exports, + }) + } + catch (err: any) { + const { processError } = await importId('vitest/browser') as typeof import('vitest/browser') + channel.postMessage({ + type: 'mock-factory:error', + error: processError(err), + }) + } } }) } @@ -86,6 +95,7 @@ export class VitestBrowserClientMocker { if (!ids.length) return await rpc().invalidate(ids) + channel.postMessage({ type: 'mock:invalidate' }) this.ids.clear() this.mocks = {} this.mockObjects = {} diff --git a/packages/browser/src/client/msw.ts b/packages/browser/src/client/msw.ts index 2e66f1ff1c5d..d2b2bcc418a0 100644 --- a/packages/browser/src/client/msw.ts +++ b/packages/browser/src/client/msw.ts @@ -1,37 +1,24 @@ import { http } from 'msw/core/http' import { setupWorker } from 'msw/browser' import { rpc } from './rpc' -import type { IframeChannelEvent, IframeChannelIncomingEvent } from './channel' +import type { IframeChannelEvent, IframeMockEvent, IframeMockingDoneEvent, IframeUnmockEvent } from './channel' import { channel } from './channel' -export async function createModuleMocker() { - const mocks: Record = {} - - channel.addEventListener('message', (e: MessageEvent) => { - switch (e.data.type) { - case 'mock': - for (const path of e.data.paths) - mocks[path] = e.data.mock - break - case 'unmock': - for (const path of e.data.paths) - delete mocks[path] - break - } - }) +export function createModuleMocker() { + const mocks: Map = new Map() const worker = setupWorker( http.get(/.+/, async ({ request }) => { const path = removeTimestamp(request.url.slice(location.origin.length)) - if (!(path in mocks)) + if (!mocks.has(path)) return passthrough() - const mock = mocks[path] + const mock = mocks.get(path) // using a factory if (mock === undefined) { - const exportsModule = await getFactoryExports(path) - const exports = Object.keys(exportsModule) + // TODO: check how the error looks + const exports = await getFactoryExports(path) const module = `const module = __vitest_mocker__.get('${path}');` const keys = exports.map((name) => { if (name === 'default') @@ -58,12 +45,41 @@ export async function createModuleMocker() { }), ) - await worker.start({ - serviceWorker: { - url: '/__virtual_vitest__:mocker-worker.js', + let started = false + let startPromise: undefined | Promise + + async function init() { + if (started) + return + if (startPromise) + return startPromise + startPromise = worker.start({ + serviceWorker: { + url: '/__virtual_vitest__:mocker-worker.js', + }, + quiet: true, + }).finally(() => { + started = true + startPromise = undefined + }) + await startPromise + } + + return { + async mock(event: IframeMockEvent) { + await init() + event.paths.forEach(path => mocks.set(path, event.mock)) + channel.postMessage({ type: 'mock:done' }) }, - quiet: true, - }) + async unmock(event: IframeUnmockEvent) { + await init() + event.paths.forEach(path => mocks.delete(path)) + channel.postMessage({ type: 'unmock:done' }) + }, + invalidate() { + mocks.clear() + }, + } } function getFactoryExports(id: string) { @@ -71,12 +87,16 @@ function getFactoryExports(id: string) { type: 'mock-factory:request', id, }) - return new Promise((resolve) => { + return new Promise((resolve, reject) => { channel.addEventListener('message', function onMessage(e: MessageEvent) { if (e.data.type === 'mock-factory:response') { resolve(e.data.exports) channel.removeEventListener('message', onMessage) } + if (e.data.type === 'mock-factory:error') { + reject(e.data.error) + channel.removeEventListener('message', onMessage) + } }) }) } diff --git a/packages/browser/src/client/orchestrator.ts b/packages/browser/src/client/orchestrator.ts index 842535dc9588..4e82157f0537 100644 --- a/packages/browser/src/client/orchestrator.ts +++ b/packages/browser/src/client/orchestrator.ts @@ -6,6 +6,7 @@ import { rpcDone } from './rpc' import { getBrowserState, getConfig } from './utils' import { getUiAPI } from './ui' import type { IframeChannelEvent, IframeChannelIncomingEvent } from './channel' +import { createModuleMocker } from './msw' const url = new URL(location.href) @@ -81,6 +82,8 @@ client.ws.addEventListener('open', async () => { runningFiles.clear() testFiles.forEach(file => runningFiles.add(file)) + const mocker = createModuleMocker() + channel.addEventListener('message', async (e: MessageEvent): Promise => { debug('channel event', JSON.stringify(e.data)) switch (e.data.type) { @@ -135,10 +138,18 @@ client.ws.addEventListener('open', async () => { await done() break } - case 'mock-factory:response': + case 'mock:invalidate': + mocker.invalidate() + break case 'unmock': + await mocker.unmock(e.data) + break case 'mock': - // ignore, it is processed by the mocker + await mocker.mock(e.data) + break + case 'mock-factory:error': + case 'mock-factory:response': + // handled manually break default: { e.data satisfies never diff --git a/packages/browser/src/client/tester.ts b/packages/browser/src/client/tester.ts index b7b5f68a6976..4ae62b489a13 100644 --- a/packages/browser/src/client/tester.ts +++ b/packages/browser/src/client/tester.ts @@ -118,12 +118,12 @@ async function prepareTestEnvironment(files: string[]) { browserHashMap.set(filename, [true, version]) }) - const [runner, { startTests, setupCommonEnv, Spy }] = await Promise.all([ + const [runner, { startTests, setupCommonEnv, SpyModule }] = await Promise.all([ initiateRunner(state, mocker, config), importId('vitest/browser') as Promise, ]) - mocker.setSpyModule(Spy) + mocker.setSpyModule(SpyModule) mocker.setupWorker() onCancel.then((reason) => { diff --git a/packages/browser/src/client/utils.ts b/packages/browser/src/client/utils.ts index 836f48a2d32b..87410721b1f1 100644 --- a/packages/browser/src/client/utils.ts +++ b/packages/browser/src/client/utils.ts @@ -9,7 +9,7 @@ export function getConfig(): ResolvedConfig { return getBrowserState().config } -interface BrowserRunnerState { +export interface BrowserRunnerState { files: string[] runningFiles: string[] moduleCache: WorkerGlobalState['moduleCache'] diff --git a/packages/browser/src/node/plugins/pluginContext.ts b/packages/browser/src/node/plugins/pluginContext.ts index f367011cd7fd..146164999c1f 100644 --- a/packages/browser/src/node/plugins/pluginContext.ts +++ b/packages/browser/src/node/plugins/pluginContext.ts @@ -1,7 +1,7 @@ import { fileURLToPath } from 'node:url' import type { Plugin } from 'vitest/config' import type { BrowserProvider, WorkspaceProject } from 'vitest/node' -import { dirname } from 'pathe' +import { dirname, resolve } from 'pathe' import type { PluginContext } from 'rollup' import { slash } from '@vitest/utils' import builtinCommands from '../commands/index' @@ -41,18 +41,19 @@ async function generateContextFile(this: PluginContext, project: WorkspaceProjec const filepathCode = '__vitest_worker__.filepath || __vitest_worker__.current?.file?.filepath || undefined' const provider = project.browserProvider! - const commandsCode = commands.map((command) => { + const commandsCode = commands.filter(command => !command.startsWith('__vitest')).map((command) => { return ` ["${command}"]: (...args) => rpc().triggerCommand(contextId, "${command}", filepath(), args),` }).join('\n') const userEventNonProviderImport = await getUserEventImport(provider, this.resolve.bind(this)) + const distContextPath = slash(`/@fs/${resolve(__dirname, 'context.js')}`) return ` +import { page, userEvent as __userEvent_CDP__ } from '${distContextPath}' ${userEventNonProviderImport} const filepath = () => ${filepathCode} const rpc = () => __vitest_worker__.rpc const contextId = __vitest_browser_runner__.contextId -const channel = new BroadcastChannel('vitest:' + contextId) export const server = { platform: ${JSON.stringify(process.platform)}, @@ -64,79 +65,8 @@ export const server = { } } export const commands = server.commands - -const screenshotIds = {} -export const page = { - get config() { - return __vitest_browser_runner__.config - }, - viewport(width, height) { - const id = __vitest_browser_runner__.iframeId - channel.postMessage({ type: 'viewport', width, height, id }) - return new Promise((resolve, reject) => { - channel.addEventListener('message', function handler(e) { - if (e.data.type === 'viewport:done' && e.data.id === id) { - channel.removeEventListener('message', handler) - resolve() - } - if (e.data.type === 'viewport:fail' && e.data.id === id) { - channel.removeEventListener('message', handler) - reject(new Error(e.data.error)) - } - }) - }) - }, - screenshot(options) { - const currentTest = __vitest_worker__.current - if (!currentTest) { - throw new Error('Cannot take a screenshot outside of a test') - } - if (currentTest.concurrent) { - throw new Error('Cannot take a screenshot in a concurrent test') - } - const repeatCount = currentTest.result.repeatCount ?? 0 - const taskName = getTaskFullName(currentTest) - const number = screenshotIds[repeatCount]?.[taskName] ?? 1 - - screenshotIds[repeatCount] ??= {} - screenshotIds[repeatCount][taskName] = number + 1 - - const name = \`\${taskName.replace(/[^a-z0-9]/g, '-')}-\${number}.png\` - return rpc().triggerCommand(contextId, '__vitest_screenshot', currentTest.file.filepath, options ? [name, options] : [name]) - } -} - -export const userEvent = ${getUserEventScript(project)} - -function getTaskFullName(task) { - return task.suite ? getTaskFullName(task.suite) + ' ' + task.name : task.name -} - -function convertElementToXPath(element) { - if (!element || !(element instanceof Element)) { - // TODO: better error message - throw new Error('Expected element to be an instance of Element') - } - return getPathTo(element) -} - -function getPathTo(element) { - if (element.id !== '') - return \`id("\${element.id}")\` - - if (!element.parentNode || element === document.documentElement) - return element.tagName - - let ix = 0 - const siblings = element.parentNode.childNodes - for (let i = 0; i < siblings.length; i++) { - const sibling = siblings[i] - if (sibling === element) - return \`\${getPathTo(element.parentNode)}/\${element.tagName}[\${ix + 1}]\` - if (sibling.nodeType === 1 && sibling.tagName === element.tagName) - ix++ - } -} +export const userEvent = ${provider.name === 'preview' ? '__vitest_user_event__' : '__userEvent_CDP__'} +export { page } ` } @@ -148,14 +78,3 @@ async function getUserEventImport(provider: BrowserProvider, resolve: (id: strin throw new Error(`Failed to resolve user-event package from ${__dirname}`) return `import { userEvent as __vitest_user_event__ } from '${slash(`/@fs/${resolved.id}`)}'` } - -function getUserEventScript(project: WorkspaceProject) { - if (project.browserProvider?.name === 'preview') - return `__vitest_user_event__` - return `{ - async click(element, options) { - const xpath = convertElementToXPath(element) - return rpc().triggerCommand(contextId, '__vitest_click', filepath(), options ? [xpath, options] : [xpath]); - }, -}` -} diff --git a/packages/vitest/src/api/browser.ts b/packages/vitest/src/api/browser.ts index 0631faf6f37b..ad4bcbbf6614 100644 --- a/packages/vitest/src/api/browser.ts +++ b/packages/vitest/src/api/browser.ts @@ -143,16 +143,17 @@ export function setupBrowserRpc(project: WorkspaceProject, server: ViteDevServer getProvidedContext() { return 'ctx' in project ? project.getProvidedContext() : ({} as any) }, + // TODO: cache this automock result async automock(id) { - const request = await project.browser!.transformRequest(id) - if (!request) + const result = await project.browser!.transformRequest(id) + if (!result) throw new Error(`Module "${id}" not found.`) - const ms = automockModule(request.code, parseAst) + const ms = automockModule(result.code, parseAst) const code = ms.toString() const sourcemap = ms.generateMap({ hires: 'boundary', source: id }) - const combinedMap = request.map && request.map.mappings + const combinedMap = result.map && result.map.mappings ? remapping( - [{ ...sourcemap, version: 3 }, request.map as EncodedSourceMap], + [{ ...sourcemap, version: 3 }, result.map as EncodedSourceMap], () => null, ) : sourcemap diff --git a/packages/vitest/src/api/types.ts b/packages/vitest/src/api/types.ts index 5659935c7c20..d17cd7594aaf 100644 --- a/packages/vitest/src/api/types.ts +++ b/packages/vitest/src/api/types.ts @@ -41,7 +41,7 @@ export interface WebSocketBrowserHandlers { snapshotSaved: (snapshot: SnapshotResult) => void debug: (...args: string[]) => void resolveId: (id: string, importer?: string) => Promise - triggerCommand: (contextId: string, command: string, testPath: string | undefined, payload: unknown[]) => Promise + triggerCommand: (contextId: string, command: string, testPath: string | undefined, payload: unknown[]) => Promise resolveMock: (id: string, importer: string, hasFactory: boolean) => Promise<{ type: 'factory' | 'redirect' | 'automock' mockPath?: string | null diff --git a/packages/vitest/src/browser.ts b/packages/vitest/src/browser.ts index 5515f458c441..e9eb3ea0cb3c 100644 --- a/packages/vitest/src/browser.ts +++ b/packages/vitest/src/browser.ts @@ -10,4 +10,4 @@ export { getCoverageProvider, startCoverageInsideWorker, } from './integrations/coverage' -export * as Spy from './integrations/spy' +export * as SpyModule from './integrations/spy' diff --git a/test/browser/test/__screenshots__/dom.test.ts/dom-related-activity-renders-div-1.png b/test/browser/test/__screenshots__/dom.test.ts/dom-related-activity-renders-div-1.png index 4d5679084b3a905357c4c16bacc41f004df64b19..e4eb88823e4a01fb334460f1f002d0ce57673c12 100644 GIT binary patch literal 5543 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 AApigX literal 5597 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 FzW`DJ!|(tA From 6984ecfadc8eb7869bcb3413b5f30c567fd25fe5 Mon Sep 17 00:00:00 2001 From: Vladimir Sheremet Date: Wed, 12 Jun 2024 11:01:03 +0200 Subject: [PATCH 17/29] fix: correct automocking --- packages/browser/src/client/msw.ts | 4 ++-- packages/vitest/LICENSE.md | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/browser/src/client/msw.ts b/packages/browser/src/client/msw.ts index d2b2bcc418a0..e170a67f0ae7 100644 --- a/packages/browser/src/client/msw.ts +++ b/packages/browser/src/client/msw.ts @@ -1,8 +1,8 @@ import { http } from 'msw/core/http' import { setupWorker } from 'msw/browser' -import { rpc } from './rpc' import type { IframeChannelEvent, IframeMockEvent, IframeMockingDoneEvent, IframeUnmockEvent } from './channel' import { channel } from './channel' +import { client } from './client' export function createModuleMocker() { const mocks: Map = new Map() @@ -36,7 +36,7 @@ export function createModuleMocker() { if (typeof mock === 'string') return Response.redirect(mock) - const content = await rpc().automock(path) + const content = await client.rpc.automock(path) return new Response(content, { headers: { 'Content-Type': 'application/javascript', diff --git a/packages/vitest/LICENSE.md b/packages/vitest/LICENSE.md index 00d5225a173b..e012ba88698a 100644 --- a/packages/vitest/LICENSE.md +++ b/packages/vitest/LICENSE.md @@ -312,7 +312,7 @@ Repository: micromatch/braces > The MIT License (MIT) > -> Copyright (c) 2014-2018, Jon Schlinkert. +> Copyright (c) 2014-present, Jon Schlinkert. > > Permission is hereby granted, free of charge, to any person obtaining a copy > of this software and associated documentation files (the "Software"), to deal From 26c6bcdafe7f85b744fb7b119008bc7eb9a26ae7 Mon Sep 17 00:00:00 2001 From: Vladimir Sheremet Date: Wed, 12 Jun 2024 11:08:59 +0200 Subject: [PATCH 18/29] chore: fix browser stack trace for async methods --- packages/utils/src/source-map.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/packages/utils/src/source-map.ts b/packages/utils/src/source-map.ts index 701e42a59c35..484bc0379c82 100644 --- a/packages/utils/src/source-map.ts +++ b/packages/utils/src/source-map.ts @@ -47,6 +47,8 @@ function extractLocation(urlLike: string) { if (!parts) return [urlLike] let url = parts[1] + if (url.startsWith('async ')) + url = url.slice(6) if (url.startsWith('http:') || url.startsWith('https:')) { const urlObj = new URL(url) url = urlObj.pathname From ebc6aefc9f07f9dc7dce6c6950187078acfb5cf7 Mon Sep 17 00:00:00 2001 From: Vladimir Sheremet Date: Wed, 12 Jun 2024 11:15:51 +0200 Subject: [PATCH 19/29] feat: support screenshot in webdriverio --- .../browser/src/node/commands/screenshot.ts | 10 ++++++++++ packages/vitest/src/node/cli/cli-config.ts | 1 + .../dom-related-activity-renders-div-1.png | Bin 5543 -> 4420 bytes 3 files changed, 11 insertions(+) diff --git a/packages/browser/src/node/commands/screenshot.ts b/packages/browser/src/node/commands/screenshot.ts index 81c63c131764..9d1682f012d9 100644 --- a/packages/browser/src/node/commands/screenshot.ts +++ b/packages/browser/src/node/commands/screenshot.ts @@ -4,6 +4,7 @@ import { basename, dirname, relative, resolve } from 'pathe' import type { ResolvedConfig } from 'vitest' import type { ScreenshotOptions } from '../../../context' import { PlaywrightBrowserProvider } from '../providers/playwright' +import { WebdriverBrowserProvider } from '../providers/webdriver' // TODO: expose provider specific options in types export const screenshot: BrowserCommand<[string, ScreenshotOptions]> = async (context, name: string, options = {}) => { @@ -26,6 +27,15 @@ export const screenshot: BrowserCommand<[string, ScreenshotOptions]> = async (co return path } + if (context.provider instanceof WebdriverBrowserProvider) { + const page = context.provider.browser! + const frame = await page.findElement('css selector', 'iframe[data-vitest]') + await page.switchToFrame(frame) + const element = options.element ? `//${options.element}` : `//body` + ;(await page.$(element)).saveScreenshot(path) + return path + } + throw new Error(`Provider "${context.provider.name}" does not support screenshots`) } diff --git a/packages/vitest/src/node/cli/cli-config.ts b/packages/vitest/src/node/cli/cli-config.ts index 6911ba6fcd26..9e51d02dd31b 100644 --- a/packages/vitest/src/node/cli/cli-config.ts +++ b/packages/vitest/src/node/cli/cli-config.ts @@ -354,6 +354,7 @@ export const cliOptionsConfig: VitestCLIOptions = { testerScripts: null, commands: null, viewport: null, + screenshotDirectory: null, }, }, pool: { diff --git a/test/browser/test/__screenshots__/dom.test.ts/dom-related-activity-renders-div-1.png b/test/browser/test/__screenshots__/dom.test.ts/dom-related-activity-renders-div-1.png index e4eb88823e4a01fb334460f1f002d0ce57673c12..9875758b64a02a85129a7f61f303607c6643f4c4 100644 GIT binary patch delta 3008 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 E4{MIYi2wiq delta 2673 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 Date: Wed, 12 Jun 2024 11:18:15 +0200 Subject: [PATCH 20/29] chore: cleanup --- .../browser/src/node/commands/screenshot.ts | 8 ++++++-- .../dom-related-activity-renders-div-1.png | Bin 4420 -> 2891 bytes test/browser/test/dom.test.ts | 4 +++- 3 files changed, 9 insertions(+), 3 deletions(-) diff --git a/packages/browser/src/node/commands/screenshot.ts b/packages/browser/src/node/commands/screenshot.ts index 9d1682f012d9..b6beb9e2ba73 100644 --- a/packages/browser/src/node/commands/screenshot.ts +++ b/packages/browser/src/node/commands/screenshot.ts @@ -29,10 +29,14 @@ export const screenshot: BrowserCommand<[string, ScreenshotOptions]> = async (co if (context.provider instanceof WebdriverBrowserProvider) { const page = context.provider.browser! + if (!options.element) { + ;(await page.$('iframe[data-vitest]')).saveScreenshot(path) + return path + } const frame = await page.findElement('css selector', 'iframe[data-vitest]') await page.switchToFrame(frame) - const element = options.element ? `//${options.element}` : `//body` - ;(await page.$(element)).saveScreenshot(path) + const xpath = `//${options.element}` + ;(await page.$(xpath)).saveScreenshot(path) return path } diff --git a/test/browser/test/__screenshots__/dom.test.ts/dom-related-activity-renders-div-1.png b/test/browser/test/__screenshots__/dom.test.ts/dom-related-activity-renders-div-1.png index 9875758b64a02a85129a7f61f303607c6643f4c4..417f175258b10b6a32e1b677ad968b730b510b6a 100644 GIT binary patch literal 2891 zcmZ{mc{tQv8^?!wGDL<~vd&T(j3u&UO*6)pH7UwYG$Zp^vnFIjSt6xu4`UhoP`0vW zjWX8IjC~t}tRr~{@2|h!zuxo5?>g7-y3ci=^S#gabM9vrW(GW5B3vL4h{wnfYY76e z{R7NZ!R){{2#NCoe#qc;buDf_z&nFLQpsUSipK300?{qjT)FOiXT`-X2Mm&5lX*r- z#Wl?DsQk$xWr265x*~&3r8;diQ*S;}Rb0x9#N3=bnDTmzTiQ@-`141Ob?tMO86Z5* zDF-3t6|zSSeWi_qz15^vY1=3@B702g;QgVwyiuHe-~!0;nvhMoYOH+aXx!d)U&*-# zJ#R>`Cm)iyaj2iJE>t_9A;**cQ>pZO=CJeVT@mPHeW{gfHhCz~0E3_Nlic zn_s`{ef;rC*{M1PdQs~)}B1nWzO=(OrH-ilNzt^HF8_vM`iOj-Sc4Rt}rGT8!!4R zSmR#9!G+}DgT|x%i7LO1zOt#T(2caQ;tH<_IB}pC!OyuC$Xs0wghEwKM}i<29RJSG zpVV}fuVx&PpVF>G&!fR4=_i9vA?dVu{XB+441XEDr;i-K_lmUtVRwN?RdZLnxeg0Pq~_-3E&6^Wd}(pAL^6%I(su&7c7R z_3)$L^$%CUh&r$7zW)BgwZM(3pPQR-Vm_Jt`1ju65t?G(Q0vsnj)}FCKi*x-7$NJ< z_fV+`v9WNXvZ^Xn!yhb?eKqXM5{naY#cQh3cadwNDtw2@*C-+?Y8+02hek$5>gnkb zlU~4d0EOuRGh0E6i;Ki0Kqqm{00_p?(sIF99RVan%E?g&1F^%H(h(#QnMg|*3HLEI zHBC{JP(I77?(FHocbrL*-W?1hRnq;tQzXrEHh=w!IS+?NBqC&Gxu5@B9n&48(bN;| zTv@GzP_C2gQg(KBU0aE`yOr@5ZVx`4&cn>_{-9OO&N_D|2eTM_7$<%XCW;lVr;piU zP&4o%(&@rBU3^z}7dL7i{kz@UYXXD8u4Ye9qltmII zCnv2d9Gb$9n+fJsv|a^n6PhZ4?myp^@LI-t$a->Of`}oR724K5Qm-C}l8}%9k{ieh zvi|)IsItGmUsfi&w{2o*Xc!QnipbB;zqdS8ker-cQ&ZE~*;%{sPjWJ!u&^}$GYw76 zS^p9Kuv0Hb+LIt0Gb+}7FAXI~g3O+C-`~3#qtTHqEiG|zr?dNi?e0!BhlM&jBQm~j zS{CmxbdFDQaGZ|DU}CGPs_NpgI+fEf`lU_T-Dt ztnKadM4_0c9v&WGgu1#q9*=i$aPVGPVK4**1p`(`wp97ziG|P;rFD@?5&1EOc zq0#8^T7oF6n?|Gcv9oEp%mXFN%E}sm!eEzPUnd+)fAr(v0;?FlJ9o)}JA;}OI6FJ* z9a%2!KYNl8v2aCVbXSsifiN6#v}uGP1>&NvdKv^3AiqnJcu<4X65 zRZ^fI>A#N!fAF3f9SxZI6bWb_^1jw@$?V|ZIrhAv6s9{-T+=K|qsDKkufARbr~YWK za!tOxy!`O+u+q!P32xGXl96esZ$NbC=H})|PPCesnLVWf=_eZkv$cYKk0VbEy!WJC z{u;cvx#{ih?rvfNL?n)6tfzh@6%`e!`7U%%HHE@-9(#_&=!;HC%|jbUyY8r{sF-J8 zEy(zsDDL<0VYG&V6qpMJ%f7$3m>L=!8al4yiNi?=XlrXXH8ol2gdaWXd3litvAJ-0 z&S{ilf4V)wu!KVS8;5(VbU(48V&nN)nVj+V7+y{UYpFlRRO5)jaBp(po##Dsh8#oi z@~S8+6WbEM$j>R@Z))V@^XJ;j9SCusO6loXV&S_GeV=giWx&-Ouk#wFMse^C;WD&C zcaQfE@1od#DN^054%^%2)G*zjK7j~7n9K;5>RSRYpxRGxQ39)LYbqJkTeohNT2}~y zPbRtPJpLWgBd%>|Yb&bVHZuc{pPrn|Qmw76O;k18cV3=*`1Ssl<%ts~0PIq!)Lk8w zbe~P)j+d3K?di^{?d^nPuhr4=-Cu{BvnK?auU_4m@CKY^5g%l9n*7kaiMvm~<yDc==&4ukrHy`~^UG*HaqlJ~mW(hl`>- zVg4mRIy9fS_@PNTK%&0x?xdc35HYUkw!UVd<{DXMQUV%VK>)d~7hFc6JVr`xZKCXR z9&~!D)+v}=y9V-X4nKyeamwNhH#JjAFkj%5$2F>${ksspdPIUb1NEo$=VZ zrh(oOz(Yz%N$3WEjKKAYNV^L}yAR&Zy_2)0H;73WjZ^KKAE_e{Y-IbjwKZO-mJp%h z%_Ya7^u94*V`rj$aIt2etx&hDt(v3DV= z?ClED($Xo4sGa+K&#(Xh_@Cw1*Qa;!7;8ZM-e?yfopY#N!LFHvfcOkeu` za$Jm=rx(LF0m<%1VVa6IFU=&GessMcE!n*y6x|sqp{by};vDi4YFwY6MXY e{J%zLpLb&F@Sk9GL;%nvgN*dduoR5blm7zD>zslB literal 4420 zcmeHLX;f3!76vD(h_n?!5u~kkDgt%DfC@NKoB%1xAShTxgcxQLXc8h?Yy|@jkjNAS z6qP_CV8ReaQBWdNLI@;=1Ys_8x{GY~FHhP;g`ulNGUk8-xs8AviWak(+F99!-#L;|6*9v!ctUNTzSugaj7y9?P z&CLgbg80qN&2Lh$p^?sdk$rbJy3H>v(4%8xW1&!}BfM|fNuTx%D{`HWi7N)9lM37E z72qUGAoxUl$oe*H@uFkvzdp*jc7U;L$-O<_-Fcy(?4iT)2n0fBE(C!r2zj>sA2l=6?c$>KNKy-}nkLRo|CCx(Tr7;lEO4Lx`DAy&zUlhd-Xd35 zuliZWi6Hd{GkS`rll=7I!-u_0GWN^f7-LkevK-BQPiKGaY=6Gex%ny! zhv&cm$f?>VZ7?o}GgUv+rBEnvI9zga@(lxH?I$;v`MGY4GrY5~un@SR?2b6qnxuN` znIua#Mm?M#s~sh984L!OiNNbB zkxpn>*tXQ7ii&8?i?HgU-zUadM9&&6*xu3dr;B>HXzS1nK?#h?ZqiZ$Yp(Pb0S|oU z{lwIDN0eFxjF?(fBTU~HQ7giMSzP3~ZYSmYrG}>J*(WafjjarVtCtX*)dGuc8P<`{aCvlfut& zYq5{dBv4x}={Yq>OEEOVZFvKqeDA-(<2-yYM6?;HoZ?l6oT$AF5>MjuJ+hR{_P~qW zxIxBa06LN`O}E@vdM|zULz*4#Mrrh`J5)&V>on7>EnVOovegLk9?pfaaw$+!?PWDA zARvH#XpL*MoaKv|$>rCNm zymSI@Ys2174jxJnVnwYW2L&Q{@qASqgw!;AGv@7Uv!MO5nwW%ygw7m$I^M1cK>dr; zSiGgBD41E|9;B7wU6i%m8X3OHg5r_2$E-ue0?zUoo8Di0*b? zm|R{EJwH1pdRX`55-_=yPSx&4V)FSe4h{o7J*Vu`*D_3@qh0wK&huB~62lX>){vW;+>TJK2Onp*6Z;bRz-D9Vb7s-$ftpk!}%!a`oJvfR6S_wE-V zbeu8z(_wda_Yk^Pnt{nQK(IU%Q@jB2@Ib{yk-FPOrw$(`I3YdubdSIGfsixQwnH7g z#crP$@Igt5YTwRBLZh+VUJzvT7Up`$d94mDyI){|;kszXJVn7Hx7V?dt#rGldW$?s zrca`uFTu{5kJXCoFzXT%3jH-pNs=2PPvZJGw{_;`93FEQ*e4r~?RN4d)LX4xd*!OS3A5Y^ zqn5_>uvqpWUW&Hjf}&qrlj~*gW4q6oIRV^GZy0PK!MM~cNN0IqZ6%o;85I@9Dm_!& z@85`;3w55!mf4vPA^2#@B(91CZiHh2t@jOw*li~BXb8O9$#}V(56r&-IayH&b@f8& z7Wf!mCq3gA5CgrgW*UKkOO1j9rMHsM3koOctJPb$1WWR%F770}ng|mFwkf^mW(ssDjhvmat76>r&TwJjc(5zH#<4AG|nFyKb~ z%9SfoN*Z5N5K7pV`kN#OF_4bh=s2FDR4CwZxI!*JW32|VY)1DheB&zlYjnxs@4Vdk z*d8V`mgx?0e^sXHW^g!lQNu6Cm`@^kb(9Ww?D)h42p9-Ps|FCk1}jA@-=KWSn0xll zkl~jp-cLNlQ&UO9DP|tR+kR-Y2JpgOU~2c=Jy~woPr{XajIPYTYV#yCXsT|)7E@{D zK*QMtz$eP9JwCD<c&ee@ZI`d z*T5~Y^FJ}9`5YUXwJ#}da#`Mm{QP{1I)r!EV~#7N8I@P7e6@C1qxwMqTWbeo&W#< diff --git a/test/browser/test/dom.test.ts b/test/browser/test/dom.test.ts index 875b31dfa730..0eb2f150540c 100644 --- a/test/browser/test/dom.test.ts +++ b/test/browser/test/dom.test.ts @@ -12,7 +12,9 @@ describe('dom related activity', () => { const div = createNode() wrapper.appendChild(div) expect(div.textContent).toBe('Hello World!') - const screenshotPath = await page.screenshot() + const screenshotPath = await page.screenshot({ + element: wrapper, + }) expect(screenshotPath).toMatch(/__screenshots__\/dom.test.ts\/dom-related-activity-renders-div-1.png/) }) }) From 246cbdad71a04c714929e53466a03755d15c0d12 Mon Sep 17 00:00:00 2001 From: Vladimir Sheremet Date: Wed, 12 Jun 2024 11:36:46 +0200 Subject: [PATCH 21/29] fix: await screenshot --- packages/browser/src/node/commands/screenshot.ts | 4 ++-- packages/vitest/src/api/browser.ts | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/packages/browser/src/node/commands/screenshot.ts b/packages/browser/src/node/commands/screenshot.ts index b6beb9e2ba73..4e291e8ae271 100644 --- a/packages/browser/src/node/commands/screenshot.ts +++ b/packages/browser/src/node/commands/screenshot.ts @@ -30,13 +30,13 @@ export const screenshot: BrowserCommand<[string, ScreenshotOptions]> = async (co if (context.provider instanceof WebdriverBrowserProvider) { const page = context.provider.browser! if (!options.element) { - ;(await page.$('iframe[data-vitest]')).saveScreenshot(path) + await (await page.$('iframe[data-vitest]')).saveScreenshot(path) return path } const frame = await page.findElement('css selector', 'iframe[data-vitest]') await page.switchToFrame(frame) const xpath = `//${options.element}` - ;(await page.$(xpath)).saveScreenshot(path) + await (await page.$(xpath)).saveScreenshot(path) return path } diff --git a/packages/vitest/src/api/browser.ts b/packages/vitest/src/api/browser.ts index ad4bcbbf6614..8b2d585ec045 100644 --- a/packages/vitest/src/api/browser.ts +++ b/packages/vitest/src/api/browser.ts @@ -121,7 +121,7 @@ export function setupBrowserRpc(project: WorkspaceProject, server: ViteDevServer getCountOfFailedTests() { return ctx.state.getCountOfFailedTests() }, - triggerCommand(contextId, command, testPath, payload) { + async triggerCommand(contextId, command, testPath, payload) { debug?.('[%s] Triggering command "%s"', contextId, command) if (!project.browserProvider) throw new Error('Commands are only available for browser tests.') @@ -134,7 +134,7 @@ export function setupBrowserRpc(project: WorkspaceProject, server: ViteDevServer provider: project.browserProvider, contextId, }, project.browserProvider.getCommandsContext(contextId)) - return commands[command](context, ...payload) + return await commands[command](context, ...payload) }, finishBrowserTests(contextId: string) { debug?.('[%s] Finishing browser tests for context', contextId) From 1a6174fe5c7828a59cd298c972d824afb1ec5eb4 Mon Sep 17 00:00:00 2001 From: Vladimir Sheremet Date: Wed, 12 Jun 2024 11:41:18 +0200 Subject: [PATCH 22/29] docs: add screenshot command --- docs/guide/browser.md | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/docs/guide/browser.md b/docs/guide/browser.md index 05bc06559a08..ffcf489d8404 100644 --- a/docs/guide/browser.md +++ b/docs/guide/browser.md @@ -236,6 +236,11 @@ export const page: { * Change the size of iframe's viewport. */ viewport: (width: number | string, height: number | string) => Promise + /** + * Make a screenshot of the test iframe or a specific element. + * @returns Path to the screenshot file. + */ + screenshot: (options?: ScreenshotOptions) => Promise } ``` From afd18d7a0c1b380f203b9c2eb8b78e9eb83fa874 Mon Sep 17 00:00:00 2001 From: Vladimir Sheremet Date: Wed, 12 Jun 2024 11:42:28 +0200 Subject: [PATCH 23/29] chore: typecheck fix --- packages/vitest/src/api/browser.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/packages/vitest/src/api/browser.ts b/packages/vitest/src/api/browser.ts index 8b2d585ec045..06eaf7fbc565 100644 --- a/packages/vitest/src/api/browser.ts +++ b/packages/vitest/src/api/browser.ts @@ -14,6 +14,7 @@ import { stringifyReplace } from '../utils' import type { WorkspaceProject } from '../node/workspace' import { createDebugger } from '../utils/debugger' import { automockModule } from '../node/automockBrowser' +import type { BrowserCommandContext } from '../types/browser' import type { WebSocketBrowserEvents, WebSocketBrowserHandlers } from './types' const debug = createDebugger('vitest:browser:api') @@ -133,7 +134,7 @@ export function setupBrowserRpc(project: WorkspaceProject, server: ViteDevServer project, provider: project.browserProvider, contextId, - }, project.browserProvider.getCommandsContext(contextId)) + }, project.browserProvider.getCommandsContext(contextId)) as any as BrowserCommandContext return await commands[command](context, ...payload) }, finishBrowserTests(contextId: string) { From ab47658a5f866bd38ae4637174a09c707ead9399 Mon Sep 17 00:00:00 2001 From: Vladimir Sheremet Date: Wed, 12 Jun 2024 11:45:41 +0200 Subject: [PATCH 24/29] chore: remove .base checks --- packages/browser/src/client/mocker.ts | 8 +++----- packages/browser/src/client/runner.ts | 3 +-- packages/browser/src/client/tester.ts | 2 +- packages/browser/src/client/utils.ts | 2 +- 4 files changed, 6 insertions(+), 9 deletions(-) diff --git a/packages/browser/src/client/mocker.ts b/packages/browser/src/client/mocker.ts index ecbc46b872d3..61c5a4c82104 100644 --- a/packages/browser/src/client/mocker.ts +++ b/packages/browser/src/client/mocker.ts @@ -51,8 +51,7 @@ export class VitestBrowserClientMocker { if (resolved == null) throw new Error(`[vitest] Cannot resolve ${id} imported from ${importer}`) const ext = extname(resolved.id) - const base = getBrowserState().config.base || '/' - const url = new URL(`/@id${base}${resolved.id}`, location.href) + const url = new URL(`/@id/${resolved.id}`, location.href) const query = `_vitest_original&ext.${ext}` const actualUrl = `${url.pathname}${ url.search ? `${url.search}&${query}` : `?${query}` @@ -71,12 +70,11 @@ export class VitestBrowserClientMocker { if (this.factories[resolvedId]) return await this.resolve(resolvedId) - const base = getBrowserState().config.base || '/' if (type === 'redirect') { - const url = new URL(`/@id${base}${mockPath}`, location.href) + const url = new URL(`/@id/${mockPath}`, location.href) return import(url.toString()) } - const url = new URL(`/@id${base}${resolvedId}`, location.href) + const url = new URL(`/@id/${resolvedId}`, location.href) const query = url.search ? `${url.search}&t=${now()}` : `?t=${now()}` const moduleObject = await import(`${url.pathname}${query}${url.hash}`) return this.mockObject(moduleObject) diff --git a/packages/browser/src/client/runner.ts b/packages/browser/src/client/runner.ts index bc7177be2dfb..3b64befd4818 100644 --- a/packages/browser/src/client/runner.ts +++ b/packages/browser/src/client/runner.ts @@ -89,10 +89,9 @@ export function createBrowserRunner( hash = Date.now().toString() this.hashMap.set(filepath, [false, hash]) } - const base = this.config.base || '/' // on Windows we need the unit to resolve the test file - const prefix = `${base}${/^\w:/.test(filepath) ? '@fs/' : ''}` + const prefix = `/${/^\w:/.test(filepath) ? '@fs/' : ''}` const query = `${test ? 'browserv' : 'v'}=${hash}` const importpath = `${prefix}${filepath}?${query}`.replace(/\/+/g, '/') await import(importpath) diff --git a/packages/browser/src/client/tester.ts b/packages/browser/src/client/tester.ts index 4ae62b489a13..9cd1c19d6141 100644 --- a/packages/browser/src/client/tester.ts +++ b/packages/browser/src/client/tester.ts @@ -60,7 +60,7 @@ async function prepareTestEnvironment(files: string[]) { debug('trying to resolve runner', `${reloadStart}`) const config = getConfig() - const viteClientPath = `${config.base || '/'}@vite/client` + const viteClientPath = `/@vite/client` await import(viteClientPath) const rpc: any = await loadSafeRpc(client) diff --git a/packages/browser/src/client/utils.ts b/packages/browser/src/client/utils.ts index 87410721b1f1..54f99d73be1d 100644 --- a/packages/browser/src/client/utils.ts +++ b/packages/browser/src/client/utils.ts @@ -1,7 +1,7 @@ import type { ResolvedConfig, WorkerGlobalState } from 'vitest' export async function importId(id: string) { - const name = `${getConfig().base || '/'}@id/${id}` + const name = `/@id/${id}` return getBrowserState().wrapModule(() => import(name)) } From ce0fc4f34d8061bb93a8835f3e0c84f23985c728 Mon Sep 17 00:00:00 2001 From: Vladimir Sheremet Date: Wed, 12 Jun 2024 13:22:09 +0200 Subject: [PATCH 25/29] fix: correctly switch the iframe for webdriverio command --- packages/browser/src/node/commands/click.ts | 21 ++++++++++--------- .../browser/src/node/commands/keyboard.ts | 7 ++++--- .../browser/src/node/commands/screenshot.ts | 8 +++---- .../browser/src/node/providers/webdriver.ts | 10 +++++++++ packages/vitest/src/api/browser.ts | 19 +++++++++++++---- packages/vitest/src/types/browser.ts | 2 ++ 6 files changed, 46 insertions(+), 21 deletions(-) diff --git a/packages/browser/src/node/commands/click.ts b/packages/browser/src/node/commands/click.ts index 34806c0e60d9..5d93e34ca715 100644 --- a/packages/browser/src/node/commands/click.ts +++ b/packages/browser/src/node/commands/click.ts @@ -1,23 +1,24 @@ import type { UserEvent } from '../../../context' import { PlaywrightBrowserProvider } from '../providers/playwright' +import { WebdriverBrowserProvider } from '../providers/webdriver' import type { UserEventCommand } from './utils' export const click: UserEventCommand = async ( - { provider, contextId }, - element, + context, + xpath, options = {}, ) => { + const provider = context.provider if (provider instanceof PlaywrightBrowserProvider) { - const page = provider.getPage(contextId) - await page.frameLocator('iframe[data-vitest]').locator(`xpath=${element}`).click(options) + const tester = context.tester + await tester.locator(`xpath=${xpath}`).click(options) return } - if (provider.name === 'webdriverio') { - const page = (provider as any).browser as WebdriverIO.Browser - const frame = await page.findElement('css selector', 'iframe[data-vitest]') - await page.switchToFrame(frame) - const xpath = `//${element}` - await (await page.$(xpath)).click(options) + if (provider instanceof WebdriverBrowserProvider) { + const page = provider.browser! + const markedXpath = `//${xpath}` + const element = await page.$(markedXpath) + await element.click(options) return } throw new Error(`Provider "${provider.name}" doesn't support click command`) diff --git a/packages/browser/src/node/commands/keyboard.ts b/packages/browser/src/node/commands/keyboard.ts index ec32cf81f3b3..6b7959971df2 100644 --- a/packages/browser/src/node/commands/keyboard.ts +++ b/packages/browser/src/node/commands/keyboard.ts @@ -10,6 +10,7 @@ import type { UpPayload, } from '../../../context' import { PlaywrightBrowserProvider } from '../providers/playwright' +import { WebdriverBrowserProvider } from '../providers/webdriver' function isObject(payload: unknown): payload is Record { return payload != null && typeof payload === 'object' @@ -77,8 +78,8 @@ export const sendKeys: BrowserCommand> = else if (isUpPayload(payload)) await page.keyboard.up(payload.up) } - else if (provider.name === 'webdriverio') { - const browser = (provider as any).browser as WebdriverIO.Browser + else if (provider instanceof WebdriverBrowserProvider) { + const browser = provider.browser! if (isTypePayload(payload)) await browser.keys(payload.type.split('')) else if (isPressPayload(payload)) @@ -87,6 +88,6 @@ export const sendKeys: BrowserCommand> = throw new Error('Only "press" and "type" are supported by webdriverio.') } else { - throw new Error(`"sendKeys" is not supported for ${provider.name} browser provider.`) + throw new TypeError(`"sendKeys" is not supported for ${provider.name} browser provider.`) } } diff --git a/packages/browser/src/node/commands/screenshot.ts b/packages/browser/src/node/commands/screenshot.ts index 4e291e8ae271..dde69ec3ef4e 100644 --- a/packages/browser/src/node/commands/screenshot.ts +++ b/packages/browser/src/node/commands/screenshot.ts @@ -30,13 +30,13 @@ export const screenshot: BrowserCommand<[string, ScreenshotOptions]> = async (co if (context.provider instanceof WebdriverBrowserProvider) { const page = context.provider.browser! if (!options.element) { - await (await page.$('iframe[data-vitest]')).saveScreenshot(path) + const body = await page.$('body') + await body.saveScreenshot(path) return path } - const frame = await page.findElement('css selector', 'iframe[data-vitest]') - await page.switchToFrame(frame) const xpath = `//${options.element}` - await (await page.$(xpath)).saveScreenshot(path) + const element = await page.$(xpath) + await element.saveScreenshot(path) return path } diff --git a/packages/browser/src/node/providers/webdriver.ts b/packages/browser/src/node/providers/webdriver.ts index 03976b8e62c4..cc7d010addc5 100644 --- a/packages/browser/src/node/providers/webdriver.ts +++ b/packages/browser/src/node/providers/webdriver.ts @@ -29,6 +29,16 @@ export class WebdriverBrowserProvider implements BrowserProvider { this.options = options as RemoteOptions } + async beforeCommand() { + const page = this.browser! + const iframe = await page.findElement('css selector', 'iframe[data-vitest]') + await page.switchToFrame(iframe) + } + + async afterCommand() { + await this.browser!.switchToParentFrame() + } + getCommandsContext() { return {} } diff --git a/packages/vitest/src/api/browser.ts b/packages/vitest/src/api/browser.ts index 06eaf7fbc565..bd93edc85ce6 100644 --- a/packages/vitest/src/api/browser.ts +++ b/packages/vitest/src/api/browser.ts @@ -124,18 +124,29 @@ export function setupBrowserRpc(project: WorkspaceProject, server: ViteDevServer }, async triggerCommand(contextId, command, testPath, payload) { debug?.('[%s] Triggering command "%s"', contextId, command) - if (!project.browserProvider) + const provider = project.browserProvider + if (!provider) throw new Error('Commands are only available for browser tests.') const commands = project.config.browser?.commands if (!commands || !commands[command]) throw new Error(`Unknown command "${command}".`) + if (provider.beforeCommand) + await provider.beforeCommand(command, payload) const context = Object.assign({ testPath, project, - provider: project.browserProvider, + provider, contextId, - }, project.browserProvider.getCommandsContext(contextId)) as any as BrowserCommandContext - return await commands[command](context, ...payload) + }, provider.getCommandsContext(contextId)) as any as BrowserCommandContext + let result + try { + result = await commands[command](context, ...payload) + } + finally { + if (provider.afterCommand) + await provider.afterCommand(command, payload) + } + return result }, finishBrowserTests(contextId: string) { debug?.('[%s] Finishing browser tests for context', contextId) diff --git a/packages/vitest/src/types/browser.ts b/packages/vitest/src/types/browser.ts index e7d2fe1af140..e0c0715a13b1 100644 --- a/packages/vitest/src/types/browser.ts +++ b/packages/vitest/src/types/browser.ts @@ -14,6 +14,8 @@ export interface BrowserProvider { */ supportsParallelism: boolean getSupportedBrowsers: () => readonly string[] + beforeCommand?: (command: string, args: unknown[]) => Awaitable + afterCommand?: (command: string, args: unknown[]) => Awaitable getCommandsContext: (contextId: string) => Record openPage: (contextId: string, url: string) => Promise close: () => Awaitable From bec920eef962f8916f6220560634766a24df15b0 Mon Sep 17 00:00:00 2001 From: Vladimir Sheremet Date: Wed, 12 Jun 2024 13:25:42 +0200 Subject: [PATCH 26/29] chore: cleanup --- packages/browser/src/node/commands/utils.ts | 11 +---------- packages/vitest/src/types/browser.ts | 2 +- 2 files changed, 2 insertions(+), 11 deletions(-) diff --git a/packages/browser/src/node/commands/utils.ts b/packages/browser/src/node/commands/utils.ts index 18d20a63eba3..6e4db8456872 100644 --- a/packages/browser/src/node/commands/utils.ts +++ b/packages/browser/src/node/commands/utils.ts @@ -1,13 +1,4 @@ -import type { BrowserCommand, BrowserProvider } from 'vitest/node' -import type { PreviewBrowserProvider } from '../providers/preview' -import type { WebdriverBrowserProvider } from '../providers/webdriver' -import type { PlaywrightBrowserProvider } from '../providers/playwright' - -declare module 'vitest/node' { - export interface BrowserCommandContext { - provider: PlaywrightBrowserProvider | WebdriverBrowserProvider | BrowserProvider | PreviewBrowserProvider - } -} +import type { BrowserCommand } from 'vitest/node' export type UserEventCommand any> = BrowserCommand< ConvertUserEventParameters> diff --git a/packages/vitest/src/types/browser.ts b/packages/vitest/src/types/browser.ts index e0c0715a13b1..85dd2e72a26e 100644 --- a/packages/vitest/src/types/browser.ts +++ b/packages/vitest/src/types/browser.ts @@ -143,7 +143,7 @@ export interface BrowserConfigOptions { export interface BrowserCommandContext { testPath: string | undefined - // provider: BrowserProvider + provider: BrowserProvider project: WorkspaceProject contextId: string } From ad006723aafd5fd580ad0712835f081bc76702f7 Mon Sep 17 00:00:00 2001 From: Vladimir Sheremet Date: Wed, 12 Jun 2024 13:39:58 +0200 Subject: [PATCH 27/29] docs: add docs about extending commands --- docs/guide/browser.md | 57 +++++++++++++++++++ packages/browser/providers/webdriverio.d.ts | 4 ++ .../browser/src/node/providers/webdriver.ts | 4 +- 3 files changed, 64 insertions(+), 1 deletion(-) diff --git a/docs/guide/browser.md b/docs/guide/browser.md index ffcf489d8404..998f9a865c1d 100644 --- a/docs/guide/browser.md +++ b/docs/guide/browser.md @@ -365,6 +365,63 @@ declare module '@vitest/browser/context' { Custom functions will override built-in ones if they have the same name. ::: +### Custom `playwright` commands + +Vitest exposes several `playwright` specific properties on the command context. + +- `page` references the full page that contains the test iframe. This is the orchestrator HTML and you most likely shouldn't touch it to not break things. +- `tester` is the iframe locator. The API is pretty limited here, but you can chain it further to access your HTML elements. +- `body` is the iframe's `body` locator that exposes more Playwright APIs. + +```ts +import { defineCommand } from '@vitest/browser' + +export const myCommand = defineCommand(async (ctx, arg1, arg2) => { + if (ctx.provider.name === 'playwright') { + const element = await ctx.tester.findByRole('alert') + const screenshot = await element.screenshot() + // do something with the screenshot + return difference + } +}) +``` + +::: tip +If you are using TypeScript, don't forget to add `@vitest/browser/providers/playwright` to your `tsconfig` "compilerOptions.types" field to get autocompletion: + +```json +{ + "compilerOptions": { + "types": [ + "@vitest/browser/providers/playwright" + ] + } +} +``` +::: + +### Custom `webdriverio` commands + +Vitest exposes some `webdriverio` specific properties on the context object. + +- `browser` is the `WebdriverIO.Browser` API. + +Vitest automatically switches the `webdriver` context to the test iframe by calling `browser.switchToFrame` before the command is called, so `$` and `$$` methods refer to the elements inside the iframe, not in the orchestrator, but non-webdriver APIs will still refer to the parent frame context. + +::: tip +If you are using TypeScript, don't forget to add `@vitest/browser/providers/webdriverio` to your `tsconfig` "compilerOptions.types" field to get autocompletion: + +```json +{ + "compilerOptions": { + "types": [ + "@vitest/browser/providers/webdriverio" + ] + } +} +``` +::: + ## Limitations ### Thread Blocking Dialogs diff --git a/packages/browser/providers/webdriverio.d.ts b/packages/browser/providers/webdriverio.d.ts index 06550f11162a..890a878c8e6e 100644 --- a/packages/browser/providers/webdriverio.d.ts +++ b/packages/browser/providers/webdriverio.d.ts @@ -2,4 +2,8 @@ import type { RemoteOptions } from 'webdriverio' declare module 'vitest/node' { interface BrowserProviderOptions extends RemoteOptions {} + + export interface BrowserCommandContext { + browser: WebdriverIO.Browser + } } diff --git a/packages/browser/src/node/providers/webdriver.ts b/packages/browser/src/node/providers/webdriver.ts index cc7d010addc5..e7a90fe53616 100644 --- a/packages/browser/src/node/providers/webdriver.ts +++ b/packages/browser/src/node/providers/webdriver.ts @@ -40,7 +40,9 @@ export class WebdriverBrowserProvider implements BrowserProvider { } getCommandsContext() { - return {} + return { + browser: this.browser, + } } async openBrowser() { From 9bffe041b9e10b8aa43b389a98f0b39540fb5613 Mon Sep 17 00:00:00 2001 From: Vladimir Date: Wed, 12 Jun 2024 13:52:10 +0200 Subject: [PATCH 28/29] fix: specify correct screenshot location --- .../browser/src/node/commands/screenshot.ts | 10 ++++++---- .../dom-related-activity-renders-div-1.png | Bin 2891 -> 1689 bytes 2 files changed, 6 insertions(+), 4 deletions(-) diff --git a/packages/browser/src/node/commands/screenshot.ts b/packages/browser/src/node/commands/screenshot.ts index dde69ec3ef4e..c9e44fa33506 100644 --- a/packages/browser/src/node/commands/screenshot.ts +++ b/packages/browser/src/node/commands/screenshot.ts @@ -1,4 +1,5 @@ import { mkdir } from 'node:fs/promises' +import { normalize } from 'node:path' import type { BrowserCommand } from 'vitest/node' import { basename, dirname, relative, resolve } from 'pathe' import type { ResolvedConfig } from 'vitest' @@ -12,6 +13,7 @@ export const screenshot: BrowserCommand<[string, ScreenshotOptions]> = async (co throw new Error(`Cannot take a screenshot without a test path`) const path = resolveScreenshotPath(context.testPath, name, context.project.config) + const savePath = normalize(path) await mkdir(dirname(path), { recursive: true }) if (context.provider instanceof PlaywrightBrowserProvider) { @@ -19,10 +21,10 @@ export const screenshot: BrowserCommand<[string, ScreenshotOptions]> = async (co const { element: elementXpath, ...config } = options const iframe = context.tester const element = iframe.locator(`xpath=${elementXpath}`) - await element.screenshot({ ...config, path }) + await element.screenshot({ ...config, path: savePath }) } else { - await context.body.screenshot({ ...options, path }) + await context.body.screenshot({ ...options, path: savePath }) } return path } @@ -31,12 +33,12 @@ export const screenshot: BrowserCommand<[string, ScreenshotOptions]> = async (co const page = context.provider.browser! if (!options.element) { const body = await page.$('body') - await body.saveScreenshot(path) + await body.saveScreenshot(savePath) return path } const xpath = `//${options.element}` const element = await page.$(xpath) - await element.saveScreenshot(path) + await element.saveScreenshot(savePath) return path } diff --git a/test/browser/test/__screenshots__/dom.test.ts/dom-related-activity-renders-div-1.png b/test/browser/test/__screenshots__/dom.test.ts/dom-related-activity-renders-div-1.png index 417f175258b10b6a32e1b677ad968b730b510b6a..f880ae781001e078f11f49859d260cd69edbe020 100644 GIT binary patch delta 1674 zcmaKse>l^L1IItkwJDOVOv))!^E=m>pIc-W-*uyBi=$)7^ssW9+-!1weD08#kk*~C z(szCnE5!V0ex5p(iK#J4ku=Jrg(f=B{c(SNpYQw6_w#z*f4$#t38G;8RdW!~Vv>Du zfKY0p1OUiM0^ZaA;*EFDI->SOn?M&&Nn$oY`2)T0dtjHlqMZt5{yVa*RF)YlhI zes!%wpc4U~9rdi4mZO^~R)~6DS1dtrp*zed>oK3JTEtewz1GWB>=EA&J^6FXzFe>8 ziAA%_=i=4%HD@g#?XUH9P`o@64gjkk4CsIXF2f1XF~jc#(hQ2X12~-_2w;{*)Be=} zcTel6j+p}3pY|^%0L$Hno8CnU2Y_FZ(s&fN0R3V?1*$A^n!HO$wg^&*R%aruFk<%usWeAp zw$o?)VeUT3%#wePHo!aW#7|mTxH%=|9vP_|qV1OhF9_EqGu|bF_Kx%S#+oR$h>C9v zfzBFEZ!vwGgSqV;Tt@EF>;bTXwzjt8WyQ@6+C z^#s~iR%1kNA5b>5o+~VmFXB%f2$xoMi-oGukg;8JkxHnMd1~6h z;@v*ShayfreKh^s?3~Y_8M62dXyg2+pR>9vbWG7y^Llvvjxj7xHjnMH3_G_OJ#18~ zS>M>iw_HCok_#tpP$^42nf+xlCNu6T$?tYENY+)gIH_FOk3EI;3)VjIi6E2BoDt9T~M%&5`bP`>y4G5_;}FW^JJL4k{|%Qgg$ zYGM^Yf0La~T9{CO4b5wx5B>DZ=NI+LK1U>VYxu;=CkysSBq#b6E$F7}q{pNi9jyDFIPHLyS*9M>Gi4P>> ztKKjg`yuYRskFcPxj9sKoz{@CTdKN4)*>t)vQ z?j`wc`Z}8>PAGbXYiF(#@VJkw4ae}M(U!3m%y~B$m?zU*u$A0QT<%69sZn1bhK!$E zvX2EO!BE^wwe$+e>{}LXH~4f$K&(;pL1DytH5W3yvftHZ5x9u-$S zDptVoi~6E&j_~3(FV}>mD=P;UMlN>JV~(!wS9JHEGi;IfC`V0ZqVZwsrGdR9yM7#j zA(v0~|F|$dihO&gl9h1$la3@(<3s9(-)1F14kKqW%t%qw~vAPeezA5@luO-DI`NaDnOvu(^v8EoNlXQZSdr7BNvhg3o5RqIuo-Ed%I< z=~IW!o^3(D-Ai0b(jG&c$}HXt9OC z$ky$s$hIDC>7YJ*19JqmmJYFn^tzB0R`AS6SkoZePhTdAN`lVsbSA{QLnlGL)6}|P z9RB3~=aVS#gmk^f0ZvrAX}bx+J*F&Nq4^fZN?6Y|@)h$k znv6vWvk$Uv+VHvsXk`ugk2% zj;+8M)D32@D7dQ0-Jw8FQwl7mp?xK1zpKXBEEr|=>hu%6fYrP7_ds2B`p z>?gK@qy-S|`2&7xYw~@nF@R8DPuJOwHc_hl7m>SxRdC#8wt(oJWNJ+`rl-)y2O=7t zF@pbiqFNz6)arbT_RYd0TZPdK28!E9@bcwZDF6V$owI8L0LhmB_s{?9#x?sqkBlx6 S*;7PdQ-ouFc%e4#Ov>LZwJCJ~ delta 2885 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 aeGa%LrjGs&Mu!J5fo7y Date: Wed, 12 Jun 2024 13:53:48 +0200 Subject: [PATCH 29/29] fix: respect options.path --- packages/browser/src/client/context.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/browser/src/client/context.ts b/packages/browser/src/client/context.ts index 860369d842aa..675644242ac1 100644 --- a/packages/browser/src/client/context.ts +++ b/packages/browser/src/client/context.ts @@ -92,7 +92,7 @@ export const page: BrowserPage = { screenshotIds[repeatCount] ??= {} screenshotIds[repeatCount][taskName] = number + 1 - const name = `${taskName.replace(/[^a-z0-9]/g, '-')}-${number}.png` + const name = options.path || `${taskName.replace(/[^a-z0-9]/g, '-')}-${number}.png` return triggerCommand( '__vitest_screenshot',