diff --git a/packages/nuxt/src/app/components/nuxt-root.vue b/packages/nuxt/src/app/components/nuxt-root.vue
index 49835190622..698899bce4e 100644
--- a/packages/nuxt/src/app/components/nuxt-root.vue
+++ b/packages/nuxt/src/app/components/nuxt-root.vue
@@ -2,6 +2,7 @@
+
@@ -21,6 +22,10 @@ const IslandRenderer = process.server
const nuxtApp = useNuxtApp()
const onResolve = nuxtApp.deferHydration()
+const url = process.server ? nuxtApp.ssrContext.url : window.location.pathname
+const SingleRenderer = process.dev && process.server && url.startsWith('/__nuxt_component_test__/') && defineAsyncComponent(() => import('#build/test-component-wrapper.mjs')
+ .then(r => r.default(process.server ? url : window.location.href)))
+
// Inject default route (outside of pages) as active route
provide('_route', useRoute())
diff --git a/packages/nuxt/src/app/components/test-component-wrapper.ts b/packages/nuxt/src/app/components/test-component-wrapper.ts
new file mode 100644
index 00000000000..676b821e8be
--- /dev/null
+++ b/packages/nuxt/src/app/components/test-component-wrapper.ts
@@ -0,0 +1,19 @@
+import { parseURL } from 'ufo'
+import { defineComponent, h } from 'vue'
+import { parseQuery } from 'vue-router'
+
+export default (url:string) => defineComponent({
+ name: 'NuxtTestComponentWrapper',
+
+ async setup (props, { attrs }) {
+ const query = parseQuery(parseURL(url).search)
+ const urlProps = query.props ? JSON.parse(query.props as string) : {}
+ const comp = await import(/* @vite-ignore */ query.path as string).then(r => r.default)
+ return () => [
+ h('div', 'Component Test Wrapper for ' + query.path),
+ h('div', { id: 'nuxt-component-root' }, [
+ h(comp, { ...attrs, ...props, ...urlProps })
+ ])
+ ]
+ }
+})
diff --git a/packages/nuxt/src/core/templates.ts b/packages/nuxt/src/core/templates.ts
index 33c031cd08c..0f75b2bc292 100644
--- a/packages/nuxt/src/core/templates.ts
+++ b/packages/nuxt/src/core/templates.ts
@@ -40,6 +40,11 @@ export const errorComponentTemplate: NuxtTemplate = {
filename: 'error-component.mjs',
getContents: ctx => genExport(ctx.app.errorComponent!, ['default'])
}
+// TODO: Use an alias
+export const testComponentWrapperTemplate = {
+ filename: 'test-component-wrapper.mjs',
+ getContents: (ctx: TemplateContext) => genExport(resolve(ctx.nuxt.options.appDir, 'components/test-component-wrapper'), ['default'])
+}
export const cssTemplate: NuxtTemplate = {
filename: 'css.mjs',
diff --git a/packages/test-utils/build.config.ts b/packages/test-utils/build.config.ts
index 4b6640762b1..e0f61579a17 100644
--- a/packages/test-utils/build.config.ts
+++ b/packages/test-utils/build.config.ts
@@ -4,6 +4,7 @@ export default defineBuildConfig({
declaration: true,
entries: [
'src/index',
+ 'src/experimental',
{ input: 'src/runtime/', outDir: 'dist/runtime', format: 'esm' }
],
externals: [
diff --git a/packages/test-utils/experimental.d.ts b/packages/test-utils/experimental.d.ts
new file mode 100644
index 00000000000..c9d24adffad
--- /dev/null
+++ b/packages/test-utils/experimental.d.ts
@@ -0,0 +1 @@
+export * from './dist/experimental'
diff --git a/packages/test-utils/package.json b/packages/test-utils/package.json
index 603989324ed..d0ce8c87e4b 100644
--- a/packages/test-utils/package.json
+++ b/packages/test-utils/package.json
@@ -9,6 +9,10 @@
".": {
"types": "./dist/index.d.ts",
"import": "./dist/index.mjs"
+ },
+ "./experimental": {
+ "types": "./dist/experimental.d.ts",
+ "import": "./dist/experimental.mjs"
}
},
"files": [
@@ -26,7 +30,8 @@
"get-port-please": "^3.0.1",
"jiti": "^1.18.2",
"ofetch": "^1.0.1",
- "pathe": "^1.1.0"
+ "pathe": "^1.1.0",
+ "ufo": "^1.1.1"
},
"devDependencies": {
"playwright": "^1.32.2",
diff --git a/packages/test-utils/src/experimental.ts b/packages/test-utils/src/experimental.ts
new file mode 100644
index 00000000000..7d24def0a55
--- /dev/null
+++ b/packages/test-utils/src/experimental.ts
@@ -0,0 +1,23 @@
+import { fetch as _fetch, $fetch as _$fetch } from 'ofetch'
+import * as _kit from '@nuxt/kit'
+import { resolve } from 'pathe'
+import { stringifyQuery } from 'ufo'
+import { useTestContext } from './context'
+import { $fetch } from './server'
+
+/**
+ * This is a function to render a component directly with the Nuxt server.
+ */
+export function $fetchComponent (filepath: string, props?: Record) {
+ return $fetch(componentTestUrl(filepath, props))
+}
+
+export function componentTestUrl (filepath: string, props?: Record) {
+ const ctx = useTestContext()
+ filepath = resolve(ctx.options.rootDir, filepath)
+ const path = stringifyQuery({
+ path: filepath,
+ props: JSON.stringify(props)
+ })
+ return `/__nuxt_component_test__/?${path}`
+}
diff --git a/packages/test-utils/src/server.ts b/packages/test-utils/src/server.ts
index 56261dd8982..77b580e2ae3 100644
--- a/packages/test-utils/src/server.ts
+++ b/packages/test-utils/src/server.ts
@@ -1,9 +1,9 @@
-import { resolve } from 'node:path'
import { execa } from 'execa'
import { getRandomPort, waitForPort } from 'get-port-please'
import type { FetchOptions } from 'ofetch'
import { fetch as _fetch, $fetch as _$fetch } from 'ofetch'
import * as _kit from '@nuxt/kit'
+import { resolve } from 'pathe'
import { useTestContext } from './context'
// @ts-ignore type cast
@@ -75,5 +75,8 @@ export function url (path: string) {
if (!ctx.url) {
throw new Error('url is not available (is server option enabled?)')
}
+ if (path.startsWith(ctx.url)) {
+ return path
+ }
return ctx.url + path
}
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 1a63d5dcdde..cdfee34c886 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -754,6 +754,9 @@ importers:
pathe:
specifier: ^1.1.0
version: 1.1.0
+ ufo:
+ specifier: ^1.1.1
+ version: 1.1.1
vue:
specifier: ^3.2.47
version: 3.2.47
diff --git a/test/basic.test.ts b/test/basic.test.ts
index 9e34bb31205..35f764b4371 100644
--- a/test/basic.test.ts
+++ b/test/basic.test.ts
@@ -7,6 +7,7 @@ import { setup, fetch, $fetch, startServer, isDev, createPage, url } from '@nuxt
import type { NuxtIslandResponse } from '../packages/nuxt/src/core/runtime/nitro/renderer'
import { expectNoClientErrors, expectWithPolling, renderPage, withLogs } from './utils'
+import { $fetchComponent } from '@nuxt/test-utils/experimental'
const isWebpack = process.env.TEST_BUILDER === 'webpack'
@@ -1277,3 +1278,13 @@ describe.skipIf(isWindows)('useAsyncData', () => {
await expectNoClientErrors('/useAsyncData/promise-all')
})
})
+
+describe.runIf(isDev())('component testing', () => {
+ it('should work', async () => {
+ const comp1 = await $fetchComponent('components/SugarCounter.vue', { multiplier: 2 })
+ expect(comp1).toContain('12 x 2 = 24')
+
+ const comp2 = await $fetchComponent('components/SugarCounter.vue', { multiplier: 4 })
+ expect(comp2).toContain('12 x 4 = 48')
+ })
+})
diff --git a/vitest.config.ts b/vitest.config.ts
index 6a054ad90c5..048dfb643bd 100644
--- a/vitest.config.ts
+++ b/vitest.config.ts
@@ -7,6 +7,7 @@ export default defineConfig({
resolve: {
alias: {
'#app': resolve('./packages/nuxt/dist/app/index'),
+ '@nuxt/test-utils/experimental': resolve('./packages/test-utils/src/experimental.ts'),
'@nuxt/test-utils': resolve('./packages/test-utils/src/index.ts')
}
},