Skip to content

Commit

Permalink
feat(browser): add commands to communicate betweens server and the br…
Browse files Browse the repository at this point in the history
…owser
  • Loading branch information
sheremet-va committed Feb 1, 2024
1 parent 1fa90d1 commit 015216e
Show file tree
Hide file tree
Showing 12 changed files with 239 additions and 28 deletions.
18 changes: 18 additions & 0 deletions packages/browser/commands.d.ts
Original file line number Diff line number Diff line change
@@ -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<string | null>
export const writeFile: (path: string, data: string, options?: BufferEncoding | FsOptions & { mode?: number | string }) => Promise<void>
export const removeFile: (path: string) => Promise<void>
export const sendKeys: (keys: SendKeysPayload) => Promise<void>
}
34 changes: 34 additions & 0 deletions packages/browser/src/node/commands/fs.ts
Original file line number Diff line number Diff line change
@@ -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)
}
13 changes: 13 additions & 0 deletions packages/browser/src/node/commands/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
import {
readFile,
removeFile,
writeFile,
} from './fs'
import { sendKeys } from './keyboard'

export default {
readFile,
removeFile,
writeFile,
sendKeys,
}
92 changes: 92 additions & 0 deletions packages/browser/src/node/commands/keyboard.ts
Original file line number Diff line number Diff line change
@@ -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<string, unknown> {
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.`)
}
}
27 changes: 27 additions & 0 deletions packages/browser/src/node/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,11 +5,16 @@ import sirv from 'sirv'
import type { Plugin } from 'vite'
import type { WorkspaceProject } from 'vitest/node'
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',
Expand Down Expand Up @@ -102,6 +107,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',
Expand Down
35 changes: 18 additions & 17 deletions packages/browser/src/node/providers/playwright.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,9 +13,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?: {
Expand All @@ -29,36 +30,36 @@ 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.cachedPage.on('close', () => {
this.page.on('close', () => {
browser.close()
})

return this.cachedPage
return this.page
}

catchError(cb: (error: Error) => Awaitable<void>) {
this.cachedPage?.on('pageerror', cb)
this.page?.on('pageerror', cb)
return () => {
this.cachedPage?.off('pageerror', cb)
this.page?.off('pageerror', cb)
}
}

Expand All @@ -68,10 +69,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()
}
Expand Down
23 changes: 12 additions & 11 deletions packages/browser/src/node/providers/webdriver.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,8 +13,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
Expand All @@ -25,37 +26,37 @@ 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.')
}

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 = {
Expand All @@ -65,7 +66,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] || {}
Expand All @@ -88,7 +89,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
Expand Down
5 changes: 5 additions & 0 deletions packages/browser/src/node/types.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
import type { BrowserProvider, WorkspaceProject } from 'vitest/node'

export interface BrowserCommand<T extends any[]> {
(payload: T, options: { provider: BrowserProvider; project: WorkspaceProject }): void
}
8 changes: 8 additions & 0 deletions packages/vitest/src/api/setup.ts
Original file line number Diff line number Diff line change
Expand Up @@ -143,6 +143,14 @@ export function setup(vitestOrWorkspace: Vitest | WorkspaceProject, _server?: Vi
getCountOfFailedTests() {
return ctx.state.getCountOfFailedTests()
},
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 })
},
// browser should have a separate RPC in the future, UI doesn't care for provided context
getProvidedContext() {
return 'ctx' in vitestOrWorkspace ? vitestOrWorkspace.getProvidedContext() : ({} as any)
Expand Down
1 change: 1 addition & 0 deletions packages/vitest/src/api/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ export interface WebSocketHandlers {
updateSnapshot(file?: File): Promise<void>
getProvidedContext(): ProvidedContext
getUnhandledErrors(): unknown[]
triggerCommand(command: string, payload: unknown[]): Promise<void>
}

export interface WebSocketEvents extends Pick<Reporter, 'onCollected' | 'onFinished' | 'onTaskUpdate' | 'onUserConsoleLog' | 'onPathsCollected'> {
Expand Down
4 changes: 4 additions & 0 deletions packages/vitest/src/node/workspace.ts
Original file line number Diff line number Diff line change
Expand Up @@ -368,6 +368,10 @@ export class WorkspaceProject {
inspect: this.ctx.config.inspect,
inspectBrk: this.ctx.config.inspectBrk,
alias: [],
browser: {
...this.ctx.config.browser,
commands: {},
},
}, this.ctx.configOverride || {} as any) as ResolvedConfig
}

Expand Down
Loading

0 comments on commit 015216e

Please sign in to comment.