From 78b62ffe1dc078d3e48573b3517f19bbeb898726 Mon Sep 17 00:00:00 2001 From: Vladimir Date: Thu, 19 Dec 2024 14:21:41 +0100 Subject: [PATCH] feat: allow multi-browser configuration (#6975) --- .github/workflows/ci.yml | 15 +- docs/.vitepress/config.ts | 33 ++ docs/.vitepress/scripts/cli-generator.ts | 2 +- docs/advanced/api/vitest.md | 9 +- docs/config/index.md | 212 +----------- docs/guide/browser/commands.md | 2 +- docs/guide/browser/config.md | 327 ++++++++++++++++++ docs/guide/browser/index.md | 82 ++--- docs/guide/browser/locators.md | 4 +- docs/guide/browser/multiple-setups.md | 134 +++++++ docs/guide/browser/playwright.md | 96 +++++ docs/guide/browser/webdriverio.md | 69 ++++ docs/guide/cli-generated.md | 24 +- docs/guide/migration.md | 25 ++ eslint.config.js | 2 +- packages/browser/providers/webdriverio.d.ts | 2 +- packages/browser/src/client/channel.ts | 2 +- packages/browser/src/client/client.ts | 6 +- packages/browser/src/client/orchestrator.ts | 13 +- .../src/client/public/esm-client-injector.js | 2 +- packages/browser/src/client/tester/context.ts | 6 +- .../src/client/tester/locators/index.ts | 2 +- packages/browser/src/client/tester/runner.ts | 9 +- packages/browser/src/client/tester/state.ts | 10 +- packages/browser/src/client/utils.ts | 2 +- .../browser/src/node/commands/keyboard.ts | 10 +- packages/browser/src/node/commands/type.ts | 4 +- packages/browser/src/node/index.ts | 14 +- .../middlewares/orchestratorMiddleware.ts | 25 ++ .../src/node/middlewares/testerMiddleware.ts | 24 ++ .../browser/src/node/middlewares/utils.ts | 14 + packages/browser/src/node/plugin.ts | 122 +++---- .../browser/src/node/plugins/pluginContext.ts | 44 +-- packages/browser/src/node/pool.ts | 38 +- packages/browser/src/node/project.ts | 122 +++++++ .../src/node/{server.ts => projectParent.ts} | 239 ++++++------- .../browser/src/node/providers/playwright.ts | 42 +-- .../browser/src/node/providers/preview.ts | 4 +- .../browser/src/node/providers/webdriver.ts | 2 +- packages/browser/src/node/rpc.ts | 148 ++++---- .../browser/src/node/serverOrchestrator.ts | 69 ++-- packages/browser/src/node/serverTester.ts | 62 ++-- packages/browser/src/node/state.ts | 29 +- packages/browser/src/node/types.ts | 8 +- .../mocker/src/browser/interceptor-msw.ts | 7 +- packages/runner/src/fixture.ts | 4 - packages/runner/src/types/tasks.ts | 4 + packages/vitest/src/api/setup.ts | 8 +- packages/vitest/src/create/browser/creator.ts | 29 +- packages/vitest/src/node/browser/sessions.ts | 34 ++ packages/vitest/src/node/cli/cac.ts | 8 + packages/vitest/src/node/cli/cli-api.ts | 9 - packages/vitest/src/node/cli/cli-config.ts | 7 +- .../vitest/src/node/config/resolveConfig.ts | 25 +- packages/vitest/src/node/core.ts | 20 +- packages/vitest/src/node/packageInstaller.ts | 8 +- packages/vitest/src/node/project.ts | 100 ++++-- packages/vitest/src/node/reporters/base.ts | 4 +- packages/vitest/src/node/types/browser.ts | 79 ++++- packages/vitest/src/node/types/config.ts | 6 +- .../src/node/workspace/resolveWorkspace.ts | 159 ++++++++- packages/vitest/src/public/node.ts | 7 +- packages/vitest/src/utils/env.ts | 3 + .../fixtures/benchmark/vitest.config.ts | 8 +- .../fixtures/locators/vitest.config.ts | 7 +- .../fixtures/mocking-watch/vitest.config.ts | 7 +- .../browser/fixtures/mocking/vitest.config.ts | 7 +- .../multiple-different-configs/basic.test.js | 23 ++ .../customTester.html | 13 + .../vitest.config.js | 33 ++ .../fixtures/server-url/vitest.config.ts | 10 +- .../fixtures/setup-file/vitest.config.ts | 7 +- .../browser/fixtures/timeout/vitest.config.ts | 21 +- .../fixtures/unhandled/vitest.config.ts | 7 +- .../fixtures/update-snapshot/vitest.config.ts | 7 +- .../fixtures/user-event/vitest.config.ts | 7 +- test/browser/package.json | 1 + test/browser/settings.ts | 25 ++ test/browser/setup.unit.ts | 55 +++ test/browser/specs/benchmark.test.ts | 3 +- test/browser/specs/filter.test.ts | 10 +- test/browser/specs/fix-4686.test.ts | 17 +- test/browser/specs/inspect.test.ts | 4 +- test/browser/specs/locators.test.ts | 15 +- test/browser/specs/mocking.test.ts | 50 +-- .../specs/multiple-different-configs.test.ts | 25 ++ test/browser/specs/runner.test.ts | 38 +- test/browser/specs/server-url.test.ts | 4 +- test/browser/specs/setup-file.test.ts | 19 +- test/browser/specs/unhandled.test.ts | 9 +- test/browser/specs/update-snapshot.test.ts | 3 +- test/browser/specs/utils.ts | 4 +- test/browser/tsconfig.json | 9 +- test/browser/vitest.config.mts | 23 +- test/browser/vitest.config.unit.mts | 6 +- test/cli/test/__snapshots__/list.test.ts.snap | 12 +- test/config/fixtures/bail/vitest.config.ts | 4 +- test/config/test/bail.test.ts | 31 +- test/config/test/browser-configs.test.ts | 177 ++++++++++ test/config/test/browser-html.test.ts | 12 +- test/config/test/failures.test.ts | 96 +++++ test/coverage-test/test/isolation.test.ts | 1 - test/test-utils/index.ts | 12 +- 103 files changed, 2439 insertions(+), 1014 deletions(-) create mode 100644 docs/guide/browser/config.md create mode 100644 docs/guide/browser/multiple-setups.md create mode 100644 docs/guide/browser/playwright.md create mode 100644 docs/guide/browser/webdriverio.md create mode 100644 packages/browser/src/node/middlewares/orchestratorMiddleware.ts create mode 100644 packages/browser/src/node/middlewares/testerMiddleware.ts create mode 100644 packages/browser/src/node/middlewares/utils.ts create mode 100644 packages/browser/src/node/project.ts rename packages/browser/src/node/{server.ts => projectParent.ts} (56%) create mode 100644 packages/vitest/src/node/browser/sessions.ts create mode 100644 test/browser/fixtures/multiple-different-configs/basic.test.js create mode 100644 test/browser/fixtures/multiple-different-configs/customTester.html create mode 100644 test/browser/fixtures/multiple-different-configs/vitest.config.js create mode 100644 test/browser/settings.ts create mode 100644 test/browser/setup.unit.ts create mode 100644 test/browser/specs/multiple-different-configs.test.ts create mode 100644 test/config/test/browser-configs.test.ts diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index bae9ce02fa3e..b043692271de 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -124,7 +124,7 @@ jobs: test-browser: needs: changed - name: 'Browser: ${{ matrix.browser[0] }}, ${{ matrix.os }}' + name: 'Browsers: node-20, ${{ matrix.os }}' if: needs.changed.outputs.should_skip != 'true' runs-on: ${{ matrix.os }} @@ -133,10 +133,6 @@ jobs: os: - macos-latest - windows-latest - browser: - - [chromium, chrome] - - [firefox, firefox] - - [webkit] fail-fast: false timeout-minutes: 30 @@ -149,26 +145,19 @@ jobs: node-version: 20 - uses: browser-actions/setup-chrome@v1 - if: ${{ matrix.browser[0] == 'chromium' }} - uses: browser-actions/setup-firefox@v1 - if: ${{ matrix.browser[0] == 'firefox' }} - name: Install run: pnpm i - name: Install Playwright Dependencies - run: pnpm exec playwright install ${{ matrix.browser[0] }} --with-deps --only-shell + run: pnpm exec playwright install --with-deps --only-shell - name: Build run: pnpm run build - name: Test Browser (playwright) run: pnpm run test:browser:playwright - env: - BROWSER: ${{ matrix.browser[0] }} - name: Test Browser (webdriverio) run: pnpm run test:browser:webdriverio - if: ${{ matrix.browser[1] }} - env: - BROWSER: ${{ matrix.browser[1] }} diff --git a/docs/.vitepress/config.ts b/docs/.vitepress/config.ts index 246443e63853..991389fe0374 100644 --- a/docs/.vitepress/config.ts +++ b/docs/.vitepress/config.ts @@ -67,6 +67,7 @@ export default ({ mode }: { mode: string }) => { groupIconVitePlugin({ customIcon: { 'CLI': 'vscode-icons:file-type-shell', + 'vitest.shims': 'vscode-icons:file-type-vitest', 'vitest.workspace': 'vscode-icons:file-type-vitest', 'vitest.config': 'vscode-icons:file-type-vitest', '.spec.ts': 'vscode-icons:file-type-testts', @@ -214,6 +215,27 @@ export default ({ mode }: { mode: string }) => { }, ], }, + { + text: 'Configuration', + collapsed: false, + items: [ + { + text: 'Browser Config Reference', + link: '/guide/browser/config', + docFooterText: 'Browser Config Reference | Browser Mode', + }, + { + text: 'Configuring Playwright', + link: '/guide/browser/playwright', + docFooterText: 'Configuring Playwright | Browser Mode', + }, + { + text: 'Configuring WebdriverIO', + link: '/guide/browser/webdriverio', + docFooterText: 'Configuring WebdriverIO | Browser Mode', + }, + ], + }, { text: 'API', collapsed: false, @@ -245,6 +267,17 @@ export default ({ mode }: { mode: string }) => { }, ], }, + { + text: 'Guides', + collapsed: false, + items: [ + { + text: 'Multiple Setups', + link: '/guide/browser/multiple-setups', + docFooterText: 'Multiple Setups | Browser Mode', + }, + ], + }, { items: [ ...footer(), diff --git a/docs/.vitepress/scripts/cli-generator.ts b/docs/.vitepress/scripts/cli-generator.ts index b71dfa5c3ad4..4f3ba4fe06cb 100644 --- a/docs/.vitepress/scripts/cli-generator.ts +++ b/docs/.vitepress/scripts/cli-generator.ts @@ -75,7 +75,7 @@ const options = resolveOptions(cliOptionsConfig) const template = options.map((option) => { const title = option.title const cli = option.cli - const config = skipConfig.has(title) ? '' : `[${title}](/config/#${title.toLowerCase().replace(/\./g, '-')})` + const config = skipConfig.has(title) ? '' : `[${title}](${title.includes('browser.') ? '/guide/browser/config' : '/config/'}#${title.toLowerCase().replace(/\./g, '-')})` return `### ${title}\n\n- **CLI:** ${cli}\n${config ? `- **Config:** ${config}\n` : ''}\n${option.description}\n` }).join('\n') diff --git a/docs/advanced/api/vitest.md b/docs/advanced/api/vitest.md index cb8582ba970f..ad6861e9b98f 100644 --- a/docs/advanced/api/vitest.md +++ b/docs/advanced/api/vitest.md @@ -34,7 +34,6 @@ Vitest 3 is one step closer to stabilising the public API. To achieve that, we d - `changeNamePattern` - `changeFilenamePattern` - `rerunFailed` -- `updateSnapshot` - `_createRootProject` (renamed to `_ensureRootProject`, but still private) - `filterTestsBySource` (this was moved to the new internal `vitest.specifications` instance) - `runFiles` (use [`runTestSpecifications`](#runtestspecifications) instead) @@ -326,6 +325,14 @@ function runTestSpecifications( This method emits `reporter.onWatcherRerun` and `onTestsRerun` events, then it runs tests with [`runTestSpecifications`](#runtestspecifications). If there were no errors in the main process, it will emit `reporter.onWatcherStart` event. +## updateSnapshot + +```ts +function updateSnapshot(files?: string[]): Promise +``` + +Update snapshots in specified files. If no files are provided, it will update files with failed tests and obsolete snapshots. + ## collectTests ```ts diff --git a/docs/config/index.md b/docs/config/index.md index d6127a7d294c..6d0e1cab56a5 100644 --- a/docs/config/index.md +++ b/docs/config/index.md @@ -1754,221 +1754,17 @@ Open Vitest UI (WIP) Listen to port and serve API. When set to true, the default port is 51204 -### browser {#browser} +### browser experimental {#browser} -- **Type:** `{ enabled?, name?, provider?, headless?, api? }` -- **Default:** `{ enabled: false, headless: process.env.CI, api: 63315 }` -- **CLI:** `--browser`, `--browser=`, `--browser.name=chrome --browser.headless` +- **Default:** `{ enabled: false }` +- **CLI:** `--browser=`, `--browser.name=chrome --browser.headless` -Run Vitest tests in a browser. We use [WebdriverIO](https://webdriver.io/) for running tests by default, but it can be configured with [browser.provider](#browser-provider) option. - -::: tip NOTE -Read more about testing in a real browser in the [guide page](/guide/browser/). -::: +Configuration for running browser tests. Please, refer to the ["Browser Config Reference"](/guide/browser/config) article. ::: warning This is an experimental feature. Breaking changes might not follow SemVer, please pin Vitest's version when using it. ::: -#### browser.enabled - -- **Type:** `boolean` -- **Default:** `false` -- **CLI:** `--browser`, `--browser.enabled=false` - -Run all tests inside a browser by default. - -#### browser.name - -- **Type:** `string` -- **CLI:** `--browser=safari` - -Run all tests in a specific browser. Possible options in different providers: - -- `webdriverio`: `firefox`, `chrome`, `edge`, `safari` -- `playwright`: `firefox`, `webkit`, `chromium` -- custom: any string that will be passed to the provider - -#### browser.headless - -- **Type:** `boolean` -- **Default:** `process.env.CI` -- **CLI:** `--browser.headless`, `--browser.headless=false` - -Run the browser in a `headless` mode. If you are running Vitest in CI, it will be enabled by default. - -#### browser.isolate - -- **Type:** `boolean` -- **Default:** `true` -- **CLI:** `--browser.isolate`, `--browser.isolate=false` - -Run every test in a separate iframe. - -#### browser.testerHtmlPath 2.1.4 {#browser-testerhtmlpath} - -- **Type:** `string` -- **Default:** `@vitest/browser/tester.html` - -A path to the HTML entry point. Can be relative to the root of the project. This file will be processed with [`transformIndexHtml`](https://vite.dev/guide/api-plugin#transformindexhtml) hook. - -#### browser.api - -- **Type:** `number | { port?, strictPort?, host? }` -- **Default:** `63315` -- **CLI:** `--browser.api=63315`, `--browser.api.port=1234, --browser.api.host=example.com` - -Configure options for Vite server that serves code in the browser. Does not affect [`test.api`](#api) option. By default, Vitest assigns port `63315` to avoid conflicts with the development server, allowing you to run both in parallel. - -#### browser.provider - -- **Type:** `'webdriverio' | 'playwright' | 'preview' | string` -- **Default:** `'preview'` -- **CLI:** `--browser.provider=playwright` - -Path to a provider that will be used when running browser tests. Vitest provides three providers which are `preview` (default), `webdriverio` and `playwright`. Custom providers should be exported using `default` export and have this shape: - -```ts -export interface BrowserProvider { - name: string - getSupportedBrowsers: () => readonly string[] - initialize: (ctx: Vitest, options: { browser: string; options?: BrowserProviderOptions }) => Awaitable - openPage: (url: string) => Awaitable - close: () => Awaitable -} -``` - -::: warning -This is an advanced API for library authors. If you just need to run tests in a browser, use the [browser](#browser) option. -::: - -#### browser.providerOptions {#browser-provideroptions} - -- **Type:** `BrowserProviderOptions` - -Options that will be passed down to provider when calling `provider.initialize`. - -```ts -import { defineConfig } from 'vitest/config' - -export default defineConfig({ - test: { - browser: { - providerOptions: { - launch: { - devtools: true, - }, - }, - }, - }, -}) -``` - -::: tip -To have a better type safety when using built-in providers, you should reference one of these types (for provider that you are using) in your [config file](/config/): - -```ts -/// -/// -``` -::: - -#### browser.ui {#browser-ui} - -- **Type:** `boolean` -- **Default:** `!isCI` -- **CLI:** `--browser.ui=false` - -Should Vitest UI be injected into the page. By default, injects UI iframe during development. - -#### browser.viewport {#browser-viewport} - -- **Type:** `{ width, height }` -- **Default:** `414x896` - -Default iframe's viewport. - -#### browser.locators {#browser-locators} - -Options for built-in [browser locators](/guide/browser/locators). - -##### browser.locators.testIdAttribute - -- **Type:** `string` -- **Default:** `data-testid` - -Attribute used to find elements with `getByTestId` locator. - -#### browser.screenshotDirectory {#browser-screenshotdirectory} - -- **Type:** `string` -- **Default:** `__snapshots__` in the test file directory - -Path to the snapshots directory relative to the `root`. - -#### browser.screenshotFailures {#browser-screenshotfailures} - -- **Type:** `boolean` -- **Default:** `!browser.ui` - -Should Vitest take screenshots if the test fails. - -#### browser.orchestratorScripts {#browser-orchestratorscripts} - -- **Type:** `BrowserScript[]` -- **Default:** `[]` - -Custom scripts that should be injected into the orchestrator HTML before test iframes are initiated. This HTML document only sets up iframes and doesn't actually import your code. - -The script `src` and `content` will be processed by Vite plugins. Script should be provided in the following shape: - -```ts -export interface BrowserScript { - /** - * If "content" is provided and type is "module", this will be its identifier. - * - * If you are using TypeScript, you can add `.ts` extension here for example. - * @default `injected-${index}.js` - */ - id?: string - /** - * JavaScript content to be injected. This string is processed by Vite plugins if type is "module". - * - * You can use `id` to give Vite a hint about the file extension. - */ - content?: string - /** - * Path to the script. This value is resolved by Vite so it can be a node module or a file path. - */ - src?: string - /** - * If the script should be loaded asynchronously. - */ - async?: boolean - /** - * Script type. - * @default 'module' - */ - type?: string -} -``` - -#### browser.testerScripts {#browser-testerscripts} - -- **Type:** `BrowserScript[]` -- **Default:** `[]` - -Custom scripts that should be injected into the tester HTML before the tests environment is initiated. This is useful to inject polyfills required for Vitest browser implementation. It is recommended to use [`setupFiles`](#setupfiles) in almost all cases instead of this. - -The script `src` and `content` will be processed by Vite plugins. - -#### browser.commands {#browser-commands} - -- **Type:** `Record` -- **Default:** `{ readFile, writeFile, ... }` - -Custom [commands](/guide/browser/commands) that can be imported during browser tests from `@vitest/browser/commands`. - ### clearMocks - **Type:** `boolean` diff --git a/docs/guide/browser/commands.md b/docs/guide/browser/commands.md index 419cd0b04e4a..d00232caa106 100644 --- a/docs/guide/browser/commands.md +++ b/docs/guide/browser/commands.md @@ -61,7 +61,7 @@ CDP session works only with `playwright` provider and only when using `chromium` ## Custom Commands -You can also add your own commands via [`browser.commands`](/config/#browser-commands) config option. If you develop a library, you can provide them via a `config` hook inside a plugin: +You can also add your own commands via [`browser.commands`](/guide/browser/config#browser-commands) config option. If you develop a library, you can provide them via a `config` hook inside a plugin: ```ts import type { Plugin } from 'vitest/config' diff --git a/docs/guide/browser/config.md b/docs/guide/browser/config.md new file mode 100644 index 000000000000..e71099de3667 --- /dev/null +++ b/docs/guide/browser/config.md @@ -0,0 +1,327 @@ +# Browser Config Reference + +You can change the browser configuration by updating the `test.browser` field in your [config file](/config/). An example of a simple config file: + +```ts [vitest.config.ts] +import { defineConfig } from 'vitest/config' + +export default defineConfig({ + test: { + browser: { + enabled: true, + provider: 'playwright', + instances: [ + { + browser: 'chromium', + setupFile: './chromium-setup.js', + }, + ], + }, + }, +}) +``` + +Please, refer to the ["Config Reference"](/config/) article for different config examples. + +::: warning +_All listed options_ on this page are located within a `test` property inside the configuration: + +```ts [vitest.config.js] +export default defineConfig({ + test: { + browser: {}, + }, +}) +``` +::: + +## browser.enabled + +- **Type:** `boolean` +- **Default:** `false` +- **CLI:** `--browser`, `--browser.enabled=false` + +Run all tests inside a browser by default. Note that `--browser` only works if you have at least one [`browser.instances`](#browser-instances) item. + +## browser.instances + +- **Type:** `BrowserConfig` +- **Default:** `[{ browser: name }]` + +Defines multiple browser setups. Every config has to have at least a `browser` field. The config supports your providers configurations: + +- [Configuring Playwright](/guide/browser/playwright) +- [Configuring WebdriverIO](/guide/browser/webdriverio) + +::: tip +To have a better type safety when using built-in providers, you should reference one of these types (for provider that you are using) in your [config file](/config/): + +```ts +/// +/// +``` +::: + +In addition to that, you can also specify most of the [project options](/config/) (not marked with a icon) and some of the `browser` options like `browser.testerHtmlPath`. + +::: warning +Every browser config inherits options from the root config: + +```ts{3,9} [vitest.config.ts] +export default defineConfig({ + test: { + setupFile: ['./root-setup-file.js'], + browser: { + enabled: true, + testerHtmlPath: './custom-path.html', + instances: [ + { + // will have both setup files: "root" and "browser" + setupFile: ['./browser-setup-file.js'], + // implicitly has "testerHtmlPath" from the root config // [!code warning] + // testerHtmlPath: './custom-path.html', // [!code warning] + }, + ], + }, + }, +}) +``` + +During development, Vitest supports only one [non-headless](#browser-headless) configuration. You can limit the headed project yourself by specifying `headless: false` in the config, or by providing the `--browser.headless=false` flag, or by filtering projects with `--project=chromium` flag. + +For more examples, refer to the ["Multiple Setups" guide](/guide/browser/multiple-setups). +::: + +List of available `browser` options: + +- [`browser.headless`](#browser-headless) +- [`browser.locators`](#browser-locators) +- [`browser.viewport`](#browser-viewport) +- [`browser.testerHtmlPath`](#browser-testerhtmlpath) +- [`browser.screenshotDirectory`](#browser-screenshotdirectory) +- [`browser.screenshotFailures`](#browser-screenshotfailures) + +By default, Vitest creates an array with a single element which uses the [`browser.name`](#browser-name) field as a `browser`. Note that this behaviour will be removed with Vitets 4. + +Under the hood, Vitest transforms these instances into separate [test projects](/advanced/api/test-project) sharing a single Vite server for better caching performance. + +## browser.name deprecated {#browser-name} + +- **Type:** `string` +- **CLI:** `--browser=safari` + +::: danger +This API is deprecated an will be removed in Vitest 4. Please, use [`browser.instances`](#browser-instances) option instead. +::: + +Run all tests in a specific browser. Possible options in different providers: + +- `webdriverio`: `firefox`, `chrome`, `edge`, `safari` +- `playwright`: `firefox`, `webkit`, `chromium` +- custom: any string that will be passed to the provider + +## browser.headless + +- **Type:** `boolean` +- **Default:** `process.env.CI` +- **CLI:** `--browser.headless`, `--browser.headless=false` + +Run the browser in a `headless` mode. If you are running Vitest in CI, it will be enabled by default. + +## browser.isolate + +- **Type:** `boolean` +- **Default:** `true` +- **CLI:** `--browser.isolate`, `--browser.isolate=false` + +Run every test in a separate iframe. + +## browser.testerHtmlPath + +- **Type:** `string` + +A path to the HTML entry point. Can be relative to the root of the project. This file will be processed with [`transformIndexHtml`](https://vite.dev/guide/api-plugin#transformindexhtml) hook. + +## browser.api + +- **Type:** `number | { port?, strictPort?, host? }` +- **Default:** `63315` +- **CLI:** `--browser.api=63315`, `--browser.api.port=1234, --browser.api.host=example.com` + +Configure options for Vite server that serves code in the browser. Does not affect [`test.api`](#api) option. By default, Vitest assigns port `63315` to avoid conflicts with the development server, allowing you to run both in parallel. + +## browser.provider experimental {#browser-provider} + +- **Type:** `'webdriverio' | 'playwright' | 'preview' | string` +- **Default:** `'preview'` +- **CLI:** `--browser.provider=playwright` + +::: danger ADVANCED API +The provider API is highly experimental and can change between patches. If you just need to run tests in a browser, use the [`browser.instances`](#browser-instances) option instead. +::: + +Path to a provider that will be used when running browser tests. Vitest provides three providers which are `preview` (default), `webdriverio` and `playwright`. Custom providers should be exported using `default` export and have this shape: + +```ts +export interface BrowserProvider { + name: string + supportsParallelism: boolean + getSupportedBrowsers: () => readonly string[] + beforeCommand?: (command: string, args: unknown[]) => Awaitable + afterCommand?: (command: string, args: unknown[]) => Awaitable + getCommandsContext: (sessionId: string) => Record + openPage: (sessionId: string, url: string, beforeNavigate?: () => Promise) => Promise + getCDPSession?: (sessionId: string) => Promise + close: () => Awaitable + initialize( + ctx: TestProject, + options: BrowserProviderInitializationOptions + ): Awaitable +} +``` + +## browser.providerOptions deprecated {#browser-provideroptions} + +- **Type:** `BrowserProviderOptions` + +::: danger +This API is deprecated an will be removed in Vitest 4. Please, use [`browser.instances`](#browser-instances) option instead. +::: + +Options that will be passed down to provider when calling `provider.initialize`. + +```ts +import { defineConfig } from 'vitest/config' + +export default defineConfig({ + test: { + browser: { + providerOptions: { + launch: { + devtools: true, + }, + }, + }, + }, +}) +``` + +::: tip +To have a better type safety when using built-in providers, you should reference one of these types (for provider that you are using) in your [config file](/config/): + +```ts +/// +/// +``` +::: + +## browser.ui + +- **Type:** `boolean` +- **Default:** `!isCI` +- **CLI:** `--browser.ui=false` + +Should Vitest UI be injected into the page. By default, injects UI iframe during development. + +## browser.viewport + +- **Type:** `{ width, height }` +- **Default:** `414x896` + +Default iframe's viewport. + +## browser.locators + +Options for built-in [browser locators](/guide/browser/locators). + +### browser.locators.testIdAttribute + +- **Type:** `string` +- **Default:** `data-testid` + +Attribute used to find elements with `getByTestId` locator. + +## browser.screenshotDirectory + +- **Type:** `string` +- **Default:** `__snapshots__` in the test file directory + +Path to the screenshots directory relative to the `root`. + +## browser.screenshotFailures + +- **Type:** `boolean` +- **Default:** `!browser.ui` + +Should Vitest take screenshots if the test fails. + +## browser.orchestratorScripts + +- **Type:** `BrowserScript[]` +- **Default:** `[]` + +Custom scripts that should be injected into the orchestrator HTML before test iframes are initiated. This HTML document only sets up iframes and doesn't actually import your code. + +The script `src` and `content` will be processed by Vite plugins. Script should be provided in the following shape: + +```ts +export interface BrowserScript { + /** + * If "content" is provided and type is "module", this will be its identifier. + * + * If you are using TypeScript, you can add `.ts` extension here for example. + * @default `injected-${index}.js` + */ + id?: string + /** + * JavaScript content to be injected. This string is processed by Vite plugins if type is "module". + * + * You can use `id` to give Vite a hint about the file extension. + */ + content?: string + /** + * Path to the script. This value is resolved by Vite so it can be a node module or a file path. + */ + src?: string + /** + * If the script should be loaded asynchronously. + */ + async?: boolean + /** + * Script type. + * @default 'module' + */ + type?: string +} +``` + +## browser.testerScripts + +- **Type:** `BrowserScript[]` +- **Default:** `[]` + +::: danger +This API is deprecated an will be removed in Vitest 4. Please, use [`browser.testerHtmlPath`](#browser-testerHtmlPath) field instead. +::: + +Custom scripts that should be injected into the tester HTML before the tests environment is initiated. This is useful to inject polyfills required for Vitest browser implementation. It is recommended to use [`setupFiles`](#setupfiles) in almost all cases instead of this. + +The script `src` and `content` will be processed by Vite plugins. + +## browser.commands + +- **Type:** `Record` +- **Default:** `{ readFile, writeFile, ... }` + +Custom [commands](/guide/browser/commands) that can be imported during browser tests from `@vitest/browser/commands`. + +## browser.connectTimeout + +- **Type:** `number` +- **Default:** `60_000` + +The timeout in milliseconds. If connection to the browser takes longer, the test suite will fail. + +::: info +This is the time it should take for the browser to establish the WebSocket connection with the Vitest server. In normal circumstances, this timeout should never be reached. +::: diff --git a/docs/guide/browser/index.md b/docs/guide/browser/index.md index 4712eac69ec8..a9fa766d8e5e 100644 --- a/docs/guide/browser/index.md +++ b/docs/guide/browser/index.md @@ -95,7 +95,7 @@ bun add -D vitest @vitest/browser webdriverio ## Configuration -To activate browser mode in your Vitest configuration, you can use the `--browser` flag or set the `browser.enabled` field to `true` in your Vitest configuration file. Here is an example configuration using the browser field: +To activate browser mode in your Vitest configuration, you can use the `--browser=name` flag or set the `browser.enabled` field to `true` in your Vitest configuration file. Here is an example configuration using the browser field: ```ts [vitest.config.ts] import { defineConfig } from 'vitest/config' @@ -104,7 +104,10 @@ export default defineConfig({ browser: { provider: 'playwright', // or 'webdriverio' enabled: true, - name: 'chromium', // browser name is required + // at least one instance is required + instances: [ + { browser: 'chromium' }, + ], }, } }) @@ -129,7 +132,9 @@ export default defineConfig({ browser: { enabled: true, provider: 'playwright', - name: 'chromium', + instances: [ + { browser: 'chromium' }, + ], } } }) @@ -144,7 +149,9 @@ export default defineConfig({ browser: { enabled: true, provider: 'playwright', - name: 'chromium', + instances: [ + { browser: 'chromium' }, + ], } } }) @@ -159,7 +166,9 @@ export default defineConfig({ browser: { enabled: true, provider: 'playwright', - name: 'chromium', + instances: [ + { browser: 'chromium' }, + ], } } }) @@ -174,7 +183,9 @@ export default defineConfig({ browser: { enabled: true, provider: 'playwright', - name: 'chromium', + instances: [ + { browser: 'chromium' }, + ], } } }) @@ -189,7 +200,9 @@ export default defineConfig({ browser: { enabled: true, provider: 'playwright', - name: 'chromium', + instances: [ + { browser: 'chromium' }, + ], } } }) @@ -227,60 +240,15 @@ export default defineWorkspace([ name: 'browser', browser: { enabled: true, - name: 'chrome', + instances: [ + { browser: 'chromium' }, + ], }, }, }, ]) ``` -### Provider Configuration - -:::tabs key:provider -== Playwright -You can configure how Vitest [launches the browser](https://playwright.dev/docs/api/class-browsertype#browser-type-launch) and creates the [page context](https://playwright.dev/docs/api/class-browsercontext) via [`providerOptions`](/config/#browser-provideroptions) field: - -```ts [vitest.config.ts] -export default defineConfig({ - test: { - browser: { - providerOptions: { - launch: { - devtools: true, - }, - context: { - geolocation: { - latitude: 45, - longitude: -30, - }, - reducedMotion: 'reduce', - }, - }, - }, - }, -}) -``` -== WebdriverIO -You can configure what [options](https://webdriver.io/docs/configuration#webdriverio) Vitest should use when starting a browser via [`providerOptions`](/config/#browser-provideroptions) field: - -```ts -export default defineConfig({ - test: { - browser: { - browser: 'chrome', - providerOptions: { - region: 'eu', - capabilities: { - browserVersion: '27.0', - platformName: 'Windows 10', - }, - }, - }, - }, -}) -``` -::: - ## Browser Option Types The browser option in Vitest depends on the provider. Vitest will fail, if you pass `--browser` and don't specify its name in the config file. Available options: @@ -361,7 +329,7 @@ npx vitest --browser=chrome Or you can provide browser options to CLI with dot notation: ```sh -npx vitest --browser.name=chrome --browser.headless +npx vitest --browser.headless ``` By default, Vitest will automatically open the browser UI for development. Your tests will run inside an iframe in the center. You can configure the viewport by selecting the preferred dimensions, calling `page.viewport` inside the test, or setting default values in [the config](/config/#browser-viewport). @@ -390,7 +358,7 @@ export default defineConfig({ You can also set headless mode using the `--browser.headless` flag in the CLI, like this: ```sh -npx vitest --browser.name=chrome --browser.headless +npx vitest --browser.headless ``` In this case, Vitest will run in headless mode using the Chrome browser. diff --git a/docs/guide/browser/locators.md b/docs/guide/browser/locators.md index be8ed5e5cf7e..f30b05ccdd80 100644 --- a/docs/guide/browser/locators.md +++ b/docs/guide/browser/locators.md @@ -364,7 +364,7 @@ page.getByTitle('Create') // ❌ function getByTestId(text: string | RegExp): Locator ``` -Creates a locator capable of finding an element that matches the specified test id attribute. You can configure the attribute name with [`browser.locators.testIdAttribute`](/config/#browser-locators-testidattribute). +Creates a locator capable of finding an element that matches the specified test id attribute. You can configure the attribute name with [`browser.locators.testIdAttribute`](/guide/browser/config#browser-locators-testidattribute). ```tsx
@@ -569,7 +569,7 @@ function screenshot(options?: LocatorScreenshotOptions & { base64?: false }): Pr Creates a screenshot of the element matching the locator's selector. -You can specify the save location for the screenshot using the `path` option, which is relative to the current test file. If the `path` option is not set, Vitest will default to using [`browser.screenshotDirectory`](/config/#browser-screenshotdirectory) (`__screenshot__` by default), along with the names of the file and the test to determine the screenshot's filepath. +You can specify the save location for the screenshot using the `path` option, which is relative to the current test file. If the `path` option is not set, Vitest will default to using [`browser.screenshotDirectory`](/guide/browser/config#browser-screenshotdirectory) (`__screenshot__` by default), along with the names of the file and the test to determine the screenshot's filepath. If you also need the content of the screenshot, you can specify `base64: true` to return it alongside the filepath where the screenshot is saved. diff --git a/docs/guide/browser/multiple-setups.md b/docs/guide/browser/multiple-setups.md new file mode 100644 index 000000000000..78bb6259c3e3 --- /dev/null +++ b/docs/guide/browser/multiple-setups.md @@ -0,0 +1,134 @@ +# Multiple Setups + +Since Vitest 3, you can specify several different browser setups using the new [`browser.instances`](/guide/browser/config#browser-instances) option. + +The main advatage of using the `browser.instances` over the [workspace](/guide/workspace) is improved caching. Every project will use the same Vite server meaning the file transform and [dependency pre-bundling](https://vite.dev/guide/dep-pre-bundling.html) has to happen only once. + +## Several Browsers + +You can use the `browser.instances` field to specify options for different browsers. For example, if you want to run the same tests in different browsers, the minimal configuration will look like this: + +```ts [vitest.config.ts] +import { defineConfig } from 'vitest/config' +export default defineConfig({ + test: { + browser: { + enabled: true, + provider: 'playwright', + headless: true, + instances: [ + { browser: 'chromium' }, + { browser: 'firefox' }, + { browser: 'webkit' }, + ], + }, + }, +}) +``` + +## Different Setups + +You can also specify different config options independently from the browser (although, the instances _can_ also have `browser` fields): + +::: code-group +```ts [vitest.config.ts] +import { defineConfig } from 'vitest/config' +export default defineConfig({ + test: { + browser: { + enabled: true, + provider: 'playwright', + headless: true, + instances: [ + { + browser: 'chromium', + name: 'chromium-1', + setupFiles: ['./ratio-setup.ts'], + provide: { + ratio: 1, + } + }, + { + browser: 'chromium', + name: 'chromium-2', + provide: { + ratio: 2, + } + }, + ], + }, + }, +}) +``` +```ts [example.test.ts] +import { expect, inject, test } from 'vitest' +import { globalSetupModifier } from './example.js' + +test('ratio works', () => { + expect(inject('ratio') * globalSetupModifier).toBe(14) +}) +``` +::: + +In this example Vitest will run all tests in `chromium` browser, but execute a `'./ratio-setup.ts'` file only in the first configuration and inject a different `ratio` value depending on the [`provide` field](/config/#provide). + +::: warning +Note that you need to define the custom `name` value if you are using the same browser name because Vitest will assign the `browser` as the project name otherwise. +::: + +## Filtering + +You can filter what projects to run with the [`--project` flag](/guide/cli#project). Vitest will automatically assign the browser name as a project name if it is not assigned manually. If the root config already has a name, Vitest will merge them: `custom` -> `custom (browser)`. + +```shell +$ vitest --project=chromium +``` + +::: code-group +```ts{6,8} [default] +export default defineConfig({ + test: { + browser: { + instances: [ + // name: chromium + { browser: 'chromium' }, + // name: custom + { browser: 'firefox', name: 'custom' }, + ] + } + } +}) +``` +```ts{3,7,9} [custom] +export default defineConfig({ + test: { + name: 'custom', + browser: { + instances: [ + // name: custom (chromium) + { browser: 'chromium' }, + // name: manual + { browser: 'firefox', name: 'manual' }, + ] + } + } +}) +``` +::: + +::: warning +Vitest cannot run multiple instances that have `headless` mode set to `false` (the default behaviour). During development, you can select what project to run in your terminal: + +```shell +? Found multiple projects that run browser tests in headed mode: "chromium", "firefox". +Vitest cannot run multiple headed browsers at the same time. Select a single project +to run or cancel and run tests with "headless: true" option. Note that you can also +start tests with --browser=name or --project=name flag. › - Use arrow-keys. Return to submit. +❯ chromium + firefox +``` + +If you have several non-headless projects in CI (i.e. the `headless: false` is set manually in the config and not overriden in CI env), Vitest will fail the run and won't start any tests. + +The ability to run tests in headless mode is not affected by this. You can still run all instances in parallel as long as they don't have `headless: false`. +::: diff --git a/docs/guide/browser/playwright.md b/docs/guide/browser/playwright.md new file mode 100644 index 000000000000..d143a2b4c759 --- /dev/null +++ b/docs/guide/browser/playwright.md @@ -0,0 +1,96 @@ +# Configuring Playwright + +By default, TypeScript doesn't recognize providers options and extra `expect` properties. Make sure to reference `@vitest/browser/providers/playwright` so TypeScript can pick up definitions for custom options: + +```ts [vitest.shims.d.ts] +/// +``` + +Alternatively, you can also add it to `compilerOptions.types` field in your `tsconfig.json` file. Note that specifying anything in this field will disable [auto loading](https://www.typescriptlang.org/tsconfig/#types) of `@types/*` packages. + +```json [tsconfig.json] +{ + "compilerOptions": { + "types": ["@vitest/browser/providers/playwright"] + } +} +``` + +Vitest opens a single page to run all tests in the same file. You can configure the `launch` and `context` properties in `instances`: + +```ts{9-10} [vitest.config.ts] +import { defineConfig } from 'vitest/config' + +export default defineConfig({ + test: { + browser: { + instances: [ + { + browser: 'firefox', + launch: {}, + context: {}, + }, + ], + }, + }, +}) +``` + +::: warning +Before Vitest 3, these options were located on `test.browser.providerOptions` property: + +```ts [vitest.config.ts] +export default defineConfig({ + test: { + browser: { + providerOptions: { + launch: {}, + context: {}, + }, + }, + }, +}) +``` + +`providerOptions` is deprecated in favour of `instances`. +::: + +## launch + +These options are directly passed down to `playwright[browser].launch` command. You can read more about the command and available arguments in the [Playwright documentation](https://playwright.dev/docs/api/class-browsertype#browser-type-launch). + +::: warning +Vitest will ignore `launch.headless` option. Instead, use [`test.browser.headless`](/guide/browser/config#browser-headless). + +Note that Vitest will push debugging flags to `launch.args` if [`--inspect`](/guide/cli#inspect) is enabled. +::: + +## context + +Vitest creates a new context for every test file by calling [`browser.newContext()`](https://playwright.dev/docs/api/class-browsercontext). You can configure this behaviour by specifying [custom arguments](https://playwright.dev/docs/api/class-apirequest#api-request-new-context). + +::: tip +Note that the context is created for every _test file_, not every _test_ like in playwright test runner. +::: + +::: warning +Vitest awlays sets `ignoreHTTPSErrors` to `true` in case your server is served via HTTPS and `serviceWorkers` to `'allow'` to support module mocking via [MSW](https://mswjs.io). + +It is also recommended to use [`test.browser.viewport`](/guide/browser/config#browser-headless) instead of specifying it here as it will be lost when tests are running in headless mode. +::: + +## `actionTimeout` 3.0.0 + +- **Default:** no timeout, 1 second before 3.0.0 + +This value configures the default timeout it takes for Playwright to wait until all accessibility checks pass and [the action](/guide/browser/interactivity-api) is actually done. + +You can also configure the action timeout per-action: + +```ts +import { page, userEvent } from '@vitest/browser/context' + +await userEvent.click(page.getByRole('button'), { + timeout: 1_000, +}) +``` diff --git a/docs/guide/browser/webdriverio.md b/docs/guide/browser/webdriverio.md new file mode 100644 index 000000000000..b0afdf789713 --- /dev/null +++ b/docs/guide/browser/webdriverio.md @@ -0,0 +1,69 @@ +# Configuring WebdriverIO + +::: info Playwright vs WebdriverIO +If you do not already use WebdriverIO in your project, we recommend starting with [Playwright](/guide/browser/playwright) as it is easier to configure and has more flexible API. +::: + +By default, TypeScript doesn't recognize providers options and extra `expect` properties. Make sure to reference `@vitest/browser/providers/webdriverio` so TypeScript can pick up definitions for custom options: + +```ts [vitest.shims.d.ts] +/// +``` + +Alternatively, you can also add it to `compilerOptions.types` field in your `tsconfig.json` file. Note that specifying anything in this field will disable [auto loading](https://www.typescriptlang.org/tsconfig/#types) of `@types/*` packages. + +```json [tsconfig.json] +{ + "compilerOptions": { + "types": ["@vitest/browser/providers/webdriverio"] + } +} +``` + +Vitest opens a single page to run all tests in the same file. You can configure any property specified in `RemoteOptions` in `instances`: + +```ts{9-12} [vitest.config.ts] +import { defineConfig } from 'vitest/config' + +export default defineConfig({ + test: { + browser: { + instances: [ + { + browser: 'chrome', + capabilities: { + browserVersion: 86, + platformName: 'Windows 10', + }, + }, + ], + }, + }, +}) +``` + +::: warning +Before Vitest 3, these options were located on `test.browser.providerOptions` property: + +```ts [vitest.config.ts] +export default defineConfig({ + test: { + browser: { + providerOptions: { + capabilities: {}, + }, + }, + }, +}) +``` + +`providerOptions` is deprecated in favour of `instances`. +::: + +You can find most available options in the [WebdriverIO documentation](https://webdriver.io/docs/configuration/). Note that Vitest will ignore all test runner options because we only use `webdriverio`'s browser capabilities. + +::: tip +Most useful options are located on `capabilities` object. WebdriverIO allows nested capabilities, but Vitest will ignore those options because we rely on a different mechanism to spawn several browsers. + +Note that Vitest will ignore `capabilities.browserName`. Use [`test.browser.instances.name`](/guide/browser/config#browser-capabilities-name) instead. +::: diff --git a/docs/guide/cli-generated.md b/docs/guide/cli-generated.md index 9acbdfb0421d..3a0dc829b266 100644 --- a/docs/guide/cli-generated.md +++ b/docs/guide/cli-generated.md @@ -316,77 +316,77 @@ Mock browser API with happy-dom ### browser.enabled - **CLI:** `--browser.enabled` -- **Config:** [browser.enabled](/config/#browser-enabled) +- **Config:** [browser.enabled](/guide/browser/config#browser-enabled) Run tests in the browser. Equivalent to `--browser.enabled` (default: `false`) ### browser.name - **CLI:** `--browser.name ` -- **Config:** [browser.name](/config/#browser-name) +- **Config:** [browser.name](/guide/browser/config#browser-name) -Run all tests in a specific browser. Some browsers are only available for specific providers (see `--browser.provider`). Visit [`browser.name`](https://vitest.dev/config/#browser-name) for more information +Run all tests in a specific browser. Some browsers are only available for specific providers (see `--browser.provider`). Visit [`browser.name`](https://vitest.dev/guide/browser/config/#browser-name) for more information ### browser.headless - **CLI:** `--browser.headless` -- **Config:** [browser.headless](/config/#browser-headless) +- **Config:** [browser.headless](/guide/browser/config#browser-headless) Run the browser in headless mode (i.e. without opening the GUI (Graphical User Interface)). If you are running Vitest in CI, it will be enabled by default (default: `process.env.CI`) ### browser.api.port - **CLI:** `--browser.api.port [port]` -- **Config:** [browser.api.port](/config/#browser-api-port) +- **Config:** [browser.api.port](/guide/browser/config#browser-api-port) Specify server port. Note if the port is already being used, Vite will automatically try the next available port so this may not be the actual port the server ends up listening on. If true will be set to `63315` ### browser.api.host - **CLI:** `--browser.api.host [host]` -- **Config:** [browser.api.host](/config/#browser-api-host) +- **Config:** [browser.api.host](/guide/browser/config#browser-api-host) Specify which IP addresses the server should listen on. Set this to `0.0.0.0` or `true` to listen on all addresses, including LAN and public addresses ### browser.api.strictPort - **CLI:** `--browser.api.strictPort` -- **Config:** [browser.api.strictPort](/config/#browser-api-strictport) +- **Config:** [browser.api.strictPort](/guide/browser/config#browser-api-strictport) Set to true to exit if port is already in use, instead of automatically trying the next available port ### browser.provider - **CLI:** `--browser.provider ` -- **Config:** [browser.provider](/config/#browser-provider) +- **Config:** [browser.provider](/guide/browser/config#browser-provider) Provider used to run browser tests. Some browsers are only available for specific providers. Can be "webdriverio", "playwright", "preview", or the path to a custom provider. Visit [`browser.provider`](https://vitest.dev/config/#browser-provider) for more information (default: `"preview"`) ### browser.providerOptions - **CLI:** `--browser.providerOptions ` -- **Config:** [browser.providerOptions](/config/#browser-provideroptions) +- **Config:** [browser.providerOptions](/guide/browser/config#browser-provideroptions) Options that are passed down to a browser provider. Visit [`browser.providerOptions`](https://vitest.dev/config/#browser-provideroptions) for more information ### browser.isolate - **CLI:** `--browser.isolate` -- **Config:** [browser.isolate](/config/#browser-isolate) +- **Config:** [browser.isolate](/guide/browser/config#browser-isolate) Run every browser test file in isolation. To disable isolation, use `--browser.isolate=false` (default: `true`) ### browser.ui - **CLI:** `--browser.ui` -- **Config:** [browser.ui](/config/#browser-ui) +- **Config:** [browser.ui](/guide/browser/config#browser-ui) Show Vitest UI when running tests (default: `!process.env.CI`) ### browser.fileParallelism - **CLI:** `--browser.fileParallelism` -- **Config:** [browser.fileParallelism](/config/#browser-fileparallelism) +- **Config:** [browser.fileParallelism](/guide/browser/config#browser-fileparallelism) Should browser test files run in parallel. Use `--browser.fileParallelism=false` to disable (default: `true`) diff --git a/docs/guide/migration.md b/docs/guide/migration.md index cfed94061c6e..d57569fd7c98 100644 --- a/docs/guide/migration.md +++ b/docs/guide/migration.md @@ -29,6 +29,31 @@ test('validation works', () => { }, 1000) // Ok ✅ ``` +### `browser.name` and `browser.providerOptions` are Deprecated + +Both [`browser.name`](/guide/browser/config#browser-name) and [`browser.providerOptions`](/guide/browser/config#browser-provideroptions) will be removed in Vitest 4. Instead of them, use the new [`browser.instances`](/guide/browser/config#browser-instances) option: + +```ts +export default defineConfig({ + test: { + browser: { + name: 'chromium', // [!code --] + providerOptions: { // [!code --] + launch: { devtools: true }, // [!code --] + }, // [!code --] + instances: [ // [!code ++] + { // [!code ++] + browser: 'chromium', // [!code ++] + launch: { devtools: true }, // [!code ++] + }, // [!code ++] + ], // [!code ++] + }, + }, +}) +``` + +With the new `browser.instances` field you can also specify multiple browser configurations. + ### `Custom` Type is Deprecated API {#custom-type-is-deprecated} The `Custom` type is now an alias for the `Test` type. Note that Vitest updated the public types in 2.1 and changed exported names to `RunnerCustomCase` and `RunnerTestCase`: diff --git a/eslint.config.js b/eslint.config.js index 2b17354694d6..1bccb592d1ec 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -79,7 +79,7 @@ export default antfu( 'no-restricted-imports': [ 'error', { - paths: ['vitest', 'path'], + paths: ['vitest', 'path', 'vitest/node'], }, ], }, diff --git a/packages/browser/providers/webdriverio.d.ts b/packages/browser/providers/webdriverio.d.ts index 3675cd995b47..1c4a2b75bfdb 100644 --- a/packages/browser/providers/webdriverio.d.ts +++ b/packages/browser/providers/webdriverio.d.ts @@ -2,7 +2,7 @@ import type { RemoteOptions, ClickOptions, DragAndDropOptions } from 'webdriveri import '../matchers.js' declare module 'vitest/node' { - interface BrowserProviderOptions extends RemoteOptions {} + interface BrowserProviderOptions extends Partial {} export interface UserEventClickOptions extends ClickOptions {} diff --git a/packages/browser/src/client/channel.ts b/packages/browser/src/client/channel.ts index b1aaa1964475..4a69579e2852 100644 --- a/packages/browser/src/client/channel.ts +++ b/packages/browser/src/client/channel.ts @@ -41,7 +41,7 @@ export type IframeChannelEvent = | IframeChannelOutgoingEvent export const channel = new BroadcastChannel( - `vitest:${getBrowserState().contextId}`, + `vitest:${getBrowserState().sessionId}`, ) export const globalChannel = new BroadcastChannel('vitest:global') diff --git a/packages/browser/src/client/client.ts b/packages/browser/src/client/client.ts index fd4e8c48a50b..b6fa768adcb1 100644 --- a/packages/browser/src/client/client.ts +++ b/packages/browser/src/client/client.ts @@ -8,13 +8,13 @@ const PAGE_TYPE = getBrowserState().type export const PORT = location.port export const HOST = [location.hostname, PORT].filter(Boolean).join(':') -export const SESSION_ID +export const RPC_ID = PAGE_TYPE === 'orchestrator' - ? getBrowserState().contextId + ? getBrowserState().sessionId : getBrowserState().testerId export const ENTRY_URL = `${ location.protocol === 'https:' ? 'wss:' : 'ws:' -}//${HOST}/__vitest_browser_api__?type=${PAGE_TYPE}&sessionId=${SESSION_ID}` +}//${HOST}/__vitest_browser_api__?type=${PAGE_TYPE}&rpcId=${RPC_ID}&sessionId=${getBrowserState().sessionId}&projectName=${getBrowserState().config.name || ''}` let setCancel = (_: CancelReason) => {} export const onCancel = new Promise((resolve) => { diff --git a/packages/browser/src/client/orchestrator.ts b/packages/browser/src/client/orchestrator.ts index fc576692bd3c..4450ed22213c 100644 --- a/packages/browser/src/client/orchestrator.ts +++ b/packages/browser/src/client/orchestrator.ts @@ -14,9 +14,7 @@ class IframeOrchestrator { private runningFiles = new Set() private iframes = new Map() - public async init() { - const testFiles = getBrowserState().files - + public async init(testFiles: string[]) { debug('test files', testFiles.join(', ')) this.runningFiles.clear() @@ -38,6 +36,7 @@ class IframeOrchestrator { testFiles.forEach(file => this.runningFiles.add(file)) const config = getConfig() + debug('create testers', testFiles.join(', ')) const container = await getContainer(config) if (config.browser.ui) { @@ -51,6 +50,7 @@ class IframeOrchestrator { this.iframes.clear() if (config.isolate === false) { + debug('create iframe', ID_ALL) const iframe = this.createIframe(container, ID_ALL) await setIframeViewport(iframe, width, height) @@ -63,6 +63,7 @@ class IframeOrchestrator { return } + debug('create iframe', file) const iframe = this.createIframe(container, file) await setIframeViewport(iframe, width, height) @@ -93,7 +94,7 @@ class IframeOrchestrator { iframe.setAttribute( 'src', `${url.pathname}__vitest_test__/__test__/${ - getBrowserState().contextId + getBrowserState().sessionId }/${encodeURIComponent(file)}`, ) iframe.setAttribute('data-vitest', 'true') @@ -212,7 +213,7 @@ getBrowserState().createTesters = async (files) => { } async function done() { - await client.rpc.finishBrowserTests(getBrowserState().contextId) + await client.rpc.finishBrowserTests(getBrowserState().sessionId) } async function getContainer(config: SerializedConfig): Promise { @@ -233,7 +234,7 @@ async function getContainer(config: SerializedConfig): Promise { client.waitForConnection().then(async () => { const testFiles = getBrowserState().files - await orchestrator.init() + await orchestrator.init(testFiles) // if page was refreshed, there will be no test files // createTesters will be called again when tests are running in the UI diff --git a/packages/browser/src/client/public/esm-client-injector.js b/packages/browser/src/client/public/esm-client-injector.js index fe4fef717e92..a3eae26951bb 100644 --- a/packages/browser/src/client/public/esm-client-injector.js +++ b/packages/browser/src/client/public/esm-client-injector.js @@ -24,7 +24,7 @@ viteConfig: { __VITEST_VITE_CONFIG__ }, files: { __VITEST_FILES__ }, type: { __VITEST_TYPE__ }, - contextId: { __VITEST_CONTEXT_ID__ }, + sessionId: { __VITEST_SESSION_ID__ }, testerId: { __VITEST_TESTER_ID__ }, provider: { __VITEST_PROVIDER__ }, providedContext: { __VITEST_PROVIDED_CONTEXT__ }, diff --git a/packages/browser/src/client/tester/context.ts b/packages/browser/src/client/tester/context.ts index 433b5b1220c1..ee408be8c0d1 100644 --- a/packages/browser/src/client/tester/context.ts +++ b/packages/browser/src/client/tester/context.ts @@ -22,11 +22,11 @@ function filepath() { return getWorkerState().filepath || getWorkerState().current?.file?.filepath || undefined } const rpc = () => getWorkerState().rpc as any as BrowserRPC -const contextId = getBrowserState().contextId -const channel = new BroadcastChannel(`vitest:${contextId}`) +const sessionId = getBrowserState().sessionId +const channel = new BroadcastChannel(`vitest:${sessionId}`) function triggerCommand(command: string, ...args: any[]) { - return rpc().triggerCommand(contextId, command, filepath(), args) + return rpc().triggerCommand(sessionId, command, filepath(), args) } export function createUserEvent(__tl_user_event_base__?: TestingLibraryUserEvent, options?: TestingLibraryOptions): UserEvent { diff --git a/packages/browser/src/client/tester/locators/index.ts b/packages/browser/src/client/tester/locators/index.ts index 665ff43d616b..0ae190d02e17 100644 --- a/packages/browser/src/client/tester/locators/index.ts +++ b/packages/browser/src/client/tester/locators/index.ts @@ -211,7 +211,7 @@ export abstract class Locator { || undefined return ensureAwaited(() => this.rpc.triggerCommand( - this.state.contextId, + this.state.sessionId, command, filepath, args, diff --git a/packages/browser/src/client/tester/runner.ts b/packages/browser/src/client/tester/runner.ts index 449e0ac6c360..df6ba60e3178 100644 --- a/packages/browser/src/client/tester/runner.ts +++ b/packages/browser/src/client/tester/runner.ts @@ -61,8 +61,13 @@ export function createBrowserRunner( } onTaskFinished = async (task: Task) => { - if (this.config.browser.screenshotFailures && task.result?.state === 'fail') { - task.meta.failScreenshotPath = await page.screenshot() + if (this.config.browser.screenshotFailures && document.body.clientHeight > 0 && task.result?.state === 'fail') { + const screenshot = await page.screenshot().catch((err) => { + console.error('[vitest] Failed to take a screenshot', err) + }) + if (screenshot) { + task.meta.failScreenshotPath = screenshot + } } } diff --git a/packages/browser/src/client/tester/state.ts b/packages/browser/src/client/tester/state.ts index 2c7591a75ac5..a1698446dbf8 100644 --- a/packages/browser/src/client/tester/state.ts +++ b/packages/browser/src/client/tester/state.ts @@ -4,7 +4,7 @@ import { parse } from 'flatted' import { getBrowserState } from '../utils' const config = getBrowserState().config -const contextId = getBrowserState().contextId +const sessionId = getBrowserState().sessionId const providedContext = parse(getBrowserState().providedContext) @@ -65,13 +65,13 @@ function createCdp() { const cdp = { send(method: string, params?: Record) { - return rpc().sendCdpEvent(contextId, method, params) + return rpc().sendCdpEvent(sessionId, method, params) }, on(event: string, listener: (payload: any) => void) { const listenerId = getId(listener) listeners[event] = listeners[event] || [] listeners[event].push(listener) - rpc().trackCdpEvent(contextId, 'on', event, listenerId).catch(error) + rpc().trackCdpEvent(sessionId, 'on', event, listenerId).catch(error) return cdp }, once(event: string, listener: (payload: any) => void) { @@ -82,7 +82,7 @@ function createCdp() { } listeners[event] = listeners[event] || [] listeners[event].push(handler) - rpc().trackCdpEvent(contextId, 'once', event, listenerId).catch(error) + rpc().trackCdpEvent(sessionId, 'once', event, listenerId).catch(error) return cdp }, off(event: string, listener: (payload: any) => void) { @@ -90,7 +90,7 @@ function createCdp() { if (listeners[event]) { listeners[event] = listeners[event].filter(l => l !== listener) } - rpc().trackCdpEvent(contextId, 'off', event, listenerId).catch(error) + rpc().trackCdpEvent(sessionId, 'off', event, listenerId).catch(error) return cdp }, emit(event: string, payload: unknown) { diff --git a/packages/browser/src/client/utils.ts b/packages/browser/src/client/utils.ts index 147b7c3dc344..1eba8160656c 100644 --- a/packages/browser/src/client/utils.ts +++ b/packages/browser/src/client/utils.ts @@ -72,7 +72,7 @@ export interface BrowserRunnerState { type: 'tester' | 'orchestrator' wrapModule: (module: () => T) => T iframeId?: string - contextId: string + sessionId: string testerId: string runTests?: (tests: string[]) => Promise createTesters?: (files: string[]) => Promise diff --git a/packages/browser/src/node/commands/keyboard.ts b/packages/browser/src/node/commands/keyboard.ts index c0be014d0804..4413260f5636 100644 --- a/packages/browser/src/node/commands/keyboard.ts +++ b/packages/browser/src/node/commands/keyboard.ts @@ -27,7 +27,7 @@ export const keyboard: UserEventCommand<(text: string, state: KeyboardState) => await keyboardImplementation( pressed, context.provider, - context.contextId, + context.sessionId, text, async () => { if (context.provider instanceof PlaywrightBrowserProvider) { @@ -53,9 +53,9 @@ export const keyboardCleanup: UserEventCommand<(state: KeyboardState) => Promise context, state, ) => { - const { provider, contextId } = context + const { provider, sessionId } = context if (provider instanceof PlaywrightBrowserProvider) { - const page = provider.getPage(contextId) + const page = provider.getPage(sessionId) for (const key of state.unreleased) { await page.keyboard.up(key) } @@ -79,13 +79,13 @@ const VALID_KEYS = new Set(['Escape', 'F1', 'F2', 'F3', 'F4', 'F5', 'F6', 'F7', export async function keyboardImplementation( pressed: Set, provider: BrowserProvider, - contextId: string, + sessionId: string, text: string, selectAll: () => Promise, skipRelease: boolean, ) { if (provider instanceof PlaywrightBrowserProvider) { - const page = provider.getPage(contextId) + const page = provider.getPage(sessionId) const actions = parseKeyDef(defaultKeyMap, text) for (const { releasePrevious, releaseSelf, repeat, keyDef } of actions) { diff --git a/packages/browser/src/node/commands/type.ts b/packages/browser/src/node/commands/type.ts index a330c2bd313b..0e38734d5da7 100644 --- a/packages/browser/src/node/commands/type.ts +++ b/packages/browser/src/node/commands/type.ts @@ -24,7 +24,7 @@ export const type: UserEventCommand = async ( await keyboardImplementation( unreleased, context.provider, - context.contextId, + context.sessionId, text, () => element.selectText(), skipAutoClose, @@ -41,7 +41,7 @@ export const type: UserEventCommand = async ( await keyboardImplementation( unreleased, context.provider, - context.contextId, + context.sessionId, text, () => browser.execute(() => { const element = document.activeElement as HTMLInputElement diff --git a/packages/browser/src/node/index.ts b/packages/browser/src/node/index.ts index c7af212b3c98..fd1143f65254 100644 --- a/packages/browser/src/node/index.ts +++ b/packages/browser/src/node/index.ts @@ -4,13 +4,13 @@ import c from 'tinyrainbow' import { createViteLogger, createViteServer } from 'vitest/node' import { version } from '../../package.json' import BrowserPlugin from './plugin' +import { ParentBrowserProject } from './projectParent' import { setupBrowserRpc } from './rpc' -import { BrowserServer } from './server' export { distRoot } from './constants' export { createBrowserPool } from './pool' -export type { BrowserServer } from './server' +export type { ProjectBrowser } from './project' export async function createBrowserServer( project: TestProject, @@ -18,23 +18,23 @@ export async function createBrowserServer( prePlugins: Plugin[] = [], postPlugins: Plugin[] = [], ) { - if (project.ctx.version !== version) { - project.ctx.logger.warn( + if (project.vitest.version !== version) { + project.vitest.logger.warn( c.yellow( - `Loaded ${c.inverse(c.yellow(` vitest@${project.ctx.version} `))} and ${c.inverse(c.yellow(` @vitest/browser@${version} `))}.` + `Loaded ${c.inverse(c.yellow(` vitest@${project.vitest.version} `))} and ${c.inverse(c.yellow(` @vitest/browser@${version} `))}.` + '\nRunning mixed versions is not supported and may lead into bugs' + '\nUpdate your dependencies and make sure the versions match.', ), ) } - const server = new BrowserServer(project, '/') + const server = new ParentBrowserProject(project, '/') const configPath = typeof configFile === 'string' ? configFile : false const logLevel = (process.env.VITEST_BROWSER_DEBUG as 'info') ?? 'info' - const logger = createViteLogger(project.logger, logLevel, { + const logger = createViteLogger(project.vitest.logger, logLevel, { allowClearScreen: false, }) diff --git a/packages/browser/src/node/middlewares/orchestratorMiddleware.ts b/packages/browser/src/node/middlewares/orchestratorMiddleware.ts new file mode 100644 index 000000000000..c6d5d8848a8d --- /dev/null +++ b/packages/browser/src/node/middlewares/orchestratorMiddleware.ts @@ -0,0 +1,25 @@ +import type { Connect } from 'vite' +import type { ParentBrowserProject } from '../projectParent' +import { resolveOrchestrator } from '../serverOrchestrator' +import { allowIframes, disableCache } from './utils' + +export function createOrchestratorMiddleware(parentServer: ParentBrowserProject): Connect.NextHandleFunction { + return async function vitestOrchestratorMiddleware(req, res, next) { + if (!req.url) { + return next() + } + const url = new URL(req.url, 'http://localhost') + if (url.pathname !== parentServer.base) { + return next() + } + + const html = await resolveOrchestrator(parentServer, url, res) + if (html) { + disableCache(res) + allowIframes(res) + + res.write(html, 'utf-8') + res.end() + } + } +} diff --git a/packages/browser/src/node/middlewares/testerMiddleware.ts b/packages/browser/src/node/middlewares/testerMiddleware.ts new file mode 100644 index 000000000000..435ff7149ed1 --- /dev/null +++ b/packages/browser/src/node/middlewares/testerMiddleware.ts @@ -0,0 +1,24 @@ +import type { Connect } from 'vite' +import type { ParentBrowserProject } from '../projectParent' +import { resolveTester } from '../serverTester' +import { allowIframes, disableCache } from './utils' + +export function createTesterMiddleware(browserServer: ParentBrowserProject): Connect.NextHandleFunction { + return async function vitestTesterMiddleware(req, res, next) { + if (!req.url) { + return next() + } + const url = new URL(req.url, 'http://localhost') + if (!url.pathname.startsWith(browserServer.prefixTesterUrl)) { + return next() + } + + const html = await resolveTester(browserServer, url, res, next) + if (html) { + disableCache(res) + allowIframes(res) + res.write(html, 'utf-8') + res.end() + } + } +} diff --git a/packages/browser/src/node/middlewares/utils.ts b/packages/browser/src/node/middlewares/utils.ts new file mode 100644 index 000000000000..0d27a8e1fb64 --- /dev/null +++ b/packages/browser/src/node/middlewares/utils.ts @@ -0,0 +1,14 @@ +import type { ServerResponse } from 'node:http' + +export function disableCache(res: ServerResponse) { + res.setHeader( + 'Cache-Control', + 'no-cache, max-age=0, must-revalidate', + ) + res.setHeader('Content-Type', 'text/html; charset=utf-8') +} + +export function allowIframes(res: ServerResponse) { + // remove custom iframe related headers to allow the iframe to load + res.removeHeader('X-Frame-Options') +} diff --git a/packages/browser/src/node/plugin.ts b/packages/browser/src/node/plugin.ts index 2d6c85d9bb1f..73f43899d760 100644 --- a/packages/browser/src/node/plugin.ts +++ b/packages/browser/src/node/plugin.ts @@ -1,7 +1,7 @@ import type { Stats } from 'node:fs' import type { HtmlTagDescriptor } from 'vite' -import type { TestProject } from 'vitest/node' -import type { BrowserServer } from './server' +import type { Vitest } from 'vitest/node' +import type { ParentBrowserProject } from './projectParent' import { lstatSync, readFileSync } from 'node:fs' import { createRequire } from 'node:module' import { dynamicImportPlugin } from '@vitest/mocker/node' @@ -12,20 +12,18 @@ import sirv from 'sirv' import { coverageConfigDefaults, type Plugin } from 'vitest/config' import { getFilePoolName, resolveApiServerConfig, resolveFsAllow, distDir as vitestDist } from 'vitest/node' import { distRoot } from './constants' +import { createOrchestratorMiddleware } from './middlewares/orchestratorMiddleware' +import { createTesterMiddleware } from './middlewares/testerMiddleware' import BrowserContext from './plugins/pluginContext' -import { resolveOrchestrator } from './serverOrchestrator' -import { resolveTester } from './serverTester' export { defineBrowserCommand } from './commands/utils' export type { BrowserCommand } from 'vitest/node' const versionRegexp = /(?:\?|&)v=\w{8}/ -export default (browserServer: BrowserServer, base = '/'): Plugin[] => { - const project = browserServer.project - +export default (parentServer: ParentBrowserProject, base = '/'): Plugin[] => { function isPackageExists(pkg: string, root: string) { - return browserServer.project.ctx.packageInstaller.isPackageExists?.(pkg, { + return parentServer.vitest.packageInstaller.isPackageExists?.(pkg, { paths: [root], }) } @@ -35,7 +33,7 @@ export default (browserServer: BrowserServer, base = '/'): Plugin[] => { enforce: 'pre', name: 'vitest:browser', async configureServer(server) { - browserServer.setServer(server) + parentServer.setServer(server) // eslint-disable-next-line prefer-arrow-callback server.middlewares.use(function vitestHeaders(_req, res, next) { @@ -47,38 +45,8 @@ export default (browserServer: BrowserServer, base = '/'): Plugin[] => { } next() }) - // eslint-disable-next-line prefer-arrow-callback - server.middlewares.use(async function vitestBrowserMode(req, res, next) { - if (!req.url || !browserServer.provider) { - return next() - } - const url = new URL(req.url, 'http://localhost') - if (!url.pathname.startsWith(browserServer.prefixTesterUrl) && url.pathname !== base) { - return next() - } - - res.setHeader( - 'Cache-Control', - 'no-cache, max-age=0, must-revalidate', - ) - res.setHeader('Content-Type', 'text/html; charset=utf-8') - - // remove custom iframe related headers to allow the iframe to load - res.removeHeader('X-Frame-Options') - - if (url.pathname === base) { - const html = await resolveOrchestrator(browserServer, url, res) - res.write(html, 'utf-8') - res.end() - return - } - - const html = await resolveTester(browserServer, url, res, next) - if (html) { - res.write(html, 'utf-8') - res.end() - } - }) + server.middlewares.use(createOrchestratorMiddleware(parentServer)) + server.middlewares.use(createTesterMiddleware(parentServer)) server.middlewares.use( `${base}favicon.svg`, @@ -89,7 +57,7 @@ export default (browserServer: BrowserServer, base = '/'): Plugin[] => { }, ) - const coverageFolder = resolveCoverageFolder(project) + const coverageFolder = resolveCoverageFolder(parentServer.vitest) const coveragePath = coverageFolder ? coverageFolder[1] : undefined if (coveragePath && base === coveragePath) { throw new Error( @@ -113,12 +81,12 @@ export default (browserServer: BrowserServer, base = '/'): Plugin[] => { ) } - const screenshotFailures = project.config.browser.ui && project.config.browser.screenshotFailures + const uiEnabled = parentServer.config.browser.ui - if (screenshotFailures) { + if (uiEnabled) { // eslint-disable-next-line prefer-arrow-callback server.middlewares.use(`${base}__screenshot-error`, function vitestBrowserScreenshotError(req, res) { - if (!req.url || !browserServer.provider) { + if (!req.url) { res.statusCode = 404 res.end() return @@ -184,6 +152,7 @@ export default (browserServer: BrowserServer, base = '/'): Plugin[] => { name: 'vitest:browser:tests', enforce: 'pre', async config() { + const project = parentServer.vitest.getProjectByName(parentServer.config.name) const { testFiles: allTestFiles } = await project.globTestFiles() const browserTestFiles = allTestFiles.filter( file => getFilePoolName(project, file) === 'browser', @@ -234,18 +203,18 @@ export default (browserServer: BrowserServer, base = '/'): Plugin[] => { entries.push(project.config.diff) } - if (project.ctx.coverageProvider) { - const coverage = project.ctx.config.coverage + if (parentServer.vitest.coverageProvider) { + const coverage = parentServer.vitest.config.coverage const provider = coverage.provider if (provider === 'v8') { - const path = tryResolve('@vitest/coverage-v8', [project.config.root]) + const path = tryResolve('@vitest/coverage-v8', [parentServer.config.root]) if (path) { entries.push(path) exclude.push('@vitest/coverage-v8/browser') } } else if (provider === 'istanbul') { - const path = tryResolve('@vitest/coverage-istanbul', [project.config.root]) + const path = tryResolve('@vitest/coverage-istanbul', [parentServer.config.root]) if (path) { entries.push(path) exclude.push('@vitest/coverage-istanbul') @@ -333,14 +302,14 @@ export default (browserServer: BrowserServer, base = '/'): Plugin[] => { } }, transform(code, id) { - if (id.includes(browserServer.vite.config.cacheDir) && id.includes('loupe.js')) { + if (id.includes(parentServer.vite.config.cacheDir) && id.includes('loupe.js')) { // loupe bundle has a nastry require('util') call that leaves a warning in the console const utilRequire = 'nodeUtil = require_util();' return code.replace(utilRequire, ' '.repeat(utilRequire.length)) } }, }, - BrowserContext(browserServer), + BrowserContext(parentServer), dynamicImportPlugin({ globalThisAccessor: '"__vitest_browser_runner__"', filter(id) { @@ -360,7 +329,7 @@ export default (browserServer: BrowserServer, base = '/'): Plugin[] => { viteConfig.esbuild.legalComments = 'inline' } - const defaultPort = project.ctx._browserLastPort++ + const defaultPort = parentServer.vitest._browserLastPort++ const api = resolveApiServerConfig( viteConfig.test?.browser || {}, @@ -378,8 +347,8 @@ export default (browserServer: BrowserServer, base = '/'): Plugin[] => { viteConfig.server.fs.allow = viteConfig.server.fs.allow || [] viteConfig.server.fs.allow.push( ...resolveFsAllow( - project.ctx.config.root, - project.ctx.server.config.configFile, + parentServer.vitest.config.root, + parentServer.vitest.vite.config.configFile, ), distRoot, ) @@ -394,6 +363,7 @@ export default (browserServer: BrowserServer, base = '/'): Plugin[] => { { name: 'vitest:browser:in-source-tests', transform(code, id) { + const project = parentServer.vitest.getProjectByName(parentServer.config.name) if (!project.isCachedTestFile(id) || !code.includes('import.meta.vitest')) { return } @@ -425,26 +395,30 @@ export default (browserServer: BrowserServer, base = '/'): Plugin[] => { name: 'vitest:browser:transform-tester-html', enforce: 'pre', async transformIndexHtml(html, ctx) { - if (ctx.filename !== browserServer.testerFilepath) { + const projectBrowser = [...parentServer.children].find((server) => { + return ctx.filename === server.testerFilepath + }) + if (!projectBrowser) { return } - if (!browserServer.testerScripts) { - const testerScripts = await browserServer.formatScripts( - project.config.browser.testerScripts, + if (!parentServer.testerScripts) { + const testerScripts = await parentServer.formatScripts( + parentServer.config.browser.testerScripts, ) - browserServer.testerScripts = testerScripts + parentServer.testerScripts = testerScripts } - const stateJs = typeof browserServer.stateJs === 'string' - ? browserServer.stateJs - : await browserServer.stateJs + const stateJs = typeof parentServer.stateJs === 'string' + ? parentServer.stateJs + : await parentServer.stateJs const testerTags: HtmlTagDescriptor[] = [] - const isDefaultTemplate = resolve(distRoot, 'client/tester/tester.html') === browserServer.testerFilepath + + const isDefaultTemplate = resolve(distRoot, 'client/tester/tester.html') === projectBrowser.testerFilepath if (!isDefaultTemplate) { - const manifestContent = browserServer.manifest instanceof Promise - ? await browserServer.manifest - : browserServer.manifest + const manifestContent = parentServer.manifest instanceof Promise + ? await parentServer.manifest + : parentServer.manifest const testerEntry = manifestContent['tester/tester.html'] testerTags.push({ @@ -452,7 +426,7 @@ export default (browserServer: BrowserServer, base = '/'): Plugin[] => { attrs: { type: 'module', crossorigin: '', - src: `${browserServer.base}${testerEntry.file}`, + src: `${parentServer.base}${testerEntry.file}`, }, injectTo: 'head', }) @@ -464,7 +438,7 @@ export default (browserServer: BrowserServer, base = '/'): Plugin[] => { { tag: 'link', attrs: { - href: `${browserServer.base}${entryManifest.file}`, + href: `${parentServer.base}${entryManifest.file}`, rel: 'modulepreload', crossorigin: '', }, @@ -508,21 +482,21 @@ body { tag: 'script', attrs: { type: 'module', - src: browserServer.errorCatcherUrl, + src: parentServer.errorCatcherUrl, }, injectTo: 'head' as const, }, - browserServer.locatorsUrl + parentServer.locatorsUrl ? { tag: 'script', attrs: { type: 'module', - src: browserServer.locatorsUrl, + src: parentServer.locatorsUrl, }, injectTo: 'head', } as const : null, - ...browserServer.testerScripts, + ...parentServer.testerScripts, ...testerTags, { tag: 'script', @@ -583,8 +557,8 @@ function getRequire() { return _require } -function resolveCoverageFolder(project: TestProject) { - const options = project.ctx.config +function resolveCoverageFolder(vitest: Vitest) { + const options = vitest.config const htmlReporter = options.coverage?.enabled ? toArray(options.coverage.reporter).find((reporter) => { if (typeof reporter === 'string') { diff --git a/packages/browser/src/node/plugins/pluginContext.ts b/packages/browser/src/node/plugins/pluginContext.ts index 54c193d74b67..5929822ae557 100644 --- a/packages/browser/src/node/plugins/pluginContext.ts +++ b/packages/browser/src/node/plugins/pluginContext.ts @@ -1,33 +1,16 @@ import type { PluginContext } from 'rollup' import type { Plugin } from 'vitest/config' -import type { BrowserProvider } from 'vitest/node' -import type { BrowserServer } from '../server' +import type { ParentBrowserProject } from '../projectParent' import { fileURLToPath } from 'node:url' import { slash } from '@vitest/utils' import { dirname, resolve } from 'pathe' -import builtinCommands from '../commands/index' const VIRTUAL_ID_CONTEXT = '\0@vitest/browser/context' const ID_CONTEXT = '@vitest/browser/context' const __dirname = dirname(fileURLToPath(import.meta.url)) -export default function BrowserContext(server: BrowserServer): Plugin { - const project = server.project - project.config.browser.commands ??= {} - for (const [name, command] of Object.entries(builtinCommands)) { - project.config.browser.commands[name] ??= command - } - - // validate names because they can't be used as identifiers - for (const command in project.config.browser.commands) { - if (!/^[a-z_$][\w$]*$/i.test(command)) { - throw new Error( - `Invalid command name "${command}". Only alphanumeric characters, $ and _ are allowed.`, - ) - } - } - +export default function BrowserContext(globalServer: ParentBrowserProject): Plugin { return { name: 'vitest:browser:virtual-module:context', enforce: 'pre', @@ -38,7 +21,7 @@ export default function BrowserContext(server: BrowserServer): Plugin { }, load(id) { if (id === VIRTUAL_ID_CONTEXT) { - return generateContextFile.call(this, server) + return generateContextFile.call(this, globalServer) } }, } @@ -46,22 +29,23 @@ export default function BrowserContext(server: BrowserServer): Plugin { async function generateContextFile( this: PluginContext, - server: BrowserServer, + globalServer: ParentBrowserProject, ) { - const commands = Object.keys(server.project.config.browser.commands ?? {}) + const commands = Object.keys(globalServer.commands) const filepathCode = '__vitest_worker__.filepath || __vitest_worker__.current?.file?.filepath || undefined' - const provider = server.provider + const provider = [...globalServer.children][0].provider || { name: 'preview' } + const providerName = provider.name const commandsCode = commands .filter(command => !command.startsWith('__vitest')) .map((command) => { - return ` ["${command}"]: (...args) => rpc().triggerCommand(contextId, "${command}", filepath(), args),` + return ` ["${command}"]: (...args) => rpc().triggerCommand(sessionId, "${command}", filepath(), args),` }) .join('\n') const userEventNonProviderImport = await getUserEventImport( - provider, + providerName, this.resolve.bind(this), ) const distContextPath = slash(`/@fs/${resolve(__dirname, 'context.js')}`) @@ -71,13 +55,13 @@ import { page, createUserEvent, cdp } from '${distContextPath}' ${userEventNonProviderImport} const filepath = () => ${filepathCode} const rpc = () => __vitest_worker__.rpc -const contextId = __vitest_browser_runner__.contextId +const sessionId = __vitest_browser_runner__.sessionId export const server = { platform: ${JSON.stringify(process.platform)}, version: ${JSON.stringify(process.version)}, - provider: ${JSON.stringify(provider.name)}, - browser: ${JSON.stringify(server.project.config.browser.name)}, + provider: ${JSON.stringify(providerName)}, + browser: __vitest_browser_runner__.config.browser.name, commands: { ${commandsCode} }, @@ -89,8 +73,8 @@ export { page, cdp } ` } -async function getUserEventImport(provider: BrowserProvider, resolve: (id: string, importer: string) => Promise) { - if (provider.name !== 'preview') { +async function getUserEventImport(provider: string, resolve: (id: string, importer: string) => Promise) { + if (provider !== 'preview') { return 'const _userEventSetup = undefined' } const resolved = await resolve('@testing-library/user-event', __dirname) diff --git a/packages/browser/src/node/pool.ts b/packages/browser/src/node/pool.ts index aef0f4ef9d01..22cb41377a60 100644 --- a/packages/browser/src/node/pool.ts +++ b/packages/browser/src/node/pool.ts @@ -8,19 +8,19 @@ const debug = createDebugger('vitest:browser:pool') async function waitForTests( method: 'run' | 'collect', - contextId: string, + sessionId: string, project: TestProject, files: string[], ) { - const context = project.browser!.state.createAsyncContext(method, contextId, files) + const context = project.vitest._browserSessions.createAsyncSession(method, sessionId, files, project) return await context } -export function createBrowserPool(ctx: Vitest): ProcessPool { +export function createBrowserPool(vitest: Vitest): ProcessPool { const providers = new Set() const executeTests = async (method: 'run' | 'collect', project: TestProject, files: string[]) => { - ctx.state.clearFiles(project, files) + vitest.state.clearFiles(project, files) const browser = project.browser! const threadsCount = getThreadsCount(project) @@ -37,7 +37,7 @@ export function createBrowserPool(ctx: Vitest): ProcessPool { ) } - async function setBreakpoint(contextId: string, file: string) { + async function setBreakpoint(sessionId: string, file: string) { if (!project.config.inspector.waitForDebugger) { return } @@ -46,7 +46,7 @@ export function createBrowserPool(ctx: Vitest): ProcessPool { throw new Error('Unable to set breakpoint, CDP not supported') } - const session = await provider.getCDPSession(contextId) + const session = await provider.getCDPSession(sessionId) await session.send('Debugger.enable', {}) await session.send('Debugger.setBreakpointByUrl', { lineNumber: 0, @@ -79,13 +79,13 @@ export function createBrowserPool(ctx: Vitest): ProcessPool { chunks.forEach((files, index) => { if (orchestrators[index]) { - const [contextId, orchestrator] = orchestrators[index] + const [sessionId, orchestrator] = orchestrators[index] debug?.( - 'Reusing orchestrator (context %s) for files: %s', - contextId, + 'Reusing orchestrator (session %s) for files: %s', + sessionId, [...files.map(f => relative(project.config.root, f))].join(', '), ) - const promise = waitForTests(method, contextId, project, files) + const promise = waitForTests(method, sessionId, project, files) const tester = orchestrator.createTesters(files).catch((error) => { if (error instanceof Error && error.message.startsWith('[birpc] rpc is closed')) { return @@ -95,17 +95,17 @@ export function createBrowserPool(ctx: Vitest): ProcessPool { promises.push(promise, tester) } else { - const contextId = crypto.randomUUID() - const waitPromise = waitForTests(method, contextId, project, files) + const sessionId = crypto.randomUUID() + const waitPromise = waitForTests(method, sessionId, project, files) debug?.( - 'Opening a new context %s for files: %s', - contextId, + 'Opening a new session %s for files: %s', + sessionId, [...files.map(f => relative(project.config.root, f))].join(', '), ) const url = new URL('/', origin) - url.searchParams.set('contextId', contextId) + url.searchParams.set('sessionId', sessionId) const page = provider - .openPage(contextId, url.toString(), () => setBreakpoint(contextId, files[0])) + .openPage(sessionId, url.toString(), () => setBreakpoint(sessionId, files[0])) promises.push(page, waitPromise) } }) @@ -122,7 +122,7 @@ export function createBrowserPool(ctx: Vitest): ProcessPool { } let isCancelled = false - ctx.onCancel(() => { + vitest.onCancel(() => { isCancelled = true }) @@ -152,7 +152,7 @@ export function createBrowserPool(ctx: Vitest): ProcessPool { return 1 } - return ctx.config.watch + return vitest.config.watch ? Math.max(Math.floor(numCpus / 2), 1) : Math.max(numCpus - 1, 1) } @@ -162,7 +162,7 @@ export function createBrowserPool(ctx: Vitest): ProcessPool { async close() { await Promise.all([...providers].map(provider => provider.close())) providers.clear() - ctx.resolvedProjects.forEach((project) => { + vitest.resolvedProjects.forEach((project) => { project.browser?.state.orchestrators.forEach((orchestrator) => { orchestrator.$close() }) diff --git a/packages/browser/src/node/project.ts b/packages/browser/src/node/project.ts new file mode 100644 index 000000000000..f528c6ee25e9 --- /dev/null +++ b/packages/browser/src/node/project.ts @@ -0,0 +1,122 @@ +import type { StackTraceParserOptions } from '@vitest/utils/source-map' +import type { ErrorWithDiff, SerializedConfig } from 'vitest' +import type { + BrowserProvider, + ProjectBrowser as IProjectBrowser, + ResolvedConfig, + TestProject, + Vitest, +} from 'vitest/node' +import type { ParentBrowserProject } from './projectParent' +import { existsSync } from 'node:fs' +import { readFile } from 'node:fs/promises' +import { fileURLToPath } from 'node:url' +import { resolve } from 'pathe' +import { BrowserServerState } from './state' +import { getBrowserProvider } from './utils' + +export class ProjectBrowser implements IProjectBrowser { + public testerHtml: Promise | string + public testerFilepath: string + public locatorsUrl: string | undefined + + public provider!: BrowserProvider + public vitest: Vitest + public config: ResolvedConfig + public children = new Set() + + public parent!: ParentBrowserProject + + public state = new BrowserServerState() + + constructor( + public project: TestProject, + public base: string, + ) { + this.vitest = project.vitest + this.config = project.config + + const pkgRoot = resolve(fileURLToPath(import.meta.url), '../..') + const distRoot = resolve(pkgRoot, 'dist') + + const testerHtmlPath = project.config.browser.testerHtmlPath + ? resolve(project.config.root, project.config.browser.testerHtmlPath) + : resolve(distRoot, 'client/tester/tester.html') + if (!existsSync(testerHtmlPath)) { + throw new Error(`Tester HTML file "${testerHtmlPath}" doesn't exist.`) + } + this.testerFilepath = testerHtmlPath + + this.testerHtml = readFile( + testerHtmlPath, + 'utf8', + ).then(html => (this.testerHtml = html)) + } + + get vite() { + return this.parent.vite + } + + wrapSerializedConfig() { + const config = wrapConfig(this.project.serializedConfig) + config.env ??= {} + config.env.VITEST_BROWSER_DEBUG = process.env.VITEST_BROWSER_DEBUG || '' + return config + } + + async initBrowserProvider(project: TestProject) { + if (this.provider) { + return + } + const Provider = await getBrowserProvider(project.config.browser, project) + this.provider = new Provider() + const browser = project.config.browser.name + const name = project.name ? `[${project.name}] ` : '' + if (!browser) { + throw new Error( + `${name}Browser name is required. Please, set \`test.browser.instances[].browser\` option manually.`, + ) + } + const supportedBrowsers = this.provider.getSupportedBrowsers() + if (supportedBrowsers.length && !supportedBrowsers.includes(browser)) { + throw new Error( + `${name}Browser "${browser}" is not supported by the browser provider "${ + this.provider.name + }". Supported browsers: ${supportedBrowsers.join(', ')}.`, + ) + } + const providerOptions = project.config.browser.providerOptions + await this.provider.initialize(project, { + browser, + options: providerOptions, + }) + } + + public parseErrorStacktrace( + e: ErrorWithDiff, + options: StackTraceParserOptions = {}, + ) { + return this.parent.parseErrorStacktrace(e, options) + } + + public parseStacktrace( + trace: string, + options: StackTraceParserOptions = {}, + ) { + return this.parent.parseStacktrace(trace, options) + } + + async close() { + await this.parent.vite.close() + } +} + +function wrapConfig(config: SerializedConfig): SerializedConfig { + return { + ...config, + // workaround RegExp serialization + testNamePattern: config.testNamePattern + ? (config.testNamePattern.toString() as any as RegExp) + : undefined, + } +} diff --git a/packages/browser/src/node/server.ts b/packages/browser/src/node/projectParent.ts similarity index 56% rename from packages/browser/src/node/server.ts rename to packages/browser/src/node/projectParent.ts index 56dfbde9f7d6..9e90a954f78b 100644 --- a/packages/browser/src/node/server.ts +++ b/packages/browser/src/node/projectParent.ts @@ -1,50 +1,52 @@ import type { HtmlTagDescriptor } from 'vite' -import type { ErrorWithDiff, SerializedConfig } from 'vitest' +import type { ErrorWithDiff, ParsedStack } from 'vitest' import type { - BrowserProvider, + BrowserCommand, BrowserScript, CDPSession, - BrowserServer as IBrowserServer, + ResolvedConfig, TestProject, Vite, + Vitest, } from 'vitest/node' -import { existsSync } from 'node:fs' +import type { BrowserServerState } from './state' import { readFile } from 'node:fs/promises' -import { fileURLToPath } from 'node:url' -import { slash } from '@vitest/utils' import { parseErrorStacktrace, parseStacktrace, type StackTraceParserOptions } from '@vitest/utils/source-map' import { join, resolve } from 'pathe' import { BrowserServerCDPHandler } from './cdp' -import { BrowserServerState } from './state' -import { getBrowserProvider } from './utils' - -export class BrowserServer implements IBrowserServer { - public faviconUrl: string - public prefixTesterUrl: string +import builtinCommands from './commands/index' +import { distRoot } from './constants' +import { ProjectBrowser } from './project' +import { slash } from './utils' +export class ParentBrowserProject { public orchestratorScripts: string | undefined public testerScripts: HtmlTagDescriptor[] | undefined + public faviconUrl: string + public prefixTesterUrl: string public manifest: Promise | Vite.Manifest - public testerHtml: Promise | string - public testerFilepath: string + + public vite!: Vite.ViteDevServer + private stackTraceOptions: StackTraceParserOptions public orchestratorHtml: Promise | string public injectorJs: Promise | string public errorCatcherUrl: string public locatorsUrl: string | undefined public stateJs: Promise | string - public state: BrowserServerState - public provider!: BrowserProvider - - public vite!: Vite.ViteDevServer + public commands: Record> = {} + public children = new Set() + public vitest: Vitest - private stackTraceOptions: StackTraceParserOptions + public config: ResolvedConfig constructor( public project: TestProject, public base: string, ) { + this.vitest = project.vitest + this.config = project.config this.stackTraceOptions = { frameFilter: project.config.onStackTrace, getSourceMap: (id) => { @@ -64,10 +66,19 @@ export class BrowserServer implements IBrowserServer { }, } - this.state = new BrowserServerState() + for (const [name, command] of Object.entries(builtinCommands)) { + this.commands[name] ??= command + } - const pkgRoot = resolve(fileURLToPath(import.meta.url), '../..') - const distRoot = resolve(pkgRoot, 'dist') + // validate names because they can't be used as identifiers + for (const command in project.config.browser.commands) { + if (!/^[a-z_$][\w$]*$/i.test(command)) { + throw new Error( + `Invalid command name "${command}". Only alphanumeric characters, $ and _ are allowed.`, + ) + } + this.commands[command] = project.config.browser.commands[command] + } this.prefixTesterUrl = `${base}__vitest_test__/__test__/` this.faviconUrl = `${base}__vitest__/favicon.svg` @@ -78,18 +89,6 @@ export class BrowserServer implements IBrowserServer { ) })().then(manifest => (this.manifest = manifest)) - const testerHtmlPath = project.config.browser.testerHtmlPath - ? resolve(project.config.root, project.config.browser.testerHtmlPath) - : resolve(distRoot, 'client/tester/tester.html') - if (!existsSync(testerHtmlPath)) { - throw new Error(`Tester HTML file "${testerHtmlPath}" doesn't exist.`) - } - this.testerFilepath = testerHtmlPath - - this.testerHtml = readFile( - testerHtmlPath, - 'utf8', - ).then(html => (this.testerHtml = html)) this.orchestratorHtml = (project.config.browser.ui ? readFile(resolve(distRoot, 'client/__vitest__/index.html'), 'utf8') : readFile(resolve(distRoot, 'client/orchestrator.html'), 'utf8')) @@ -111,91 +110,27 @@ export class BrowserServer implements IBrowserServer { ).then(js => (this.stateJs = js)) } - setServer(server: Vite.ViteDevServer) { - this.vite = server - } - - getSerializableConfig() { - const config = wrapConfig(this.project.getSerializableConfig()) - config.env ??= {} - config.env.VITEST_BROWSER_DEBUG = process.env.VITEST_BROWSER_DEBUG || '' - return config - } - - resolveTesterUrl(pathname: string) { - const [contextId, testFile] = pathname - .slice(this.prefixTesterUrl.length) - .split('/') - const decodedTestFile = decodeURIComponent(testFile) - return { contextId, testFile: decodedTestFile } + public setServer(vite: Vite.ViteDevServer) { + this.vite = vite } - async formatScripts( - scripts: BrowserScript[] | undefined, - ) { - if (!scripts?.length) { - return [] + public spawn(project: TestProject): ProjectBrowser { + if (!this.vite) { + throw new Error(`Cannot spawn child server without a parent dev server.`) } - const server = this.vite - const promises = scripts.map( - async ({ content, src, async, id, type = 'module' }, index): Promise => { - const srcLink = (src ? (await server.pluginContainer.resolveId(src))?.id : undefined) || src - const transformId = srcLink || join(server.config.root, `virtual__${id || `injected-${index}.js`}`) - await server.moduleGraph.ensureEntryFromUrl(transformId) - const contentProcessed - = content && type === 'module' - ? (await server.pluginContainer.transform(content, transformId)).code - : content - return { - tag: 'script', - attrs: { - type, - ...(async ? { async: '' } : {}), - ...(srcLink - ? { - src: srcLink.startsWith('http') ? srcLink : slash(`/@fs/${srcLink}`), - } - : {}), - }, - injectTo: 'head', - children: contentProcessed || '', - } - }, + const clone = new ProjectBrowser( + project, + '/', ) - return (await Promise.all(promises)) - } - - async initBrowserProvider() { - if (this.provider) { - return - } - const Provider = await getBrowserProvider(this.project.config.browser, this.project) - this.provider = new Provider() - const browser = this.project.config.browser.name - if (!browser) { - throw new Error( - `[${this.project.name}] Browser name is required. Please, set \`test.browser.name\` option manually.`, - ) - } - const supportedBrowsers = this.provider.getSupportedBrowsers() - if (supportedBrowsers.length && !supportedBrowsers.includes(browser)) { - throw new Error( - `[${this.project.name}] Browser "${browser}" is not supported by the browser provider "${ - this.provider.name - }". Supported browsers: ${supportedBrowsers.join(', ')}.`, - ) - } - const providerOptions = this.project.config.browser.providerOptions - await this.provider.initialize(this.project, { - browser, - options: providerOptions, - }) + clone.parent = this + this.children.add(clone) + return clone } public parseErrorStacktrace( e: ErrorWithDiff, options: StackTraceParserOptions = {}, - ) { + ): ParsedStack[] { return parseErrorStacktrace(e, { ...this.stackTraceOptions, ...options, @@ -205,59 +140,99 @@ export class BrowserServer implements IBrowserServer { public parseStacktrace( trace: string, options: StackTraceParserOptions = {}, - ) { + ): ParsedStack[] { return parseStacktrace(trace, { ...this.stackTraceOptions, ...options, }) } + public readonly cdps = new Map() private cdpSessionsPromises = new Map>() - async ensureCDPHandler(contextId: string, sessionId: string) { - const cachedHandler = this.state.cdps.get(sessionId) + async ensureCDPHandler(sessionId: string, rpcId: string) { + const cachedHandler = this.cdps.get(rpcId) if (cachedHandler) { return cachedHandler } + const browserSession = this.vitest._browserSessions.getSession(sessionId) + if (!browserSession) { + throw new Error(`Session "${sessionId}" not found.`) + } - const provider = this.provider + const browser = browserSession.project.browser! + const provider = browser.provider + if (!provider) { + throw new Error(`Browser provider is not defined for the project "${browserSession.project.name}".`) + } if (!provider.getCDPSession) { throw new Error(`CDP is not supported by the provider "${provider.name}".`) } - const promise = this.cdpSessionsPromises.get(sessionId) ?? await (async () => { - const promise = provider.getCDPSession!(contextId).finally(() => { - this.cdpSessionsPromises.delete(sessionId) + const promise = this.cdpSessionsPromises.get(rpcId) ?? await (async () => { + const promise = provider.getCDPSession!(sessionId).finally(() => { + this.cdpSessionsPromises.delete(rpcId) }) - this.cdpSessionsPromises.set(sessionId, promise) + this.cdpSessionsPromises.set(rpcId, promise) return promise })() const session = await promise - const rpc = this.state.testers.get(sessionId) + const rpc = (browser.state as BrowserServerState).testers.get(rpcId) if (!rpc) { - throw new Error(`Tester RPC "${sessionId}" was not established.`) + throw new Error(`Tester RPC "${rpcId}" was not established.`) } const handler = new BrowserServerCDPHandler(session, rpc) - this.state.cdps.set( - sessionId, + this.cdps.set( + rpcId, handler, ) return handler } - async close() { - await this.vite.close() + removeCDPHandler(sessionId: string) { + this.cdps.delete(sessionId) + } + + async formatScripts(scripts: BrowserScript[] | undefined) { + if (!scripts?.length) { + return [] + } + const server = this.vite + const promises = scripts.map( + async ({ content, src, async, id, type = 'module' }, index): Promise => { + const srcLink = (src ? (await server.pluginContainer.resolveId(src))?.id : undefined) || src + const transformId = srcLink || join(server.config.root, `virtual__${id || `injected-${index}.js`}`) + await server.moduleGraph.ensureEntryFromUrl(transformId) + const contentProcessed + = content && type === 'module' + ? (await server.pluginContainer.transform(content, transformId)).code + : content + return { + tag: 'script', + attrs: { + type, + ...(async ? { async: '' } : {}), + ...(srcLink + ? { + src: srcLink.startsWith('http') ? srcLink : slash(`/@fs/${srcLink}`), + } + : {}), + }, + injectTo: 'head', + children: contentProcessed || '', + } + }, + ) + return (await Promise.all(promises)) } -} -function wrapConfig(config: SerializedConfig): SerializedConfig { - return { - ...config, - // workaround RegExp serialization - testNamePattern: config.testNamePattern - ? (config.testNamePattern.toString() as any as RegExp) - : undefined, + resolveTesterUrl(pathname: string) { + const [sessionId, testFile] = pathname + .slice(this.prefixTesterUrl.length) + .split('/') + const decodedTestFile = decodeURIComponent(testFile) + return { sessionId, testFile: decodedTestFile } } } diff --git a/packages/browser/src/node/providers/playwright.ts b/packages/browser/src/node/providers/playwright.ts index f01e3cf37f3e..2a08cef23695 100644 --- a/packages/browser/src/node/providers/playwright.ts +++ b/packages/browser/src/node/providers/playwright.ts @@ -80,7 +80,7 @@ export class PlaywrightBrowserProvider implements BrowserProvider { launchOptions.args.push(`--remote-debugging-port=${port}`) launchOptions.args.push(`--remote-debugging-address=${host}`) - this.project.logger.log(`Debugger listening on ws://${host}:${port}`) + this.project.vitest.logger.log(`Debugger listening on ws://${host}:${port}`) } // start Vitest UI maximized only on supported browsers @@ -102,9 +102,9 @@ export class PlaywrightBrowserProvider implements BrowserProvider { return this.browserPromise } - private async createContext(contextId: string) { - if (this.contexts.has(contextId)) { - return this.contexts.get(contextId)! + private async createContext(sessionId: string) { + if (this.contexts.has(sessionId)) { + return this.contexts.get(sessionId)! } const browser = await this.openBrowser() @@ -121,23 +121,23 @@ export class PlaywrightBrowserProvider implements BrowserProvider { if (actionTimeout) { context.setDefaultTimeout(actionTimeout) } - this.contexts.set(contextId, context) + this.contexts.set(sessionId, context) return context } - public getPage(contextId: string) { - const page = this.pages.get(contextId) + public getPage(sessionId: string) { + const page = this.pages.get(sessionId) if (!page) { - throw new Error(`Page "${contextId}" not found`) + throw new Error(`Page "${sessionId}" not found in ${this.browserName} browser.`) } return page } - public getCommandsContext(contextId: string) { - const page = this.getPage(contextId) + public getCommandsContext(sessionId: string) { + const page = this.getPage(sessionId) return { page, - context: this.contexts.get(contextId)!, + context: this.contexts.get(sessionId)!, frame() { return new Promise((resolve, reject) => { const frame = page.frame('vitest-iframe') @@ -161,16 +161,16 @@ export class PlaywrightBrowserProvider implements BrowserProvider { } } - private async openBrowserPage(contextId: string) { - if (this.pages.has(contextId)) { - const page = this.pages.get(contextId)! + private async openBrowserPage(sessionId: string) { + if (this.pages.has(sessionId)) { + const page = this.pages.get(sessionId)! await page.close() - this.pages.delete(contextId) + this.pages.delete(sessionId) } - const context = await this.createContext(contextId) + const context = await this.createContext(sessionId) const page = await context.newPage() - this.pages.set(contextId, page) + this.pages.set(sessionId, page) if (process.env.VITEST_PW_DEBUG) { page.on('requestfailed', (request) => { @@ -188,14 +188,14 @@ export class PlaywrightBrowserProvider implements BrowserProvider { return page } - async openPage(contextId: string, url: string, beforeNavigate?: () => Promise) { - const browserPage = await this.openBrowserPage(contextId) + async openPage(sessionId: string, url: string, beforeNavigate?: () => Promise) { + const browserPage = await this.openBrowserPage(sessionId) await beforeNavigate?.() await browserPage.goto(url, { timeout: 0 }) } - async getCDPSession(contextId: string) { - const page = this.getPage(contextId) + async getCDPSession(sessionid: string) { + const page = this.getPage(sessionid) const cdp = await page.context().newCDPSession(page) return { async send(method: string, params: any) { diff --git a/packages/browser/src/node/providers/preview.ts b/packages/browser/src/node/providers/preview.ts index 2a495996d469..3df60c36a6c5 100644 --- a/packages/browser/src/node/providers/preview.ts +++ b/packages/browser/src/node/providers/preview.ts @@ -27,10 +27,10 @@ export class PreviewBrowserProvider implements BrowserProvider { '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', ) } - project.ctx.logger.printBrowserBanner(project) + project.vitest.logger.printBrowserBanner(project) } - async openPage(_contextId: string, url: string) { + async openPage(_sessionId: string, url: string) { this.open = true if (!this.project.browser) { throw new Error('Browser is not initialized') diff --git a/packages/browser/src/node/providers/webdriver.ts b/packages/browser/src/node/providers/webdriver.ts index c28584f42b1b..fe80067e39f3 100644 --- a/packages/browser/src/node/providers/webdriver.ts +++ b/packages/browser/src/node/providers/webdriver.ts @@ -120,7 +120,7 @@ export class WebdriverBrowserProvider implements BrowserProvider { return capabilities } - async openPage(_contextId: string, url: string) { + async openPage(_sessionId: string, url: string) { const browserInstance = await this.openBrowser() await browserInstance.url(url) } diff --git a/packages/browser/src/node/rpc.ts b/packages/browser/src/node/rpc.ts index 71f12893d534..45c4a5b22c3a 100644 --- a/packages/browser/src/node/rpc.ts +++ b/packages/browser/src/node/rpc.ts @@ -1,7 +1,9 @@ +import type { Duplex } from 'node:stream' import type { ErrorWithDiff } from 'vitest' -import type { BrowserCommandContext, ResolveSnapshotPathHandlerContext, TestModule } from 'vitest/node' +import type { BrowserCommandContext, ResolveSnapshotPathHandlerContext, TestModule, TestProject } from 'vitest/node' import type { WebSocket } from 'ws' -import type { BrowserServer } from './server' +import type { ParentBrowserProject } from './projectParent' +import type { BrowserServerState } from './state' import type { WebSocketBrowserEvents, WebSocketBrowserHandlers } from './types' import { existsSync, promises as fs } from 'node:fs' import { ServerMockResolver } from '@vitest/mocker/node' @@ -15,14 +17,13 @@ const debug = createDebugger('vitest:browser:api') const BROWSER_API_PATH = '/__vitest_browser_api__' -export function setupBrowserRpc(server: BrowserServer) { - const project = server.project - const vite = server.vite - const ctx = project.ctx +export function setupBrowserRpc(globalServer: ParentBrowserProject) { + const vite = globalServer.vite + const vitest = globalServer.vitest const wss = new WebSocketServer({ noServer: true }) - vite.httpServer?.on('upgrade', (request, socket, head) => { + vite.httpServer?.on('upgrade', (request, socket: Duplex, head: Buffer) => { if (!request.url) { return } @@ -32,27 +33,61 @@ export function setupBrowserRpc(server: BrowserServer) { return } - const type = searchParams.get('type') ?? 'tester' - const sessionId = searchParams.get('sessionId') ?? '0' + const type = searchParams.get('type') + const rpcId = searchParams.get('rpcId') + const sessionId = searchParams.get('sessionId') + const projectName = searchParams.get('projectName') + + if (type !== 'tester' && type !== 'orchestrator') { + return error( + new Error(`[vitest] Type query in ${request.url} is invalid. Type should be either "tester" or "orchestrator".`), + ) + } + + if (!sessionId || !rpcId || projectName == null) { + return error( + new Error(`[vitest] Invalid URL ${request.url}. "projectName", "sessionId" and "rpcId" queries are required.`), + ) + } + + if (type === 'orchestrator') { + const session = vitest._browserSessions.getSession(sessionId) + // it's possible the session was already resolved by the preview provider + session?.connected() + } + + const project = vitest.getProjectByName(projectName) + + if (!project) { + return error( + new Error(`[vitest] Project "${projectName}" not found.`), + ) + } wss.handleUpgrade(request, socket, head, (ws) => { wss.emit('connection', ws, request) - const rpc = setupClient(sessionId, ws) - const state = server.state + const rpc = setupClient(project, rpcId, ws) + const state = project.browser!.state as BrowserServerState const clients = type === 'tester' ? state.testers : state.orchestrators - clients.set(sessionId, rpc) + clients.set(rpcId, rpc) - debug?.('[%s] Browser API connected to %s', sessionId, type) + debug?.('[%s] Browser API connected to %s', rpcId, type) ws.on('close', () => { - debug?.('[%s] Browser API disconnected from %s', sessionId, type) - clients.delete(sessionId) - server.state.removeCDPHandler(sessionId) + debug?.('[%s] Browser API disconnected from %s', rpcId, type) + clients.delete(rpcId) + globalServer.removeCDPHandler(rpcId) }) }) }) + // we don't throw an error inside a stream because this can segfault the process + function error(err: Error) { + console.error(err) + vitest.state.catchError(err, 'RPC Error') + } + function checkFileAccess(path: string) { if (!isFileServingAllowed(path, vite)) { throw new Error( @@ -61,8 +96,8 @@ export function setupBrowserRpc(server: BrowserServer) { } } - function setupClient(sessionId: string, ws: WebSocket) { - const mockResolver = new ServerMockResolver(server.vite, { + function setupClient(project: TestProject, rpcId: string, ws: WebSocket) { + const mockResolver = new ServerMockResolver(globalServer.vite, { moduleDirectories: project.config.server?.deps?.moduleDirectories, }) @@ -71,39 +106,39 @@ export function setupBrowserRpc(server: BrowserServer) { async onUnhandledError(error, type) { if (error && typeof error === 'object') { const _error = error as ErrorWithDiff - _error.stacks = server.parseErrorStacktrace(_error) + _error.stacks = globalServer.parseErrorStacktrace(_error) } - ctx.state.catchError(error, type) + vitest.state.catchError(error, type) }, async onQueued(file) { - ctx.state.collectFiles(project, [file]) - const testModule = ctx.state.getReportedEntity(file) as TestModule - await ctx.report('onTestModuleQueued', testModule) + vitest.state.collectFiles(project, [file]) + const testModule = vitest.state.getReportedEntity(file) as TestModule + await vitest.report('onTestModuleQueued', testModule) }, async onCollected(files) { - ctx.state.collectFiles(project, files) - await ctx.report('onCollected', files) + vitest.state.collectFiles(project, files) + await vitest.report('onCollected', files) }, async onTaskUpdate(packs) { - ctx.state.updateTasks(packs) - await ctx.report('onTaskUpdate', packs) + vitest.state.updateTasks(packs) + await vitest.report('onTaskUpdate', packs) }, onAfterSuiteRun(meta) { - ctx.coverageProvider?.onAfterSuiteRun(meta) + vitest.coverageProvider?.onAfterSuiteRun(meta) }, sendLog(log) { - return ctx.report('onUserConsoleLog', log) + return vitest.report('onUserConsoleLog', log) }, resolveSnapshotPath(testPath) { - return ctx.snapshot.resolvePath(testPath, { - config: project.getSerializableConfig(), + return vitest.snapshot.resolvePath(testPath, { + config: project.serializedConfig, }) }, resolveSnapshotRawPath(testPath, rawPath) { - return ctx.snapshot.resolveRawPath(testPath, rawPath) + return vitest.snapshot.resolveRawPath(testPath, rawPath) }, snapshotSaved(snapshot) { - ctx.snapshot.add(snapshot) + vitest.snapshot.add(snapshot) }, async readSnapshotFile(snapshotPath) { checkFileAccess(snapshotPath) @@ -125,57 +160,54 @@ export function setupBrowserRpc(server: BrowserServer) { return fs.unlink(id) }, getBrowserFileSourceMap(id) { - const mod = server.vite.moduleGraph.getModuleById(id) + const mod = globalServer.vite.moduleGraph.getModuleById(id) return mod?.transformResult?.map }, onCancel(reason) { - ctx.cancelCurrentRun(reason) + vitest.cancelCurrentRun(reason) }, async resolveId(id, importer) { return mockResolver.resolveId(id, importer) }, debug(...args) { - ctx.logger.console.debug(...args) + vitest.logger.console.debug(...args) }, getCountOfFailedTests() { - return ctx.state.getCountOfFailedTests() + return vitest.state.getCountOfFailedTests() }, - async triggerCommand(contextId, command, testPath, payload) { - debug?.('[%s] Triggering command "%s"', contextId, command) - const provider = server.provider + async triggerCommand(sessionId, command, testPath, payload) { + debug?.('[%s] Triggering command "%s"', sessionId, command) + const provider = project.browser!.provider if (!provider) { throw new Error('Commands are only available for browser tests.') } - const commands = project.config.browser?.commands + const commands = globalServer.commands if (!commands || !commands[command]) { throw new Error(`Unknown command "${command}".`) } - if (provider.beforeCommand) { - await provider.beforeCommand(command, payload) - } + await provider.beforeCommand?.(command, payload) const context = Object.assign( { testPath, project, provider, - contextId, + contextId: sessionId, + sessionId, }, - provider.getCommandsContext(contextId), + provider.getCommandsContext(sessionId), ) as any as BrowserCommandContext let result try { result = await commands[command](context, ...payload) } finally { - if (provider.afterCommand) { - await provider.afterCommand(command, payload) - } + await provider.afterCommand?.(command, payload) } return result }, - finishBrowserTests(contextId: string) { - debug?.('[%s] Finishing browser tests for context', contextId) - return server.state.getContext(contextId)?.resolve() + finishBrowserTests(sessionId: string) { + debug?.('[%s] Finishing browser tests for session', sessionId) + return vitest._browserSessions.getSession(sessionId)?.resolve() }, resolveMock(rawId, importer, options) { return mockResolver.resolveMock(rawId, importer, options) @@ -185,12 +217,12 @@ export function setupBrowserRpc(server: BrowserServer) { }, // CDP - async sendCdpEvent(contextId: string, event: string, payload?: Record) { - const cdp = await server.ensureCDPHandler(contextId, sessionId) + async sendCdpEvent(sessionId: string, event: string, payload?: Record) { + const cdp = await globalServer.ensureCDPHandler(sessionId, rpcId) return cdp.send(event, payload) }, - async trackCdpEvent(contextId: string, type: 'on' | 'once' | 'off', event: string, listenerId: string) { - const cdp = await server.ensureCDPHandler(contextId, sessionId) + async trackCdpEvent(sessionId: string, type: 'on' | 'once' | 'off', event: string, listenerId: string) { + const cdp = await globalServer.ensureCDPHandler(sessionId, rpcId) cdp[type](event, listenerId) }, }, @@ -206,13 +238,13 @@ export function setupBrowserRpc(server: BrowserServer) { }, ) - ctx.onCancel(reason => rpc.onCancel(reason)) + vitest.onCancel(reason => rpc.onCancel(reason)) return rpc } } -// Serialization support utils. +// Serialization support utils. function cloneByOwnProperties(value: any) { // Clones the value's properties into a new Object. The simpler approach of // Object.assign() won't work in the case that properties are not enumerable. diff --git a/packages/browser/src/node/serverOrchestrator.ts b/packages/browser/src/node/serverOrchestrator.ts index ece1471e0f8e..f1d61fd713ef 100644 --- a/packages/browser/src/node/serverOrchestrator.ts +++ b/packages/browser/src/node/serverOrchestrator.ts @@ -1,35 +1,44 @@ import type { IncomingMessage, ServerResponse } from 'node:http' -import type { BrowserServer } from './server' +import type { ProjectBrowser } from './project' +import type { ParentBrowserProject } from './projectParent' import { replacer } from './utils' export async function resolveOrchestrator( - server: BrowserServer, + globalServer: ParentBrowserProject, url: URL, res: ServerResponse, ) { - const project = server.project - let contextId = url.searchParams.get('contextId') + let sessionId = url.searchParams.get('sessionId') // it's possible to open the page without a context - if (!contextId) { - const contexts = [...server.state.orchestrators.keys()] - contextId = contexts[contexts.length - 1] ?? 'none' + if (!sessionId) { + const contexts = [...globalServer.children].flatMap(p => [...p.state.orchestrators.keys()]) + sessionId = contexts[contexts.length - 1] ?? 'none' } - const files = server.state.getContext(contextId!)?.files ?? [] + // it's ok to not have a session here, especially in the preview provider + // because the user could refresh the page which would remove the session id from the url - const injectorJs = typeof server.injectorJs === 'string' - ? server.injectorJs - : await server.injectorJs + const session = globalServer.vitest._browserSessions.getSession(sessionId!) + const files = session?.files ?? [] + const browserProject = (session?.project.browser as ProjectBrowser | undefined) || [...globalServer.children][0] + + if (!browserProject) { + return + } + + const injectorJs = typeof globalServer.injectorJs === 'string' + ? globalServer.injectorJs + : await globalServer.injectorJs const injector = replacer(injectorJs, { - __VITEST_PROVIDER__: JSON.stringify(server.provider.name), - __VITEST_CONFIG__: JSON.stringify(server.getSerializableConfig()), + __VITEST_PROVIDER__: JSON.stringify(browserProject.config.browser.provider || 'preview'), + __VITEST_CONFIG__: JSON.stringify(browserProject.wrapSerializedConfig()), __VITEST_VITE_CONFIG__: JSON.stringify({ - root: server.vite.config.root, + root: browserProject.vite.config.root, }), __VITEST_FILES__: JSON.stringify(files), __VITEST_TYPE__: '"orchestrator"', - __VITEST_CONTEXT_ID__: JSON.stringify(contextId), + __VITEST_SESSION_ID__: JSON.stringify(sessionId), __VITEST_TESTER_ID__: '"none"', __VITEST_PROVIDED_CONTEXT__: '{}', }) @@ -37,9 +46,9 @@ export async function resolveOrchestrator( // disable CSP for the orchestrator as we are the ones controlling it res.removeHeader('Content-Security-Policy') - if (!server.orchestratorScripts) { - server.orchestratorScripts = (await server.formatScripts( - project.config.browser.orchestratorScripts, + if (!globalServer.orchestratorScripts) { + globalServer.orchestratorScripts = (await globalServer.formatScripts( + globalServer.config.browser.orchestratorScripts, )).map((script) => { let html = '`, - __VITEST_ERROR_CATCHER__: ``, - __VITEST_CONTEXT_ID__: JSON.stringify(contextId), + __VITEST_ERROR_CATCHER__: ``, + __VITEST_SESSION_ID__: JSON.stringify(sessionId), }) } diff --git a/packages/browser/src/node/serverTester.ts b/packages/browser/src/node/serverTester.ts index f9ea918796b9..182997506e2d 100644 --- a/packages/browser/src/node/serverTester.ts +++ b/packages/browser/src/node/serverTester.ts @@ -1,13 +1,14 @@ import type { IncomingMessage, ServerResponse } from 'node:http' import type { Connect } from 'vite' -import type { BrowserServer } from './server' +import type { ProjectBrowser } from './project' +import type { ParentBrowserProject } from './projectParent' import crypto from 'node:crypto' import { stringify } from 'flatted' import { join } from 'pathe' import { replacer } from './utils' export async function resolveTester( - server: BrowserServer, + globalServer: ParentBrowserProject, url: URL, res: ServerResponse, next: Connect.NextFunction, @@ -22,9 +23,16 @@ export async function resolveTester( ) } - const { contextId, testFile } = server.resolveTesterUrl(url.pathname) - const project = server.project - const state = server.state + const { sessionId, testFile } = globalServer.resolveTesterUrl(url.pathname) + const session = globalServer.vitest._browserSessions.getSession(sessionId) + + if (!session) { + res.statusCode = 400 + res.end('Invalid session ID') + return + } + + const project = globalServer.vitest.getProjectByName(session.project.name || '') 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 @@ -33,36 +41,43 @@ export async function resolveTester( ? '__vitest_browser_runner__.files' : JSON.stringify([testFile]) const iframeId = JSON.stringify(testFile) - const context = state.getContext(contextId) - const files = context?.files ?? [] - const method = context?.method ?? 'run' + const files = session.files ?? [] + const method = session.method ?? 'run' + + const browserProject = (project.browser as ProjectBrowser | undefined) || [...globalServer.children][0] + + if (!browserProject) { + res.statusCode = 400 + res.end('Invalid session ID') + return + } - const injectorJs = typeof server.injectorJs === 'string' - ? server.injectorJs - : await server.injectorJs + const injectorJs: string = typeof globalServer.injectorJs === 'string' + ? globalServer.injectorJs + : await globalServer.injectorJs const injector = replacer(injectorJs, { - __VITEST_PROVIDER__: JSON.stringify(server.provider.name), - __VITEST_CONFIG__: JSON.stringify(server.getSerializableConfig()), + __VITEST_PROVIDER__: JSON.stringify(project.browser!.provider!.name), + __VITEST_CONFIG__: JSON.stringify(browserProject.wrapSerializedConfig()), __VITEST_FILES__: JSON.stringify(files), __VITEST_VITE_CONFIG__: JSON.stringify({ - root: server.vite.config.root, + root: browserProject.vite.config.root, }), __VITEST_TYPE__: '"tester"', - __VITEST_CONTEXT_ID__: JSON.stringify(contextId), + __VITEST_SESSION_ID__: JSON.stringify(sessionId), __VITEST_TESTER_ID__: JSON.stringify(crypto.randomUUID()), __VITEST_PROVIDED_CONTEXT__: JSON.stringify(stringify(project.getProvidedContext())), }) - const testerHtml = typeof server.testerHtml === 'string' - ? server.testerHtml - : await server.testerHtml + const testerHtml = typeof browserProject.testerHtml === 'string' + ? browserProject.testerHtml + : await browserProject.testerHtml try { - const url = join('/@fs/', server.testerFilepath) - const indexhtml = await server.vite.transformIndexHtml(url, testerHtml) - return replacer(indexhtml, { - __VITEST_FAVICON__: server.faviconUrl, + const url = join('/@fs/', browserProject.testerFilepath) + const indexhtml = await browserProject.vite.transformIndexHtml(url, testerHtml) + const html = replacer(indexhtml, { + __VITEST_FAVICON__: globalServer.faviconUrl, __VITEST_INJECTOR__: injector, __VITEST_APPEND__: ` __vitest_browser_runner__.runningFiles = ${tests} @@ -71,9 +86,10 @@ export async function resolveTester( document.querySelector('script[data-vitest-append]').remove() `, }) + return html } catch (err) { - context?.reject(err) + session.reject(err) next(err) } } diff --git a/packages/browser/src/node/state.ts b/packages/browser/src/node/state.ts index 5e07bd51c33d..eb4eb49689c7 100644 --- a/packages/browser/src/node/state.ts +++ b/packages/browser/src/node/state.ts @@ -1,34 +1,7 @@ -import type { BrowserServerStateContext, BrowserServerState as IBrowserServerState } from 'vitest/node' -import type { BrowserServerCDPHandler } from './cdp' +import type { BrowserServerState as IBrowserServerState } from 'vitest/node' import type { WebSocketBrowserRPC } from './types' -import { createDefer } from '@vitest/utils' export class BrowserServerState implements IBrowserServerState { public readonly orchestrators = new Map() public readonly testers = new Map() - public readonly cdps = new Map() - - private contexts = new Map() - - getContext(contextId: string) { - return this.contexts.get(contextId) - } - - createAsyncContext(method: 'run' | 'collect', contextId: string, files: string[]): Promise { - const defer = createDefer() - this.contexts.set(contextId, { - files, - method, - resolve: () => { - defer.resolve() - this.contexts.delete(contextId) - }, - reject: defer.reject, - }) - return defer - } - - async removeCDPHandler(sessionId: string) { - this.cdps.delete(sessionId) - } } diff --git a/packages/browser/src/node/types.ts b/packages/browser/src/node/types.ts index df3837bcdadc..0276dea771c3 100644 --- a/packages/browser/src/node/types.ts +++ b/packages/browser/src/node/types.ts @@ -16,7 +16,7 @@ export interface WebSocketBrowserHandlers { saveSnapshotFile: (id: string, content: string) => Promise removeSnapshotFile: (id: string) => Promise sendLog: (log: UserConsoleLog) => void - finishBrowserTests: (contextId: string) => void + finishBrowserTests: (sessionId: string) => void snapshotSaved: (snapshot: SnapshotResult) => void debug: (...args: string[]) => void resolveId: ( @@ -24,7 +24,7 @@ export interface WebSocketBrowserHandlers { importer?: string ) => Promise triggerCommand: ( - contextId: string, + sessionId: string, command: string, testPath: string | undefined, payload: unknown[] @@ -40,8 +40,8 @@ export interface WebSocketBrowserHandlers { ) => SourceMap | null | { mappings: '' } | undefined // cdp - sendCdpEvent: (contextId: string, event: string, payload?: Record) => unknown - trackCdpEvent: (contextId: string, type: 'on' | 'once' | 'off', event: string, listenerId: string) => void + sendCdpEvent: (sessionId: string, event: string, payload?: Record) => unknown + trackCdpEvent: (sessionId: string, type: 'on' | 'once' | 'off', event: string, listenerId: string) => void } export interface WebSocketEvents diff --git a/packages/mocker/src/browser/interceptor-msw.ts b/packages/mocker/src/browser/interceptor-msw.ts index 53f50899ddad..fe480d913046 100644 --- a/packages/mocker/src/browser/interceptor-msw.ts +++ b/packages/mocker/src/browser/interceptor-msw.ts @@ -128,10 +128,11 @@ export class ModuleMockerMSWInterceptor implements ModuleMockerInterceptor { } } -const timestampRegexp = /(\?|&)t=\d{13}/ -const versionRegexp = /(\?|&)v=\w{8}/ +const trailingSeparatorRE = /[?&]$/ +const timestampRE = /\bt=\d{13}&?\b/ +const versionRE = /\bv=\w{8}&?\b/ function cleanQuery(url: string) { - return url.replace(timestampRegexp, '').replace(versionRegexp, '') + return url.replace(timestampRE, '').replace(versionRE, '').replace(trailingSeparatorRE, '') } function passthrough() { diff --git a/packages/runner/src/fixture.ts b/packages/runner/src/fixture.ts index cd073ec911e3..34a12f4e27d6 100644 --- a/packages/runner/src/fixture.ts +++ b/packages/runner/src/fixture.ts @@ -5,10 +5,6 @@ import { getFixture } from './map' export interface FixtureItem extends FixtureOptions { prop: string value: any - /** - * Indicated if the injected value should be preferred over the fixture value - */ - injected?: boolean /** * Indicates whether the fixture is a function */ diff --git a/packages/runner/src/types/tasks.ts b/packages/runner/src/types/tasks.ts index ffbe8375f5db..ced02393278b 100644 --- a/packages/runner/src/types/tasks.ts +++ b/packages/runner/src/types/tasks.ts @@ -444,6 +444,10 @@ export interface FixtureOptions { * Whether to automatically set up current fixture, even though it's not being used in tests. */ auto?: boolean + /** + * Indicated if the injected value from the config should be preferred over the fixture value + */ + injected?: boolean } export type Use = (value: T) => Promise diff --git a/packages/vitest/src/api/setup.ts b/packages/vitest/src/api/setup.ts index 879d662b60ad..d362d2ba5bc3 100644 --- a/packages/vitest/src/api/setup.ts +++ b/packages/vitest/src/api/setup.ts @@ -97,11 +97,13 @@ export function setup(ctx: Vitest, _server?: ViteDevServer) { async getModuleGraph(project, id, browser): Promise { return getModuleGraph(ctx, project, id, browser) }, - updateSnapshot(file?: File) { + async updateSnapshot(file?: File) { if (!file) { - return ctx.updateSnapshot() + await ctx.updateSnapshot() + } + else { + await ctx.updateSnapshot([file.filepath]) } - return ctx.updateSnapshot([file.filepath]) }, getUnhandledErrors() { return ctx.state.getUnhandledErrors() diff --git a/packages/vitest/src/create/browser/creator.ts b/packages/vitest/src/create/browser/creator.ts index 31875b2932b2..239bfe2fdd42 100644 --- a/packages/vitest/src/create/browser/creator.ts +++ b/packages/vitest/src/create/browser/creator.ts @@ -228,9 +228,9 @@ function getPossibleProvider(dependencies: Record) { function getProviderDocsLink(provider: string) { switch (provider) { case 'playwright': - return 'https://playwright.dev' + return 'https://vitest.dev/guide/browser/playwright' case 'webdriverio': - return 'https://webdriver.io' + return 'https://vitest.dev/guide/browser/webdriverio' } } @@ -251,7 +251,7 @@ async function generateWorkspaceFile(options: { configPath: string rootConfig: string provider: string - browser: string + browsers: string[] }) { const relativeRoot = relative(dirname(options.configPath), options.rootConfig) const workspaceContent = [ @@ -265,10 +265,11 @@ async function generateWorkspaceFile(options: { ` test: {`, ` browser: {`, ` enabled: true,`, - ` name: '${options.browser}',`, ` provider: '${options.provider}',`, options.provider !== 'preview' && ` // ${getProviderDocsLink(options.provider)}`, - options.provider !== 'preview' && ` providerOptions: {},`, + ` configs: [`, + ...options.browsers.map(browser => ` { browser: '${browser}' },`), + ` ],`, ` },`, ` },`, ` },`, @@ -283,7 +284,7 @@ async function generateFrameworkConfigFile(options: { framework: string frameworkPlugin: string | null provider: string - browser: string + browsers: string[] }) { const frameworkImport = options.framework === 'svelte' ? `import { svelte } from '${options.frameworkPlugin}'` @@ -297,10 +298,11 @@ async function generateFrameworkConfigFile(options: { ` test: {`, ` browser: {`, ` enabled: true,`, - ` name: '${options.browser}',`, ` provider: '${options.provider}',`, options.provider !== 'preview' && ` // ${getProviderDocsLink(options.provider)}`, - options.provider !== 'preview' && ` providerOptions: {},`, + ` configs: [`, + ...options.browsers.map(browser => ` { browser: '${browser}' },`), + ` ],`, ` },`, ` },`, `})`, @@ -391,9 +393,10 @@ export async function create() { return fail() } - const { browser } = await prompt({ - type: 'select', - name: 'browser', + // TODO: allow multiselect + const { browsers } = await prompt({ + type: 'multiselect', + name: 'browsers', message: 'Choose a browser', choices: getBrowserNames(provider).map(browser => ({ title: browser, @@ -471,7 +474,7 @@ export async function create() { configPath: browserWorkspaceFile, rootConfig, provider, - browser, + browsers, }) log(c.green('✔'), 'Created a workspace file for browser tests:', c.bold(relative(process.cwd(), browserWorkspaceFile))) } @@ -482,7 +485,7 @@ export async function create() { framework, frameworkPlugin, provider, - browser, + browsers, }) log(c.green('✔'), 'Created a config file for browser tests', c.bold(relative(process.cwd(), configPath))) } diff --git a/packages/vitest/src/node/browser/sessions.ts b/packages/vitest/src/node/browser/sessions.ts new file mode 100644 index 000000000000..b6847e000b05 --- /dev/null +++ b/packages/vitest/src/node/browser/sessions.ts @@ -0,0 +1,34 @@ +import type { TestProject } from '../project' +import type { BrowserServerStateSession } from '../types/browser' +import { createDefer } from '@vitest/utils' + +export class BrowserSessions { + private sessions = new Map() + + getSession(sessionId: string) { + return this.sessions.get(sessionId) + } + + createAsyncSession(method: 'run' | 'collect', sessionId: string, files: string[], project: TestProject): Promise { + const defer = createDefer() + + const timeout = setTimeout(() => { + defer.reject(new Error(`Failed to connect to the browser session "${sessionId}" within the timeout.`)) + }, project.vitest.config.browser.connectTimeout ?? 60_000).unref() + + this.sessions.set(sessionId, { + files, + method, + project, + connected: () => { + clearTimeout(timeout) + }, + resolve: () => { + defer.resolve() + this.sessions.delete(sessionId) + }, + reject: defer.reject, + }) + return defer + } +} diff --git a/packages/vitest/src/node/cli/cac.ts b/packages/vitest/src/node/cli/cac.ts index b0a8de330fb4..0fb1d604a9ad 100644 --- a/packages/vitest/src/node/cli/cac.ts +++ b/packages/vitest/src/node/cli/cac.ts @@ -256,6 +256,14 @@ function normalizeCliOptions(cliFilters: string[], argv: CliOptions): CliOptions argv.includeTaskLocation ??= true } + // running "vitest --browser.headless" + if (typeof argv.browser === 'object' && !('enabled' in argv.browser)) { + argv.browser.enabled = true + } + if (typeof argv.typecheck?.only === 'boolean') { + argv.typecheck.enabled ??= true + } + return argv } diff --git a/packages/vitest/src/node/cli/cli-api.ts b/packages/vitest/src/node/cli/cli-api.ts index 4272c30064ad..5edaac0e073a 100644 --- a/packages/vitest/src/node/cli/cli-api.ts +++ b/packages/vitest/src/node/cli/cli-api.ts @@ -143,15 +143,6 @@ export async function prepareVitest( // this shouldn't affect _application root_ that can be changed inside config const root = resolve(options.root || process.cwd()) - // running "vitest --browser.headless" - if (typeof options.browser === 'object' && !('enabled' in options.browser)) { - options.browser.enabled = true - } - - if (typeof options.typecheck?.only === 'boolean') { - options.typecheck.enabled ??= true - } - const ctx = await createVitest(mode, options, viteOverrides, vitestOptions) const environmentPackage = getEnvPackageName(ctx.config.environment) diff --git a/packages/vitest/src/node/cli/cli-config.ts b/packages/vitest/src/node/cli/cli-config.ts index 57f7f0186bc2..6e9db83aa22f 100644 --- a/packages/vitest/src/node/cli/cli-config.ts +++ b/packages/vitest/src/node/cli/cli-config.ts @@ -371,7 +371,7 @@ export const cliOptionsConfig: VitestCLIOptions = { }, name: { description: - 'Run all tests in a specific browser. Some browsers are only available for specific providers (see `--browser.provider`). Visit [`browser.name`](https://vitest.dev/config/#browser-name) for more information', + 'Run all tests in a specific browser. Some browsers are only available for specific providers (see `--browser.provider`). Visit [`browser.name`](https://vitest.dev/guide/browser/config/#browser-name) for more information', argument: '', }, headless: { @@ -408,6 +408,10 @@ export const cliOptionsConfig: VitestCLIOptions = { description: 'Should browser test files run in parallel. Use `--browser.fileParallelism=false` to disable (default: `true`)', }, + connectTimeout: { + description: 'If connection to the browser takes longer, the test suite will fail (default: `60_000`)', + argument: '', + }, orchestratorScripts: null, testerScripts: null, commands: null, @@ -416,6 +420,7 @@ export const cliOptionsConfig: VitestCLIOptions = { screenshotFailures: null, locators: null, testerHtmlPath: null, + instances: null, }, }, pool: { diff --git a/packages/vitest/src/node/config/resolveConfig.ts b/packages/vitest/src/node/config/resolveConfig.ts index 66c0ce6ef62c..24f1a59d8687 100644 --- a/packages/vitest/src/node/config/resolveConfig.ts +++ b/packages/vitest/src/node/config/resolveConfig.ts @@ -230,9 +230,30 @@ export function resolveConfig( } } + const browser = resolved.browser + + if (browser.enabled) { + if (!browser.name && !browser.instances) { + throw new Error(`Vitest Browser Mode requires "browser.name" (deprecated) or "browser.instances" options, none were set.`) + } + + const configs = browser.instances + if (browser.name && browser.instances) { + // --browser=chromium filters configs to a single one + browser.instances = browser.instances.filter(instance => instance.browser === browser.name) + } + + if (browser.instances && !browser.instances.length) { + throw new Error([ + `"browser.instances" was set in the config, but the array is empty. Define at least one browser config.`, + browser.name && configs?.length ? ` The "browser.name" was set to "${browser.name}" which filtered all configs (${configs.map(c => c.browser).join(', ')}). Did you mean to use another name?` : '', + ].join('')) + } + } + // Browser-mode "Playwright + Chromium" only features: - if (resolved.browser.enabled && !(resolved.browser.provider === 'playwright' && resolved.browser.name === 'chromium')) { - const browserConfig = { browser: { provider: resolved.browser.provider, name: resolved.browser.name } } + if (browser.enabled && !(browser.provider === 'playwright' && browser.name === 'chromium')) { + const browserConfig = { browser: { provider: browser.provider, name: browser.name } } if (resolved.coverage.enabled && resolved.coverage.provider === 'v8') { throw new Error( diff --git a/packages/vitest/src/node/core.ts b/packages/vitest/src/node/core.ts index 927c2005efaf..6be0b052ab39 100644 --- a/packages/vitest/src/node/core.ts +++ b/packages/vitest/src/node/core.ts @@ -24,6 +24,7 @@ import { defaultBrowserPort, workspacesFiles as workspaceFiles } from '../consta import { getCoverageProvider } from '../integrations/coverage' import { distDir } from '../paths' import { wildcardPatternToRegExp } from '../utils/base' +import { BrowserSessions } from './browser/sessions' import { VitestCache } from './cache' import { resolveConfig } from './config/resolveConfig' import { FilesNotFoundError } from './errors' @@ -36,7 +37,7 @@ import { createBenchmarkReporters, createReporters } from './reporters/utils' import { VitestSpecifications } from './specifications' import { StateManager } from './state' import { VitestWatcher } from './watcher' -import { resolveWorkspace } from './workspace/resolveWorkspace' +import { resolveBrowserWorkspace, resolveWorkspace } from './workspace/resolveWorkspace' const WATCHER_DEBOUNCE = 100 @@ -88,6 +89,7 @@ export class Vitest { /** @internal */ coreWorkspaceProject: TestProject | undefined /** @internal */ resolvedProjects: TestProject[] = [] /** @internal */ _browserLastPort = defaultBrowserPort + /** @internal */ _browserSessions = new BrowserSessions() /** @internal */ _options: UserConfig = {} /** @internal */ reporters: Reporter[] = undefined! /** @internal */ vitenode: ViteNodeServer = undefined! @@ -390,7 +392,7 @@ export class Vitest { this._workspaceConfigPath = workspaceConfigPath if (!workspaceConfigPath) { - return [this._ensureRootProject()] + return resolveBrowserWorkspace(this, new Set(), [this._ensureRootProject()]) } const workspaceModule = await this.import<{ @@ -817,7 +819,7 @@ export class Vitest { } /** @internal */ - async rerunFiles(files: string[] = this.state.getFilepaths(), trigger?: string, allTestsRun = true, resetTestNamePattern = false): Promise { + async rerunFiles(files: string[] = this.state.getFilepaths(), trigger?: string, allTestsRun = true, resetTestNamePattern = false): Promise { if (resetTestNamePattern) { this.configOverride.testNamePattern = undefined } @@ -832,9 +834,10 @@ export class Vitest { this.report('onWatcherRerun', files, trigger), ...this._onUserTestsRerun.map(fn => fn(specifications)), ]) - await this.runFiles(specifications, allTestsRun) + const testResult = await this.runFiles(specifications, allTestsRun) await this.report('onWatcherStart', this.state.getFiles(files)) + return testResult } /** @internal */ @@ -900,8 +903,11 @@ export class Vitest { await this.rerunFiles(this.state.getFailedFilepaths(), 'rerun failed', false) } - /** @internal */ - async updateSnapshot(files?: string[]): Promise { + /** + * Update snapshots in specified files. If no files are provided, it will update files with failed tests and obsolete snapshots. + * @param files The list of files on the file system + */ + async updateSnapshot(files?: string[]): Promise { // default to failed files files = files || [ ...this.state.getFailedFilepaths(), @@ -911,7 +917,7 @@ export class Vitest { this.enableSnapshotUpdate() try { - await this.rerunFiles(files, 'update snapshot', false) + return await this.rerunFiles(files, 'update snapshot', false) } finally { this.resetSnapshotUpdate() diff --git a/packages/vitest/src/node/packageInstaller.ts b/packages/vitest/src/node/packageInstaller.ts index 4189e88347d9..7a443681e092 100644 --- a/packages/vitest/src/node/packageInstaller.ts +++ b/packages/vitest/src/node/packageInstaller.ts @@ -2,7 +2,7 @@ import { createRequire } from 'node:module' import url from 'node:url' import { isPackageExists } from 'local-pkg' import c from 'tinyrainbow' -import { isCI } from '../utils/env' +import { isTTY } from '../utils/env' const __dirname = url.fileURLToPath(new URL('.', import.meta.url)) @@ -31,8 +31,6 @@ export class VitestPackageInstaller { return true } - const promptInstall = !isCI && process.stdout.isTTY - process.stderr.write( c.red( `${c.inverse( @@ -41,12 +39,12 @@ export class VitestPackageInstaller { ), ) - if (!promptInstall) { + if (!isTTY) { return false } const prompts = await import('prompts') - const { install } = await prompts.prompt({ + const { install } = await prompts.default({ type: 'confirm', name: 'install', message: c.reset(`Do you want to install ${c.green(dependency)}?`), diff --git a/packages/vitest/src/node/project.ts b/packages/vitest/src/node/project.ts index 864a3ed1bed4..1e156da24c5c 100644 --- a/packages/vitest/src/node/project.ts +++ b/packages/vitest/src/node/project.ts @@ -9,7 +9,7 @@ import type { ProvidedContext } from '../types/general' import type { OnTestsRerunHandler, Vitest } from './core' import type { GlobalSetupFile } from './globalSetup' import type { Logger } from './logger' -import type { BrowserServer } from './types/browser' +import type { ParentProjectBrowser, ProjectBrowser } from './types/browser' import type { ResolvedConfig, SerializedConfig, @@ -52,7 +52,7 @@ export class TestProject { /** * Browser instance if the browser is enabled. This is initialized when the tests run for the first time. */ - public browser?: BrowserServer + public browser?: ProjectBrowser /** @deprecated use `vitest` instead */ public ctx: Vitest @@ -64,6 +64,7 @@ export class TestProject { /** @internal */ vitenode!: ViteNodeServer /** @internal */ typechecker?: Typechecker + /** @internal */ _config?: ResolvedConfig private runner!: ViteNodeRunner @@ -74,14 +75,12 @@ export class TestProject { private _globalSetups?: GlobalSetupFile[] private _provided: ProvidedContext = {} as any - private _config?: ResolvedConfig private _vite?: ViteDevServer constructor( /** @deprecated */ public path: string | number, vitest: Vitest, - /** @deprecated */ public options?: InitializeProjectOptions, ) { this.vitest = vitest @@ -161,6 +160,12 @@ export class TestProject { if (!this._vite) { throw new Error('The server was not set. It means that `project.vite` was called before the Vite server was established.') } + // checking it once should be enough + Object.defineProperty(this, 'vite', { + configurable: true, + writable: true, + value: this._vite, + }) return this._vite } @@ -171,6 +176,12 @@ export class TestProject { if (!this._config) { throw new Error('The config was not set. It means that `project.config` was called before the Vite server was established.') } + // checking it once should be enough + // Object.defineProperty(this, 'config', { + // configurable: true, + // writable: true, + // value: this._config, + // }) return this._config } @@ -481,11 +492,19 @@ export class TestProject { } /** @internal */ - async _initBrowserServer() { - if (!this.isBrowserEnabled() || this.browser) { + _parentBrowser?: ParentProjectBrowser + /** @internal */ + _parent?: TestProject + /** @internal */ + _initParentBrowser = deduped(async () => { + if (!this.isBrowserEnabled() || this._parentBrowser) { return } - await this.vitest.packageInstaller.ensureInstalled('@vitest/browser', this.config.root, this.ctx.version) + await this.vitest.packageInstaller.ensureInstalled( + '@vitest/browser', + this.config.root, + this.vitest.version, + ) const { createBrowserServer, distRoot } = await import('@vitest/browser') const browser = await createBrowserServer( this, @@ -500,13 +519,22 @@ export class TestProject { }, }), ], - [CoverageTransform(this.ctx)], + [CoverageTransform(this.vitest)], ) - this.browser = browser + this._parentBrowser = browser if (this.config.browser.ui) { setup(this.vitest, browser.vite) } - } + }) + + /** @internal */ + _initBrowserServer = deduped(async () => { + await this._parent?._initParentBrowser() + + if (!this.browser && this._parent?._parentBrowser) { + this.browser = this._parent._parentBrowser.spawn(this) + } + }) /** * Closes the project and all associated resources. This can only be called once; the closing promise is cached until the server restarts. @@ -614,14 +642,26 @@ export class TestProject { } /** @internal */ - async _initBrowserProvider(): Promise { + _initBrowserProvider = deduped(async (): Promise => { if (!this.isBrowserEnabled() || this.browser?.provider) { return } if (!this.browser) { await this._initBrowserServer() } - await this.browser?.initBrowserProvider() + await this.browser?.initBrowserProvider(this) + }) + + /** @internal */ + public _provideObject(context: Partial): void { + for (const _providedKey in context) { + const providedKey = _providedKey as keyof ProvidedContext + // type is very strict here, so we cast it to any + (this.provide as (key: string, value: unknown) => void)( + providedKey, + context[providedKey], + ) + } } /** @internal */ @@ -634,16 +674,36 @@ export class TestProject { project.runner = vitest.runner project._vite = vitest.server project._config = vitest.config - for (const _providedKey in vitest.config.provide) { - const providedKey = _providedKey as keyof ProvidedContext - // type is very strict here, so we cast it to any - (project.provide as (key: string, value: unknown) => void)( - providedKey, - vitest.config.provide[providedKey], - ) - } + project._provideObject(vitest.config.provide) return project } + + /** @internal */ + static _cloneBrowserProject(parent: TestProject, config: ResolvedConfig): TestProject { + const clone = new TestProject( + parent.path, + parent.vitest, + ) + clone.vitenode = parent.vitenode + clone.runner = parent.runner + clone._vite = parent._vite + clone._config = config + clone._parent = parent + clone._provideObject(config.provide) + return clone + } +} + +function deduped Promise>(cb: T): T { + let _promise: Promise | undefined + return ((...args: any[]) => { + if (!_promise) { + _promise = cb(...args).finally(() => { + _promise = undefined + }) + } + return _promise + }) as T } export { diff --git a/packages/vitest/src/node/reporters/base.ts b/packages/vitest/src/node/reporters/base.ts index 0b93b649b357..c31b30a29c9d 100644 --- a/packages/vitest/src/node/reporters/base.ts +++ b/packages/vitest/src/node/reporters/base.ts @@ -8,7 +8,7 @@ import { toArray } from '@vitest/utils' import { parseStacktrace } from '@vitest/utils/source-map' import { relative } from 'pathe' import c from 'tinyrainbow' -import { isCI, isDeno, isNode } from '../../utils/env' +import { isTTY } from '../../utils/env' import { hasFailedSnapshot } from '../../utils/tasks' import { F_CHECK, F_POINTER, F_RIGHT } from './renderers/figures' import { countTestErrors, divider, formatProjectName, formatTime, formatTimeString, getStateString, getStateSymbol, padSummaryTitle, renderSnapshotSummary, taskFail, withLabel } from './renderers/utils' @@ -34,7 +34,7 @@ export abstract class BaseReporter implements Reporter { private _timeStart = formatTimeString(new Date()) constructor(options: BaseOptions = {}) { - this.isTTY = options.isTTY ?? ((isNode || isDeno) && process.stdout?.isTTY && !isCI) + this.isTTY = options.isTTY ?? isTTY } onInit(ctx: Vitest) { diff --git a/packages/vitest/src/node/types/browser.ts b/packages/vitest/src/node/types/browser.ts index 19e2b9f2a6df..8ff550fbca6d 100644 --- a/packages/vitest/src/node/types/browser.ts +++ b/packages/vitest/src/node/types/browser.ts @@ -3,7 +3,7 @@ import type { Awaitable, ErrorWithDiff, ParsedStack } from '@vitest/utils' import type { StackTraceParserOptions } from '@vitest/utils/source-map' import type { ViteDevServer } from 'vite' import type { TestProject } from '../project' -import type { ApiConfig } from './config' +import type { ApiConfig, ProjectConfig } from './config' export interface BrowserProviderInitializationOptions { browser: string @@ -26,9 +26,9 @@ export interface BrowserProvider { getSupportedBrowsers: () => readonly string[] beforeCommand?: (command: string, args: unknown[]) => Awaitable afterCommand?: (command: string, args: unknown[]) => Awaitable - getCommandsContext: (contextId: string) => Record - openPage: (contextId: string, url: string, beforeNavigate?: () => Promise) => Promise - getCDPSession?: (contextId: string) => Promise + getCommandsContext: (sessionId: string) => Record + openPage: (sessionId: string, url: string, beforeNavigate?: () => Promise) => Promise + getCDPSession?: (sessionId: string) => Promise close: () => Awaitable // eslint-disable-next-line ts/method-signature-style -- we want to allow extended options initialize( @@ -45,6 +45,44 @@ export interface BrowserProviderOptions {} export type BrowserBuiltinProvider = 'webdriverio' | 'playwright' | 'preview' +type UnsupportedProperties = + | 'browser' + | 'typecheck' + | 'alias' + | 'sequence' + | 'root' + | 'pool' + | 'poolOptions' + // browser mode doesn't support a custom runner + | 'runner' + // non-browser options + | 'api' + | 'deps' + | 'testTransformMode' + | 'poolMatchGlobs' + | 'environmentMatchGlobs' + | 'environment' + | 'environmentOptions' + | 'server' + | 'benchmark' + +export interface BrowserInstanceOption extends BrowserProviderOptions, + Omit, + Pick< + BrowserConfigOptions, + | 'headless' + | 'locators' + | 'viewport' + | 'testerHtmlPath' + | 'screenshotDirectory' + | 'screenshotFailures' + > { + /** + * Name of the browser + */ + browser: string +} + export interface BrowserConfigOptions { /** * if running tests in the browser should be the default @@ -55,8 +93,14 @@ export interface BrowserConfigOptions { /** * Name of the browser + * @deprecated use `configs` instead. if both are defined, this will filter `configs` by name. */ - name: string + name?: string + + /** + * Configurations for different browser setups + */ + instances?: BrowserInstanceOption[] /** * Browser provider @@ -74,6 +118,7 @@ export interface BrowserConfigOptions { * * @example * { playwright: { launch: { devtools: true } } + * @deprecated use `configs` instead */ providerOptions?: BrowserProviderOptions @@ -175,18 +220,28 @@ export interface BrowserConfigOptions { * @see {@link https://vitest.dev/guide/browser/commands} */ commands?: Record> + + /** + * Timeout for connecting to the browser + * @default 30000 + */ + connectTimeout?: number } export interface BrowserCommandContext { testPath: string | undefined provider: BrowserProvider project: TestProject + /** @deprecated use `sessionId` instead */ contextId: string + sessionId: string } -export interface BrowserServerStateContext { +export interface BrowserServerStateSession { files: string[] method: 'run' | 'collect' + project: TestProject + connected: () => void resolve: () => void reject: (v: unknown) => void } @@ -199,16 +254,18 @@ export interface BrowserOrchestrator { export interface BrowserServerState { orchestrators: Map - getContext: (contextId: string) => BrowserServerStateContext | undefined - createAsyncContext: (method: 'collect' | 'run', contextId: string, files: string[]) => Promise } -export interface BrowserServer { +export interface ParentProjectBrowser { + spawn: (project: TestProject) => ProjectBrowser +} + +export interface ProjectBrowser { vite: ViteDevServer state: BrowserServerState provider: BrowserProvider close: () => Promise - initBrowserProvider: () => Promise + initBrowserProvider: (project: TestProject) => Promise parseStacktrace: (stack: string) => ParsedStack[] parseErrorStacktrace: (error: ErrorWithDiff, options?: StackTraceParserOptions) => ParsedStack[] } @@ -247,6 +304,8 @@ export interface BrowserScript { } export interface ResolvedBrowserOptions extends BrowserConfigOptions { + name: string + providerOptions?: BrowserProviderOptions enabled: boolean headless: boolean isolate: boolean diff --git a/packages/vitest/src/node/types/config.ts b/packages/vitest/src/node/types/config.ts index 0ae7b73db302..6e3ddbd85302 100644 --- a/packages/vitest/src/node/types/config.ts +++ b/packages/vitest/src/node/types/config.ts @@ -25,7 +25,7 @@ import type { Reporter } from './reporter' export type { CoverageOptions, ResolvedCoverageOptions } export type { BenchmarkUserOptions } export type { RuntimeConfig, SerializedConfig } from '../../runtime/config' -export type { BrowserConfigOptions, BrowserScript } from './browser' +export type { BrowserConfigOptions, BrowserInstanceOption, BrowserScript } from './browser' export type { CoverageIstanbulOptions, CoverageV8Options } from './coverage' export type { SequenceHooks, SequenceSetupFiles } from '@vitest/runner' @@ -1081,14 +1081,16 @@ type NonProjectOptions = | 'maxWorkers' | 'minWorkers' | 'fileParallelism' + | 'workspace' export type ProjectConfig = Omit< - UserConfig, + InlineConfig, NonProjectOptions | 'sequencer' | 'deps' | 'poolOptions' > & { + mode?: string sequencer?: Omit deps?: Omit poolOptions?: { diff --git a/packages/vitest/src/node/workspace/resolveWorkspace.ts b/packages/vitest/src/node/workspace/resolveWorkspace.ts index 765ce348eb4c..84451100456f 100644 --- a/packages/vitest/src/node/workspace/resolveWorkspace.ts +++ b/packages/vitest/src/node/workspace/resolveWorkspace.ts @@ -1,14 +1,17 @@ import type { Vitest } from '../core' -import type { TestProject } from '../project' -import type { TestProjectConfiguration, UserConfig, UserWorkspaceConfig } from '../types/config' +import type { BrowserInstanceOption, ResolvedConfig, TestProjectConfiguration, UserConfig, UserWorkspaceConfig } from '../types/config' import { existsSync, promises as fs } from 'node:fs' import os from 'node:os' import { limitConcurrency } from '@vitest/runner/utils' +import { deepClone, toArray } from '@vitest/utils' import fg from 'fast-glob' import { dirname, relative, resolve } from 'pathe' import { mergeConfig } from 'vite' import { configFiles as defaultConfigFiles } from '../../constants' -import { initializeProject } from '../project' +import { wildcardPatternToRegExp } from '../../utils/base' +import { isTTY } from '../../utils/env' +import { initializeProject, TestProject } from '../project' +import { withLabel } from '../reporters/renderers/utils' import { isDynamicPattern } from './fast-glob-pattern' export async function resolveWorkspace( @@ -97,7 +100,7 @@ export async function resolveWorkspace( // pretty rare case - the glob didn't match anything and there are no inline configs if (!projectPromises.length) { - return [vitest._ensureRootProject()] + return resolveBrowserWorkspace(vitest, new Set(), [vitest._ensureRootProject()]) } const resolvedProjects = await Promise.all(projectPromises) @@ -127,9 +130,157 @@ export async function resolveWorkspace( names.add(name) } + return resolveBrowserWorkspace(vitest, names, resolvedProjects) +} + +export async function resolveBrowserWorkspace( + vitest: Vitest, + names: Set, + resolvedProjects: TestProject[], +) { + const filters = toArray(vitest.config.project).map(s => wildcardPatternToRegExp(s)) + const removeProjects = new Set() + + resolvedProjects.forEach((project) => { + if (!project.config.browser.enabled) { + return + } + const configs = project.config.browser.instances || [] + if (configs.length === 0) { + // browser.name should be defined, otherwise the config fails in "resolveConfig" + configs.push({ browser: project.config.browser.name }) + console.warn( + withLabel( + 'yellow', + 'Vitest', + [ + `No browser "instances" were defined`, + project.name ? ` for the "${project.name}" project. ` : '. ', + `Running tests in "${project.config.browser.name}" browser. `, + 'The "browser.name" field is deprecated since Vitest 3. ', + 'Read more: https://vitest.dev/guide/browser/config#browser-instances', + ].filter(Boolean).join(''), + ), + ) + } + const originalName = project.config.name + const filteredConfigs = !filters.length + ? configs + : configs.filter((config) => { + const browser = config.browser + const newName = config.name || (originalName ? `${originalName} (${browser})` : browser) + return filters.some(pattern => pattern.test(newName)) + }) + + // every project was filtered out + if (!filteredConfigs.length) { + return + } + + if (project.config.browser.providerOptions) { + vitest.logger.warn( + withLabel('yellow', 'Vitest', `"providerOptions"${originalName ? ` in "${originalName}" project` : ''} is ignored because it's overriden by the configs. To hide this warning, remove the "providerOptions" property from the browser configuration.`), + ) + } + + filteredConfigs.forEach((config, index) => { + const browser = config.browser + if (!browser) { + const nth = index + 1 + const ending = nth === 2 ? 'nd' : nth === 3 ? 'rd' : 'th' + throw new Error(`The browser configuration must have a "browser" property. The ${nth}${ending} item in "browser.instances" doesn't have it. Make sure your${originalName ? ` "${originalName}"` : ''} configuration is correct.`) + } + const name = config.name + const newName = name || (originalName ? `${originalName} (${browser})` : browser) + + if (names.has(newName)) { + throw new Error( + [ + `Cannot define a nested project for a ${browser} browser. The project name "${newName}" was already defined. `, + 'If you have multiple instances for the same browser, make sure to define a custom "name". ', + 'All projects in a workspace should have unique names. Make sure your configuration is correct.', + ].join(''), + ) + } + names.add(newName) + const clonedConfig = cloneConfig(project, config) + clonedConfig.name = newName + const clone = TestProject._cloneBrowserProject(project, clonedConfig) + resolvedProjects.push(clone) + }) + + removeProjects.add(project) + }) + + resolvedProjects = resolvedProjects.filter(project => !removeProjects.has(project)) + + const headedBrowserProjects = resolvedProjects.filter((project) => { + return project.config.browser.enabled && !project.config.browser.headless + }) + if (headedBrowserProjects.length > 1) { + const message = [ + `Found multiple projects that run browser tests in headed mode: "${headedBrowserProjects.map(p => p.name).join('", "')}".`, + ` Vitest cannot run multiple headed browsers at the same time.`, + ].join('') + if (!isTTY) { + throw new Error(`${message} Please, filter projects with --browser=name or --project=name flag or run tests with "headless: true" option.`) + } + const prompts = await import('prompts') + const { projectName } = await prompts.default({ + type: 'select', + name: 'projectName', + choices: headedBrowserProjects.map(project => ({ + title: project.name, + value: project.name, + })), + message: `${message} Select a single project to run or cancel and run tests with "headless: true" option. Note that you can also start tests with --browser=name or --project=name flag.`, + }) + if (!projectName) { + throw new Error('The test run was aborted.') + } + return resolvedProjects.filter(project => project.name === projectName) + } + return resolvedProjects } +function cloneConfig(project: TestProject, { browser, ...config }: BrowserInstanceOption) { + const { + locators, + viewport, + testerHtmlPath, + headless, + screenshotDirectory, + screenshotFailures, + // @ts-expect-error remove just in case + browser: _browser, + name, + ...overrideConfig + } = config + const currentConfig = project.config.browser + return mergeConfig({ + ...deepClone(project.config), + browser: { + ...project.config.browser, + locators: locators + ? { + testIdAttribute: locators.testIdAttribute ?? currentConfig.locators.testIdAttribute, + } + : project.config.browser.locators, + viewport: viewport ?? currentConfig.viewport, + testerHtmlPath: testerHtmlPath ?? currentConfig.testerHtmlPath, + screenshotDirectory: screenshotDirectory ?? currentConfig.screenshotDirectory, + screenshotFailures: screenshotFailures ?? currentConfig.screenshotFailures, + // TODO: test that CLI arg is preferred over the local config + headless: project.vitest._options?.browser?.headless ?? headless ?? currentConfig.headless, + name: browser, + providerOptions: config, + instances: undefined, // projects cannot spawn more configs + }, + // TODO: should resolve, not merge/override + } satisfies ResolvedConfig, overrideConfig) as ResolvedConfig +} + async function resolveTestProjectConfigs( vitest: Vitest, workspaceConfigPath: string | undefined, diff --git a/packages/vitest/src/public/node.ts b/packages/vitest/src/public/node.ts index b85293dda07b..bc7fbcb502a4 100644 --- a/packages/vitest/src/public/node.ts +++ b/packages/vitest/src/public/node.ts @@ -13,6 +13,7 @@ export type { OnServerRestartHandler, OnTestsRerunHandler, Vitest, + VitestOptions, } from '../node/core' export { createVitest } from '../node/create' export { GitNotFoundError, FilesNotFoundError as TestsNotFoundError } from '../node/errors' @@ -60,16 +61,18 @@ export type { BrowserCommand, BrowserCommandContext, BrowserConfigOptions, + BrowserInstanceOption, BrowserOrchestrator, BrowserProvider, BrowserProviderInitializationOptions, BrowserProviderModule, BrowserProviderOptions, BrowserScript, - BrowserServer, BrowserServerState, - BrowserServerStateContext, + BrowserServerStateSession, CDPSession, + ParentProjectBrowser, + ProjectBrowser, ResolvedBrowserOptions, } from '../node/types/browser' /** @deprecated use `createViteServer` instead */ diff --git a/packages/vitest/src/utils/env.ts b/packages/vitest/src/utils/env.ts index 4ecfcef7e29f..29b93231f66a 100644 --- a/packages/vitest/src/utils/env.ts +++ b/packages/vitest/src/utils/env.ts @@ -1,3 +1,5 @@ +import { isCI } from 'std-env' + export const isNode: boolean = typeof process < 'u' && typeof process.stdout < 'u' @@ -9,4 +11,5 @@ export const isDeno: boolean && process.versions?.deno !== undefined export const isWindows = (isNode || isDeno) && process.platform === 'win32' export const isBrowser: boolean = typeof window !== 'undefined' +export const isTTY: boolean = ((isNode || isDeno) && process.stdout?.isTTY && !isCI) export { isCI, provider as stdProvider } from 'std-env' diff --git a/test/browser/fixtures/benchmark/vitest.config.ts b/test/browser/fixtures/benchmark/vitest.config.ts index 2214155ca2c6..c57a43ee6168 100644 --- a/test/browser/fixtures/benchmark/vitest.config.ts +++ b/test/browser/fixtures/benchmark/vitest.config.ts @@ -1,16 +1,14 @@ import { fileURLToPath } from 'node:url' import { defineConfig } from 'vitest/config' - -const provider = process.env.PROVIDER || 'playwright' -const name = - process.env.BROWSER || (provider === 'playwright' ? 'chromium' : 'chrome') +import { instances, provider } from '../../settings' export default defineConfig({ test: { browser: { enabled: true, + headless: true, provider, - name, + instances, }, }, cacheDir: fileURLToPath(new URL("./node_modules/.vite", import.meta.url)), diff --git a/test/browser/fixtures/locators/vitest.config.ts b/test/browser/fixtures/locators/vitest.config.ts index e32545f4ab22..d8b79ec319ef 100644 --- a/test/browser/fixtures/locators/vitest.config.ts +++ b/test/browser/fixtures/locators/vitest.config.ts @@ -1,9 +1,6 @@ import { fileURLToPath } from 'node:url' import { defineConfig } from 'vitest/config' - -const provider = process.env.PROVIDER || 'playwright' -const name = - process.env.BROWSER || (provider === 'playwright' ? 'chromium' : 'chrome') +import { instances, provider } from '../../settings' export default defineConfig({ optimizeDeps: { @@ -15,8 +12,8 @@ export default defineConfig({ browser: { enabled: true, provider, - name, headless: true, + instances, }, onConsoleLog(log) { if (log.includes('ReactDOMTestUtils.act')) { diff --git a/test/browser/fixtures/mocking-watch/vitest.config.ts b/test/browser/fixtures/mocking-watch/vitest.config.ts index ebe6e47aaef6..74daa1beee99 100644 --- a/test/browser/fixtures/mocking-watch/vitest.config.ts +++ b/test/browser/fixtures/mocking-watch/vitest.config.ts @@ -1,9 +1,6 @@ import { fileURLToPath } from 'node:url' import { defineConfig } from 'vitest/config' - -const provider = process.env.PROVIDER || 'playwright' -const name = - process.env.BROWSER || (provider === 'playwright' ? 'chromium' : 'chrome') +import { instances, provider } from '../../settings' export default defineConfig({ optimizeDeps: { @@ -15,7 +12,7 @@ export default defineConfig({ fileParallelism: false, enabled: true, provider, - name, + instances, headless: true, }, }, diff --git a/test/browser/fixtures/mocking/vitest.config.ts b/test/browser/fixtures/mocking/vitest.config.ts index 3620bdc48ef4..f72ca8935f64 100644 --- a/test/browser/fixtures/mocking/vitest.config.ts +++ b/test/browser/fixtures/mocking/vitest.config.ts @@ -1,9 +1,6 @@ import { fileURLToPath } from 'node:url' import { defineConfig } from 'vitest/config' - -const provider = process.env.PROVIDER || 'playwright' -const name = - process.env.BROWSER || (provider === 'playwright' ? 'chromium' : 'chrome') +import { instances, provider } from '../../settings' export default defineConfig({ optimizeDeps: { @@ -15,7 +12,7 @@ export default defineConfig({ browser: { enabled: true, provider, - name, + instances, headless: true, }, }, diff --git a/test/browser/fixtures/multiple-different-configs/basic.test.js b/test/browser/fixtures/multiple-different-configs/basic.test.js new file mode 100644 index 000000000000..f5480f9199a5 --- /dev/null +++ b/test/browser/fixtures/multiple-different-configs/basic.test.js @@ -0,0 +1,23 @@ +import { test as baseTest, expect, inject } from 'vitest'; +import { server } from '@vitest/browser/context' + +const test = baseTest.extend({ + // chromium should inject the value as "true" + // firefox doesn't provide this value in the config, it will stay undefined + providedVar: [undefined, { injected: true }] +}) + +test('html injected', ({ providedVar }) => { + // window.HTML_INJECTED_VAR is injected only for chromium via a script in customTester.html + console.log(`[${server.config.name}] HTML_INJECTED_VAR is ${window.HTML_INJECTED_VAR}`) + expect(providedVar).toBe(window.HTML_INJECTED_VAR) +}) + +test.runIf(server.config.name === 'firefox')('[firefox] firefoxValue injected', ({ providedVar }) => { + expect(providedVar).toBeUndefined() + expect(inject('firefoxValue')).toBe(true) +}) + +test.runIf(server.config.name === 'chromium')('[chromium] firefoxValue is not injected', () => { + expect(inject('firefoxValue')).toBeUndefined() +}) \ No newline at end of file diff --git a/test/browser/fixtures/multiple-different-configs/customTester.html b/test/browser/fixtures/multiple-different-configs/customTester.html new file mode 100644 index 000000000000..97cc881fe0a2 --- /dev/null +++ b/test/browser/fixtures/multiple-different-configs/customTester.html @@ -0,0 +1,13 @@ + + + + + + Document + + + + + \ No newline at end of file diff --git a/test/browser/fixtures/multiple-different-configs/vitest.config.js b/test/browser/fixtures/multiple-different-configs/vitest.config.js new file mode 100644 index 000000000000..8c0598179894 --- /dev/null +++ b/test/browser/fixtures/multiple-different-configs/vitest.config.js @@ -0,0 +1,33 @@ +import { defineConfig } from 'vitest/config'; +import { fileURLToPath } from 'node:url' + +const provider = process.env.PROVIDER || 'playwright' + +export default defineConfig({ + clearScreen: false, + cacheDir: fileURLToPath(new URL("./node_modules/.vite", import.meta.url)), + test: { + browser: { + provider: provider, + enabled: true, + headless: true, + screenshotFailures: false, + instances: [ + { + browser: provider === 'playwright' ? 'chromium' : 'chrome', + testerHtmlPath: './customTester.html', + provide: { + providedVar: true, + }, + name: 'chromium', + }, + { + browser: 'firefox', + provide: { + firefoxValue: true, + }, + }, + ], + }, + }, +}) diff --git a/test/browser/fixtures/server-url/vitest.config.ts b/test/browser/fixtures/server-url/vitest.config.ts index 25eb22e5965b..ad483f64e89d 100644 --- a/test/browser/fixtures/server-url/vitest.config.ts +++ b/test/browser/fixtures/server-url/vitest.config.ts @@ -2,13 +2,11 @@ import path from 'node:path' import { fileURLToPath } from 'node:url' import { defineConfig } from 'vitest/config' import basicSsl from '@vitejs/plugin-basic-ssl' +import { instances, provider } from '../../settings' // test https by // TEST_HTTPS=1 pnpm test-fixtures --root fixtures/server-url -const provider = process.env.PROVIDER || 'playwright'; -const browser = process.env.BROWSER || (provider === 'playwright' ? 'chromium' : 'chrome'); - // ignore https errors due to self-signed certificate from plugin-basic-ssl // https://playwright.dev/docs/api/class-browser#browser-new-context-option-ignore-https-errors // https://webdriver.io/docs/configuration/#strictssl and acceptInsecureCerts in https://webdriver.io/docs/api/browser/#properties @@ -28,8 +26,10 @@ export default defineConfig({ api: process.env.TEST_HTTPS ? 51122 : 51133, enabled: true, provider, - name: browser, - providerOptions, + instances: instances.map(instance => ({ + ...instance, + ...providerOptions, + })), }, }, // separate cacheDir from test/browser/vite.config.ts diff --git a/test/browser/fixtures/setup-file/vitest.config.ts b/test/browser/fixtures/setup-file/vitest.config.ts index 8172e44773a7..e14b3cbf47f9 100644 --- a/test/browser/fixtures/setup-file/vitest.config.ts +++ b/test/browser/fixtures/setup-file/vitest.config.ts @@ -1,9 +1,6 @@ import { fileURLToPath } from 'node:url' import { defineConfig } from 'vitest/config' - -const provider = process.env.PROVIDER || 'playwright' -const name = - process.env.BROWSER || (provider === 'playwright' ? 'chromium' : 'chrome') +import { instances, provider } from '../../settings' export default defineConfig({ cacheDir: fileURLToPath(new URL("./node_modules/.vite", import.meta.url)), @@ -12,7 +9,7 @@ export default defineConfig({ browser: { enabled: true, provider, - name, + instances, headless: false, }, }, diff --git a/test/browser/fixtures/timeout/vitest.config.ts b/test/browser/fixtures/timeout/vitest.config.ts index 997fa84abcc4..b482d8bb44e6 100644 --- a/test/browser/fixtures/timeout/vitest.config.ts +++ b/test/browser/fixtures/timeout/vitest.config.ts @@ -1,9 +1,6 @@ import { fileURLToPath } from 'node:url' import { defineConfig } from 'vitest/config' - -const provider = process.env.PROVIDER || 'playwright' -const name = - process.env.BROWSER || (provider === 'playwright' ? 'chromium' : 'chrome') +import { instances, provider } from '../../settings' export default defineConfig({ cacheDir: fileURLToPath(new URL("./node_modules/.vite", import.meta.url)), @@ -11,17 +8,17 @@ export default defineConfig({ browser: { enabled: true, provider, - name, - providerOptions: { + instances: instances.map(instance => ({ + ...instance, context: { - actionTimeout: 500 - } - } + actionTimeout: 500, + }, + })), }, expect: { poll: { - timeout: 500 - } - } + timeout: 500, + }, + }, }, }) diff --git a/test/browser/fixtures/unhandled/vitest.config.ts b/test/browser/fixtures/unhandled/vitest.config.ts index 5f5d430812b3..c98221e84305 100644 --- a/test/browser/fixtures/unhandled/vitest.config.ts +++ b/test/browser/fixtures/unhandled/vitest.config.ts @@ -1,9 +1,6 @@ import { fileURLToPath } from 'node:url' import { defineConfig } from 'vitest/config' - -const provider = process.env.PROVIDER || 'playwright' -const name = - process.env.BROWSER || (provider === 'playwright' ? 'chromium' : 'chrome') +import { instances, provider } from '../../settings' export default defineConfig({ cacheDir: fileURLToPath(new URL("./node_modules/.vite", import.meta.url)), @@ -11,7 +8,7 @@ export default defineConfig({ browser: { enabled: true, provider, - name, + instances, headless: true, }, }, diff --git a/test/browser/fixtures/update-snapshot/vitest.config.ts b/test/browser/fixtures/update-snapshot/vitest.config.ts index aec07d121a3f..23e4c31f07ff 100644 --- a/test/browser/fixtures/update-snapshot/vitest.config.ts +++ b/test/browser/fixtures/update-snapshot/vitest.config.ts @@ -1,22 +1,19 @@ import path from 'node:path' import { fileURLToPath } from 'node:url' import { defineConfig } from 'vitest/config' +import { instances, provider } from '../../settings' /* manually test snapshot by pnpm -C test/browser test-fixtures --root fixtures/update-snapshot */ -const provider = process.env.PROVIDER || 'playwright' -const browser = - process.env.BROWSER || (provider === 'playwright' ? 'chromium' : 'chrome') - export default defineConfig({ test: { browser: { enabled: true, provider, - name: browser, + instances, }, }, cacheDir: path.join( diff --git a/test/browser/fixtures/user-event/vitest.config.ts b/test/browser/fixtures/user-event/vitest.config.ts index c3fe79b6ac9c..52962b06a2cc 100644 --- a/test/browser/fixtures/user-event/vitest.config.ts +++ b/test/browser/fixtures/user-event/vitest.config.ts @@ -1,9 +1,6 @@ import { fileURLToPath } from 'node:url' import { defineConfig } from 'vitest/config' - -const provider = process.env.PROVIDER || 'playwright' -const name = - process.env.BROWSER || (provider === 'playwright' ? 'chromium' : 'chrome') +import { provider, instances } from '../../settings' export default defineConfig({ cacheDir: fileURLToPath(new URL("./node_modules/.vite", import.meta.url)), @@ -11,7 +8,7 @@ export default defineConfig({ browser: { enabled: true, provider, - name, + instances, }, }, }) diff --git a/test/browser/package.json b/test/browser/package.json index aa7c3602ba69..0d9c6742185d 100644 --- a/test/browser/package.json +++ b/test/browser/package.json @@ -12,6 +12,7 @@ "test-mocking": "vitest --root ./fixtures/mocking", "test-mocking-watch": "vitest --root ./fixtures/mocking-watch", "test-locators": "vitest --root ./fixtures/locators", + "test-different-configs": "vitest --root ./fixtures/multiple-different-configs", "test-setup-file": "vitest --root ./fixtures/setup-file", "test-snapshots": "vitest --root ./fixtures/update-snapshot", "coverage": "vitest --coverage.enabled --coverage.provider=istanbul --browser.headless=yes", diff --git a/test/browser/settings.ts b/test/browser/settings.ts new file mode 100644 index 000000000000..19022e7baaef --- /dev/null +++ b/test/browser/settings.ts @@ -0,0 +1,25 @@ +import type { BrowserInstanceOption } from 'vitest/node' + +export const provider = process.env.PROVIDER || 'playwright' +export const browser = process.env.BROWSER || (provider !== 'playwright' ? 'chromium' : 'chrome') + +const devInstances: BrowserInstanceOption[] = [ + { browser }, +] + +const playwrightInstances: BrowserInstanceOption[] = [ + { browser: 'chromium' }, + { browser: 'firefox' }, + { browser: 'webkit' }, +] + +const webdriverioInstances: BrowserInstanceOption[] = [ + { browser: 'chrome' }, + { browser: 'firefox' }, +] + +export const instances = process.env.BROWSER + ? devInstances + : provider === 'playwright' + ? playwrightInstances + : webdriverioInstances diff --git a/test/browser/setup.unit.ts b/test/browser/setup.unit.ts new file mode 100644 index 000000000000..dd69c73378c1 --- /dev/null +++ b/test/browser/setup.unit.ts @@ -0,0 +1,55 @@ +import { expect } from 'vitest' + +interface SummaryOptions { + passed?: number +} + +expect.extend({ + toReportPassedTest(stdout: string, testName: string, testProject?: string) { + const includePattern = `✓ ${testProject ? `|${testProject}| ` : ''}${testName}` + const pass = stdout.includes(`✓ ${testProject ? `|${testProject}| ` : ''}${testName}`) + return { + pass, + message: () => `expected ${pass ? 'not ' : ''}to have "${includePattern}" in the report.\n\nstdout:\n${stdout}`, + } + }, + toReportSummaryTestFiles(stdout: string, { passed }: SummaryOptions) { + const includePattern = `Test Files ${passed} passed` + const pass = !passed || stdout.includes(includePattern) + return { + pass, + message: () => `expected ${pass ? 'not ' : ''}to have "${includePattern}" in the report.\n\nstdout:\n${stdout}`, + } + }, + toReportSummaryTests(stdout: string, { passed }: SummaryOptions) { + const includePattern = `Tests ${passed} passed` + const pass = !passed || stdout.includes(includePattern) + return { + pass, + message: () => `expected ${pass ? 'not ' : ''}to have "${includePattern}" in the report.\n\nstdout:\n${stdout}`, + } + }, + toReportNoErrors(stderr: string) { + const pass = !stderr + return { + pass, + message: () => `expected ${pass ? 'not ' : ''}to have no errors.\n\nstderr:\n${stderr}`, + } + }, +}) + +declare module 'vitest' { + // eslint-disable-next-line unused-imports/no-unused-vars + interface Assertion { + // eslint-disable-next-line ts/method-signature-style + toReportPassedTest(testName: string, testProject?: string): void + // eslint-disable-next-line ts/method-signature-style + toReportSummaryTestFiles(options: SummaryOptions): void + // eslint-disable-next-line ts/method-signature-style + toReportSummaryTests(options: SummaryOptions): void + // eslint-disable-next-line ts/method-signature-style + toReportNoErrors(): void + } +} + +export {} diff --git a/test/browser/specs/benchmark.test.ts b/test/browser/specs/benchmark.test.ts index ac5cec0f7959..1b3eb94b5d7c 100644 --- a/test/browser/specs/benchmark.test.ts +++ b/test/browser/specs/benchmark.test.ts @@ -3,7 +3,8 @@ import { runVitest } from '../../test-utils' test('benchmark', async () => { const result = await runVitest({ root: 'fixtures/benchmark' }, [], 'benchmark') - expect(result.stderr).toBe('') + expect(result.stderr).toReportNoErrors() + // TODO 2024-12-11 check |name| when it's supported expect(result.stdout).toContain('✓ basic.bench.ts > suite-a') expect(result.exitCode).toBe(0) }) diff --git a/test/browser/specs/filter.test.ts b/test/browser/specs/filter.test.ts index c07099cfcaf7..864bfbc6fd83 100644 --- a/test/browser/specs/filter.test.ts +++ b/test/browser/specs/filter.test.ts @@ -1,5 +1,5 @@ import { expect, test } from 'vitest' -import { runBrowserTests } from './utils' +import { instances, runBrowserTests } from './utils' test('filter', async () => { const { stderr, stdout } = await runBrowserTests({ @@ -8,7 +8,9 @@ test('filter', async () => { }, ['test/basic.test.ts']) expect(stderr).toBe('') - expect(stdout).toContain('✓ test/basic.test.ts > basic 2') - expect(stdout).toContain('Test Files 1 passed') - expect(stdout).toContain('Tests 1 passed | 3 skipped') + instances.forEach(({ browser }) => { + expect(stdout).toReportPassedTest('test/basic.test.ts > basic 2', browser) + }) + expect(stdout).toContain(`Test Files ${instances.length} passed`) + expect(stdout).toContain(`Tests ${instances.length} passed | ${instances.length * 3} skipped`) }) diff --git a/test/browser/specs/fix-4686.test.ts b/test/browser/specs/fix-4686.test.ts index 85e9e0af2e3b..bbc1846d96e0 100644 --- a/test/browser/specs/fix-4686.test.ts +++ b/test/browser/specs/fix-4686.test.ts @@ -1,10 +1,10 @@ // fix #4686 import { expect, test } from 'vitest' -import { runBrowserTests } from './utils' +import { instances, runBrowserTests } from './utils' test('tests run in presence of config.base', async () => { - const { stderr, ctx } = await runBrowserTests( + const { stderr, stdout } = await runBrowserTests( { config: './vitest.config-basepath.mts', }, @@ -12,13 +12,8 @@ test('tests run in presence of config.base', async () => { ) expect(stderr).toBe('') - expect( - Object.fromEntries( - ctx.state.getFiles().map(f => [f.name, f.result.state]), - ), - ).toMatchInlineSnapshot(` - { - "test/basic.test.ts": "pass", - } - `) + + instances.forEach(({ browser }) => { + expect(stdout).toContain(`✓ |${browser}| test/basic.test.ts`) + }) }) diff --git a/test/browser/specs/inspect.test.ts b/test/browser/specs/inspect.test.ts index b186d10889e2..efc063984fbc 100644 --- a/test/browser/specs/inspect.test.ts +++ b/test/browser/specs/inspect.test.ts @@ -6,10 +6,10 @@ import { runVitestCli } from '../../test-utils' type Message = Partial> -const IS_PLAYWRIGHT_CHROMIUM = process.env.BROWSER === 'chromium' && process.env.PROVIDER === 'playwright' +const IS_PLAYWRIGHT = process.env.PROVIDER === 'playwright' const REMOTE_DEBUG_URL = '127.0.0.1:9123' -test.runIf(IS_PLAYWRIGHT_CHROMIUM || !process.env.CI)('--inspect-brk stops at test file', async () => { +test.runIf(IS_PLAYWRIGHT || !process.env.CI)('--inspect-brk stops at test file', async () => { const { vitest, waitForClose } = await runVitestCli( '--root', 'fixtures/inspect', diff --git a/test/browser/specs/locators.test.ts b/test/browser/specs/locators.test.ts index 753926422536..55003c55ab7b 100644 --- a/test/browser/specs/locators.test.ts +++ b/test/browser/specs/locators.test.ts @@ -1,5 +1,5 @@ import { expect, test } from 'vitest' -import { runBrowserTests } from './utils' +import { instances, runBrowserTests } from './utils' test('locators work correctly', async () => { const { stderr, stdout } = await runBrowserTests({ @@ -7,8 +7,13 @@ test('locators work correctly', async () => { reporters: [['verbose', { isTTY: false }]], }) - expect(stderr).toBe('') - expect(stdout).toContain('✓ blog.test.tsx') - expect(stdout).toContain('✓ query.test.ts') - expect(stdout).toContain('Test Files 2 passed (2)') + expect(stderr).toReportNoErrors() + + instances.forEach(({ browser }) => { + expect(stdout).toReportPassedTest('blog.test.tsx', browser) + expect(stdout).toReportPassedTest('query.test.ts', browser) + }) + + expect(stdout).toReportSummaryTestFiles({ passed: instances.length * 2 }) + expect(stdout).toReportSummaryTests({ passed: instances.length * 3 }) }) diff --git a/test/browser/specs/mocking.test.ts b/test/browser/specs/mocking.test.ts index a42fc91957fd..1f2a8855fcc3 100644 --- a/test/browser/specs/mocking.test.ts +++ b/test/browser/specs/mocking.test.ts @@ -1,5 +1,6 @@ import { expect, onTestFailed, onTestFinished, test } from 'vitest' import { editFile, runVitest } from '../../test-utils' +import { instances } from '../settings' test.each([true, false])('mocking works correctly - isolated %s', async (isolate) => { const result = await runVitest({ @@ -12,19 +13,23 @@ test.each([true, false])('mocking works correctly - isolated %s', async (isolate console.error(result.stderr) }) - expect(result.stderr).toBe('') - expect(result.stdout).toContain('automocked.test.ts') - expect(result.stdout).toContain('mocked-__mocks__.test.ts') - expect(result.stdout).toContain('mocked-factory.test.ts') - expect(result.stdout).toContain('mocked-factory-hoisted.test.ts') - expect(result.stdout).toContain('not-mocked.test.ts') - expect(result.stdout).toContain('mocked-nested.test.ts') - expect(result.stdout).toContain('not-mocked-nested.test.ts') - expect(result.stdout).toContain('import-actual-in-mock.test.ts') - expect(result.stdout).toContain('import-actual-query.test.ts') - expect(result.stdout).toContain('import-mock.test.ts') - expect(result.stdout).toContain('mocked-do-mock-factory.test.ts') - expect(result.stdout).toContain('import-actual-dep.test.ts') + expect(result.stderr).toReportNoErrors() + + instances.forEach(({ browser }) => { + expect(result.stdout).toReportPassedTest('automocked.test.ts', browser) + expect(result.stdout).toReportPassedTest('mocked-__mocks__.test.ts', browser) + expect(result.stdout).toReportPassedTest('mocked-factory.test.ts', browser) + expect(result.stdout).toReportPassedTest('mocked-factory-hoisted.test.ts', browser) + expect(result.stdout).toReportPassedTest('not-mocked.test.ts', browser) + expect(result.stdout).toReportPassedTest('mocked-nested.test.ts', browser) + expect(result.stdout).toReportPassedTest('not-mocked-nested.test.ts', browser) + expect(result.stdout).toReportPassedTest('import-actual-in-mock.test.ts', browser) + expect(result.stdout).toReportPassedTest('import-actual-query.test.ts', browser) + expect(result.stdout).toReportPassedTest('import-mock.test.ts', browser) + expect(result.stdout).toReportPassedTest('mocked-do-mock-factory.test.ts', browser) + expect(result.stdout).toReportPassedTest('import-actual-dep.test.ts', browser) + }) + expect(result.exitCode).toBe(0) }) @@ -35,21 +40,26 @@ test('mocking dependency correctly invalidates it on rerun', async () => { }) onTestFinished(async () => { await ctx.close() - await ctx.closingPromise }) await vitest.waitForStdout('Waiting for file changes...') - expect(vitest.stderr).toBe('') - expect(vitest.stdout).toContain('1_mocked-on-watch-change.test.ts') - expect(vitest.stdout).toContain('2_not-mocked-import.test.ts') + expect(vitest.stderr).toReportNoErrors() + + instances.forEach(({ browser }) => { + expect(vitest.stdout).toReportPassedTest('1_mocked-on-watch-change.test.ts', browser) + expect(vitest.stdout).toReportPassedTest('2_not-mocked-import.test.ts', browser) + }) vitest.resetOutput() editFile('./fixtures/mocking-watch/1_mocked-on-watch-change.test.ts', content => `${content}\n`) await vitest.waitForStdout('Waiting for file changes...') - expect(vitest.stderr).toBe('') - expect(vitest.stdout).toContain('1_mocked-on-watch-change.test.ts') - expect(vitest.stdout).not.toContain('2_not-mocked-import.test.ts') + expect(vitest.stderr).toReportNoErrors() + + instances.forEach(({ browser }) => { + expect(vitest.stdout).toReportPassedTest('1_mocked-on-watch-change.test.ts', browser) + expect(vitest.stdout).not.toReportPassedTest('2_not-mocked-import.test.ts', browser) + }) }) diff --git a/test/browser/specs/multiple-different-configs.test.ts b/test/browser/specs/multiple-different-configs.test.ts new file mode 100644 index 000000000000..48cd36e4a1a2 --- /dev/null +++ b/test/browser/specs/multiple-different-configs.test.ts @@ -0,0 +1,25 @@ +import { expect, test } from 'vitest' +import { provider } from '../settings' +import { runBrowserTests } from './utils' + +test.runIf(provider === 'playwright')('[playwright] runs multiple different configurations correctly', async () => { + const { stdout, exitCode, stderr } = await runBrowserTests({ + root: './fixtures/multiple-different-configs', + }) + + expect(stderr).toBe('') + expect(exitCode).toBe(0) + expect(stdout).toContain('[chromium] HTML_INJECTED_VAR is true') + expect(stdout).toContain('[firefox] HTML_INJECTED_VAR is undefined') +}) + +test.runIf(provider === 'webdriverio')('[webdriverio] runs multiple different configurations correctly', async () => { + const { stdout, exitCode, stderr } = await runBrowserTests({ + root: './fixtures/multiple-different-configs', + }) + + expect(stderr).toBe('') + expect(exitCode).toBe(0) + expect(stdout).toContain('[chromium] HTML_INJECTED_VAR is true') + expect(stdout).toContain('[firefox] HTML_INJECTED_VAR is undefined') +}) diff --git a/test/browser/specs/runner.test.ts b/test/browser/specs/runner.test.ts index 4ccc02129dbe..64dfaafdb0e2 100644 --- a/test/browser/specs/runner.test.ts +++ b/test/browser/specs/runner.test.ts @@ -1,6 +1,6 @@ import { readFile } from 'node:fs/promises' import { beforeAll, describe, expect, onTestFailed, test } from 'vitest' -import { browser, provider, runBrowserTests } from './utils' +import { instances, provider, runBrowserTests } from './utils' describe('running browser tests', async () => { let stderr: string @@ -28,9 +28,9 @@ describe('running browser tests', async () => { console.error(stderr) }) - expect(browserResultJson.testResults).toHaveLength(19) - expect(passedTests).toHaveLength(17) - expect(failedTests).toHaveLength(2) + expect(browserResultJson.testResults).toHaveLength(19 * instances.length) + expect(passedTests).toHaveLength(17 * instances.length) + expect(failedTests).toHaveLength(2 * instances.length) expect(stderr).not.toContain('optimized dependencies changed') expect(stderr).not.toContain('has been externalized for browser compatibility') @@ -89,7 +89,7 @@ describe('running browser tests', async () => { expect(stderr).toMatch(/hello from console.trace\s+(\w+|@)/) }) - test.runIf(browser !== 'webkit')(`logs have stack traces in non-safari`, () => { + test(`logs have stack traces`, () => { expect(stdout).toMatch(` log with a stack ❯ test/logs.test.ts:58:10 @@ -100,20 +100,20 @@ error with a stack `.trim()) // console.trace processes the stack trace correctly expect(stderr).toMatch('test/logs.test.ts:60:10') - }) - test.runIf(browser === 'webkit')(`logs have stack traces in safari`, () => { + if (instances.some(({ browser }) => browser === 'webkit')) { // safari print stack trace in a different place - expect(stdout).toMatch(` + expect(stdout).toMatch(` log with a stack ❯ test/logs.test.ts:58:14 `.trim()) - expect(stderr).toMatch(` + expect(stderr).toMatch(` error with a stack ❯ test/logs.test.ts:59:16 `.trim()) - // console.trace processes the stack trace correctly - expect(stderr).toMatch('test/logs.test.ts:60:16') + // console.trace processes the stack trace correctly + expect(stderr).toMatch('test/logs.test.ts:60:16') + } }) test(`stack trace points to correct file in every browser`, () => { @@ -141,17 +141,15 @@ error with a stack }) test('user-event', async () => { - const { ctx } = await runBrowserTests({ + const { stdout } = await runBrowserTests({ root: './fixtures/user-event', }) - expect(Object.fromEntries(ctx.state.getFiles().map(f => [f.name, f.result.state]))).toMatchInlineSnapshot(` - { - "cleanup-retry.test.ts": "pass", - "cleanup1.test.ts": "pass", - "cleanup2.test.ts": "pass", - "keyboard.test.ts": "pass", - } - `) + instances.forEach(({ browser }) => { + expect(stdout).toReportPassedTest('cleanup-retry.test.ts', browser) + expect(stdout).toReportPassedTest('cleanup1.test.ts', browser) + expect(stdout).toReportPassedTest('cleanup2.test.ts', browser) + expect(stdout).toReportPassedTest('keyboard.test.ts', browser) + }) }) test('timeout', async () => { diff --git a/test/browser/specs/server-url.test.ts b/test/browser/specs/server-url.test.ts index 153cbd1cba55..0a1c5a5d5fc3 100644 --- a/test/browser/specs/server-url.test.ts +++ b/test/browser/specs/server-url.test.ts @@ -1,5 +1,5 @@ import { afterEach, expect, test } from 'vitest' -import { runBrowserTests } from './utils' +import { instances, runBrowserTests } from './utils' afterEach(() => { delete process.env.TEST_HTTPS @@ -24,5 +24,5 @@ test('server-url https', async () => { expect(stderr).toBe('') const url = ctx?.projects[0].browser?.vite.resolvedUrls?.local[0] expect(url).toBe('https://localhost:51122/') - expect(stdout).toContain('Test Files 1 passed') + expect(stdout).toReportSummaryTestFiles({ passed: instances.length }) }) diff --git a/test/browser/specs/setup-file.test.ts b/test/browser/specs/setup-file.test.ts index bdf97071bbe5..73699beb1b6e 100644 --- a/test/browser/specs/setup-file.test.ts +++ b/test/browser/specs/setup-file.test.ts @@ -1,23 +1,18 @@ // fix https://github.com/vitest-dev/vitest/issues/6690 import { expect, test } from 'vitest' -import { runBrowserTests } from './utils' +import { instances, runBrowserTests } from './utils' test('setup file imports the same modules', async () => { - const { stderr, ctx } = await runBrowserTests( + const { stderr, stdout } = await runBrowserTests( { root: './fixtures/setup-file', }, ) - expect(stderr).toBe('') - expect( - Object.fromEntries( - ctx.state.getFiles().map(f => [f.name, f.result.state]), - ), - ).toMatchInlineSnapshot(` - { - "module-equality.test.ts": "pass", - } - `) + expect(stderr).toReportNoErrors() + + instances.forEach(({ browser }) => { + expect(stdout).toReportPassedTest('module-equality.test.ts', browser) + }) }) diff --git a/test/browser/specs/unhandled.test.ts b/test/browser/specs/unhandled.test.ts index a45ed8da2997..c05ebc95a2a1 100644 --- a/test/browser/specs/unhandled.test.ts +++ b/test/browser/specs/unhandled.test.ts @@ -1,15 +1,14 @@ import { expect, test } from 'vitest' -import { browser, runBrowserTests } from './utils' +import { instances, runBrowserTests } from './utils' test('prints correct unhandled error stack', async () => { const { stderr } = await runBrowserTests({ root: './fixtures/unhandled', }) - if (browser === 'webkit') { + expect(stderr).toContain('throw-unhandled-error.test.ts:9:10') + + if (instances.some(({ browser }) => browser === 'webkit')) { expect(stderr).toContain('throw-unhandled-error.test.ts:9:20') } - else { - expect(stderr).toContain('throw-unhandled-error.test.ts:9:10') - } }) diff --git a/test/browser/specs/update-snapshot.test.ts b/test/browser/specs/update-snapshot.test.ts index 688d776874b4..d61ad03fb72d 100644 --- a/test/browser/specs/update-snapshot.test.ts +++ b/test/browser/specs/update-snapshot.test.ts @@ -1,7 +1,7 @@ import { readFileSync } from 'node:fs' import { expect, onTestFailed, onTestFinished, test } from 'vitest' import { createFile, editFile } from '../../test-utils' -import { runBrowserTests } from './utils' +import { instances, runBrowserTests } from './utils' test('update snapshot', async () => { // setup wrong snapshot value @@ -15,6 +15,7 @@ test('update snapshot', async () => { const ctx = await runBrowserTests({ watch: true, root: './fixtures/update-snapshot', + project: [instances[0].browser], // TODO 2024-12-11 Sheremet V.A. test with multiple browsers reporters: ['default'], // use simple reporter to not pollute stdout browser: { headless: true }, }, [], { diff --git a/test/browser/specs/utils.ts b/test/browser/specs/utils.ts index 2b29e55d7a69..f46242609e6e 100644 --- a/test/browser/specs/utils.ts +++ b/test/browser/specs/utils.ts @@ -1,9 +1,9 @@ import type { UserConfig as ViteUserConfig } from 'vite' import type { UserConfig } from 'vitest/node' import { runVitest } from '../../test-utils' +import { browser } from '../settings' -export const provider = process.env.PROVIDER || 'playwright' -export const browser = process.env.BROWSER || (provider !== 'playwright' ? 'chromium' : 'chrome') +export { browser, instances, provider } from '../settings' export async function runBrowserTests( config?: Omit & { browser?: Partial }, diff --git a/test/browser/tsconfig.json b/test/browser/tsconfig.json index 083a747ea373..fe4b26cb6b5f 100644 --- a/test/browser/tsconfig.json +++ b/test/browser/tsconfig.json @@ -13,5 +13,12 @@ "vitest/import-meta" ], "esModuleInterop": true - } + }, + "include": [ + "fixtures/**/*.ts", + "test/**/*.ts", + "specs/**/*.ts", + "./vitest.config.*", + "./setup.unit.ts" + ] } diff --git a/test/browser/vitest.config.mts b/test/browser/vitest.config.mts index d252d3c91e51..0436736e63ec 100644 --- a/test/browser/vitest.config.mts +++ b/test/browser/vitest.config.mts @@ -1,4 +1,4 @@ -import type { BrowserCommand } from 'vitest/node' +import type { BrowserCommand, BrowserInstanceOption } from 'vitest/node' import { dirname, resolve } from 'node:path' import { fileURLToPath } from 'node:url' import * as util from 'node:util' @@ -19,6 +19,21 @@ const stripVTControlCharacters: BrowserCommand<[text: string]> = (_, text) => { return util.stripVTControlCharacters(text) } +const devInstances: BrowserInstanceOption[] = [ + { browser }, +] + +const playwrightInstances: BrowserInstanceOption[] = [ + { browser: 'chromium' }, + { browser: 'firefox' }, + { browser: 'webkit' }, +] + +const webdriverioInstances: BrowserInstanceOption[] = [ + { browser: 'chrome' }, + { browser: 'firefox' }, +] + export default defineConfig({ server: { headers: { @@ -38,8 +53,12 @@ export default defineConfig({ snapshotEnvironment: './custom-snapshot-env.ts', browser: { enabled: true, - name: browser, headless: false, + instances: process.env.BROWSER + ? devInstances + : provider === 'playwright' + ? playwrightInstances + : webdriverioInstances, provider, isolate: false, testerScripts: [ diff --git a/test/browser/vitest.config.unit.mts b/test/browser/vitest.config.unit.mts index 0618ce0ca59a..8bcb174b0ae8 100644 --- a/test/browser/vitest.config.unit.mts +++ b/test/browser/vitest.config.unit.mts @@ -8,7 +8,9 @@ export default defineConfig({ singleFork: true, }, }, - hookTimeout: process.env.CI ? 120_000 : 20_000, - testTimeout: process.env.CI ? 120_000 : 20_000, + setupFiles: ['./setup.unit.ts'], + // 3 is the maximum of browser instances - in a perfect world they will run in parallel + hookTimeout: process.env.CI ? 120_000 * 3 : 20_000, + testTimeout: process.env.CI ? 120_000 * 3 : 20_000, }, }) diff --git a/test/cli/test/__snapshots__/list.test.ts.snap b/test/cli/test/__snapshots__/list.test.ts.snap index efc6f0b467ad..2dc10a05972d 100644 --- a/test/cli/test/__snapshots__/list.test.ts.snap +++ b/test/cli/test/__snapshots__/list.test.ts.snap @@ -20,12 +20,12 @@ Error: describe error `; exports[`correctly outputs all tests with args: "--browser.enabled" 1`] = ` -"basic.test.ts > basic suite > inner suite > some test -basic.test.ts > basic suite > inner suite > another test -basic.test.ts > basic suite > basic test -basic.test.ts > outside test -math.test.ts > 1 plus 1 -math.test.ts > failing test +"[chromium] basic.test.ts > basic suite > inner suite > some test +[chromium] basic.test.ts > basic suite > inner suite > another test +[chromium] basic.test.ts > basic suite > basic test +[chromium] basic.test.ts > outside test +[chromium] math.test.ts > 1 plus 1 +[chromium] math.test.ts > failing test " `; diff --git a/test/config/fixtures/bail/vitest.config.ts b/test/config/fixtures/bail/vitest.config.ts index f117ebe338a0..0a7dfa7957b9 100644 --- a/test/config/fixtures/bail/vitest.config.ts +++ b/test/config/fixtures/bail/vitest.config.ts @@ -29,8 +29,10 @@ export default defineConfig({ }, browser: { headless: true, - name: 'chrome', provider: 'webdriverio', + instances: [ + { browser: 'chrome' }, + ], }, }, }) diff --git a/test/config/test/bail.test.ts b/test/config/test/bail.test.ts index d03c1b0cedc9..5fc1e72db67d 100644 --- a/test/config/test/bail.test.ts +++ b/test/config/test/bail.test.ts @@ -4,24 +4,32 @@ import { expect, test } from 'vitest' import { runVitest } from '../../test-utils' const configs: UserConfig[] = [] -const pools: UserConfig[] = [{ pool: 'threads' }, { pool: 'forks' }, { pool: 'threads', poolOptions: { threads: { singleThread: true } } }] +const pools: UserConfig[] = [ + { pool: 'threads' }, + { pool: 'forks' }, + { pool: 'threads', poolOptions: { threads: { singleThread: true } } }, +] if (process.platform !== 'win32') { pools.push( { browser: { enabled: true, - name: 'chromium', provider: 'playwright', fileParallelism: false, + instances: [ + { browser: 'chromium' }, + ], }, }, { browser: { enabled: true, - name: 'chromium', provider: 'playwright', fileParallelism: true, + instances: [ + { browser: 'chromium' }, + ], }, }, ) @@ -61,7 +69,7 @@ for (const config of configs) { // THREADS here means that multiple tests are run parallel process.env.THREADS = isParallel ? 'true' : 'false' - const { exitCode, stdout } = await runVitest({ + const { exitCode, stdout, ctx } = await runVitest({ root: './fixtures/bail', bail: 1, ...config, @@ -70,9 +78,20 @@ for (const config of configs) { }, }) + expect(ctx?.config.pool).toBe(config.pool || 'forks') + expect(ctx?.config.browser.enabled).toBe(config.browser?.enabled ?? false) + + const browser = config.browser?.enabled + expect(exitCode).toBe(1) - expect(stdout).toMatch('✓ test/first.test.ts > 1 - first.test.ts - this should pass') - expect(stdout).toMatch('× test/first.test.ts > 2 - first.test.ts - this should fail') + if (browser) { + expect(stdout).toMatch(`✓ |chromium| test/first.test.ts > 1 - first.test.ts - this should pass`) + expect(stdout).toMatch(`× |chromium| test/first.test.ts > 2 - first.test.ts - this should fail`) + } + else { + expect(stdout).toMatch('✓ test/first.test.ts > 1 - first.test.ts - this should pass') + expect(stdout).toMatch('× test/first.test.ts > 2 - first.test.ts - this should fail') + } // Cancelled tests should not be run expect(stdout).not.toMatch('test/first.test.ts > 3 - first.test.ts - this should be skipped') diff --git a/test/config/test/browser-configs.test.ts b/test/config/test/browser-configs.test.ts new file mode 100644 index 000000000000..efdd91b47396 --- /dev/null +++ b/test/config/test/browser-configs.test.ts @@ -0,0 +1,177 @@ +import type { ViteUserConfig } from 'vitest/config' +import type { UserConfig, VitestOptions } from 'vitest/node' +import { expect, test } from 'vitest' +import { createVitest } from 'vitest/node' + +async function vitest(cliOptions: UserConfig, configValue: UserConfig = {}, viteConfig: ViteUserConfig = {}, vitestOptions: VitestOptions = {}) { + return await createVitest('test', { ...cliOptions, watch: false }, { ...viteConfig, test: configValue as any }, vitestOptions) +} + +test('assignes names as browsers', async () => { + const { projects } = await vitest({ + browser: { + enabled: true, + headless: true, + instances: [ + { browser: 'chromium' }, + { browser: 'firefox' }, + { browser: 'webkit' }, + ], + }, + }) + expect(projects.map(p => p.name)).toEqual([ + 'chromium', + 'firefox', + 'webkit', + ]) +}) + +test('filters projects', async () => { + const { projects } = await vitest({ + project: 'chromium', + browser: { + enabled: true, + instances: [ + { browser: 'chromium' }, + { browser: 'firefox' }, + { browser: 'webkit' }, + ], + }, + }) + expect(projects.map(p => p.name)).toEqual([ + 'chromium', + ]) +}) + +test('filters projects with a wildecard', async () => { + const { projects } = await vitest({ + project: 'chrom*', + browser: { + enabled: true, + instances: [ + { browser: 'chromium' }, + { browser: 'firefox' }, + { browser: 'webkit' }, + ], + }, + }) + expect(projects.map(p => p.name)).toEqual([ + 'chromium', + ]) +}) + +test('assignes names as browsers in a custom project', async () => { + const { projects } = await vitest({ + workspace: [ + { + test: { + name: 'custom', + browser: { + enabled: true, + headless: true, + instances: [ + { browser: 'chromium' }, + { browser: 'firefox' }, + { browser: 'webkit' }, + { browser: 'webkit', name: 'hello-world' }, + ], + }, + }, + }, + ], + }) + expect(projects.map(p => p.name)).toEqual([ + 'custom (chromium)', + 'custom (firefox)', + 'custom (webkit)', + 'hello-world', + ]) +}) + +test('inherits browser options', async () => { + const { projects } = await vitest({ + setupFiles: ['/test/setup.ts'], + provide: { + browser: true, + } as any, + browser: { + enabled: true, + headless: true, + screenshotFailures: false, + testerHtmlPath: '/custom-path.html', + screenshotDirectory: '/custom-directory', + fileParallelism: false, + viewport: { + width: 300, + height: 900, + }, + locators: { + testIdAttribute: 'data-tid', + }, + instances: [ + { + browser: 'chromium', + screenshotFailures: true, + }, + { + browser: 'firefox', + screenshotFailures: true, + locators: { + testIdAttribute: 'data-custom', + }, + viewport: { + width: 900, + height: 300, + }, + testerHtmlPath: '/custom-overriden-path.html', + screenshotDirectory: '/custom-overriden-directory', + }, + ], + }, + }) + expect(projects.map(p => p.config)).toMatchObject([ + { + name: 'chromium', + setupFiles: ['/test/setup.ts'], + provide: { + browser: true, + } as any, + browser: { + enabled: true, + headless: true, + screenshotFailures: true, + screenshotDirectory: '/custom-directory', + viewport: { + width: 300, + height: 900, + }, + locators: { + testIdAttribute: 'data-tid', + }, + fileParallelism: false, + testerHtmlPath: '/custom-path.html', + }, + }, + { + name: 'firefox', + setupFiles: ['/test/setup.ts'], + provide: { + browser: true, + } as any, + browser: { + enabled: true, + headless: true, + screenshotFailures: true, + viewport: { + width: 900, + height: 300, + }, + screenshotDirectory: '/custom-overriden-directory', + locators: { + testIdAttribute: 'data-custom', + }, + testerHtmlPath: '/custom-overriden-path.html', + }, + }, + ]) +}) diff --git a/test/config/test/browser-html.test.ts b/test/config/test/browser-html.test.ts index 19abfc738cdc..93f31b8563de 100644 --- a/test/config/test/browser-html.test.ts +++ b/test/config/test/browser-html.test.ts @@ -26,10 +26,10 @@ test('allows correct custom html', async () => { const { stderr, stdout, exitCode } = await runVitest({ root, config: './vitest.config.correct.ts', - reporters: ['basic'], + reporters: [['default', { summary: false }]], }) expect(stderr).toBe('') - expect(stdout).toContain('✓ browser-basic.test.ts') + expect(stdout).toContain('✓ |chromium| browser-basic.test.ts') expect(exitCode).toBe(0) }) @@ -37,10 +37,10 @@ test('allows custom transformIndexHtml with custom html file', async () => { const { stderr, stdout, exitCode } = await runVitest({ root, config: './vitest.config.custom-transformIndexHtml.ts', - reporters: ['basic'], + reporters: [['default', { summary: false }]], }) expect(stderr).toBe('') - expect(stdout).toContain('✓ browser-custom.test.ts') + expect(stdout).toContain('✓ |chromium| browser-custom.test.ts') expect(exitCode).toBe(0) }) @@ -48,9 +48,9 @@ test('allows custom transformIndexHtml without custom html file', async () => { const { stderr, stdout, exitCode } = await runVitest({ root, config: './vitest.config.default-transformIndexHtml.ts', - reporters: ['basic'], + reporters: [['default', { summary: false }]], }) expect(stderr).toBe('') - expect(stdout).toContain('✓ browser-custom.test.ts') + expect(stdout).toContain('✓ |chromium| browser-custom.test.ts') expect(exitCode).toBe(0) }) diff --git a/test/config/test/failures.test.ts b/test/config/test/failures.test.ts index 407ace135c85..74a42e121db2 100644 --- a/test/config/test/failures.test.ts +++ b/test/config/test/failures.test.ts @@ -289,6 +289,102 @@ test('maxConcurrency 0 prints a warning', async () => { expect(stderr).toMatch('The option "maxConcurrency" cannot be set to 0. Using default value 5 instead.') }) +test('browser.name or browser.instances are required', async () => { + const { stderr, exitCode } = await runVitestCli('--browser.enabled') + expect(exitCode).toBe(1) + expect(stderr).toMatch('Vitest Browser Mode requires "browser.name" (deprecated) or "browser.instances" options, none were set.') +}) + +test('browser.instances is empty', async () => { + const { stderr } = await runVitest({ + browser: { + enabled: true, + provider: 'playwright', + instances: [], + }, + }) + expect(stderr).toMatch('"browser.instances" was set in the config, but the array is empty. Define at least one browser config.') +}) + +test('browser.name filteres all browser.instances are required', async () => { + const { stderr } = await runVitest({ + browser: { + enabled: true, + name: 'chromium', + provider: 'playwright', + instances: [ + { browser: 'firefox' }, + ], + }, + }) + expect(stderr).toMatch('"browser.instances" was set in the config, but the array is empty. Define at least one browser config. The "browser.name" was set to "chromium" which filtered all configs (firefox). Did you mean to use another name?') +}) + +test('browser.instances throws an error if no custom name is provided', async () => { + const { stderr } = await runVitest({ + browser: { + enabled: true, + provider: 'playwright', + instances: [ + { browser: 'firefox' }, + { browser: 'firefox' }, + ], + }, + }) + expect(stderr).toMatch('Cannot define a nested project for a firefox browser. The project name "firefox" was already defined. If you have multiple instances for the same browser, make sure to define a custom "name". All projects in a workspace should have unique names. Make sure your configuration is correct.') +}) + +test('browser.instances throws an error if no custom name is provided, but the config name is inherited', async () => { + const { stderr } = await runVitest({ + name: 'custom', + browser: { + enabled: true, + provider: 'playwright', + instances: [ + { browser: 'firefox' }, + { browser: 'firefox' }, + ], + }, + }) + expect(stderr).toMatch('Cannot define a nested project for a firefox browser. The project name "custom (firefox)" was already defined. If you have multiple instances for the same browser, make sure to define a custom "name". All projects in a workspace should have unique names. Make sure your configuration is correct.') +}) + +test('throws an error if name conflicts with a workspace name', async () => { + const { stderr } = await runVitest({ + workspace: [ + { test: { name: '1 (firefox)' } }, + { + test: { + browser: { + enabled: true, + provider: 'playwright', + instances: [ + { browser: 'firefox' }, + ], + }, + }, + }, + ], + }) + expect(stderr).toMatch('Cannot define a nested project for a firefox browser. The project name "1 (firefox)" was already defined. If you have multiple instances for the same browser, make sure to define a custom "name". All projects in a workspace should have unique names. Make sure your configuration is correct.') +}) + +test('throws an error if several browsers are headed in nonTTY mode', async () => { + const { stderr } = await runVitest({ + browser: { + enabled: true, + provider: 'playwright', + headless: false, + instances: [ + { browser: 'chromium' }, + { browser: 'firefox' }, + ], + }, + }) + expect(stderr).toContain('Found multiple projects that run browser tests in headed mode: "chromium", "firefox"') + expect(stderr).toContain('Please, filter projects with --browser=name or --project=name flag or run tests with "headless: true" option') +}) + test('non existing project name will throw', async () => { const { stderr } = await runVitest({ project: 'non-existing-project' }) expect(stderr).toMatch('No projects matched the filter "non-existing-project".') diff --git a/test/coverage-test/test/isolation.test.ts b/test/coverage-test/test/isolation.test.ts index 8c8cd3c89896..15b05965e40e 100644 --- a/test/coverage-test/test/isolation.test.ts +++ b/test/coverage-test/test/isolation.test.ts @@ -31,7 +31,6 @@ for (const isolate of [true, false]) { reporter: ['json', 'html'], }, - // @ts-expect-error -- merged in runVitest browser: { isolate, }, diff --git a/test/test-utils/index.ts b/test/test-utils/index.ts index cf4eb1e793e9..85b4cbf4c0f2 100644 --- a/test/test-utils/index.ts +++ b/test/test-utils/index.ts @@ -134,7 +134,11 @@ export async function runVitest( } } -export async function runCli(command: string, _options?: Partial | string, ...args: string[]) { +interface CliOptions extends Partial { + earlyReturn?: boolean +} + +export async function runCli(command: string, _options?: CliOptions | string, ...args: string[]) { let options = _options if (typeof _options === 'string') { @@ -172,7 +176,7 @@ export async function runCli(command: string, _options?: Partial | stri await isDone }) - if (args.includes('--inspect') || args.includes('--inspect-brk')) { + if ((options as CliOptions)?.earlyReturn || args.includes('--inspect') || args.includes('--inspect-brk')) { return output() } @@ -192,12 +196,12 @@ export async function runCli(command: string, _options?: Partial | stri return output() } -export async function runVitestCli(_options?: Partial | string, ...args: string[]) { +export async function runVitestCli(_options?: CliOptions | string, ...args: string[]) { process.env.VITE_TEST_WATCHER_DEBUG = 'true' return runCli('vitest', _options, ...args) } -export async function runViteNodeCli(_options?: Partial | string, ...args: string[]) { +export async function runViteNodeCli(_options?: CliOptions | string, ...args: string[]) { process.env.VITE_TEST_WATCHER_DEBUG = 'true' const { vitest, ...rest } = await runCli('vite-node', _options, ...args)