import path from 'node:path' import dns from 'node:dns/promises' // @ts-expect-error mock feature import { instances, setThrowError, clearInstances, type ClientOptions } from 'ws' import { describe, it, expect, vi, beforeEach } from 'vitest' import logger from '@wdio/logger' import { listWebsocketCandidateUrls, createBidiConnection } from '../../src/node/bidi.js' // @ts-expect-error mock feature import { proxyUrlValueFn, noProxyValueFn } from '../../src/environment.js' import { environment } from '../../build/environment.js' vi.mock('@wdio/logger', () => import(path.join(process.cwd(), '__mocks__', '@wdio/logger'))) vi.mock('dns/promises', () => ({ default: { lookup: vi.fn().mockResolvedValue([{ address: '127.0.0.1' }, { address: '::1' }]) } })) vi.mock('../../src/environment.js', () => { const proxyUrlValueFn = vi.fn() const noProxyValueFn = vi.fn() return { proxyUrlValueFn, noProxyValueFn, environment: { value: { get variables() { return { PROXY_URL: proxyUrlValueFn(), NO_PROXY: noProxyValueFn() } } } } } }) vi.mock('ws', () => { const instances: WebSocket[] = [] let throwError: Error | undefined return { default: class WebSocketMock { wsUrl: string options: ClientOptions | undefined on = vi.fn() send = vi.fn() once = vi.fn() error = vi.fn() terminate = vi.fn() removeAllListeners = vi.fn() constructor(url: string, options?: ClientOptions) { instances.push(this as unknown as WebSocket) this.wsUrl = url this.options = options if (throwError) { throw throwError } } }, instances, clearInstances: () => { instances.length = 0 }, setThrowError: (error: Error) => { throwError = error } } }) const log = logger('test') describe('Bidi Node.js implementation', () => { beforeEach(() => { clearInstances() vi.mocked(log.error).mockClear() }) describe('listWebsocketCandidateUrls', () => { it('should return the correct candidate urls', async () => { const candidateUrls = await listWebsocketCandidateUrls('ws://foo/bar') expect(candidateUrls).toEqual([ 'ws://foo/bar', 'ws://127.0.0.1/bar', 'ws://::1/bar' ]) }) it('should return an empty array if the hostname is not resolvable', async () => { vi.mocked(dns.lookup).mockRejectedValueOnce(new Error('ESERVFAIL')) const candidateUrls = await listWebsocketCandidateUrls('ws://foo/bar') expect(candidateUrls).toEqual(['ws://foo/bar']) }) }) it('createBidiConnection', async () => { proxyUrlValueFn.mockReturnValue(undefined) noProxyValueFn.mockReturnValue(undefined) const wsPromise = createBidiConnection('ws://foo/bar') await new Promise((resolve) => setTimeout(resolve, 100)) expect(instances.length).toBe(3) expect(instances[0].wsUrl).toBe('ws://foo/bar') expect(instances[0].options).toEqual({}) expect(instances[1].wsUrl).toBe('ws://127.0.0.1/bar') expect(instances[1].options).toEqual({}) expect(instances[2].wsUrl).toBe('ws://::1/bar') expect(instances[2].options).toEqual({}) expect(instances[0].once).toHaveBeenCalledWith('open', expect.any(Function)) expect(instances[1].once).toHaveBeenCalledWith('open', expect.any(Function)) expect(instances[2].once).toHaveBeenCalledWith('open', expect.any(Function)) instances[0].once.mock.calls[1][1](new Error('foo')) // error callback instances[1].once.mock.calls[0][1]() // success callback instances[2].once.mock.calls[1][1](new Error('bar')) // error callback const ws = await wsPromise as any expect(instances[0].terminate).toHaveBeenCalled() expect(instances[1].terminate).not.toHaveBeenCalled() expect(instances[2].terminate).toHaveBeenCalled() expect(ws.wsUrl).toBe('ws://127.0.0.1/bar') expect(log.info).toHaveBeenCalledWith( 'Connected to Bidi protocol at ws://127.0.0.1/bar' ) expect(log.error).not.toHaveBeenCalled() }) it('createBidiConnection with a proxy', async () => { proxyUrlValueFn.mockReturnValue('http://127.0.0.1:8888') noProxyValueFn.mockReturnValue(undefined) const wsPromise = createBidiConnection('ws://foo/bar') await new Promise((resolve) => setTimeout(resolve, 100)) expect(instances.length).toBe(3) expect(instances[0].wsUrl).toBe('ws://foo/bar') expect(instances[0].options).toEqual({ agent: expect.any(Object) }) expect(instances[1].wsUrl).toBe('ws://127.0.0.1/bar') expect(instances[1].options).toEqual({ agent: expect.any(Object) }) expect(instances[2].wsUrl).toBe('ws://::1/bar') expect(instances[2].options).toEqual({ agent: expect.any(Object) }) expect(instances[0].once).toHaveBeenCalledWith('open', expect.any(Function)) expect(instances[1].once).toHaveBeenCalledWith('open', expect.any(Function)) expect(instances[2].once).toHaveBeenCalledWith('open', expect.any(Function)) instances[0].once.mock.calls[1][1](new Error('foo')) // error callback instances[1].once.mock.calls[0][1]() // success callback instances[2].once.mock.calls[1][1](new Error('bar')) // error callback const ws = await wsPromise as any expect(instances[0].terminate).toHaveBeenCalled() expect(instances[1].terminate).not.toHaveBeenCalled() expect(instances[2].terminate).toHaveBeenCalled() expect(ws.wsUrl).toBe('ws://127.0.0.1/bar') expect(log.info).toHaveBeenCalledWith( 'Connected to Bidi protocol at ws://127.0.0.1/bar' ) expect(log.error).not.toHaveBeenCalled() }) it('createBidiConnection and skip the proxy with a no proxy', async () => { proxyUrlValueFn.mockReturnValue('http://127.0.0.1:8888') noProxyValueFn.mockReturnValue(['foo', '127.0.0.1']) const wsPromise = createBidiConnection('ws://foo/bar') await new Promise((resolve) => setTimeout(resolve, 100)) expect(instances.length).toBe(3) expect(instances[0].wsUrl).toBe('ws://foo/bar') expect(instances[0].options).toEqual({}) expect(instances[1].wsUrl).toBe('ws://127.0.0.1/bar') expect(instances[1].options).toEqual({}) expect(instances[2].wsUrl).toBe('ws://::1/bar') expect(instances[2].options).toEqual({ agent: expect.any(Object) }) expect(instances[0].once).toHaveBeenCalledWith('open', expect.any(Function)) expect(instances[1].once).toHaveBeenCalledWith('open', expect.any(Function)) expect(instances[2].once).toHaveBeenCalledWith('open', expect.any(Function)) instances[0].once.mock.calls[1][1](new Error('foo')) // error callback instances[1].once.mock.calls[0][1]() // success callback instances[2].once.mock.calls[1][1](new Error('bar')) // error callback const ws = await wsPromise as any expect(instances[0].terminate).toHaveBeenCalled() expect(instances[1].terminate).not.toHaveBeenCalled() expect(instances[2].terminate).toHaveBeenCalled() expect(ws.wsUrl).toBe('ws://127.0.0.1/bar') expect(log.info).toHaveBeenCalledWith( 'Connected to Bidi protocol at ws://127.0.0.1/bar' ) expect(log.error).not.toHaveBeenCalled() }) it('createBidiConnection with options for the ws', async ()=>{ proxyUrlValueFn.mockReturnValue(undefined) noProxyValueFn.mockReturnValue(undefined) const wsPromise = createBidiConnection('ws://foo/bar', { headers: { 'cf-access-token': 'MY_TOKEN', 'X-Custom': 'xyz' } }) await new Promise((resolve) => setTimeout(resolve, 100)) expect(instances.length).toBe(3) expect(instances[0].options).toStrictEqual({ headers: { 'cf-access-token': 'MY_TOKEN', 'X-Custom': 'xyz' } }) }) it('createBidiConnection returns undefined if no socket connection is established', async () => { proxyUrlValueFn.mockReturnValue(undefined) noProxyValueFn.mockReturnValue(undefined) const wsPromise = createBidiConnection('ws://foo/bar') await new Promise((resolve) => setTimeout(resolve, 100)) setTimeout(() => instances[0].once.mock.calls[1][1](new Error('foo')), 300) // error callback setTimeout(() => instances[1].once.mock.calls[1][1](new Error('bar')), 100) // error callback setTimeout(() => instances[2].once.mock.calls[1][1](new Error('loo')), 200) // error callback await expect(wsPromise).resolves.toBeUndefined() expect(log.error).toBeCalledWith('Could not connect to Bidi protocol\n - bar\n - loo\n - foo') }) it('createBidiConnection times out', async () => { vi.useFakeTimers() proxyUrlValueFn.mockReturnValue(undefined) noProxyValueFn.mockReturnValue(undefined) const wsPromise = createBidiConnection('ws://foo/bar') vi.runAllTimersAsync() expect(await wsPromise).toBeUndefined() expect(log.error).toHaveBeenCalledWith( 'Could not connect to Bidi protocol of any candidate url in time: ' + '"ws://foo/bar", "ws://127.0.0.1/bar", "ws://::1/bar"' ) }) it('should not fail if WebSocket url is invalid', async () => { setThrowError(new Error('ESERVFAIL')) const wsPromise = createBidiConnection('ws://foo/bar') expect(await wsPromise).toBeUndefined() }) })