From 7844f6d85688ce2f0accac657e09fffd6153ddea Mon Sep 17 00:00:00 2001 From: Vladimir Sheremet Date: Thu, 1 Feb 2024 11:49:47 +0100 Subject: [PATCH] feat(browser): add commands to communicate betweens server and the browser --- packages/browser/commands.d.ts | 18 ++++ 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 | 27 ++++++ .../browser/src/node/providers/playwright.ts | 33 ++++--- .../browser/src/node/providers/webdriver.ts | 23 ++--- packages/browser/src/node/types.ts | 5 + packages/vitest/src/api/setup.ts | 8 ++ packages/vitest/src/api/types.ts | 1 + packages/vitest/src/node/workspace.ts | 4 + packages/vitest/src/types/browser.ts | 7 ++ 12 files changed, 240 insertions(+), 25 deletions(-) create mode 100644 packages/browser/commands.d.ts 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/types.ts diff --git a/packages/browser/commands.d.ts b/packages/browser/commands.d.ts new file mode 100644 index 000000000000..30d8c97ea2a0 --- /dev/null +++ b/packages/browser/commands.d.ts @@ -0,0 +1,18 @@ +declare module '$commands' { + interface FsOptions { + encoding?: BufferEncoding + flag?: string | number + } + + interface TypePayload { type: string } + interface PressPayload { press: string } + interface DownPayload { down: string } + interface UpPayload { up: string } + + type SendKeysPayload = TypePayload | PressPayload | DownPayload | UpPayload + + export const readFile: (path: string, options?: BufferEncoding | FsOptions) => Promise + export const writeFile: (path: string, data: string, options?: BufferEncoding | FsOptions & { mode?: number | string }) => Promise + export const removeFile: (path: string) => Promise + export const sendKeys: (keys: SendKeysPayload) => Promise +} diff --git a/packages/browser/src/node/commands/fs.ts b/packages/browser/src/node/commands/fs.ts new file mode 100644 index 000000000000..ff3e00f5a684 --- /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 'vite' +import type { WorkspaceProject } from 'vitest/node' +import type { BrowserCommand } from '../types' + +export interface FsOptions { + encoding?: BufferEncoding + flag?: string | number +} + +function assertFileAccess(path: string, project: WorkspaceProject) { + const resolvedPath = resolve(path) + if (!isFileServingAllowed(resolvedPath, project.server) && !isFileServingAllowed(resolvedPath, project.ctx.server)) + throw new Error(`Access denied to "${resolvedPath}". See Vite config documentation for "server.fs": https://vitejs.dev/config/server-options.html#server-fs-strict.`) +} + +export const readFile: BrowserCommand<[string, BufferEncoding | FsOptions]> = async ([path, options], { project }) => { + assertFileAccess(path, project) + return fsp.readFile(path, options).catch(() => null) +} + +export const writeFile: BrowserCommand<[string, string, BufferEncoding | FsOptions & { mode?: number | string }]> = async ([path, data, options], { project }) => { + assertFileAccess(path, project) + const dir = dirname(path) + if (!fs.existsSync(dir)) + await fsp.mkdir(dir, { recursive: true }) + await fsp.writeFile(path, data, options).catch(() => null) +} + +export const removeFile: BrowserCommand<[string]> = async ([path], { project }) => { + assertFileAccess(path, project) + await fsp.rm(path).catch(() => null) +} diff --git a/packages/browser/src/node/commands/index.ts b/packages/browser/src/node/commands/index.ts new file mode 100644 index 000000000000..bd42c06fce08 --- /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 000000000000..c64f0142ec7c --- /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 '../types' + +// TODO: remove repetition from commands.d.ts +interface TypePayload { type: string } +interface PressPayload { press: string } +interface DownPayload { down: string } +interface UpPayload { up: string } + +export type SendKeysPayload = TypePayload | PressPayload | DownPayload | UpPayload + +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<[SendKeysPayload]> = async ([payload], { provider }) => { + 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 c4465f438326..fb1439f929e4 100644 --- a/packages/browser/src/node/index.ts +++ b/packages/browser/src/node/index.ts @@ -8,11 +8,16 @@ import type { BrowserScript, WorkspaceProject } from 'vitest/node' import { coverageConfigDefaults } from 'vitest/config' import { slash } from '@vitest/utils' import { injectVitestModule } from './esmInjector' +import builtinCommands from './commands' export default (project: WorkspaceProject, base = '/'): Plugin[] => { const pkgRoot = resolve(fileURLToPath(import.meta.url), '../..') const distRoot = resolve(pkgRoot, 'dist') + project.config.browser.commands ??= {} + for (const [name, command] of Object.entries(builtinCommands)) + project.config.browser.commands[name] ??= command + return [ { enforce: 'pre', @@ -187,6 +192,28 @@ export default (project: WorkspaceProject, base = '/'): Plugin[] => { return useId }, }, + { + name: 'vitest:browser:virtual-module:commands', + resolveId(id) { + if (id === '$commands') + return '\0$commands' + }, + load(id) { + if (id === '\0$commands') { + const commands = Object.keys(project.config.browser.commands ?? {}) + const code = ` +const rpc = () => __vitest_worker__.rpc +${commands.map((command) => { + // TODO: refactor into a separate function + if (!/^[a-zA-Z_$][a-zA-Z_$0-9]*$/.test(command)) + throw new Error(`Invalid command name "${command}". Only alphanumeric characters, $ and _ are allowed.`) + return `export const ${command} = (...args) => rpc().triggerCommand('${command}', args)` +})} + ` + return code + } + }, + }, { name: 'vitest:browser:esm-injector', enforce: 'post', diff --git a/packages/browser/src/node/providers/playwright.ts b/packages/browser/src/node/providers/playwright.ts index e6cf5c048fcf..e9d746594f90 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,30 @@ 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) + + this.page.on('close', () => { + browser.close() + }) - return this.cachedPage + return this.page } async openPage(url: string) { @@ -55,10 +60,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 5a63411ae9b2..3a768430e203 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/browser/src/node/types.ts b/packages/browser/src/node/types.ts new file mode 100644 index 000000000000..26accfb06087 --- /dev/null +++ b/packages/browser/src/node/types.ts @@ -0,0 +1,5 @@ +import type { BrowserProvider, WorkspaceProject } from 'vitest/node' + +export interface BrowserCommand { + (payload: T, options: { provider: BrowserProvider; project: WorkspaceProject }): void +} diff --git a/packages/vitest/src/api/setup.ts b/packages/vitest/src/api/setup.ts index 37c3bb92dbb2..3eebce76981e 100644 --- a/packages/vitest/src/api/setup.ts +++ b/packages/vitest/src/api/setup.ts @@ -151,6 +151,14 @@ export function setup(vitestOrWorkspace: Vitest | WorkspaceProject, _server?: Vi }, // TODO: have a separate websocket conection for private browser API + triggerCommand(command: string, 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](payload, { project: vitestOrWorkspace, provider: vitestOrWorkspace.browserProvider }) + }, 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 45afb11fdf6e..3b3b50ed07b9 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, payload: unknown[]) => Promise } export interface WebSocketEvents extends Pick { diff --git a/packages/vitest/src/node/workspace.ts b/packages/vitest/src/node/workspace.ts index 4d92596e3bc8..697c8b5474b6 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 5beb46cd6879..28b795df24a4 100644 --- a/packages/vitest/src/types/browser.ts +++ b/packages/vitest/src/types/browser.ts @@ -103,6 +103,13 @@ export interface BrowserConfigOptions { * Scripts injected into the main window. */ indexScripts?: BrowserScript[] + + // TODO + commands?: Record +} + +interface BrowserCommand { + (payload: any, options: { provider: BrowserProvider; project: WorkspaceProject }): Awaitable } export interface BrowserScript {