nuxt / test-utils

🧪 Test utilities for Nuxt
http://nuxt.com/docs/getting-started/testing
MIT License
310 stars 79 forks source link

Support @nuxthub/core #934

Open hannoeru opened 1 week ago

hannoeru commented 1 week ago

Describe the feature

NuxtHub enforce build to cloudflare but test-utils only refer to node build location, so if I use setup() with build: true it will resolve to error, for the workaround now is using host that point to the dev server that can handle NuxtHub related features.

Additional information

Final checks

dosubot[bot] commented 1 week ago

Hey @hannoeru! 👋 I'm here to help you with bug fixes, questions, and becoming a contributor. Let's squash those bugs together!

Yes, the feature to enforce NuxtHub build to Cloudflare while test-utils refer to the node build location can be implemented as a module. You can use the defineNuxtModule function from the @nuxt/kit package to create a custom module that configures the build process and test-utils settings accordingly.

Here is an example of how you might implement such a module:

import { defineNuxtModule, addVitePlugin, createResolver, resolvePath } from '@nuxt/kit'
import { mergeConfig } from 'vite'
import { defu } from 'defu'
import { getVitestConfigFromNuxt } from './config'
import { setupImportMocking } from './module/mock'
import { NuxtRootStubPlugin } from './module/plugins/entry'

export interface CustomModuleOptions {
  cloudflareBuild?: boolean
  vitestConfig?: VitestConfig
}

export default defineNuxtModule<CustomModuleOptions>({
  meta: {
    name: 'custom-module',
    configKey: 'customModule',
  },
  defaults: {
    cloudflareBuild: false,
  },
  async setup(options, nuxt) {
    if (options.cloudflareBuild) {
      // Enforce NuxtHub build to Cloudflare
      nuxt.options.target = 'static'
      nuxt.options.generate = {
        fallback: true,
        dir: 'dist',
      }
    }

    if (nuxt.options.test || nuxt.options.dev) {
      setupImportMocking()
    }

    const resolver = createResolver(import.meta.url)
    addVitePlugin(NuxtRootStubPlugin.vite({
      entry: await resolvePath('#app/entry', { alias: nuxt.options.alias }),
      rootStubPath: await resolvePath(resolver.resolve('./runtime/nuxt-root')),
    }))

    if (!nuxt.options.test && !nuxt.options.dev) {
      nuxt.options.vite.define ||= {}
      nuxt.options.vite.define['import.meta.vitest'] = 'undefined'
    }

    nuxt.hook('prepare:types', ({ references }) => {
      references.push({ types: 'vitest/import-meta' })
    })

    if (!nuxt.options.dev) return

    if (process.env.TEST || process.env.VITE_TEST) return

    const rawViteConfigPromise = new Promise<ViteConfig>((resolve) => {
      nuxt.hook('app:resolve', () => {
        nuxt.hook('vite:configResolved', (config, { isClient }) => {
          if (isClient) resolve(config)
        })
      })
    })

    async function start() {
      const rawViteConfig = mergeConfig({}, await rawViteConfigPromise)
      const viteConfig = await getVitestConfigFromNuxt({ nuxt, viteConfig: defu({ test: options.vitestConfig }, rawViteConfig) })

      viteConfig.plugins = (viteConfig.plugins || []).filter((p) => {
        return !p || !('name' in p) || !vitePluginBlocklist.includes(p.name)
      })

      process.env.__NUXT_VITEST_RESOLVED__ = 'true'
      const { startVitest } = (await import(pathToFileURL(await resolvePath('vitest/node')).href)) as typeof import('vitest/node')

      const customReporter: Reporter = {
        onInit(_ctx) {
          ctx = _ctx
        },
        onTaskUpdate() {
          testFiles = ctx.state.getFiles()
          updateTabs()
        },
        onFinished() {
          testFiles = ctx.state.getFiles()
          updateTabs()
        },
      }

      const watchMode = !process.env.NUXT_VITEST_DEV_TEST && !isCI

      const PORT = await getPort({ port: 15555 })
      const PROTOCOL = nuxt.options.devServer.https ? 'https' : 'http'
      const URL = `${PROTOCOL}://localhost:${PORT}/__vitest__/`

      const overrides: VitestConfig = watchMode
        ? {
            passWithNoTests: true,
            reporters: options.logToConsole
              ? [
                  ...toArray(options.vitestConfig?.reporters ?? ['default']),
                  customReporter,
                ]
              : [customReporter],
            watch: true,
            ui: true,
            open: false,
            api: {
              port: PORT,
            },
          }
        : { watch: false }

      const promise = startVitest('test', [], defu(overrides, viteConfig.test), viteConfig)
      promise.catch(() => process.exit(1))

      if (watchMode) {
        logger.info(`Vitest UI starting on ${URL}`)
        nuxt.hook('close', () => promise.then(v => v?.close()))
        await new Promise(resolve => setTimeout(resolve, 1000))
      } else {
        promise.then(v => nuxt.close().then(() => v?.close()).then(() => process.exit()))
      }

      loaded = true
    }

    nuxt.hook('devtools:customTabs', (tabs) => {
      const failedCount = testFiles?.filter(f => f.result?.state === 'fail').length ?? 0
      const passedCount = testFiles?.filter(f => f.result?.state === 'pass').length ?? 0
      const totalCount = testFiles?.length ?? 0

      tabs.push({
        title: 'Vitest',
        name: 'vitest',
        icon: 'logos-vitest',
        view: loaded
          ? {
              type: 'iframe',
              src: URL,
            }
          : {
              type: 'launch',
              description: 'Start tests along with Nuxt',
              actions: [
                {
                  label: promise ? 'Starting...' : 'Start Vitest',
                  pending: !!promise,
                  handle: () => {
                    promise = promise || start()
                    return promise
                  },
                },
              ],
            },
        extraTabVNode: totalCount
          ? h('div', { style: { color: failedCount ? 'orange' : 'green' } }, [
            h('span', {}, passedCount),
            h('span', { style: { opacity: '0.5', fontSize: '0.9em' } }, '/'),
            h('span', { style: { opacity: '0.8', fontSize: '0.9em' } }, totalCount),
          ])
          : undefined,
      })
    })

    if (options.startOnBoot) {
      promise = promise || start()
      promise.then(updateTabs)
    }
  },
})

function toArray<T>(value: T | T[]): T[] {
  return Array.isArray(value) ? value : [value]
}

This module sets up the build process for Cloudflare if the cloudflareBuild option is enabled and configures the test-utils to refer to the node build location. The getVitestConfigFromNuxt function from the test-utils package is used to obtain the Vitest configuration tailored for Nuxt, ensuring that the test environment is correctly set up [1][2].

To continue talking to Dosu, mention @dosu.

hannoeru commented 1 week ago

@atinux Can you help me to point out more details about this issue? Thanks!