fastify / fastify-nextjs

React server side rendering support for Fastify with Next
Other
526 stars 60 forks source link

In Jest tests, when closing fastify/nextjs (app.close()), nextjs does not close #855

Open NickMMG opened 9 months ago

NickMMG commented 9 months ago

Prerequisites

Fastify version

10.0.1

Plugin version

No response

Node.js version

18.18.0

Operating system

Windows

Operating system version (i.e. 20.04, 11.3, 10)

10 pro

Description

I ask for help and want to apologize if this is my mistake and not yours. Perhaps I'm doing something wrong. In Jest tests, when closing fastify/nextjs (app.close()), nextjs does not close, but continues to work. It happened a couple of times that I didn’t stop the test and after 3-5 minutes I received logs about page compilation from nextjs.

In dev and prod mode everything works correctly and without errors.

Another very important point is that my CI freezes after passing the tests. Even if I use beforeAll and afterAll in the test, then after successful completion I get a warning : "Jest did not exit one second after the test run has completed. 'This usually means that there are asynchronous operations that weren't stopped in your tests. Consider running Jest with --detectOpenHandles to troubleshoot this issue." . I think this is due to the fact that Nextjs continues to work.

my test

import { randomUUID } from 'node:crypto'
import { readFileSync } from 'node:fs'

import { createApp } from './app.js'
import { server, rest } from './test-utils/mswServer.js'
import { fetchRemote } from './utils/fetchRemote.js'

let app: ReturnType<typeof createApp>

beforeEach(async () => {
    app = createApp({
        logLevel: 'silent',
        formats: ['pdf', 'docx'],
        dbServer: 'http://............/db/next',
        fileServer: 'http://............/file',
    })

    await app.listen({ port: 8800 })
})

afterEach(async () => {
    await app.close()
})

it('/returns Swagger documentation', async () => {
    const response = await fetchRemote('http://localhost:8800')
    expect(response.headers.get('Content-Type')).toEqual(
        expect.stringContaining('text/html'),
    )
})

it('/extensions returns a list of supported extensions', async () => {
    const response = await fetchRemote('http://localhost:8800/extensions')
    const json = await response.json()
    expect(json).toEqual(['pdf', 'docx'])
})

describe('/file/:fileId/:ext', () => {
    it('in case the file is pdf', async () => {
        const fileId = randomUUID()

        server.use(
            rest.get('http://file-server/:fileId', (req, res, ctx) => {
                expect(req.params.fileId).toBe(fileId)
                return res(ctx.text('response-body'))
            }),
        )

        const response = await fetchRemote(`http://localhost:8800/file/${fileId}/pdf`)
        expect(response.headers.get('Content-Type')).toBe('application/pdf')

        const text = await response.text()
        expect(text).toEqual('response-body')
    })

    it('in case the file is NOT pdf', async () => {
        const fileId = randomUUID()

        server.use(
            rest.get('http://file-server/:fileId', (req, res, ctx) => {
                expect(req.params.fileId).toBe(fileId)
                return res(ctx.set('Content-Length', '13'), ctx.text('response-body'))
            }),

            rest.post('http://pdf-convert/forms/libreoffice/convert', (req, res, ctx) => {
                expect(req.body).toContain('1,3')
                return res(ctx.text('converted-body'))
            }),
        )

        const response = await fetchRemote(
            `http://localhost:8800/file/${fileId}/docx?pageRanges=1,3`,
        )
        expect(response.headers.get('Content-Type')).toBe('application/pdf')

        const text = await response.text()
        expect(text).toEqual('converted-body')
    })

    it('uses cache when requesting again', async () => {
        let numCalls = 0
        const fileId = randomUUID()

        server.use(
            rest.get('http://file-server/:fileId', (req, res, ctx) => {
                expect(req.params.fileId).toBe(fileId)
                numCalls++
                return res(ctx.set('Content-Length', '13'), ctx.text('response-body'))
            }),

            rest.post('http://pdf-convert/forms/libreoffice/convert', (req, res, ctx) => {
                numCalls++
                return res(ctx.text('converted-body'))
            }),
        )

        const firstResponse = await fetchRemote(`http://localhost:8800/file/${fileId}/docx`)
        expect(firstResponse.headers.get('Content-Type')).toBe('application/pdf')

        const firstText = await firstResponse.text()

        const secondResponse = await fetchRemote(`http://localhost:8800/file/${fileId}/docx`)
        expect(secondResponse.ok).toBe(true)
        expect(secondResponse.headers.get('Content-Type')).toBe('application/pdf')

        const secondText = await secondResponse.text()

        expect(secondText).toBe(firstText)
        expect(numCalls).toBe(2)
    })

    it('throws an error if the format is not supported', async () => {
        await expect(() =>
            fetchRemote('http://localhost:8800/file/abc/xls'),
        ).rejects.toThrowError(/Bad Request/)
    })
})

my app

import { IncomingMessage, Server, ServerResponse } from 'node:http'
import path from 'node:path'

import proxy from '@fastify/http-proxy'
import fastifyNext from '@fastify/nextjs'
import fastifyStatic from '@fastify/static'
import swagger from '@fastify/swagger'
import swaggerUi from '@fastify/swagger-ui'
import { TypeBoxTypeProvider } from '@fastify/type-provider-typebox'
import Fastify, { FastifyInstance } from 'fastify'
import pino, { P } from 'pino'

import { routes } from './routes.js'

const PUBLIC_PATH = path.join(process.cwd(), 'public')
export function createApp({
    logLevel,
    basePath = '',
    formats,
    dbServer,
    fileServer,
}: {
    logLevel?: P.LevelWithSilent
    basePath?: string
    formats: string[]
    dbServer: string
    fileServer: string
}): FastifyInstance<Server, IncomingMessage, ServerResponse, P.Logger> {
    const fastify = Fastify({
        trustProxy: true,
        requestIdHeader: 'x-request-id',
        requestIdLogLabel: 'requestId',
        logger: pino({
            base: null, 
            timestamp: false,
            level: logLevel ?? (process.env.NODE_ENV === 'production' ? 'info' : 'debug'),
            transport:
                process.env.NODE_ENV !== 'production'
                    ? {
                            target: 'pino-pretty',
                            options: { colorize: true },
                      }
                    : undefined,
        }),
    }).withTypeProvider<TypeBoxTypeProvider>()
    fastify
        .register(fastifyNext, { dev: process.env.NODE_ENV !== 'production' })
        .after(() => {
            fastify.next('/web/viewer-new')
        })

    fastify.register(fastifyStatic, { root: PUBLIC_PATH, wildcard: false })

    fastify.register(proxy, {
        upstream: dbServer,
        prefix: '/api/proxy/db/next',
        httpMethods: ['POST'],
    })
    fastify.register(proxy, {
        upstream: fileServer,
        prefix: '/api/proxy/file',
        httpMethods: ['GET', 'POST'],
    })
    fastify.register(swagger, {
        mode: 'dynamic',
        openapi: {
            info: {
                title: 'pdf-viewer-server',
                description: 'Server for displaying office documents as HTML',
                version: '0.1.0',
            },
            servers: [{ url: basePath !== '' ? basePath : '/' }],
        },
    })

    fastify.register(swaggerUi, {
        routePrefix: '/documentation',
    })

    fastify.register(routes, { basePath, formats })

    return fastify
}

my index

import { createApp } from './app.js'

const {
    OFFICE_FORMATS = 'txt,rtf,doc,docx,odt,xls,xlsx',
    BASE_PATH,
    PORT = '8800',
    NODE_ENV,
    DB_SERVER_URL,
    FILE_SERVER_URL,
} = process.env

const formatsSet = new Set(['pdf', ...OFFICE_FORMATS.split(',')])

const fastify = createApp({
    basePath: BASE_PATH,
    formats: [...formatsSet],
    dbServer: DB_SERVER_URL!,
    fileServer: FILE_SERVER_URL,
})

const ALL_AVAILABLE_IPV4_INTERFACES = '0.0.0.0'

await fastify.listen({
    port: Number(PORT),
    host: ALL_AVAILABLE_IPV4_INTERFACES,
})

fastify.log.info(`Listening on port ${PORT}. Mode: ${NODE_ENV}`)

Steps to Reproduce

If in the beforeEach and afterEach test:

  1. Run the test npm run test:coverage
  2. The first two tests passed successfully
  3. Other tests fail with errors
    thrown: "Exceeded timeout of 5000 ms for a hook.
    Add a timeout value to this test to increase the timeout, if this is a long-running test. See https://jestjs.io/docs/api#testname-fn-timeout."

      19 | })
      20 |
    > 21 | afterEach(async () => {
         | ^
      22 |      await app.close()
      23 | })
      24 |

      at afterEach (src/app.test.ts:21:1)

          thrown: "Exceeded timeout of 5000 ms for a hook.
    Add a timeout value to this test to increase the timeout, if this is a long-running test. See https://jestjs.io/docs/api#testname-fn-timeout."

       8 | let app: ReturnType<typeof createApp>
       9 |
    > 10 | beforeEach(async () => {
         | ^
      11 |      app = createApp({
      12 |              logLevel: 'silent',
      13 |              formats: ['pdf', 'docx'],

      at beforeEach (src/app.test.ts:10:1)

          TypeError: fastify.next is not a function

      47 |              .register(fastifyNext, { dev: process.env.NODE_ENV !== 'production' })
      48 |              .after(() => {
    > 49 |                      fastify.next('/web/viewer-new')
         |                              ^
      50 |              })
      51 |
      52 |      fastify.register(fastifyStatic, { root: PUBLIC_PATH, wildcard: false })

      at next (src/app.ts:49:12)
      at Object._encapsulateThreeParam (node_modules/avvio/boot.js:544:13)
      at Boot.timeoutCall (node_modules/avvio/boot.js:458:5)
      at Boot.callWithCbOrNextTick (node_modules/avvio/boot.js:440:19)
      at Boot._after (node_modules/avvio/boot.js:280:26)
      at Plugin.Object.<anonymous>.Plugin.exec (node_modules/avvio/plugin.js:130:19)
      at Boot.loadPlugin (node_modules/avvio/plugin.js:272:10)

If in the beforeAll and afterAll test:

  1. Run the test npm run test:coverage
  2. All tests pass
  3. I get a warning warning: "Jest did not exit one second after the test run has completed.

'This usually means that there are asynchronous operations that weren't stopped in your tests. Consider running Jest with --detectOpenHandles to troubleshoot this issue." But in this case CI does not work.

Expected Behavior

No response