import path from 'node:path'
import dns from 'node:dns/promises'
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'
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'))
instances[1].once.mock.calls[0][1]()
instances[2].once.mock.calls[1][1](new Error('bar'))
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'))
instances[1].once.mock.calls[0][1]()
instances[2].once.mock.calls[1][1](new Error('bar'))
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'))
instances[1].once.mock.calls[0][1]()
instances[2].once.mock.calls[1][1](new Error('bar'))
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)
setTimeout(() => instances[1].once.mock.calls[1][1](new Error('bar')), 100)
setTimeout(() => instances[2].once.mock.calls[1][1](new Error('loo')), 200)
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()
})
})