fastify-websocket icon indicating copy to clipboard operation
fastify-websocket copied to clipboard

fastify.inject like testing for WS?

Open gjovanov opened this issue 6 years ago • 9 comments

Hey guys, for faking HTTP requests, there is ab option to inject fake HTTP requests via light-my-request. Looking in the tests of this repo, I can see that Fastify really needs to listen on some port in order to create a WS client connetion.

Is there a way to Inject a WS connection without actually making Fastify server listen on a port?

I'm asking because right now we many HTTP tests using Ava testing framework, that are in many separate files (due to concurrent testing). So far for HTTP testing we were not forcing the Fastify server to listen on any port. Now we are at the point where we want to start cover the WS scenarios with such tests and it seems that we are going to need to actually make Fastify server listen on certain port.

The issue we are seeing is that each test file will need to have a separate port, because they run concurrently. I know it's doable like that, but I'm just currious if it's possible to fake WS connections as well as WS data being sent?

Thanks in advance.

/GJ

gjovanov avatar Aug 27 '19 13:08 gjovanov

That would be a fantastic feature! Would you like to send a PR? The way I would build this is to use https://github.com/mafintosh/duplexify to create a couple of "inverted" duplex streams from a couple of PassThrough. Then, we can issue an 'upgrade' event on the server. Wdyt? Would you like to send a PR?

mcollina avatar Aug 27 '19 14:08 mcollina

Hey @mcollina, indeed it would be a useful feature.

So far I haven't had a look in the source code of Fastify, so I decided to have a look in both Fastify and Duplexify, to evaluate a potential contribution via a PR. At first glance this seems too overwhelming, considering other tasks on my plate.

Maybe if you help me out providing some hints where to start looking in the source code options for checking PassThrough requests and issuing events on the server? Also what would be the flow of establishing a WS? E.g. PassThrough request with A, B, C headers etc, then Upgrade request with X, Y, Z headers?

gjovanov avatar Aug 28 '19 08:08 gjovanov

Essentially you'll need to add a decorator that calls https://github.com/fastify/fastify-websocket/blob/master/index.js#L63-L67 with the right arguments.

mcollina avatar Aug 28 '19 10:08 mcollina

This issue has been automatically marked as stale because it has not had recent activity. It will be closed if no further activity occurs. Thank you for your contributions.

stale[bot] avatar Oct 21 '20 08:10 stale[bot]

There's also the mock-socket library here (https://github.com/thoov/mock-socket), which I've had good success with. If we made the server implementation an option, you could maybe just inject the mock server and use the mock socket client which quacks more like a normal WebSocket and handles all the internal event emission for you.

airhorns avatar Dec 11 '20 14:12 airhorns

That'd work! Would you like to send a PR?

mcollina avatar Dec 11 '20 18:12 mcollina

Ideally, I'd be very happy with something like

const ws = fastify.injectWebSocket('/my/ws/path')
ws.send('hello world') // just a normal WS instance

without having to touch the original plugin options at all.

I don't think this would be doable with the ws library though, except maybe hacking with passing a null address to the WS client somehow to make it operate in server mode?

wyozi avatar Apr 23 '21 14:04 wyozi

I have not designed this :(. It probably requires some analysis and experimentation.

mcollina avatar Apr 24 '21 08:04 mcollina

I looked into this a bit but stopped once it started feeling like stacking hacks on top of hacks. I got to the point where it kind of manages to setup a WS connection, but sending data doesn't quite work yet. Maybe this will be a useful starting point for something else



  fastify.decorate('injectWebSocket', opts => {
    const client = new WebSocket(null)

    const server2client = new PassThrough()
    const client2server = new PassThrough()
    const serverStream = new class extends duplexify {
      constructor() {
        super(server2client, client2server)
      }
      setTimeout() {}
      setNoDelay() {}
    }
    const clientStream = new class extends duplexify {
      constructor() {
        super(client2server, server2client)
      }
      setTimeout() {}
      setNoDelay() {}
    }

    let serverClient
    wss.handleUpgrade({
      method: 'GET',
      headers: {
        connection: 'upgrade',
        upgrade: 'websocket',
        'sec-websocket-version': 13,
        'sec-websocket-key': randomBytes(16).toString('base64')
      },
    }, serverStream, Buffer.from([]), (ws, req) => { serverClient = ws })


    let resolve
    const promise = new Promise(_resolve => resolve = _resolve)

    function recvData(d) {
      if (d.toString().includes('101 Switching')) {
        clientStream.removeListener('data', recvData)
        client.setSocket(clientStream, Buffer.from([]))
        client._isServer = false // yikes
        resolve(client)
      }
    }
    clientStream.on('data', recvData)

    // TODO how to get the route handler
    testesttest(WebSocket.createWebSocketStream(serverStream))

    return promise
  })

wyozi avatar Apr 24 '21 12:04 wyozi

Implemented message handling it in my project so I will share results. I hope it will be helpful for implementation:

Test in jest

import WebSocket from 'ws'
import fastifyWebsocket, { SocketStream } from '@fastify/websocket'
import fastify from 'fastify'
import { Reactivity } from '../src/services/Reactivity'

describe('websocket', () => {  
  it('basic', async () => {
    let expectedMessage = ''

    const app = fastify()

    app.register(fastifyWebsocket)

    const rawContext: { connection: SocketStream | null } = {
      connection: null,
    }

    const [ctx, establishConnection] = Reactivity.useFirstChangeDetector(rawContext)

    app.get('/ws', { websocket: true }, (connection) => {
      connection.socket.on('message', async (message) => {
        expectedMessage = message.toString()
      })
      ctx.connection = connection
    })

    await app.ready()
    const address = await app.listen({ port: 0, host: '0.0.0.0' })
    new WebSocket(`${address.replace('http', 'ws')}/ws`)

    await establishConnection;

    ctx.connection?.socket.emit('message', 'xd')

    await app.close()
    expect(expectedMessage).toEqual('xd')
  })
})

and Reactivity helper

import { EventEmitter } from 'node:events'

export class Reactivity {
  static useFirstChangeDetector<T extends object>(target: T): [T, Promise<void>] {
    const e = new EventEmitter()

    const handler: ProxyHandler<T> = {
      set(obj, prop, value) {
        e.emit('change', prop, value)
        return Reflect.set(obj, prop, value)
      },
    }

    const proxy = new Proxy(target, handler)

    return [
      proxy,
      new Promise((resolve) => {
        e.on('change', () => {
          resolve(undefined)
        })
      }),
    ]
  }
}

with his own test

import dayjs from 'dayjs'
import { sleep } from './helpers'
import { Reactivity } from '../src/services/Reactivity'

describe('reactivity', () => {
  it('i am able to react on change in object instantly instead of using setTimeout', async () => {
    const target = {
      value: 'Why do programmers prefer dark mode?',
    }

    const [proxy, promise] = Reactivity.useFirstChangeDetector(target)

    let t2: dayjs.Dayjs = dayjs()

    promise.then(() => {
      t2 = dayjs()
    })

    const t1 = dayjs()
    await sleep(1)
    proxy.value = 'Because light attracts bugs.'
    await sleep(1)
    const t3 = dayjs()

    expect(t1.valueOf()).toBeLessThan(t2.valueOf())
    expect(t2.valueOf()).toBeLessThan(t3.valueOf())
    expect(proxy.value).toEqual('Because light attracts bugs.')
  })
})

with sleep function as

export const sleep = (ms: number): Promise<void> => new Promise((resolve) => setTimeout(resolve, ms))

What I mostyly dislike in my code

    await app.listen({ port: 9998, host: '0.0.0.0' })
    new WebSocket('ws://0.0.0.0:9998/ws')

I can't do it without reservation of port. If you can please share it with me. Without these lines on connection is not triggered, but when I aim triggering it by emit then cant prepare object that will be correct socket connection as first argument of handler.

Btw this proof of concept shows that we are able to test messages to websocket.

gustawdaniel avatar Mar 07 '23 04:03 gustawdaniel

Looked a bit into it trying to come up with a solution but hit a wall trying to have fastify-websocket working without reserving a port or starting the server. It looks like the whole fastify-websocket process only really starts once the server is running so the connection event won't trigger until then.

Also tried replacing the inner server with a mocked one provided externally through options but it isn't enough to solve this issue since fastify ws routes aren't really attached until the server is running.

Sbax avatar May 09 '23 08:05 Sbax

I think what this feature should do is to emit an upgrade event in https://github.com/fastify/fastify-websocket/blob/f7da62d7d2176619bb6a17c19b7da79a0b179ef4/index.js#L53.

If the API match up, every should line up correctly.

mcollina avatar May 15 '23 08:05 mcollina

i tried emitting the upgrade event, but encountered a similar issue that others are describing here that it seems very tricky to separate the websocket implementation from needing a running http.Server instance

matt-clarson avatar Jul 21 '23 12:07 matt-clarson