From aa431f4db6198b894810de1a10360cca186c81a4 Mon Sep 17 00:00:00 2001 From: Vladimir Date: Tue, 14 May 2024 19:17:15 +0200 Subject: [PATCH] feat(browser): add commands to communicate betweens server and the browser (#5097) --- docs/config/index.md | 7 + docs/guide/browser.md | 159 ++++++++++++++++++ packages/browser/context.d.ts | 83 +++++++++ packages/browser/context.js | 2 + packages/browser/package.json | 4 + packages/browser/src/node/commands/fs.ts | 34 ++++ packages/browser/src/node/commands/index.ts | 13 ++ .../browser/src/node/commands/keyboard.ts | 92 ++++++++++ packages/browser/src/node/index.ts | 8 +- packages/browser/src/node/plugins/context.ts | 59 +++++++ .../browser/src/node/providers/playwright.ts | 29 ++-- .../browser/src/node/providers/webdriver.ts | 23 +-- packages/vitest/src/api/setup.ts | 12 ++ packages/vitest/src/api/types.ts | 1 + packages/vitest/src/config.ts | 1 + packages/vitest/src/node/cli/cli-config.ts | 1 + packages/vitest/src/node/index.ts | 3 + packages/vitest/src/node/workspace.ts | 4 + packages/vitest/src/types/browser.ts | 17 ++ test/browser/specs/runner.test.ts | 4 +- test/browser/test/commands.test.ts | 147 ++++++++++++++++ test/browser/tsconfig.json | 1 + test/browser/vitest.config.mts | 8 + 23 files changed, 683 insertions(+), 29 deletions(-) create mode 100644 packages/browser/context.d.ts create mode 100644 packages/browser/context.js create mode 100644 packages/browser/src/node/commands/fs.ts create mode 100644 packages/browser/src/node/commands/index.ts create mode 100644 packages/browser/src/node/commands/keyboard.ts create mode 100644 packages/browser/src/node/plugins/context.ts create mode 100644 test/browser/test/commands.test.ts diff --git a/docs/config/index.md b/docs/config/index.md index 63d7a2e61..7c8ae5ef7 100644 --- a/docs/config/index.md +++ b/docs/config/index.md @@ -1679,6 +1679,13 @@ Custom scripts that should be injected into the tester HTML before the tests env The script `src` and `content` will be processed by Vite plugins. +#### browser.commands 2.0.0 {#browser-commands} + +- **Type:** `Record` +- **Default:** `{ readFile, writeFile, ... }` + +Custom [commands](/guide/browser#commands) that can be import during browser tests from `@vitest/browser/commands`. + ### clearMocks - **Type:** `boolean` diff --git a/docs/guide/browser.md b/docs/guide/browser.md index e19dba6cc..c49eb8c06 100644 --- a/docs/guide/browser.md +++ b/docs/guide/browser.md @@ -119,6 +119,165 @@ npx vitest --browser.name=chrome --browser.headless In this case, Vitest will run in headless mode using the Chrome browser. +## Context 2.0.0 {#context} + +Vitest exposes a context module via `@vitest/browser/context` entry point. As of 2.0, it exposes a small set of utilities that might be useful to you in tests. + +```ts +export const server: { + /** + * Platform the Vitest server is running on. + * The same as calling `process.platform` on the server. + */ + platform: Platform + /** + * Runtime version of the Vitest server. + * The same as calling `process.version` on the server. + */ + version: string + /** + * Available commands for the browser. + * @see {@link https://vitest.dev/guide/browser#commands} + */ + commands: BrowserCommands +} + +/** + * Available commands for the browser. + * A shortcut to `server.commands`. + * @see {@link https://vitest.dev/guide/browser#commands} + */ +export const commands: BrowserCommands + +export const page: { + /** + * Serialized test config. + */ + config: ResolvedConfig +} +``` + +## Commands 2.0.0 {#commands} + +Command is a function that invokes another function on the server and passes down the result back to the browser. Vitest exposes several built-in commands you can use in your browser tests. + +## Built-in Commands + +### Files Handling + +You can use `readFile`, `writeFile` and `removeFile` API to handle files inside your browser tests. All paths are resolved relative to the test file even if they are called in a helper function located in another file. + +By default, Vitest uses `utf-8` encoding but you can override it with options. + +::: tip +This API follows [`server.fs`](https://vitejs.dev/config/server-options.html#server-fs-allow) limitations for security reasons. +::: + +```ts +import { server } from '@vitest/browser/context' + +const { readFile, writeFile, removeFile } = server.commands + +it('handles files', async () => { + const file = './test.txt' + + await writeFile(file, 'hello world') + const content = await readFile(file) + + expect(content).toBe('hello world') + + await removeFile(file) +}) +``` + +### Keyboard Interactions + +Vitest also implements Web Test Runner's [`sendKeys` API](https://modern-web.dev/docs/test-runner/commands/#send-keys). It accepts an object with a single property: + +- `type` - types a sequence of characters, this API _is not_ affected by modifier keys, so having `Shift` won't make letters uppercase +- `press` - presses a single key, this API _is_ affected by modifier keys, so having `Shift` will make subsequent characters uppercase +- `up` - holds down a key (supported only with `playwright` provider) +- `down` - releases a key (supported only with `playwright` provider) + +```ts +interface TypePayload { type: string } +interface PressPayload { press: string } +interface DownPayload { down: string } +interface UpPayload { up: string } + +type SendKeysPayload = TypePayload | PressPayload | DownPayload | UpPayload + +declare function sendKeys(payload: SendKeysPayload): Promise +``` + +This is just a simple wrapper around providers APIs. Please refer to their respective documentations for details: + +- [Playwright Keyboard API](https://playwright.dev/docs/api/class-keyboard) +- [Webdriver Keyboard API](https://webdriver.io/docs/api/browser/keys/) + +## 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: + +```ts +import type { Plugin } from 'vitest/config' +import type { BrowserCommand } from 'vitest/node' + +const myCustomCommand: BrowserCommand<[arg1: string, arg2: string]> = ({ + testPath, + provider +}, arg1, arg2) => { + if (provider.name === 'playwright') { + console.log(testPath, arg1, arg2) + return { someValue: true } + } + + throw new Error(`provider ${provider.name} is not supported`) +} + +export default function BrowserCommands(): Plugin { + return { + name: 'vitest:custom-commands', + config() { + return { + test: { + browser: { + commands: { + myCustomCommand, + } + } + } + } + } + } +} +``` + +Then you can call it inside your test by importing it from `@vitest/browser/context`: + +```ts +import { commands } from '@vitest/browser/context' +import { expect, test } from 'vitest' + +test('custom command works correctly', async () => { + const result = await commands.myCustomCommand('test1', 'test2') + expect(result).toEqual({ someValue: true }) +}) + +// if you are using TypeScript, you can augment the module +declare module '@vitest/browser/context' { + interface BrowserCommands { + myCustomCommand: (arg1: string, arg2: string) => Promise<{ + someValue: true + }> + } +} +``` + +::: warning +Custom functions will override built-in ones if they have the same name. +::: + ## Limitations ### Thread Blocking Dialogs diff --git a/packages/browser/context.d.ts b/packages/browser/context.d.ts new file mode 100644 index 000000000..b9ba9fbc9 --- /dev/null +++ b/packages/browser/context.d.ts @@ -0,0 +1,83 @@ +import type { ResolvedConfig } from 'vitest' + +export type BufferEncoding = + | 'ascii' + | 'utf8' + | 'utf-8' + | 'utf16le' + | 'utf-16le' + | 'ucs2' + | 'ucs-2' + | 'base64' + | 'base64url' + | 'latin1' + | 'binary' + | 'hex' + +export interface FsOptions { + encoding?: BufferEncoding + flag?: string | number +} + +export interface TypePayload { type: string } +export interface PressPayload { press: string } +export interface DownPayload { down: string } +export interface UpPayload { up: string } + +export type SendKeysPayload = TypePayload | PressPayload | DownPayload | UpPayload + +export interface BrowserCommands { + readFile: (path: string, options?: BufferEncoding | FsOptions) => Promise + writeFile: (path: string, content: string, options?: BufferEncoding | FsOptions & { mode?: number | string }) => Promise + removeFile: (path: string) => Promise + sendKeys: (payload: SendKeysPayload) => Promise +} + +type Platform = + | 'aix' + | 'android' + | 'darwin' + | 'freebsd' + | 'haiku' + | 'linux' + | 'openbsd' + | 'sunos' + | 'win32' + | 'cygwin' + | 'netbsd' + +export const server: { + /** + * Platform the Vitest server is running on. + * The same as calling `process.platform` on the server. + */ + platform: Platform + /** + * Runtime version of the Vitest server. + * The same as calling `process.version` on the server. + */ + version: string + /** + * Name of the browser provider. + */ + provider: string + /** + * Available commands for the browser. + * @see {@link https://vitest.dev/guide/browser#commands} + */ + commands: BrowserCommands +} + +/** + * Available commands for the browser. + * A shortcut to `server.commands`. + * @see {@link https://vitest.dev/guide/browser#commands} + */ +export const commands: BrowserCommands + +export const page: { + /** + * Serialized test config. + */ + config: ResolvedConfig +} diff --git a/packages/browser/context.js b/packages/browser/context.js new file mode 100644 index 000000000..97779fdf5 --- /dev/null +++ b/packages/browser/context.js @@ -0,0 +1,2 @@ +// empty file to not break bundling +// Vitest resolves "@vitest/browser/context" as a virtual module instead diff --git a/packages/browser/package.json b/packages/browser/package.json index 24da788ca..180603142 100644 --- a/packages/browser/package.json +++ b/packages/browser/package.json @@ -24,6 +24,10 @@ "types": "./providers.d.ts", "default": "./dist/providers.js" }, + "./context": { + "types": "./context.d.ts", + "default": "./context.js" + }, "./providers/webdriverio": { "types": "./providers/webdriverio.d.ts" }, diff --git a/packages/browser/src/node/commands/fs.ts b/packages/browser/src/node/commands/fs.ts new file mode 100644 index 000000000..fc10e7a91 --- /dev/null +++ b/packages/browser/src/node/commands/fs.ts @@ -0,0 +1,34 @@ +import fs, { promises as fsp } from 'node:fs' +import { dirname, resolve } from 'node:path' +import { isFileServingAllowed } from 'vitest/node' +import type { BrowserCommand, WorkspaceProject } from 'vitest/node' +import type { BrowserCommands } from '../../../context' + +function assertFileAccess(path: string, project: WorkspaceProject) { + if (!isFileServingAllowed(path, project.server) && !isFileServingAllowed(path, project.ctx.server)) + throw new Error(`Access denied to "${path}". See Vite config documentation for "server.fs": https://vitejs.dev/config/server-options.html#server-fs-strict.`) +} + +export const readFile: BrowserCommand> = async ({ project, testPath = process.cwd() }, path, options = {}) => { + const filepath = resolve(dirname(testPath), path) + assertFileAccess(filepath, project) + // never return a Buffer + if (typeof options === 'object' && !options.encoding) + options.encoding = 'utf-8' + return fsp.readFile(filepath, options) +} + +export const writeFile: BrowserCommand> = async ({ project, testPath = process.cwd() }, path, data, options) => { + const filepath = resolve(dirname(testPath), path) + assertFileAccess(filepath, project) + const dir = dirname(filepath) + if (!fs.existsSync(dir)) + await fsp.mkdir(dir, { recursive: true }) + await fsp.writeFile(filepath, data, options) +} + +export const removeFile: BrowserCommand> = async ({ project, testPath = process.cwd() }, path) => { + const filepath = resolve(dirname(testPath), path) + assertFileAccess(filepath, project) + await fsp.rm(filepath) +} diff --git a/packages/browser/src/node/commands/index.ts b/packages/browser/src/node/commands/index.ts new file mode 100644 index 000000000..bd42c06fc --- /dev/null +++ b/packages/browser/src/node/commands/index.ts @@ -0,0 +1,13 @@ +import { + readFile, + removeFile, + writeFile, +} from './fs' +import { sendKeys } from './keyboard' + +export default { + readFile, + removeFile, + writeFile, + sendKeys, +} diff --git a/packages/browser/src/node/commands/keyboard.ts b/packages/browser/src/node/commands/keyboard.ts new file mode 100644 index 000000000..5f9dba3f2 --- /dev/null +++ b/packages/browser/src/node/commands/keyboard.ts @@ -0,0 +1,92 @@ +// based on https://github.com/modernweb-dev/web/blob/f7fcf29cb79e82ad5622665d76da3f6b23d0ef43/packages/test-runner-commands/src/sendKeysPlugin.ts + +import type { Page } from 'playwright' +import type { BrowserCommand } from 'vitest/node' +import type { + BrowserCommands, + DownPayload, + PressPayload, + SendKeysPayload, + TypePayload, + UpPayload, +} from '../../../context' + +function isObject(payload: unknown): payload is Record { + return payload != null && typeof payload === 'object' +} + +function isSendKeysPayload(payload: unknown): boolean { + const validOptions = ['type', 'press', 'down', 'up'] + + if (!isObject(payload)) + throw new Error('You must provide a `SendKeysPayload` object') + + const numberOfValidOptions = Object.keys(payload).filter(key => + validOptions.includes(key), + ).length + const unknownOptions = Object.keys(payload).filter(key => !validOptions.includes(key)) + + if (numberOfValidOptions > 1) { + throw new Error( + `You must provide ONLY one of the following properties to pass to the browser runner: ${validOptions.join( + ', ', + )}.`, + ) + } + if (numberOfValidOptions === 0) { + throw new Error( + `You must provide one of the following properties to pass to the browser runner: ${validOptions.join( + ', ', + )}.`, + ) + } + if (unknownOptions.length > 0) + throw new Error(`Unknown options \`${unknownOptions.join(', ')}\` present.`) + + return true +} + +function isTypePayload(payload: SendKeysPayload): payload is TypePayload { + return 'type' in payload +} + +function isPressPayload(payload: SendKeysPayload): payload is PressPayload { + return 'press' in payload +} + +function isDownPayload(payload: SendKeysPayload): payload is DownPayload { + return 'down' in payload +} + +function isUpPayload(payload: SendKeysPayload): payload is UpPayload { + return 'up' in payload +} + +export const sendKeys: BrowserCommand> = async ({ provider }, payload) => { + if (!isSendKeysPayload(payload) || !payload) + throw new Error('You must provide a `SendKeysPayload` object') + + if (provider.name === 'playwright') { + const page = (provider as any).page as Page + if (isTypePayload(payload)) + await page.keyboard.type(payload.type) + else if (isPressPayload(payload)) + await page.keyboard.press(payload.press) + else if (isDownPayload(payload)) + await page.keyboard.down(payload.down) + else if (isUpPayload(payload)) + await page.keyboard.up(payload.up) + } + else if (provider.name === 'webdriverio') { + const browser = (provider as any).browser as WebdriverIO.Browser + if (isTypePayload(payload)) + await browser.keys(payload.type.split('')) + else if (isPressPayload(payload)) + await browser.keys([payload.press]) + else + throw new Error('Only "press" and "type" are supported by webdriverio.') + } + else { + throw new Error(`"sendKeys" is not supported for ${provider.name} browser provider.`) + } +} diff --git a/packages/browser/src/node/index.ts b/packages/browser/src/node/index.ts index c4465f438..cb5332870 100644 --- a/packages/browser/src/node/index.ts +++ b/packages/browser/src/node/index.ts @@ -2,12 +2,15 @@ import { fileURLToPath } from 'node:url' import { readFile } from 'node:fs/promises' import { basename, join, resolve } from 'pathe' import sirv from 'sirv' -import type { Plugin, ViteDevServer } from 'vite' +import type { ViteDevServer } from 'vite' import type { ResolvedConfig } from 'vitest' import type { BrowserScript, WorkspaceProject } from 'vitest/node' -import { coverageConfigDefaults } from 'vitest/config' +import { type Plugin, coverageConfigDefaults } from 'vitest/config' import { slash } from '@vitest/utils' import { injectVitestModule } from './esmInjector' +import BrowserContext from './plugins/context' + +export type { BrowserCommand } from 'vitest/node' export default (project: WorkspaceProject, base = '/'): Plugin[] => { const pkgRoot = resolve(fileURLToPath(import.meta.url), '../..') @@ -187,6 +190,7 @@ export default (project: WorkspaceProject, base = '/'): Plugin[] => { return useId }, }, + BrowserContext(project), { name: 'vitest:browser:esm-injector', enforce: 'post', diff --git a/packages/browser/src/node/plugins/context.ts b/packages/browser/src/node/plugins/context.ts new file mode 100644 index 000000000..2eee16e33 --- /dev/null +++ b/packages/browser/src/node/plugins/context.ts @@ -0,0 +1,59 @@ +import type { Plugin } from 'vitest/config' +import type { WorkspaceProject } from 'vitest/node' +import builtinCommands from '../commands/index' + +const VIRTUAL_ID_CONTEXT = '\0@vitest/browser/context' +const ID_CONTEXT = '@vitest/browser/context' + +export default function BrowserContext(project: WorkspaceProject): Plugin { + 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.`) + } + + return { + name: 'vitest:browser:virtual-module:context', + enforce: 'pre', + resolveId(id) { + if (id === ID_CONTEXT) + return VIRTUAL_ID_CONTEXT + }, + load(id) { + if (id === VIRTUAL_ID_CONTEXT) + return generateContextFile(project) + }, + } +} + +function generateContextFile(project: WorkspaceProject) { + const commands = Object.keys(project.config.browser.commands ?? {}) + const filepathCode = '__vitest_worker__.filepath || __vitest_worker__.current?.file?.filepath || undefined' + + const commandsCode = commands.map((command) => { + return ` ["${command}"]: (...args) => rpc().triggerCommand("${command}", ${filepathCode}, args),` + }).join('\n') + + return ` +const rpc = () => __vitest_worker__.rpc + +export const server = { + platform: ${JSON.stringify(process.platform)}, + version: ${JSON.stringify(process.version)}, + provider: ${JSON.stringify(project.browserProvider!.name)}, + commands: { + ${commandsCode} + } +} +export const commands = server.commands +export const page = { + get config() { + return __vitest_browser_runner__.config + } +} +` +} diff --git a/packages/browser/src/node/providers/playwright.ts b/packages/browser/src/node/providers/playwright.ts index e6cf5c048..d7c19d724 100644 --- a/packages/browser/src/node/providers/playwright.ts +++ b/packages/browser/src/node/providers/playwright.ts @@ -11,9 +11,10 @@ export interface PlaywrightProviderOptions extends BrowserProviderInitialization export class PlaywrightBrowserProvider implements BrowserProvider { public name = 'playwright' - private cachedBrowser: Browser | null = null - private cachedPage: Page | null = null - private browser!: PlaywrightBrowser + public browser: Browser | null = null + public page: Page | null = null + + private browserName!: PlaywrightBrowser private ctx!: WorkspaceProject private options?: { @@ -27,26 +28,26 @@ export class PlaywrightBrowserProvider implements BrowserProvider { initialize(project: WorkspaceProject, { browser, options }: PlaywrightProviderOptions) { this.ctx = project - this.browser = browser + this.browserName = browser this.options = options as any } private async openBrowserPage() { - if (this.cachedPage) - return this.cachedPage + if (this.page) + return this.page const options = this.ctx.config.browser const playwright = await import('playwright') - const browser = await playwright[this.browser].launch({ + const browser = await playwright[this.browserName].launch({ ...this.options?.launch, headless: options.headless, }) - this.cachedBrowser = browser - this.cachedPage = await browser.newPage(this.options?.page) + this.browser = browser + this.page = await browser.newPage(this.options?.page) - return this.cachedPage + return this.page } async openPage(url: string) { @@ -55,10 +56,10 @@ export class PlaywrightBrowserProvider implements BrowserProvider { } async close() { - const page = this.cachedPage - this.cachedPage = null - const browser = this.cachedBrowser - this.cachedBrowser = null + const page = this.page + this.page = null + const browser = this.browser + this.browser = null await page?.close() await browser?.close() } diff --git a/packages/browser/src/node/providers/webdriver.ts b/packages/browser/src/node/providers/webdriver.ts index 5a63411ae..3a768430e 100644 --- a/packages/browser/src/node/providers/webdriver.ts +++ b/packages/browser/src/node/providers/webdriver.ts @@ -11,8 +11,9 @@ interface WebdriverProviderOptions extends BrowserProviderInitializationOptions export class WebdriverBrowserProvider implements BrowserProvider { public name = 'webdriverio' - private cachedBrowser: WebdriverIO.Browser | null = null - private browser!: WebdriverBrowser + public browser: WebdriverIO.Browser | null = null + + private browserName!: WebdriverBrowser private ctx!: WorkspaceProject private options?: RemoteOptions @@ -23,17 +24,17 @@ export class WebdriverBrowserProvider implements BrowserProvider { async initialize(ctx: WorkspaceProject, { browser, options }: WebdriverProviderOptions) { this.ctx = ctx - this.browser = browser + this.browserName = browser this.options = options as RemoteOptions } async openBrowser() { - if (this.cachedBrowser) - return this.cachedBrowser + if (this.browser) + return this.browser const options = this.ctx.config.browser - if (this.browser === 'safari') { + if (this.browserName === 'safari') { if (options.headless) throw new Error('You\'ve enabled headless mode for Safari but it doesn\'t currently support it.') } @@ -41,19 +42,19 @@ export class WebdriverBrowserProvider implements BrowserProvider { const { remote } = await import('webdriverio') // TODO: close everything, if browser is closed from the outside - this.cachedBrowser = await remote({ + this.browser = await remote({ ...this.options, logLevel: 'error', capabilities: this.buildCapabilities(), }) - return this.cachedBrowser + return this.browser } private buildCapabilities() { const capabilities: RemoteOptions['capabilities'] = { ...this.options?.capabilities, - browserName: this.browser, + browserName: this.browserName, } const headlessMap = { @@ -63,7 +64,7 @@ export class WebdriverBrowserProvider implements BrowserProvider { } as const const options = this.ctx.config.browser - const browser = this.browser + const browser = this.browserName if (browser !== 'safari' && options.headless) { const [key, args] = headlessMap[browser] const currentValues = (this.options?.capabilities as any)?.[key] || {} @@ -81,7 +82,7 @@ export class WebdriverBrowserProvider implements BrowserProvider { async close() { await Promise.all([ - this.cachedBrowser?.sessionId ? this.cachedBrowser?.deleteSession?.() : null, + this.browser?.sessionId ? this.browser?.deleteSession?.() : null, ]) // TODO: right now process can only exit with timeout, if we use browser // needs investigating diff --git a/packages/vitest/src/api/setup.ts b/packages/vitest/src/api/setup.ts index 40d533409..484ce3ebb 100644 --- a/packages/vitest/src/api/setup.ts +++ b/packages/vitest/src/api/setup.ts @@ -151,6 +151,18 @@ export function setup(vitestOrWorkspace: Vitest | WorkspaceProject, _server?: Vi }, // TODO: have a separate websocket conection for private browser API + triggerCommand(command: string, testPath: string | undefined, payload: unknown[]) { + if (!('ctx' in vitestOrWorkspace) || !vitestOrWorkspace.browserProvider) + throw new Error('Commands are only available for browser tests.') + const commands = vitestOrWorkspace.config.browser?.commands + if (!commands || !commands[command]) + throw new Error(`Unknown command "${command}".`) + return commands[command]({ + testPath, + project: vitestOrWorkspace, + provider: vitestOrWorkspace.browserProvider, + }, ...payload) + }, getBrowserFiles() { if (!('ctx' in vitestOrWorkspace)) throw new Error('`getBrowserTestFiles` is only available in the browser API') diff --git a/packages/vitest/src/api/types.ts b/packages/vitest/src/api/types.ts index 5a72b2a4c..273e5337f 100644 --- a/packages/vitest/src/api/types.ts +++ b/packages/vitest/src/api/types.ts @@ -37,6 +37,7 @@ export interface WebSocketHandlers { finishBrowserTests: () => void getBrowserFiles: () => string[] debug: (...args: string[]) => void + triggerCommand: (command: string, testPath: string | undefined, payload: unknown[]) => Promise } export interface WebSocketEvents extends Pick { diff --git a/packages/vitest/src/config.ts b/packages/vitest/src/config.ts index e207476f3..76d07b1a6 100644 --- a/packages/vitest/src/config.ts +++ b/packages/vitest/src/config.ts @@ -9,6 +9,7 @@ export interface UserWorkspaceConfig extends ViteUserConfig { export { configDefaults, defaultInclude, defaultExclude, coverageConfigDefaults } from './defaults' export { mergeConfig } from 'vite' export { extraInlineDeps } from './constants' +export type { Plugin } from 'vite' export type { ConfigEnv, ViteUserConfig as UserConfig } export type UserConfigFnObject = (env: ConfigEnv) => ViteUserConfig diff --git a/packages/vitest/src/node/cli/cli-config.ts b/packages/vitest/src/node/cli/cli-config.ts index 6acc96b4a..f32baed7e 100644 --- a/packages/vitest/src/node/cli/cli-config.ts +++ b/packages/vitest/src/node/cli/cli-config.ts @@ -352,6 +352,7 @@ export const cliOptionsConfig: VitestCLIOptions = { }, indexScripts: null, testerScripts: null, + commands: null, }, }, pool: { diff --git a/packages/vitest/src/node/index.ts b/packages/vitest/src/node/index.ts index 92bacfeae..7781136ab 100644 --- a/packages/vitest/src/node/index.ts +++ b/packages/vitest/src/node/index.ts @@ -18,7 +18,10 @@ export type { BrowserProvider, BrowserProviderOptions, BrowserScript, + BrowserCommand, } from '../types/browser' export type { JsonOptions } from './reporters/json' export type { JUnitOptions } from './reporters/junit' export type { HTMLOptions } from './reporters/html' + +export { isFileServingAllowed } from 'vite' diff --git a/packages/vitest/src/node/workspace.ts b/packages/vitest/src/node/workspace.ts index 4d92596e3..697c8b547 100644 --- a/packages/vitest/src/node/workspace.ts +++ b/packages/vitest/src/node/workspace.ts @@ -398,6 +398,10 @@ export class WorkspaceProject { ...this.server?.config.env, ...this.config.env, }, + browser: { + ...this.ctx.config.browser, + commands: {}, + }, }, this.ctx.configOverride || {} as any) as ResolvedConfig } diff --git a/packages/vitest/src/types/browser.ts b/packages/vitest/src/types/browser.ts index 5beb46cd6..f7987cf7b 100644 --- a/packages/vitest/src/types/browser.ts +++ b/packages/vitest/src/types/browser.ts @@ -103,6 +103,23 @@ export interface BrowserConfigOptions { * Scripts injected into the main window. */ indexScripts?: BrowserScript[] + + /** + * Commands that will be executed on the server + * via the browser `import("@vitest/browser/context").commands` API. + * @see {@link https://vitest.dev/guide/browser#commands} + */ + commands?: Record> +} + +export interface BrowserCommandContext { + testPath: string | undefined + provider: BrowserProvider + project: WorkspaceProject +} + +export interface BrowserCommand { + (context: BrowserCommandContext, ...payload: Payload): Awaitable } export interface BrowserScript { diff --git a/test/browser/specs/runner.test.ts b/test/browser/specs/runner.test.ts index 680cd1f0d..99085ca1a 100644 --- a/test/browser/specs/runner.test.ts +++ b/test/browser/specs/runner.test.ts @@ -30,8 +30,8 @@ describe.each([ console.error(stderr) }) - expect(browserResultJson.testResults).toHaveLength(15) - expect(passedTests).toHaveLength(13) + expect(browserResultJson.testResults).toHaveLength(16) + expect(passedTests).toHaveLength(14) expect(failedTests).toHaveLength(2) expect(stderr).not.toContain('has been externalized for browser compatibility') diff --git a/test/browser/test/commands.test.ts b/test/browser/test/commands.test.ts new file mode 100644 index 000000000..a14c7c1da --- /dev/null +++ b/test/browser/test/commands.test.ts @@ -0,0 +1,147 @@ +import { commands, server } from '@vitest/browser/context' +import { expect, it } from 'vitest' + +const { readFile, writeFile, removeFile, sendKeys, myCustomCommand } = server.commands + +it('can manipulate files', async () => { + const file = './test.txt' + + try { + await readFile(file) + expect.unreachable() + } + catch (err) { + expect(err.message).toMatch(`ENOENT: no such file or directory, open`) + if (server.platform === 'win32') + expect(err.message).toMatch('test\\browser\\test\\test.txt') + else + expect(err.message).toMatch('test/browser/test/test.txt') + } + + await writeFile(file, 'hello world') + const content = await readFile(file) + + expect(content).toBe('hello world') + + await removeFile(file) + + try { + await readFile(file) + expect.unreachable() + } + catch (err) { + expect(err.message).toMatch(`ENOENT: no such file or directory, open`) + if (server.platform === 'win32') + expect(err.message).toMatch('test\\browser\\test\\test.txt') + else + expect(err.message).toMatch('test/browser/test/test.txt') + } +}) + +// Test Cases from https://modern-web.dev/docs/test-runner/commands/#writing-and-reading-files +it('natively types into an input', async () => { + const keys = 'abc123' + const input = document.createElement('input') + document.body.append(input) + input.focus() + + await commands.sendKeys({ + type: keys, + }) + + expect(input.value).to.equal(keys) + input.remove() +}) + +it('natively presses `Tab`', async () => { + const input1 = document.createElement('input') + const input2 = document.createElement('input') + document.body.append(input1, input2) + input1.focus() + expect(document.activeElement).to.equal(input1) + + await commands.sendKeys({ + press: 'Tab', + }) + + expect(document.activeElement).to.equal(input2) + input1.remove() + input2.remove() +}) + +it.skipIf(server.provider === 'webdriverio')('natively presses `Shift+Tab`', async () => { + const input1 = document.createElement('input') + const input2 = document.createElement('input') + document.body.append(input1, input2) + input2.focus() + expect(document.activeElement).to.equal(input2) + + await sendKeys({ + down: 'Shift', + }) + await sendKeys({ + press: 'Tab', + }) + await sendKeys({ + up: 'Shift', + }) + + expect(document.activeElement).to.equal(input1) + input1.remove() + input2.remove() +}) + +it.skipIf(server.provider === 'webdriverio')('natively holds and then releases a key', async () => { + const input = document.createElement('input') + document.body.append(input) + input.focus() + + await sendKeys({ + down: 'Shift', + }) + // Note that pressed modifier keys are only respected when using `press` or + // `down`, and only when using the `Key...` variants. + await sendKeys({ + press: 'KeyA', + }) + await sendKeys({ + press: 'KeyB', + }) + await sendKeys({ + press: 'KeyC', + }) + await sendKeys({ + up: 'Shift', + }) + await sendKeys({ + press: 'KeyA', + }) + await sendKeys({ + press: 'KeyB', + }) + await sendKeys({ + press: 'KeyC', + }) + + expect(input.value).to.equal('ABCabc') + input.remove() +}) + +it('can run custom commands', async () => { + const result = await myCustomCommand('arg1', 'arg2') + expect(result).toEqual({ + testPath: expect.stringMatching('test/browser/test/commands.test.ts'), + arg1: 'arg1', + arg2: 'arg2', + }) +}) + +declare module '@vitest/browser/context' { + interface BrowserCommands { + myCustomCommand: (arg1: string, arg2: string) => Promise<{ + testPath: string + arg1: string + arg2: string + }> + } +} diff --git a/test/browser/tsconfig.json b/test/browser/tsconfig.json index 183104f03..dd26cb7f7 100644 --- a/test/browser/tsconfig.json +++ b/test/browser/tsconfig.json @@ -5,6 +5,7 @@ "paths": { "#src/*": ["./src/*"] }, + "types": ["vite/client"], "esModuleInterop": true } } diff --git a/test/browser/vitest.config.mts b/test/browser/vitest.config.mts index 75f3435ad..2308d6c4c 100644 --- a/test/browser/vitest.config.mts +++ b/test/browser/vitest.config.mts @@ -1,6 +1,7 @@ import { dirname, resolve } from 'node:path' import { fileURLToPath } from 'node:url' import { defineConfig } from 'vitest/config' +import type { BrowserCommand } from 'vitest/node' const dir = dirname(fileURLToPath(import.meta.url)) @@ -9,6 +10,10 @@ function noop() {} const provider = process.env.PROVIDER || 'playwright' const browser = process.env.BROWSER || (provider === 'playwright' ? 'chromium' : 'chrome') +const myCustomCommand: BrowserCommand<[arg1: string, arg2: string]> = ({ testPath }, arg1, arg2) => { + return { testPath, arg1, arg2 } +} + export default defineConfig({ server: { headers: { @@ -60,6 +65,9 @@ export default defineConfig({ content: 'if(__injected[0] !== 3) throw new Error("injected not working")', }, ], + commands: { + myCustomCommand, + }, }, alias: { '#src': resolve(dir, './src'), -- 2.51.2