import path from 'node:path' import { EventEmitter } from 'node:events' import { describe, it, expect, vi, beforeEach } from 'vitest' import logger from '@wdio/logger' import type { CommandEndpoint } from '@wdio/protocols' import type { Options } from '@wdio/types' import '../src/browser.js' // @ts-expect-error mock feature import { WebDriverRequest as RequestMock, thenMock, catchMock, makeRequestMock, getCapturedRequestHandler } from '../src/request/request.js' import commandWrapper from '../src/command.js' import type { BaseClient } from '../src/types.js' import { CommandRuntimeOptions } from '../src/types.js' import type { RequestEventHandler } from '../build/request/types.js' import type { RequestOptions } from '../src/request/types.js' vi.mock('@wdio/logger', () => import(path.join(process.cwd(), '__mocks__', '@wdio/logger'))) vi.mock('fetch') const log = logger('webdriver') const commandPath = '/session/:sessionId/element/:elementId/element' const commandMethod = 'POST' const commandEndpoint: CommandEndpoint = { command: 'findElementFromElement', ref: 'https://w3c.github.io/webdriver/webdriver-spec.html#dfn-find-element-from-element', description: '', variables: [{ name: 'elementId', description: 'the id of an element returned in a previous call to Find Element(s)' }], parameters: [{ name: 'using', type: 'string', description: 'a valid element location strategy', required: true }, { name: 'value', type: 'string', description: 'the actual selector that will be used to find an element', required: true }, { name: 'customParam', type: 'number', description: 'a random not required param', required: false }, { name: 'customArrayParam', type: '(string|object|number|boolean|undefined)[]', description: 'a random not required param', required: false }] } const requestHandler = { onPerformance: expect.any(Function), onRequest: expect.any(Function), onResponse: expect.any(Function), onRetry: expect.any(Function), onLogData: expect.any(Function) } vi.mock('../src/request/request', () => { const thenMock = vi.fn() const finallyMock = vi.fn() const catchMock = vi.fn().mockReturnValue({ finally: finallyMock }) const promise = { then: thenMock, catch: catchMock, finally: finallyMock } const makeRequestMock = vi.fn().mockReturnValue(promise) thenMock.mockReturnValue(promise) const capturedRequestEventHandler = vi.fn<() => RequestEventHandler>() // Mock the constructor const WebDriverRequest = vi.fn().mockImplementation(( _method, _endpoint, _body, _abortSignal, _isHubCommand, requestEventHandler: RequestEventHandler ) => { capturedRequestEventHandler.mockReturnValue(requestEventHandler) return { makeRequest: makeRequestMock } }) return { thenMock, catchMock, finallyMock, WebDriverRequest, makeRequestMock, getCapturedRequestHandler: capturedRequestEventHandler } }) class FakeClient extends EventEmitter { isW3C = false isChromium = false isAndroid = false isMobile = false isIOS = false isSauce = false isFirefox = false isBidi = false isWindowsApp = false isMacApp = false isSeleniumStandalone = false isNativeContext = false mobileContext = '' sessionId = '123' capabilities = {} requestedCapabilities = {} options = { logLevel: 'warn' as Options.WebDriverLogTypes, headers: { 'custom-header': 'custom-value' }, } as Partial emit = vi.fn() on = vi.fn() } const scope: BaseClient = new FakeClient() type mockResponse = (...args: any[]) => any describe('command wrapper', () => { beforeEach(() => { vi.mocked(log.warn).mockClear() vi.mocked(log.info).mockClear() vi.mocked(scope.emit).mockClear() vi.mocked(scope.on).mockClear() vi.mocked(thenMock).mockClear() vi.mocked(catchMock).mockClear() vi.mocked(getCapturedRequestHandler).mockClear() }) it('should fail if wrong arguments are passed in', async () => { const commandFn = commandWrapper(commandMethod, commandPath, commandEndpoint).bind({} as unknown) await expect(commandFn) .rejects .toThrow(/Wrong parameters applied for findElementFromElement/) }) it('should fail if arguments are malformed', async () => { const commandFn = commandWrapper(commandMethod, commandPath, commandEndpoint) await expect(() => commandFn.call(scope, '123', 123, '123')) .rejects .toThrow(/Malformed type for "using" parameter of command/) }) it('should fail if not required param has wrong type', async () => { const commandFn = commandWrapper(commandMethod, commandPath, commandEndpoint) await expect(() => commandFn.call(scope, '123', '123', '123', 'foobar')) .rejects .toThrow(/Malformed type for "customParam" parameter of command/) }) it('should throw if param type within array is not met', async () => { expect.assertions(1) const commandFn = commandWrapper(commandMethod, commandPath, commandEndpoint) await expect(commandFn.call(scope, '123', '123', '123', 234, () => {})) .rejects .toThrow(/Actual: \(function\)\[\]/) }) it('should do a proper request', async () => { const commandFn = commandWrapper(commandMethod, commandPath, commandEndpoint) await commandFn.call(scope, '123', 'css selector', '#body', undefined) as unknown as mockResponse const thenCallback = thenMock.mock.calls[0][0] expect(thenCallback({ value: 14 })).toBe(14) vi.mocked(thenMock).mockClear() expect(RequestMock).toHaveBeenCalledWith( 'POST', '/session/:sessionId/element/123/element', { using: 'css selector', value: '#body' }, expect.any(AbortSignal), false, requestHandler ) }) it('should do a proper request with non required params', async () => { const commandFn = commandWrapper(commandMethod, commandPath, commandEndpoint) await commandFn.call(scope, '123', 'css selector', '#body', 123) as unknown as mockResponse expect(RequestMock).toHaveBeenCalledWith( 'POST', '/session/:sessionId/element/123/element', { using: 'css selector', value: '#body', customParam: 123 }, expect.any(AbortSignal), false, requestHandler ) }) it('should encode uri parameters', async () => { const commandFn = commandWrapper(commandMethod, commandPath, commandEndpoint) await commandFn.call(scope, '/path', 'css selector', '#body', 123) expect(RequestMock).toHaveBeenCalledWith( 'POST', '/session/:sessionId/element/%2Fpath/element', expect.anything(), expect.any(AbortSignal), false, requestHandler ) }) it('should double encode uri parameters if using selenium', async () => { const commandFn = commandWrapper(commandMethod, commandPath, commandEndpoint, true) await commandFn.call(scope, '/path', 'css selector', '#body', 123) expect(RequestMock).toHaveBeenCalledWith( 'POST', '/session/:sessionId/element/%252Fpath/element', expect.anything(), expect.any(AbortSignal), false, requestHandler ) expect(log.warn).toHaveBeenCalledTimes(0) }) it('should register abort listener', async () => { scope.sessionId = '456' // Emulate new session const commandFn = commandWrapper(commandMethod, commandPath, commandEndpoint, true) await commandFn.call(scope, '/path', 'css selector', '#body', 123) expect(scope.on).toHaveBeenCalledTimes(1) expect(scope.on).toHaveBeenLastCalledWith('result', expect.any(Function)) }) it('should register abort listener once when request was called multiple times', async () => { scope.sessionId = '789' // Emulate new session const commandFn = commandWrapper(commandMethod, commandPath, commandEndpoint, true) await commandFn.call(scope, '/path', 'css selector', '#body', 123) await commandFn.call(scope, '/path', 'css selector', '#body', 123) expect(scope.on).toHaveBeenCalledTimes(1) }) it('should log deprecation notice', async () => { const deprecatedCommandEndpoint = { deprecated: 'This command will soon be deprecated.', ...commandEndpoint } const commandFn = commandWrapper(commandMethod, commandPath, deprecatedCommandEndpoint) await commandFn.call(scope, '123', 'css selector', '#body', undefined) as unknown as mockResponse expect(log.warn).toBeCalledWith('The "findElementFromElement" command will soon be deprecated.') }) it('should emit result when request throws', async () => { const commandFn = commandWrapper(commandMethod, commandPath, commandEndpoint) await commandFn.call(scope, '/path', 'css selector', '#body') const errorCallback = catchMock.mock.calls[0][0] const error = new Error('Request failed') expect(() => errorCallback(error)).toThrow(error) expect(scope.emit).toBeCalledWith('result', { command: 'findElementFromElement', method: 'POST', endpoint: '/session/:sessionId/element/%2Fpath/element', body: { using: 'css selector', value: '#body' }, result: { error } }) }) describe('masking command', () => { const maskCommandEndpoint: CommandEndpoint = { command: 'elementSendKeys', ref: 'https://w3c.github.io/webdriver/#dfn-element-send-keys', description: '', variables: [{ name: 'elementId', description: 'the id of an element returned in a previous call to Find Element(s)' }], parameters: [{ name: 'text', type: 'string', description: 'the text to be sent to the element', required: true }] } const elementId = '123' const text = 'mySecretPassword' const runtimeOptionsWithMasking = new CommandRuntimeOptions({ mask: true }) it('should do the http request with the unmasked text when masking is requested', async () => { const commandFn = commandWrapper(commandMethod, commandPath, maskCommandEndpoint) await commandFn.call(scope, elementId, text, runtimeOptionsWithMasking) expect(RequestMock).toHaveBeenCalledWith( expect.any(String), expect.any(String), { text: 'mySecretPassword', }, expect.any(AbortSignal), false, requestHandler ) }) it('should emit "result" with masked text on success', async () => { const protocolCommandFunction = commandWrapper(commandMethod, commandPath, maskCommandEndpoint) const test = await protocolCommandFunction.call(scope, elementId, text, runtimeOptionsWithMasking) vi.mocked(scope.emit).mockClear() const thenCallback = thenMock.mock.calls[0][0] thenCallback({}) expect(scope.emit).toBeCalledWith('result', expect.objectContaining({ body: { text: '**MASKED**' }, })) }) it('should emit "result" with masked text on error', async () => { const protocolCommandFunction = commandWrapper(commandMethod, commandPath, maskCommandEndpoint) const test = await protocolCommandFunction.call(scope, elementId, text, runtimeOptionsWithMasking) vi.mocked(scope.emit).mockClear() const errorCallback = catchMock.mock.calls[0][0] const error = new Error('Request failed') expect(() => errorCallback(error)).toThrow(error) expect(scope.emit).toBeCalledWith('result', expect.objectContaining({ body: { text: '**MASKED**' }, })) }) it('should emit "command" with masked text', async () => { const protocolCommandFunction = commandWrapper(commandMethod, commandPath, maskCommandEndpoint) await protocolCommandFunction.call(scope, elementId, text, runtimeOptionsWithMasking) expect(scope.emit).toBeCalledWith('command', expect.objectContaining({ body: { text: '**MASKED**' }, })) }) it('should emit "request.performance" with masked text when using the RequestEventHandler.onPerformance', async () => { const protocolCommandFunction = commandWrapper(commandMethod, commandPath, maskCommandEndpoint) await protocolCommandFunction.call(scope, elementId, text, runtimeOptionsWithMasking) vi.mocked(scope.emit).mockClear() getCapturedRequestHandler().onPerformance({ data: 'data', request: { body: { text: 'mySecretPassword' } } }) expect(scope.emit).toBeCalledWith('request.performance', expect.objectContaining({ request: { body: { text: '**MASKED**' } }, })) }) it('should emit "request.start" with masked text when using the RequestEventHandler.onRequest', async () => { const protocolCommandFunction = commandWrapper(commandMethod, commandPath, maskCommandEndpoint) await protocolCommandFunction.call(scope, elementId, text, runtimeOptionsWithMasking) vi.mocked(scope.emit).mockClear() getCapturedRequestHandler().onRequest({ data: 'data', body: { text: 'mySecretPassword' } }) expect(scope.emit).toBeCalledWith('request.start', expect.objectContaining( { body: { text: '**MASKED**' } } )) }) it('should log "DATA" with masked text when using the RequestEventHandle.onLogData', async () => { const protocolCommandFunction = commandWrapper(commandMethod, commandPath, maskCommandEndpoint) await protocolCommandFunction.call(scope, elementId, text, runtimeOptionsWithMasking) vi.mocked(scope.emit).mockClear() getCapturedRequestHandler().onLogData({ text: 'mySecretPassword' }) expect(log.info).toBeCalledWith('DATA', { text: '**MASKED**' }) }) it('should log "COMMAND" with masked text', async () => { const protocolCommandFunction = commandWrapper(commandMethod, commandPath, maskCommandEndpoint) await protocolCommandFunction.call(scope, elementId, text, runtimeOptionsWithMasking) expect(log.info).toBeCalledWith('COMMAND', 'elementSendKeys("123", "**MASKED**")') }) it('should makeRequest with the appium header for sensitive data', async () => { const protocolCommandFunction = commandWrapper(commandMethod, commandPath, maskCommandEndpoint) await protocolCommandFunction.call(scope, elementId, text, runtimeOptionsWithMasking) expect(makeRequestMock).toHaveBeenCalledWith(expect.objectContaining( { headers: { 'custom-header': 'custom-value', 'x-appium-is-sensitive': 'true' } } ), expect.any(String)) expect(scope.options.headers).toEqual({ 'custom-header': 'custom-value', }) }) }) }) describe('command wrapper result log', () => { async function getRequestCallback (method: string, path: string, endpoint: CommandEndpoint) { const commandFn = commandWrapper(method, path, endpoint) await commandFn.call(scope) expect(RequestMock).toHaveBeenCalledTimes(1) expect(RequestMock).toHaveBeenCalledWith(method, path, expect.any(Object), expect.any(AbortSignal), false, requestHandler) const callback = thenMock.mock.calls[0][0] vi.mocked(thenMock).mockClear() vi.mocked(log.info).mockClear() return callback } const takeScreenshotCmd = { path: '/foobar', method: 'GET', endpoint: { command: 'takeScreenshot', ref: 'https://foobar.com', parameters: [], description: '' } } const deleteSessionCmd = { path: '/foobar', method: 'POST', endpoint: { command: 'deleteSession', ref: 'https://foobar.com', parameters: [], description: '' } } const scenarios = [{ title: 'truncate long string value', command: { ...takeScreenshotCmd }, value: 'f'.repeat(65), log: 'f'.repeat(61) + '...' }, { title: 'truncate long string value', command: { ...takeScreenshotCmd, endpoint: { ...takeScreenshotCmd.endpoint, command: 'takeElementScreenshot' } }, value: 'f'.repeat(123), log: 'f'.repeat(61) + '...' }, { title: 'truncate long string value', command: { ...takeScreenshotCmd, endpoint: { ...takeScreenshotCmd.endpoint, command: 'startRecordingScreen' } }, value: 'f'.repeat(123), log: 'f'.repeat(61) + '...' }, { title: 'truncate long string value', command: { ...takeScreenshotCmd, endpoint: { ...takeScreenshotCmd.endpoint, command: 'stopRecordingScreen' } }, value: 'f'.repeat(123), log: 'f'.repeat(61) + '...' }, { title: 'do nothing to values with length less then 65', command: { ...takeScreenshotCmd }, value: 'f'.repeat(64), get log () { return this.value } }, { title: 'not truncate long string value', command: { ...takeScreenshotCmd, endpoint: { ...takeScreenshotCmd.endpoint, command: 'any-other-command' } }, value: 'f'.repeat(66), get log () { return this.value } }, { title: 'do nothing to non string value: array', command: { ...takeScreenshotCmd }, value: [], get log () { return this.value } }, { title: 'do nothing to non string value: object', command: { ...takeScreenshotCmd }, value: {}, get log () { return this.value } }, { title: 'do nothing to non string value: number', command: { ...takeScreenshotCmd }, value: 3, get log () { return this.value } }, { title: 'do nothing to non string value: boolean', command: { ...takeScreenshotCmd }, value: false, get log () { return this.value } }] logger.clearLogger = vi.fn() const clearLoggerSpy = vi.spyOn(logger, 'clearLogger') beforeEach(() => { delete process.env.WDIO_WORKER_ID vi.clearAllMocks() }) for (const scenario of scenarios) { it(`should ${scenario.title} for ${scenario.command.endpoint.command}`, async () => { const resultFunction = await getRequestCallback( scenario.command.method, scenario.command.path, scenario.command.endpoint ) as unknown as mockResponse resultFunction({ value: scenario.value }) expect(vi.mocked(log.info).mock.calls[0][1]).toBe(scenario.log) expect(clearLoggerSpy).not.toHaveBeenCalled() }) } it('should be no result in log if there is value in response', async () => { process.env.WDIO_WORKER_ID = '0-0' const resultFunction = await getRequestCallback( deleteSessionCmd.method, takeScreenshotCmd.path, takeScreenshotCmd.endpoint ) as unknown as mockResponse resultFunction({}) expect(vi.mocked(log.info).mock.calls).toHaveLength(0) expect(clearLoggerSpy).not.toHaveBeenCalled() }) it('should call clearLogger on deleteSession cmd', async () => { const resultFunction = await getRequestCallback( deleteSessionCmd.method, deleteSessionCmd.path, deleteSessionCmd.endpoint ) as unknown as mockResponse resultFunction({}) expect(vi.mocked(log.info).mock.calls).toHaveLength(0) expect(clearLoggerSpy).toHaveBeenCalledTimes(1) }) })