diff --git a/__tests__/e2e/.vitepress/config.ts b/__tests__/e2e/.vitepress/config.ts index 7ec72c302211..3e2745f6ef94 100644 --- a/__tests__/e2e/.vitepress/config.ts +++ b/__tests__/e2e/.vitepress/config.ts @@ -1,5 +1,10 @@ +import path from 'node:path' import { defineConfig, type DefaultTheme } from 'vitepress' +let renderCapturedMarkdown: (() => Promise) | undefined +const batchHeadHookPages = new Set() +let batchHeadHookSequence = 0 + const nav: DefaultTheme.Config['nav'] = [ { text: 'Home', @@ -154,8 +159,15 @@ const sidebar: DefaultTheme.Config['sidebar'] = { export default defineConfig({ title: 'Example', description: 'An example app using VitePress.', + srcExclude: process.env.VITE_TEST_SSR_BATCH ? [] : ['ssr-*.md'], + ssrBuildBatchSize: process.env.VITE_TEST_SSR_BATCH ? 10 : undefined, + ssrBuildWorkerConcurrency: process.env.VITE_TEST_SSR_BATCH ? 2 : undefined, markdown: { - image: { lazyLoad: true } + image: { lazyLoad: true }, + config(md) { + renderCapturedMarkdown = () => + md.renderAsync('```ts\nconst batch = true\n```') + } }, themeConfig: { nav, @@ -181,11 +193,101 @@ export default defineConfig({ } }, vite: { + build: { + // Test the batching guard. It prevents SSR workers from copying the + // public directory into temporary output. + copyPublicDir: true + }, + plugins: [ + { + name: 'test:ssr-batch-public-copy', + config() { + if (process.env.VITE_TEST_SSR_BATCH) { + return { + publicDir: 'batch-public', + resolve: { + alias: { + '/vitepress.png': path.resolve( + import.meta.dirname, + '../public/vitepress.png' + ) + } + }, + environments: { + ssr: { build: { copyPublicDir: true } } + } + } + } + }, + configResolved(config) { + if ( + process.env.VITE_TEST_SSR_BATCH && + config.build.ssr && + (config.build.copyPublicDir !== false || + config.environments.ssr?.build.copyPublicDir !== false) + ) { + throw new Error('SSR batch worker would copy the public directory') + } + } + } + ], server: { watch: { usePolling: true, interval: 100 } } + }, + buildEnd(siteConfig) { + if ( + process.env.VITE_TEST_SSR_BATCH && + siteConfig.publicDir !== path.resolve(siteConfig.srcDir, 'batch-public') + ) { + throw new Error('Resolved publicDir was not restored in the coordinator') + } + if ( + process.env.VITE_TEST_SSR_BATCH && + (!batchHeadHookPages.has('ssr-static.md') || + !batchHeadHookPages.has('dynamic-routes/foo.md')) + ) { + throw new Error( + 'Coordinator-owned build hook state was not preserved across SSR workers' + ) + } + }, + transformHead(context) { + if (!process.env.VITE_TEST_SSR_BATCH) return + batchHeadHookPages.add(context.page) + return [ + [ + 'meta', + { + name: 'ssr-batch-hook-state', + content: `${++batchHeadHookSequence}:${context.pageData.relativePath}` + } + ] + ] + }, + transformHtml(code, _id, context) { + if (!process.env.VITE_TEST_SSR_BATCH) return + if (!batchHeadHookPages.has(context.page)) { + throw new Error( + 'transformHtml ran without coordinator transformHead state' + ) + } + + return code.replace( + '', + `\n ` + ) + }, + async postRender(context) { + if (process.env.VITE_TEST_SSR_BATCH) { + if (!renderCapturedMarkdown) { + throw new Error('Markdown renderer was not captured during SSR setup') + } + await renderCapturedMarkdown() + } + return context } }) diff --git a/__tests__/e2e/batch-public/batch-public.txt b/__tests__/e2e/batch-public/batch-public.txt new file mode 100644 index 000000000000..a12dd8ad6181 --- /dev/null +++ b/__tests__/e2e/batch-public/batch-public.txt @@ -0,0 +1 @@ +copied once by the client build diff --git a/__tests__/e2e/dynamic-routes/dynamic-routes.test.ts b/__tests__/e2e/dynamic-routes/dynamic-routes.test.ts index 5829ae8940ab..d206b88dc2e7 100644 --- a/__tests__/e2e/dynamic-routes/dynamic-routes.test.ts +++ b/__tests__/e2e/dynamic-routes/dynamic-routes.test.ts @@ -3,6 +3,7 @@ describe('dynamic routes', () => { await goto('/dynamic-routes/foo') expect(await page.textContent('h1')).toMatch('Foo') expect(await page.textContent('pre.params')).toMatch('"id": "foo"') + expect(await page.title()).toBe('Foo - transformed | Example') await goto('/dynamic-routes/bar') expect(await page.textContent('h1')).toMatch('Bar') diff --git a/__tests__/e2e/local-search/local-search.test.ts b/__tests__/e2e/local-search/local-search.test.ts index 07ec19265bec..b3e982e3be6c 100644 --- a/__tests__/e2e/local-search/local-search.test.ts +++ b/__tests__/e2e/local-search/local-search.test.ts @@ -83,6 +83,29 @@ describe('local search', () => { ).toBe(0) }) + test.runIf(process.env.VITE_TEST_SSR_BATCH)( + 'indexes page HTML produced by the artifact pipeline', + async () => { + await page.locator('.VPNavBarSearchButton').click() + + const input = await page.waitForSelector('input#localsearch-input') + await input.type('Static HTML marker') + + await page.waitForFunction(() => + [ + ...document.querySelectorAll('#localsearch-list li[role=option]') + ].some((option) => option.textContent?.includes('Static batching page')) + ) + + expect( + await page + .locator('#localsearch-list li[role=option]') + .filter({ hasText: 'Static batching page' }) + .count() + ).toBeGreaterThan(0) + } + ) + test('uses the same desktop breakpoint as the nav bar', async () => { try { for (const { width, isDesktop } of [ diff --git a/__tests__/e2e/ssr-batching.test.ts b/__tests__/e2e/ssr-batching.test.ts new file mode 100644 index 000000000000..7b04f40157ae --- /dev/null +++ b/__tests__/e2e/ssr-batching.test.ts @@ -0,0 +1,120 @@ +import { access, readFile } from 'node:fs/promises' +import path from 'node:path' + +test('batched SSR writes complete shared and per-page artifacts', async () => { + if (!process.env.VITE_TEST_SSR_BATCH) return + + const outDir = path.resolve('.vitepress/dist') + const [ + indexHtml, + dynamicHtml, + staticHtml, + scopedHtml, + lastBatchHtml, + notFoundHtml, + iconsCss, + hashmap + ] = await Promise.all([ + readFile(path.join(outDir, 'index.html'), 'utf8'), + readFile(path.join(outDir, 'dynamic-routes/foo.html'), 'utf8'), + readFile(path.join(outDir, 'ssr-static.html'), 'utf8'), + readFile(path.join(outDir, 'ssr-scoped.html'), 'utf8'), + readFile(path.join(outDir, 'text-literals/index.html'), 'utf8'), + readFile(path.join(outDir, '404.html'), 'utf8'), + readFile(path.join(outDir, 'vp-icons.css'), 'utf8'), + readFile(path.join(outDir, 'hashmap.json'), 'utf8') + ]) + + expect(indexHtml).toContain('
') + expect(dynamicHtml).toContain('Foo - transformed | Example') + expect(dynamicHtml).toContain('name="ssr-batch-hook-state"') + expect(dynamicHtml).toContain( + 'data-ssr-batch-transform="dynamic-routes/foo.md"' + ) + expect(staticHtml).toContain('Static batching page | Example') + expect(staticHtml).toContain('

Static HTML marker

' + ) + expect(staticHtml).toContain( + 'static badge' + ) + expect(staticHtml).toContain('') + expect(staticHtml).toContain( + 'Static public asset' + ) + expect(scopedHtml).toContain('Scoped module identity') + expect(scopedHtml).toMatch(/class="scoped-batch-marker" data-v-[\da-f]+/) + expect(staticHtml).toMatch( + // + ) + expect(staticHtml).toContain( + '' + ) + expect(staticHtml).toContain('data-ssr-batch-transform="ssr-static.md"') + expect(lastBatchHtml).toContain('

') + expect(dynamicHtml).toContain('"id": "foo"') + expect(dynamicHtml).not.toContain('{{ $params }}') + expect(notFoundHtml).toContain('404 | Example') + expect(iconsCss).toContain('.vpi-social-github') + expect(hashmap).not.toContain('undefined') + await expect( + access(path.join(outDir, 'batch-public.txt')) + ).resolves.toBeUndefined() + if (!process.env.DEBUG) { + await expect(access(path.resolve('.vitepress/.temp'))).rejects.toThrow() + } +}) + +test('resolved config-file hooks preserve legacy physical Markdown SSR semantics', async () => { + if (!process.env.VITE_TEST_BUILD) return + + const html = await readFile( + path.resolve('.vitepress/dist/ssr-plugin-safety.html'), + 'utf8' + ) + expect(html).toContain( + '

physical Markdown load hook

' + ) + expect(html).toContain( + '

environment-sensitive Markdown transform

' + ) + expect(html).toContain( + '

production plugin context

' + ) + expect(html).not.toContain('data-resolved-transform-mode="client"') +}) + +test('a batched SSR page hydrates with normal client-page semantics', async () => { + if (!process.env.VITE_TEST_SSR_BATCH) return + + await goto('/ssr-static.html') + + expect( + await page + .getByRole('heading', { level: 1, name: 'Static batching page' }) + .isVisible() + ).toBe(true) + expect( + await page.locator('[data-static-batch-marker="preserved"]').textContent() + ).toBe('Static HTML marker') + expect(await page.locator('.VPBadge.warning').textContent()).toBe( + 'static badge' + ) + expect( + await page.locator('[data-static-public-asset]').getAttribute('src') + ).toBe('/batch-public.txt') +}) + +test('scoped pages preserve client and SSR module identity', async () => { + if (!process.env.VITE_TEST_SSR_BATCH) return + + await goto('/ssr-scoped.html') + + const marker = page.locator('.scoped-batch-marker') + expect(await marker.textContent()).toBe('Scoped module identity') + expect( + await marker.evaluate((element) => getComputedStyle(element).color) + ).toBe('rgb(1, 2, 3)') +}) diff --git a/__tests__/e2e/ssr-plugin-safety.md b/__tests__/e2e/ssr-plugin-safety.md new file mode 100644 index 000000000000..379c93808b23 --- /dev/null +++ b/__tests__/e2e/ssr-plugin-safety.md @@ -0,0 +1,7 @@ +--- +title: Resolved plugin artifact safety +--- + +# Resolved plugin artifact safety + +This page is transformed by a plugin loaded from `vite.config.ts`. diff --git a/__tests__/e2e/ssr-scoped.md b/__tests__/e2e/ssr-scoped.md new file mode 100644 index 000000000000..597d6e75816e --- /dev/null +++ b/__tests__/e2e/ssr-scoped.md @@ -0,0 +1,14 @@ +--- +title: Scoped batching page +description: A page that must preserve its physical Markdown module identity. +--- + +# Scoped batching page + +
Scoped module identity
+ + diff --git a/__tests__/e2e/ssr-static.md b/__tests__/e2e/ssr-static.md new file mode 100644 index 000000000000..3367e8a39d6a --- /dev/null +++ b/__tests__/e2e/ssr-static.md @@ -0,0 +1,18 @@ +--- +title: Static batching page +description: A page used to verify batched SSR output. +--- + +# Static batching page + +This content is rendered without evaluating a per-page SSR module. + +

Static HTML marker

+ +## Static presentational markup + +static badge + + + +Static public asset diff --git a/__tests__/e2e/vite.config.ts b/__tests__/e2e/vite.config.ts new file mode 100644 index 000000000000..426a4c1741a6 --- /dev/null +++ b/__tests__/e2e/vite.config.ts @@ -0,0 +1,49 @@ +import { readFile } from 'node:fs/promises' +import path from 'node:path' +import { defineConfig } from 'vite' + +const artifactSafetyPageRE = /(?:^|\/)ssr-plugin-safety[.]md$/ + +export default defineConfig({ + publicDir: process.env.VITE_TEST_SSR_PLUGIN_PARITY + ? 'batch-public' + : undefined, + resolve: process.env.VITE_TEST_SSR_PLUGIN_PARITY + ? { + alias: { + '/vitepress.png': path.resolve( + import.meta.dirname, + 'public/vitepress.png' + ) + } + } + : undefined, + plugins: [ + { + name: 'test:config-file-artifact-safety', + apply: 'build', + applyToEnvironment(environment) { + const environmentName = environment.name + return { + name: `test:resolved-artifact-safety:${environmentName}`, + enforce: 'pre', + load: { + filter: { id: artifactSafetyPageRE }, + async handler(id) { + const source = await readFile(id, 'utf8') + return `${source}\n

physical Markdown load hook

` + } + }, + transform: { + filter: { id: artifactSafetyPageRE }, + handler(code, _id, options) { + const mode = options?.ssr ? 'server' : 'client' + const pluginContext = `${this.environment.mode}:${this.meta.watchMode}` + return `${code}\n

environment-sensitive Markdown transform

\n

production plugin context

` + } + } + } + } + } + ] +}) diff --git a/__tests__/e2e/vitestGlobalSetup.ts b/__tests__/e2e/vitestGlobalSetup.ts index 74596801f64d..ea13d593e507 100644 --- a/__tests__/e2e/vitestGlobalSetup.ts +++ b/__tests__/e2e/vitestGlobalSetup.ts @@ -21,7 +21,28 @@ export async function setup() { process.env['PORT'] = port.toString() if (process.env['VITE_TEST_BUILD']) { - await build(root) + if (process.env.VITE_TEST_SSR_BATCH) { + let afterConfigResolveCalls = 0 + await build(root, { + onAfterConfigResolve(siteConfig) { + afterConfigResolveCalls++ + siteConfig.site.head.push([ + 'meta', + { + name: 'ssr-batch-after-config-resolve', + content: 'coordinator mutation retained' + } + ]) + } + }) + if (afterConfigResolveCalls !== 1) { + throw new Error( + `Expected one coordinator config hook call, received ${afterConfigResolveCalls}` + ) + } + } else { + await build(root) + } server = (await serve({ root, port })).server } else { server = await createServer(root, { port }) @@ -30,7 +51,8 @@ export async function setup() { } export async function teardown() { - await browserServer.close() + await browserServer?.close() + if (!server) return if ('ws' in server) { await server.close() } else { diff --git a/__tests__/unit/node/build/artifacts/store.test.ts b/__tests__/unit/node/build/artifacts/store.test.ts new file mode 100644 index 000000000000..21b26e0a2593 --- /dev/null +++ b/__tests__/unit/node/build/artifacts/store.test.ts @@ -0,0 +1,117 @@ +import { PageArtifactStore } from 'node/build/artifacts/store' +import type { MarkdownCompileResult } from 'node/markdownToVue' +import { mkdtemp, readdir, rm } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import path from 'node:path' + +describe('PageArtifactStore', () => { + let root: string | undefined + + afterEach(async () => { + if (root) { + await rm(root, { recursive: true, force: true }) + root = undefined + } + }) + + async function createRoot() { + root = await mkdtemp(path.join(tmpdir(), 'vitepress-page-artifacts-')) + return root + } + + test('deduplicates compilation and spools the artifact to disk', async () => { + const storeRoot = await createRoot() + const store = new PageArtifactStore(storeRoot) + const artifact = createArtifact() + const compile = vi.fn(async () => artifact) + + const results = await Promise.all( + Array.from({ length: 4 }, () => + store.getOrCreate('page.md', '# Page', compile) + ) + ) + + expect(compile).toHaveBeenCalledTimes(1) + expect(results).toEqual([artifact, artifact, artifact, artifact]) + expect(await store.getCurrent('./page.md')).toEqual(artifact) + expect(await countFiles(storeRoot)).toBe(1) + }) + + test('preserves non-JSON page data without making artifacts cross-build', async () => { + const storeRoot = await createRoot() + const published = new Date('2025-01-02T03:04:05.000Z') + const artifact = createArtifact({ + pageData: { + ...createArtifact().pageData, + frontmatter: { published, optional: undefined } + } + }) + const store = new PageArtifactStore(storeRoot) + + await store.getOrCreate('page.md', '# Page', async () => artifact) + const restored = await store.getCurrent('page.md') + + expect(restored).toEqual(artifact) + expect(restored?.pageData.frontmatter.published).toBeInstanceOf(Date) + expect( + Object.hasOwn(restored?.pageData.frontmatter ?? {}, 'optional') + ).toBe(true) + expect( + await new PageArtifactStore(storeRoot).getCurrent('page.md') + ).toBeUndefined() + }) + + test('finalizes once for a source and recompiles when the input changes', async () => { + const store = new PageArtifactStore(await createRoot()) + const compile = vi.fn(async () => createArtifact()) + const finalize = vi.fn(async (artifact: MarkdownCompileResult) => ({ + ...artifact, + pageData: { ...artifact.pageData, title: 'Finalized' } + })) + + const first = await store.getOrCreate( + 'page.md', + '# Page', + compile, + finalize + ) + const second = await store.getOrCreate( + 'page.md', + '# Page', + compile, + finalize + ) + await store.getOrCreate('page.md', '# Changed', compile, finalize) + await store.flush() + + expect(first.pageData.title).toBe('Finalized') + expect(second).toEqual(first) + expect(compile).toHaveBeenCalledTimes(2) + expect(finalize).toHaveBeenCalledTimes(2) + }) +}) + +async function countFiles(root: string): Promise { + const entries = await readdir(root, { recursive: true, withFileTypes: true }) + return entries.filter((entry) => entry.isFile()).length +} + +function createArtifact( + overrides: Partial = {} +): MarkdownCompileResult { + return { + vueSrc: '', + html: '

Page

', + pageData: { + title: 'Page', + description: '', + frontmatter: {}, + headers: [], + relativePath: 'page.md', + filePath: 'page.md' + }, + deadLinks: [], + includes: [], + ...overrides + } +} diff --git a/__tests__/unit/node/build/render/metadata.test.ts b/__tests__/unit/node/build/render/metadata.test.ts new file mode 100644 index 000000000000..62e70b8d8973 --- /dev/null +++ b/__tests__/unit/node/build/render/metadata.test.ts @@ -0,0 +1,91 @@ +import type { SiteConfig } from 'node/config' +import { createRenderMetadata } from 'node/build/render/metadata' +import type { Rolldown } from 'vite' + +const chunk = (values: Partial): Rolldown.OutputChunk => + ({ + type: 'chunk', + fileName: '', + name: '', + code: '', + imports: [], + moduleIds: [], + isEntry: false, + ...values + }) as Rolldown.OutputChunk + +const asset = (fileName: string): Rolldown.OutputAsset => + ({ + type: 'asset', + fileName, + names: [], + originalFileNames: [], + source: '' + }) as Rolldown.OutputAsset + +test('retains only compact client metadata', () => { + const pagePath = '/site/guide.md' + const clientResult = { + output: [ + chunk({ + fileName: 'assets/app.123.js', + facadeModuleId: '/vitepress/app/index.js', + imports: ['assets/framework.js'], + isEntry: true, + code: 'large app code that must not be retained' + }), + chunk({ + fileName: 'assets/guide.123.js', + facadeModuleId: pagePath, + imports: ['assets/theme.js'], + isEntry: true, + code: 'large page code that must not be retained' + }), + chunk({ + name: 'theme', + moduleIds: ['/vitepress/client/theme-default/index.js'] + }), + asset('assets/style.123.css'), + asset('assets/logo.123.svg') + ] + } as Rolldown.RolldownOutput + const config = { + mpa: false, + site: { base: '/docs/' } + } as SiteConfig + + const metadata = createRenderMetadata(config, clientResult, null) + expect(metadata.appChunk).toEqual({ + fileName: 'assets/app.123.js', + imports: ['assets/framework.js'] + }) + expect(metadata.cssChunk).toEqual({ fileName: 'assets/style.123.css' }) + expect(metadata.assets).toEqual(['/docs/assets/logo.123.svg']) + expect(metadata.isDefaultTheme).toBe(true) + expect(metadata.pageImports.get(pagePath)).toEqual(['assets/theme.js']) +}) + +test('retains inlineable page chunks for normal MPA rendering', () => { + const pagePath = '/site/index.md' + const clientResult = { + output: [ + chunk({ + fileName: 'assets/index.js', + facadeModuleId: pagePath, + isEntry: true, + code: 'console.log("client")' + }) + ] + } as Rolldown.RolldownOutput + const serverResult = { + output: [asset('assets/mpa.css')] + } as Rolldown.RolldownOutput + const config = { mpa: true, site: { base: '/' } } as SiteConfig + + const metadata = createRenderMetadata(config, clientResult, serverResult) + expect(metadata.pageChunks.get(pagePath)).toEqual({ + fileName: 'assets/index.js', + code: 'console.log("client")' + }) + expect(metadata.cssChunk).toEqual({ fileName: 'assets/mpa.css' }) +}) diff --git a/__tests__/unit/node/build/render/page.test.ts b/__tests__/unit/node/build/render/page.test.ts new file mode 100644 index 000000000000..6cae1d2e735d --- /dev/null +++ b/__tests__/unit/node/build/render/page.test.ts @@ -0,0 +1,42 @@ +import { + deserializeRenderedPage, + serializeRenderedPage +} from 'node/build/render/page' + +test('round-trips worker render results with sorted Set-backed state', () => { + const renderedPage = { + page: 'guide.md', + pageData: { + title: 'Guide', + description: '', + frontmatter: {}, + headers: [], + relativePath: 'guide.md', + filePath: 'guide.md' + }, + hasCustom404: true, + context: { + content: '
Guide
', + teleports: { body: '
teleported
' }, + vpSocialIcons: new Set(['z-icon', 'a-icon']), + __watcherHandles: [() => undefined] + } + } + + const serialized = serializeRenderedPage(renderedPage) + expect(serialized.context.vpSocialIcons).toEqual(['a-icon', 'z-icon']) + expect(serialized.context).not.toHaveProperty('__watcherHandles') + + const restored = deserializeRenderedPage(serialized) + expect(restored).toMatchObject({ + page: renderedPage.page, + pageData: renderedPage.pageData, + hasCustom404: true, + context: { + content: '
Guide
', + teleports: { body: '
teleported
' } + } + }) + expect(restored.context.vpSocialIcons).toBeInstanceOf(Set) + expect([...restored.context.vpSocialIcons]).toEqual(['a-icon', 'z-icon']) +}) diff --git a/__tests__/unit/node/build/ssr/clientAssets.test.ts b/__tests__/unit/node/build/ssr/clientAssets.test.ts new file mode 100644 index 000000000000..ea3db77f0dba --- /dev/null +++ b/__tests__/unit/node/build/ssr/clientAssets.test.ts @@ -0,0 +1,76 @@ +import { captureClientAssetUrls } from 'node/build/ssr/clientAssets' +import type { SiteConfig } from 'node/config' +import type { ResolvedConfig, Rolldown } from 'vite' + +function assetCaptureTransform(assetMap: Record) { + const plugin = captureClientAssetUrls( + { site: { base: '/' } } as SiteConfig, + assetMap + ) + const transform = plugin.transform as { + handler(code: string, id: string): void + } + return transform.handler +} + +test('captures inlined assets without treating raw or arbitrary root strings as URLs', () => { + const assetMap: Record = Object.create(null) + const transform = assetCaptureTransform(assetMap) + + transform('export default "data:image/png;base64,cGl4ZWw="', '/logo.png') + transform('export default "data:not-an-asset"', '/message.txt?raw') + transform('export default "/arbitrary-string"', '/message.txt?custom') + + expect(assetMap['/logo.png']).toBe('data:image/png;base64,cGl4ZWw=') + expect(assetMap['/message.txt?raw']).toBeUndefined() + expect(assetMap['/message.txt?custom']).toBeUndefined() +}) + +test('rejects runtime renderBuiltUrl expressions for batched SSR assets', () => { + const assetMap: Record = Object.create(null) + const plugin = captureClientAssetUrls( + { site: { base: '/' } } as SiteConfig, + assetMap + ) + const configResolved = plugin.configResolved as ( + config: ResolvedConfig + ) => void + configResolved({ + experimental: { + renderBuiltUrl() { + return { runtime: 'globalThis.__assetUrl' } + } + } + } as ResolvedConfig) + + const transform = plugin.transform as { + handler(code: string, id: string): void + } + const assetId = '/logo.svg?url' + transform.handler('export default "__VITE_ASSET__logo__"', assetId) + + const generateBundle = plugin.generateBundle as ( + this: Rolldown.PluginContext, + options: Rolldown.NormalizedOutputOptions, + bundle: Rolldown.OutputBundle + ) => void + expect(() => + generateBundle.call( + { + getFileName() { + return 'assets/logo.svg' + } + } as unknown as Rolldown.PluginContext, + {} as Rolldown.NormalizedOutputOptions, + { + 'page.js': { + type: 'chunk', + moduleIds: [assetId], + fileName: 'page.js' + } + } as Rolldown.OutputBundle + ) + ).toThrow( + 'ssrBuildBatchSize cannot materialize the runtime renderBuiltUrl expression for assets/logo.svg. Return a URL string for SSR assets instead.' + ) +}) diff --git a/__tests__/unit/node/build/ssr/modules/compiler.test.ts b/__tests__/unit/node/build/ssr/modules/compiler.test.ts new file mode 100644 index 000000000000..3a91876e994b --- /dev/null +++ b/__tests__/unit/node/build/ssr/modules/compiler.test.ts @@ -0,0 +1,1003 @@ +import { + createSsrModuleCompiler, + type SsrModuleCompiler +} from 'node/build/ssr/modules/compiler' +import { SsrModuleArtifactTransport } from 'node/build/ssr/modules/transport' +import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import path from 'node:path' +import { pathToFileURL } from 'node:url' +import { perEnvironmentState, type Plugin } from 'vite' +import { createNodeImportMeta, ModuleRunner } from 'vite/module-runner' + +describe('SsrModuleCompiler', () => { + let root: string | undefined + const compilers = new Set() + + afterEach(async () => { + await Promise.all([...compilers].map((compiler) => compiler.close())) + compilers.clear() + if (root) { + await rm(root, { recursive: true, force: true }) + root = undefined + } + }) + + async function createFixture() { + root = await mkdtemp(path.join(tmpdir(), 'vitepress-ssr-modules-')) + await writeFile( + path.join(root, 'package.json'), + JSON.stringify({ type: 'module' }) + ) + return { + root, + artifactDir: path.join(root, '.artifacts') + } + } + + test('materializes final asset URLs and externalizes shared runtime bridges', async () => { + const fixture = await createFixture() + const bridge = path.join(fixture.root, 'runtime-bridge.mjs') + await writeFile(bridge, 'export const shared = true') + + const compiler = createSsrModuleCompiler( + { + root: fixture.root, + logLevel: 'silent', + plugins: [ + { + name: 'test:resolve-runtime-bridge', + resolveId(id) { + if (id === 'virtual:runtime') return '\0test:runtime' + if (id === 'virtual:runtime?raw') return '\0test:runtime?raw' + }, + load(id) { + if (id === '\0test:runtime?raw') { + return 'export default "raw-runtime-source"' + } + } + } + ] + }, + fixture.artifactDir, + { + runtimeBridges: new Map([['\0test:runtime', bridge]]), + resolveAsset: new Map([ + ['virtual:logo', '/assets/logo.content-hash.svg'] + ]) + } + ) + compilers.add(compiler) + await compiler.init() + + await expect(compiler.handleFetch(['virtual:runtime'])).resolves.toEqual({ + externalize: pathToFileURL(bridge).href, + type: 'module' + }) + + const queriedRuntime = await compiler.handleFetch(['virtual:runtime?raw']) + expect('externalize' in queriedRuntime).toBe(false) + expect('code' in queriedRuntime).toBe(true) + if ('code' in queriedRuntime) { + expect(queriedRuntime.code).toContain('raw-runtime-source') + } + + const asset = await compiler.handleFetch(['virtual:logo']) + expect('cache' in asset).toBe(false) + expect('code' in asset).toBe(true) + if ('code' in asset) { + expect(asset.code).toContain('/assets/logo.content-hash.svg') + expect(asset.code).not.toContain('/@fs/') + expect(asset.invalidate).toBe(false) + } + }) + + test('runs SSR plugin buildStart hooks before transforming modules', async () => { + const fixture = await createFixture() + let initialized = false + const compiler = createSsrModuleCompiler( + { + root: fixture.root, + logLevel: 'silent', + plugins: [ + { + name: 'test:ssr-build-start', + buildStart() { + initialized = true + }, + resolveId(id) { + if (id === 'virtual:after-build-start') { + return '\0test:after-build-start' + } + }, + load(id) { + if (id !== '\0test:after-build-start') return + if (!initialized) throw new Error('buildStart did not run') + return 'export const initialized = true' + } + } + ] + }, + fixture.artifactDir + ) + compilers.add(compiler) + await compiler.init() + + const result = await compiler.precompile('virtual:after-build-start') + expect(initialized).toBe(true) + expect('code' in result && result.code).toContain('initialized') + }) + + test('presents production non-watch semantics to user plugin hooks', async () => { + const fixture = await createFixture() + const observations: { + hook: string + mode: string + watchMode: boolean + }[] = [] + const environments = new Set() + const environmentState = perEnvironmentState(() => ({ + hooks: [] as string[] + })) + let sharedEnvironmentState: { hooks: string[] } | undefined + let sawBuildContextSurface = false + let readModuleMeta = false + const observe = ( + hook: string, + context: { + environment: { mode: string } + meta: { watchMode: boolean } + } + ) => { + const state = environmentState(context as never) + sharedEnvironmentState ||= state + expect(state).toBe(sharedEnvironmentState) + state.hooks.push(hook) + environments.add(context.environment) + observations.push({ + hook, + mode: context.environment.mode, + watchMode: context.meta.watchMode + }) + } + let readCombinedSourcemap = false + const compiler = createSsrModuleCompiler( + { + root: fixture.root, + logLevel: 'silent', + plugins: [ + { + name: 'test:ssr-build-context', + options(options) { + observe('options', this) + expect(this.emitFile).toBeUndefined() + expect(this.getFileName).toBeUndefined() + expect(this.getModuleInfo).toBeUndefined() + expect(this.getModuleIds).toBeUndefined() + return options + }, + buildStart() { + observe('buildStart', this) + }, + resolveId(id) { + if (id !== 'virtual:build-context') return + observe('resolveId', this) + return '\0test:build-context' + }, + load(id) { + if (id !== '\0test:build-context') return + observe('load', this) + return 'export const context = true' + }, + transform(code, id) { + if (id !== '\0test:build-context') return + observe('transform', this) + sawBuildContextSurface = + typeof this.emitFile === 'function' && + typeof this.getFileName === 'function' && + typeof this.getModuleInfo === 'function' && + typeof this.getModuleIds === 'function' && + this.setAssetSource === undefined && + this.getWatchFiles === undefined + readModuleMeta = this.getModuleInfo(id)?.meta != null + this.getCombinedSourcemap() + readCombinedSourcemap = true + return code + }, + buildEnd() { + observe('buildEnd', this) + }, + closeBundle() { + observe('closeBundle', this) + } + } + ] + }, + fixture.artifactDir + ) + compilers.add(compiler) + await compiler.init() + await compiler.precompile('virtual:build-context') + await compiler.close() + + expect(readCombinedSourcemap).toBe(true) + expect(readModuleMeta).toBe(true) + expect(sawBuildContextSurface).toBe(true) + expect(environments.size).toBe(1) + expect(observations.map(({ hook }) => hook)).toEqual([ + 'options', + 'buildStart', + 'resolveId', + 'load', + 'transform', + 'buildEnd', + 'closeBundle' + ]) + expect( + observations.every( + ({ mode, watchMode }) => mode === 'build' && watchMode === false + ) + ).toBe(true) + expect(sharedEnvironmentState?.hooks).toEqual( + observations.map(({ hook }) => hook) + ) + }) + + test('rejects Rolldown-only context methods instead of ignoring them', async () => { + const fixture = await createFixture() + const compiler = createSsrModuleCompiler( + { + root: fixture.root, + logLevel: 'silent', + plugins: [ + { + name: 'test:ssr-emit-file', + resolveId(id) { + if (id === 'virtual:emit-file') return '\0test:emit-file' + }, + load(id) { + if (id !== '\0test:emit-file') return + this.emitFile({ + type: 'asset', + name: 'server-only.txt', + source: 'server-only' + }) + return 'export const emitted = true' + } + } + ] + }, + fixture.artifactDir + ) + compilers.add(compiler) + await compiler.init() + + await expect(compiler.precompile('virtual:emit-file')).rejects.toThrow( + 'plugin "test:ssr-emit-file" called this.emitFile()' + ) + }) + + test('runs supported plugin teardown hooks when closing', async () => { + const fixture = await createFixture() + const lifecycle: string[] = [] + const compiler = createSsrModuleCompiler( + { + root: fixture.root, + logLevel: 'silent', + plugins: [ + { + name: 'test:ssr-teardown-lifecycle', + buildEnd() { + lifecycle.push('buildEnd') + }, + closeBundle() { + lifecycle.push('closeBundle') + } + } + ] + }, + fixture.artifactDir + ) + compilers.add(compiler) + await compiler.init() + + await compiler.close() + expect(lifecycle).toEqual(['buildEnd', 'closeBundle']) + }) + + test('propagates plugin teardown errors after closing the environment', async () => { + const fixture = await createFixture() + const compiler = createSsrModuleCompiler( + { + root: fixture.root, + logLevel: 'silent', + plugins: [ + { + name: 'test:ssr-teardown-error', + closeBundle() { + throw new Error('SSR teardown failed') + } + } + ] + }, + fixture.artifactDir + ) + compilers.add(compiler) + await compiler.init() + + const environment = ( + compiler as unknown as { + environment: { close: () => Promise } + } + ).environment + const closeEnvironment = vi.spyOn(environment, 'close') + + await expect(compiler.close()).rejects.toThrow('SSR teardown failed') + expect(closeEnvironment).toHaveBeenCalledOnce() + }) + + test('rejects output hooks before starting the unbundled page environment', async () => { + const fixture = await createFixture() + const compiler = createSsrModuleCompiler( + { + root: fixture.root, + logLevel: 'silent', + plugins: [ + { + name: 'test:ssr-page-output', + renderChunk(code) { + return code + } + } + ] + }, + fixture.artifactDir + ) + compilers.add(compiler) + + await expect(compiler.init()).rejects.toThrow( + 'plugin "test:ssr-page-output": renderChunk' + ) + }) + + test('rejects output hooks from build.rolldownOptions.plugins once', async () => { + const fixture = await createFixture() + const compiler = createSsrModuleCompiler( + { + root: fixture.root, + logLevel: 'silent', + build: { + rolldownOptions: { + plugins: [ + { + name: 'test:rolldown-page-output', + augmentChunkHash() { + return 'page-output' + } + } + ] + } + } + }, + fixture.artifactDir + ) + compilers.add(compiler) + + const error = await compiler.init().catch((error: unknown) => error) + expect(error).toBeInstanceOf(Error) + const message = (error as Error).message + expect(message).toContain( + 'plugin "test:rolldown-page-output": augmentChunkHash' + ) + expect(message.match(/test:rolldown-page-output/g)).toHaveLength(1) + }) + + test('allows bundled-only output hooks excluded from the page environment', async () => { + const fixture = await createFixture() + const compiler = createSsrModuleCompiler( + { + root: fixture.root, + logLevel: 'silent', + plugins: [ + { + name: 'test:bundled-page-output', + applyToEnvironment(environment) { + return environment.config.isBundled + }, + renderChunk(code) { + return code + } + } + ] + }, + fixture.artifactDir + ) + compilers.add(compiler) + + await expect(compiler.init()).resolves.toBeUndefined() + }) + + test('deduplicates concurrent and subsequent requests through the current manifest', async () => { + const fixture = await createFixture() + const virtualId = '\0test:ssr-page' + let loadCalls = 0 + const sourcePlugin: Plugin = { + name: 'test:ssr-page-source', + resolveId(id) { + if (id === 'virtual:ssr-page') return virtualId + }, + load(id) { + if (id === virtualId) { + loadCalls++ + return 'export const page = "materialized"' + } + } + } + + const firstCompiler = createSsrModuleCompiler( + { + root: fixture.root, + logLevel: 'silent', + plugins: [sourcePlugin] + }, + fixture.artifactDir + ) + compilers.add(firstCompiler) + await firstCompiler.init() + + const [first, concurrent] = await Promise.all([ + firstCompiler.precompile('virtual:ssr-page'), + firstCompiler.precompile('virtual:ssr-page') + ]) + expect(concurrent).toEqual(first) + expect(loadCalls).toBe(1) + expect('cache' in first).toBe(false) + if ('code' in first) { + expect(first.code).toContain('materialized') + expect(first.invalidate).toBe(false) + } + await expect(firstCompiler.precompile('virtual:ssr-page')).resolves.toEqual( + first + ) + expect(loadCalls).toBe(1) + }) + + test('persists released entries and scopes dependency reuse by importer', async () => { + const fixture = await createFixture() + const entryId = '\0test:one-shot-entry' + const dependencyId = path.join(fixture.root, 'shared-dependency.js') + const transformedDependencyId = '\0test:shared-dependency' + + let entryLoads = 0 + let dependencyLoads = 0 + const compiler = createSsrModuleCompiler( + { + root: fixture.root, + logLevel: 'silent', + plugins: [ + { + name: 'test:persistence-boundary', + resolveId(id) { + if (id === 'virtual:one-shot-entry') return entryId + if (id === dependencyId) return transformedDependencyId + }, + load(id) { + if (id === entryId) { + entryLoads++ + return 'export const entry = true' + } + if (id === transformedDependencyId) { + dependencyLoads++ + return 'export const dependency = true' + } + } + } + ] + }, + fixture.artifactDir + ) + compilers.add(compiler) + await compiler.init() + + await compiler.precompile('virtual:one-shot-entry') + await compiler.precompile('virtual:one-shot-entry') + expect(entryLoads).toBe(1) + + const entryGraph = ( + compiler as unknown as { + environment: { + moduleGraph: { + getModuleById: (id: string) => unknown + urlToModuleMap: Map + } + } + } + ).environment.moduleGraph + expect(entryGraph.getModuleById(entryId)).toBeUndefined() + expect( + [...entryGraph.urlToModuleMap.values()].some( + (module) => module.id === entryId + ) + ).toBe(false) + + await compiler.handleFetch([dependencyId, '/first-page.md']) + await compiler.handleFetch([dependencyId, '/first-page.md']) + expect(dependencyLoads).toBe(1) + await compiler.handleFetch([dependencyId, '/second-page.md']) + expect(dependencyLoads).toBe(2) + }) + + test('removes absolute one-shot entries across ModuleRunner id spellings', async () => { + const fixture = await createFixture() + const entryFile = path.join(fixture.root, 'absolute-entry.js') + await writeFile(entryFile, 'export const absoluteEntry = true') + + const compiler = createSsrModuleCompiler( + { root: fixture.root, logLevel: 'silent' }, + fixture.artifactDir + ) + compilers.add(compiler) + await compiler.init() + + const result = await compiler.precompile(entryFile) + expect('code' in result && result.code).toContain('absoluteEntry') + + const graph = ( + compiler as unknown as { + environment: { + moduleGraph: { + idToModuleMap: Map + urlToModuleMap: Map + } + } + } + ).environment.moduleGraph + expect( + [...graph.idToModuleMap.values(), ...graph.urlToModuleMap.values()].some( + (module) => module.id?.replace(/[?#].*$/, '') === entryFile + ) + ).toBe(false) + }) + + test('keeps importer-aware resolution and assets distinct for absolute requests', async () => { + const fixture = await createFixture() + const firstBridge = path.join(fixture.root, 'runtime-one.mjs') + const secondBridge = path.join(fixture.root, 'runtime-two.mjs') + const absoluteId = path.join(fixture.root, 'shared-runtime.js') + const windowsAbsoluteId = 'C:/docs/shared-runtime.js' + const fileUrlId = pathToFileURL( + path.join(fixture.root, 'file-url-runtime.js') + ).href + const ids = [absoluteId, windowsAbsoluteId, fileUrlId] + const importerOne = '/first-page.md' + const importerTwo = '/second-page.md' + const resolvedIds = new Map( + ids.map((id, index) => [ + id, + [`\0test:runtime-${index}-one`, `\0test:runtime-${index}-two`] + ]) + ) + const replacements = new Map() + for (const [one, two] of resolvedIds.values()) { + replacements.set(one, firstBridge) + replacements.set(two, secondBridge) + } + const absoluteAsset = path.join(fixture.root, 'logo.svg') + const compiler = createSsrModuleCompiler( + { + root: fixture.root, + logLevel: 'silent', + plugins: [ + { + name: 'test:runtime-replacement-ids', + resolveId(id, importer) { + const resolved = resolvedIds.get(id) + if (resolved) { + return importer === importerOne ? resolved[0] : resolved[1] + } + } + } + ] + }, + fixture.artifactDir, + { + runtimeBridges: replacements, + resolveAsset(id, importer) { + if (id !== absoluteAsset && id !== fileUrlId) return + return importer === importerOne + ? '/assets/logo-one.svg' + : '/assets/logo-two.svg' + } + } + ) + compilers.add(compiler) + await compiler.init() + + for (const id of [absoluteId, windowsAbsoluteId]) { + await expect(compiler.handleFetch([id, importerOne])).resolves.toEqual({ + externalize: pathToFileURL(firstBridge).href, + type: 'module' + }) + await expect(compiler.handleFetch([id, importerTwo])).resolves.toEqual({ + externalize: pathToFileURL(secondBridge).href, + type: 'module' + }) + } + + const firstAsset = await compiler.handleFetch([absoluteAsset, importerOne]) + const secondAsset = await compiler.handleFetch([absoluteAsset, importerTwo]) + expect('code' in firstAsset && firstAsset.code).toContain( + '/assets/logo-one.svg' + ) + expect('code' in secondAsset && secondAsset.code).toContain( + '/assets/logo-two.svg' + ) + + const firstFileUrlAsset = await compiler.handleFetch([ + fileUrlId, + importerOne + ]) + const secondFileUrlAsset = await compiler.handleFetch([ + fileUrlId, + importerTwo + ]) + expect('code' in firstFileUrlAsset && firstFileUrlAsset.code).toContain( + '/assets/logo-one.svg' + ) + expect('code' in secondFileUrlAsset && secondFileUrlAsset.code).toContain( + '/assets/logo-two.svg' + ) + }) + + test('omits inline sourcemaps and releases transforms without invalidating importers', async () => { + const fixture = await createFixture() + const virtualId = '\0test:mapped-module' + const source = 'export const mapped = true' + const compiler = createSsrModuleCompiler( + { + root: fixture.root, + logLevel: 'silent', + plugins: [ + { + name: 'test:mapped-module', + resolveId(id) { + if (id === 'virtual:mapped-module') return virtualId + }, + load(id) { + if (id === virtualId) return source + }, + transform(code, id) { + if (id !== virtualId) return + return { + code, + map: { + version: 3, + names: [], + sources: ['mapped-source.ts'], + sourcesContent: [source], + mappings: 'AAAA' + } + } + } + } + ] + }, + fixture.artifactDir + ) + compilers.add(compiler) + await compiler.init() + + const graph = ( + compiler as unknown as { + environment: { + moduleGraph: { + invalidateModule: (...args: unknown[]) => void + updateModuleTransformResult: (...args: unknown[]) => void + } + } + } + ).environment.moduleGraph + const invalidate = vi.spyOn(graph, 'invalidateModule') + const release = vi.spyOn(graph, 'updateModuleTransformResult') + + const result = await compiler.precompile('virtual:mapped-module') + expect('code' in result).toBe(true) + if ('code' in result) { + expect(result.code).not.toContain('sourceMappingURL=data:') + expect(result.code).not.toContain('sourceMappingSource=vite-generated') + expect(result.invalidate).toBe(false) + } + expect(invalidate).not.toHaveBeenCalled() + expect(release).toHaveBeenCalledWith( + expect.objectContaining({ id: virtualId }), + null + ) + }) + + test('waits for accepted fetches before closing the environment', async () => { + const fixture = await createFixture() + const virtualId = '\0test:slow-module' + let finishLoad!: (source: string) => void + let markLoadStarted!: () => void + const loadStarted = new Promise((resolve) => { + markLoadStarted = resolve + }) + const compiler = createSsrModuleCompiler( + { + root: fixture.root, + logLevel: 'silent', + plugins: [ + { + name: 'test:slow-module', + resolveId(id) { + if (id === 'virtual:slow-module') return virtualId + }, + load(id) { + if (id !== virtualId) return + markLoadStarted() + return new Promise((resolve) => { + finishLoad = resolve + }) + } + } + ] + }, + fixture.artifactDir + ) + compilers.add(compiler) + await compiler.init() + + const fetch = compiler.precompile('virtual:slow-module') + await loadStarted + const closing = compiler.close() + await expect(compiler.precompile('virtual:slow-module')).rejects.toThrow( + 'SSR module compiler is closing' + ) + + finishLoad('export const slow = true') + await expect(fetch).resolves.toEqual( + expect.objectContaining({ invalidate: false }) + ) + await closing + }) + + test('materializes page graphs for an offline ModuleRunner', async () => { + const fixture = await createFixture() + const entry = path.join(fixture.root, 'page.js') + const dependency = path.join(fixture.root, 'dependency.js') + const dynamicDependency = path.join(fixture.root, 'dynamic.js') + await Promise.all([ + writeFile( + entry, + [ + "import { dependency } from './dependency.js'", + 'export const value = `page:${dependency}`', + "export const loadDynamic = () => import('./dynamic.js')" + ].join('\n') + ), + writeFile(dependency, 'export const dependency = "shared"'), + writeFile(dynamicDependency, 'export const dynamic = "loaded"') + ]) + + const compiler = createSsrModuleCompiler( + { root: fixture.root, logLevel: 'silent' }, + fixture.artifactDir + ) + compilers.add(compiler) + await compiler.init() + + const materialized = await compiler.materializeGraphs([entry], 2) + expect(materialized.entries).toBe(1) + expect(materialized.requests).toBe(3) + const snapshot = path.join(fixture.artifactDir, 'snapshots', 'page.json') + await compiler.writeSnapshotForEntries([entry], snapshot) + const builtins = compiler.getBuiltins() + await compiler.close() + compilers.delete(compiler) + + const runner = new ModuleRunner({ + transport: new SsrModuleArtifactTransport( + fixture.artifactDir, + builtins, + snapshot + ), + hmr: false, + createImportMeta: createNodeImportMeta, + sourcemapInterceptor: false + }) + try { + const page = (await runner.import(entry)) as { + value: string + loadDynamic: () => Promise<{ dynamic: string }> + } + expect(page.value).toBe('page:shared') + await expect(page.loadDynamic()).resolves.toMatchObject({ + dynamic: 'loaded' + }) + } finally { + await runner.close() + } + }) + + test('uses ModuleRunner file identity for query-module dependencies', async () => { + const fixture = await createFixture() + const entry = path.join(fixture.root, 'page.js') + const script = path.join(fixture.root, 'script.js') + const dependency = path.join(fixture.root, 'dependency.js') + await Promise.all([ + writeFile( + entry, + "import { value } from './script.js?part'\nexport { value }" + ), + writeFile( + script, + "import { dependency } from './dependency.js'\nexport const value = `query:${dependency}`" + ), + writeFile(dependency, 'export const dependency = "shared"') + ]) + + const compiler = createSsrModuleCompiler( + { root: fixture.root, logLevel: 'silent' }, + fixture.artifactDir + ) + compilers.add(compiler) + await compiler.init() + await compiler.materializeGraphs([entry]) + const snapshot = path.join(fixture.artifactDir, 'snapshots', 'query.json') + await compiler.writeSnapshotForEntries([entry], snapshot) + + const builtins = compiler.getBuiltins() + await compiler.close() + compilers.delete(compiler) + + const runner = new ModuleRunner({ + transport: new SsrModuleArtifactTransport( + fixture.artifactDir, + builtins, + snapshot + ), + hmr: false, + createImportMeta: createNodeImportMeta, + sourcemapInterceptor: false + }) + try { + await expect(runner.import(entry)).resolves.toMatchObject({ + value: 'query:shared' + }) + } finally { + await runner.close() + } + }) + + test('publishes only the module closure reachable by one worker batch', async () => { + const fixture = await createFixture() + const pageA = path.join(fixture.root, 'page-a.js') + const pageB = path.join(fixture.root, 'page-b.js') + const shared = path.join(fixture.root, 'shared.js') + const dynamicA = path.join(fixture.root, 'dynamic-a.js') + const onlyB = path.join(fixture.root, 'only-b.js') + await Promise.all([ + writeFile( + pageA, + [ + "import { shared } from './shared.js'", + 'export const value = `a:${shared}`', + "export const loadDynamic = () => import('./dynamic-a.js')" + ].join('\n') + ), + writeFile( + pageB, + [ + "import { shared } from './shared.js'", + "import { onlyB } from './only-b.js'", + 'export const value = `b:${shared}:${onlyB}`' + ].join('\n') + ), + writeFile(shared, 'export const shared = "shared"'), + writeFile(dynamicA, 'export const dynamicA = "dynamic-a"'), + writeFile(onlyB, 'export const onlyB = "only-b"') + ]) + + const compiler = createSsrModuleCompiler( + { root: fixture.root, logLevel: 'silent' }, + fixture.artifactDir + ) + compilers.add(compiler) + await compiler.init() + const materialized = await compiler.materializeGraphs([pageA, pageB], 2) + + const batchSnapshot = path.join( + fixture.artifactDir, + 'snapshots', + 'page-a.json' + ) + const requestCount = await compiler.writeSnapshotForEntries( + [pageA], + batchSnapshot + ) + const slicedSnapshot = JSON.parse( + await readFile(batchSnapshot, 'utf8') + ) as { requests: [string, string][] } + expect(slicedSnapshot.requests).toHaveLength(requestCount) + expect(requestCount).toBeLessThan(materialized.requests) + await expect( + readFile(path.join(fixture.artifactDir, 'snapshot.json'), 'utf8') + ).rejects.toMatchObject({ code: 'ENOENT' }) + + const builtins = compiler.getBuiltins() + await compiler.close() + compilers.delete(compiler) + + const runner = new ModuleRunner({ + transport: new SsrModuleArtifactTransport( + fixture.artifactDir, + builtins, + batchSnapshot + ), + hmr: false, + createImportMeta: createNodeImportMeta, + sourcemapInterceptor: false + }) + try { + const page = (await runner.import(pageA)) as { + value: string + loadDynamic: () => Promise<{ dynamicA: string }> + } + expect(page.value).toBe('a:shared') + await expect(page.loadDynamic()).resolves.toMatchObject({ + dynamicA: 'dynamic-a' + }) + + // The shared CAS contains page B. The batch snapshot must still limit the + // worker to its declared modules. + await expect(runner.import(pageB)).rejects.toThrow( + /Missing precompiled SSR module/ + ) + } finally { + await runner.close() + } + }) + + test('rejects runtime-computed imports before offline rendering', async () => { + const fixture = await createFixture() + const entry = path.join(fixture.root, 'computed.js') + await writeFile( + entry, + [ + "const target = './dependency.js'", + 'export const load = () => import(target)' + ].join('\n') + ) + + const compiler = createSsrModuleCompiler( + { root: fixture.root, logLevel: 'silent' }, + fixture.artifactDir + ) + compilers.add(compiler) + await compiler.init() + + await expect(compiler.materializeGraphs([entry])).rejects.toThrow( + /runtime-computed import/ + ) + }) + + test('reports a missing offline module with its importer', async () => { + const fixture = await createFixture() + const transport = new SsrModuleArtifactTransport( + fixture.artifactDir, + [], + path.join(fixture.artifactDir, 'snapshots', 'missing.json') + ) + + await expect( + transport.invoke({ + type: 'custom', + event: 'vite:invoke', + data: { + name: 'fetchModule', + data: ['./missing.js', '/page.js', {}] + } + }) + ).rejects.toThrow(/\.\/missing\.js.*\/page\.js/) + }) +}) diff --git a/__tests__/unit/node/build/ssr/options.test.ts b/__tests__/unit/node/build/ssr/options.test.ts new file mode 100644 index 000000000000..11e868baf117 --- /dev/null +++ b/__tests__/unit/node/build/ssr/options.test.ts @@ -0,0 +1,76 @@ +import { + createRenderQueue, + createSsrBatchPlan, + resolveSsrBatchOptions, + validateBuildConcurrency +} from 'node/build/ssr/options' + +test('requires positive global build concurrency', () => { + expect(validateBuildConcurrency(1)).toBe(1) + expect(validateBuildConcurrency(64)).toBe(64) + for (const value of [undefined, 0, -1, 1.5, Number.NaN, Infinity, '2']) { + expect(() => validateBuildConcurrency(value)).toThrow( + 'buildConcurrency must be a positive integer.' + ) + } +}) + +test('validates worker concurrency only when batching is enabled', () => { + expect( + resolveSsrBatchOptions({ + ssrBuildBatchSize: undefined, + ssrBuildWorkerConcurrency: 0 + } as any) + ).toBeUndefined() + expect( + resolveSsrBatchOptions({ + ssrBuildBatchSize: 64, + ssrBuildWorkerConcurrency: 2 + } as any) + ).toEqual({ batchSize: 64, workerConcurrency: 2 }) + + for (const value of [0, -1, 1.5, Number.NaN, Infinity, '2', null]) { + expect(() => + resolveSsrBatchOptions({ + ssrBuildBatchSize: value, + ssrBuildWorkerConcurrency: 1 + } as any) + ).toThrow('ssrBuildBatchSize must be a positive integer.') + } + for (const value of [undefined, 0, -1, 1.5, Number.NaN, '2', null]) { + expect(() => + resolveSsrBatchOptions({ + ssrBuildBatchSize: 2, + ssrBuildWorkerConcurrency: value + } as any) + ).toThrow('ssrBuildWorkerConcurrency must be a positive integer.') + } +}) + +test('partitions one normalized render queue without reordering pages', () => { + const queue = createRenderQueue([ + 'a.md', + 'b.md', + '404.md', + 'c.md', + 'd.md', + 'e.md' + ]) + const batches = createSsrBatchPlan(queue, 2) + expect(batches.flatMap((batch) => batch.pages)).toEqual([ + '404.md', + 'a.md', + 'b.md', + 'c.md', + 'd.md', + 'e.md' + ]) + expect(batches.map((batch) => batch.offset)).toEqual([0, 2, 4]) + expect(batches.every((batch) => batch.pages.length <= 2)).toBe(true) +}) + +test('supports a 404-only build', () => { + expect(createSsrBatchPlan(createRenderQueue([]), 10)).toEqual([ + { offset: 0, pages: ['404.md'] } + ]) +}) diff --git a/__tests__/unit/node/build/ssr/pluginCompatibility.test.ts b/__tests__/unit/node/build/ssr/pluginCompatibility.test.ts new file mode 100644 index 000000000000..d54c6485066d --- /dev/null +++ b/__tests__/unit/node/build/ssr/pluginCompatibility.test.ts @@ -0,0 +1,111 @@ +import { + adaptSsrBatchPagePlugins, + validateSsrBatchPageOutputHooks +} from 'node/build/ssr/pluginCompatibility' +import type { Plugin, Rolldown } from 'vite' + +test('adapts frozen user plugins without changing internal plugins', () => { + const userPlugin = Object.freeze({ + name: 'frozen-user-plugin', + transform(this: any) { + return `${this.environment.mode}:${this.meta.watchMode}:${typeof this.setAssetSource}` + } + }) as Plugin + const internalPlugin = Object.freeze({ + name: 'vite:internal-test', + transform() {} + }) as Plugin + + const [adaptedUser, adaptedInternal] = adaptSsrBatchPagePlugins([ + userPlugin, + internalPlugin + ]) + expect(adaptedUser).not.toBe(userPlugin) + expect(adaptedInternal).toBe(internalPlugin) + const transform = adaptedUser.transform + const handler = + typeof transform === 'function' ? transform : transform?.handler + expect( + handler?.call( + { + environment: { mode: 'dev' }, + meta: { watchMode: true } + }, + '', + '/page.js', + { moduleType: 'js', ssr: true } + ) + ).toBe('build:false:undefined') +}) + +test('accepts transform and teardown hooks plus Vite internal bundle hooks', async () => { + const plugins = [ + { + name: 'fabric-docs:transform-files', + transform(code: string) { + return code + }, + buildEnd() {}, + closeBundle() {} + }, + { + name: 'vite:css-post', + renderChunk() {}, + augmentChunkHash() {} + }, + { + name: 'vitepress', + renderStart() {}, + generateBundle() {} + } + ] as Plugin[] + await expect( + validateSsrBatchPageOutputHooks(plugins, undefined) + ).resolves.toBeUndefined() +}) + +test('rejects user bundle-graph and output hooks with their names', async () => { + const plugins = [ + { + name: 'custom-page-renderer', + moduleParsed() {}, + renderChunk: { handler() {} }, + augmentChunkHash() { + return 'custom' + } + }, + { + name: 'page-manifest', + resolveDynamicImport() { + return null + }, + generateBundle() {} + } + ] as Plugin[] + await expect( + validateSsrBatchPageOutputHooks(plugins, undefined) + ).rejects.toThrow( + [ + 'SSR batching cannot preserve Rolldown bundle hooks for unbundled SSR page modules:', + ' - plugin "custom-page-renderer": moduleParsed, augmentChunkHash, renderChunk', + ' - plugin "page-manifest": resolveDynamicImport, generateBundle', + 'Disable ssrBuildBatchSize' + ].join('\n') + ) +}) + +test('rejects nested output plugins and output addons', async () => { + const output = { + banner: '/* server page */', + plugins: [ + false, + [Promise.resolve({ name: 'server-page-assets', writeBundle() {} })] + ] + } as Rolldown.OutputOptions + await expect(validateSsrBatchPageOutputHooks([], output)).rejects.toThrow( + [ + ' - output options "output": banner', + ' - output plugin "server-page-assets": writeBundle' + ].join('\n') + ) +}) diff --git a/__tests__/unit/node/build/ssr/runtimeBundle.test.ts b/__tests__/unit/node/build/ssr/runtimeBundle.test.ts new file mode 100644 index 000000000000..2c8522c79cdb --- /dev/null +++ b/__tests__/unit/node/build/ssr/runtimeBundle.test.ts @@ -0,0 +1,362 @@ +import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import path from 'node:path' +import { pathToFileURL } from 'node:url' +import { + collectSsrRuntimeBridges, + createSsrRuntimeBridgePlugin, + createSsrRuntimeInput +} from 'node/build/ssr/runtimeBundle' +import type { SiteConfig } from 'node/config' +import { + build as viteBuild, + normalizePath, + type Plugin, + type Rolldown +} from 'vite' + +function invokeModuleParsed( + plugin: Plugin, + moduleInfo: Pick & + Partial, + emitFile: (file: Rolldown.EmittedFile) => string +) { + const handler = plugin.moduleParsed as ( + this: Rolldown.PluginContext, + moduleInfo: Rolldown.ModuleInfo + ) => void + handler.call( + { emitFile } as unknown as Rolldown.PluginContext, + { + importers: [], + dynamicImporters: [], + importedIds: [], + dynamicallyImportedIds: [], + ...moduleInfo + } as Rolldown.ModuleInfo + ) +} + +async function invokeBuildStart(plugin: Plugin, resolvedId: string) { + const handler = plugin.buildStart as ( + this: Rolldown.PluginContext + ) => Promise + const resolve = vi.fn( + async () => ({ id: resolvedId, external: false }) as Rolldown.ResolvedId + ) + await handler.call({ + resolve, + error(message: string | Rolldown.RollupError): never { + throw new Error(typeof message === 'string' ? message : message.message) + } + } as unknown as Rolldown.PluginContext) + return resolve +} + +test('declares only runtime roots instead of every file in a custom theme', () => { + const bridgeModuleIds = new Set() + const input = createSsrRuntimeInput( + { + themeDir: path.join(process.cwd(), 'site/.vitepress/theme') + } as SiteConfig, + bridgeModuleIds + ) + + expect(input).toMatchObject({ + app: expect.any(String), + vitepress: expect.any(String), + theme: expect.any(String), + 'site-theme': '@theme/index' + }) + expect(Object.keys(input)).toEqual([ + 'app', + 'vitepress', + 'theme', + 'site-theme' + ]) + expect([...bridgeModuleIds].sort()).toEqual( + [normalizePath(input.vitepress), normalizePath(input.theme)].sort() + ) +}) + +test('emits bounded facades for all site-local and virtual theme dependencies', async () => { + const themeDir = path.join(process.cwd(), 'site/.vitepress/theme') + const indexId = normalizePath(path.join(themeDir, 'index.ts')) + const componentId = normalizePath( + path.join(themeDir, 'components/Widget.vue') + ) + const componentScriptId = `${componentId}?vue&type=script&lang.ts` + const componentStyleId = `${componentId}?vue&type=style&index=0&lang.css` + const sharedId = normalizePath(path.join(themeDir, '../../shared/state.ts')) + const virtualId = '\0test:theme-singleton' + const virtualAssetId = '\0test:theme-logo.svg' + const customAssetId = '\0test:custom-asset' + const dependencyId = normalizePath( + path.join(process.cwd(), 'node_modules/example/index.js') + ) + const nativeId = 'node:crypto' + const bridgeModuleIds = new Set() + const plugin = createSsrRuntimeBridgePlugin(bridgeModuleIds) + const emitFile = vi.fn((_file: Rolldown.EmittedFile) => 'bridge') + + const resolve = await invokeBuildStart(plugin, indexId) + // Rolldown can parse a source through another runtime entry first. Its + // traversal order must not change the final bridge set. + invokeModuleParsed(plugin, { id: sharedId, isEntry: false }, emitFile) + invokeModuleParsed(plugin, { id: virtualId, isEntry: false }, emitFile) + invokeModuleParsed( + plugin, + { + id: indexId, + isEntry: true, + importedIds: [ + componentId, + virtualId, + virtualAssetId, + customAssetId, + dependencyId, + nativeId + ] + }, + emitFile + ) + invokeModuleParsed( + plugin, + { + id: componentId, + isEntry: false, + importedIds: [componentScriptId, componentStyleId] + }, + emitFile + ) + invokeModuleParsed(plugin, { id: componentId, isEntry: false }, emitFile) + invokeModuleParsed( + plugin, + { + id: componentScriptId, + isEntry: false, + importedIds: [sharedId] + }, + emitFile + ) + invokeModuleParsed(plugin, { id: componentStyleId, isEntry: false }, emitFile) + invokeModuleParsed(plugin, { id: virtualAssetId, isEntry: false }, emitFile) + invokeModuleParsed( + plugin, + { + id: customAssetId, + isEntry: false, + meta: { 'vite:asset': true } + }, + emitFile + ) + invokeModuleParsed(plugin, { id: dependencyId, isEntry: false }, emitFile) + invokeModuleParsed(plugin, { id: nativeId, isEntry: false }, emitFile) + invokeModuleParsed( + plugin, + { + id: normalizePath(path.join(themeDir, 'ambient.d.ts')), + isEntry: false + }, + emitFile + ) + invokeModuleParsed( + plugin, + { id: `${componentId}?vue&type=style`, isEntry: false }, + emitFile + ) + invokeModuleParsed( + plugin, + { + id: normalizePath(path.join(themeDir, '../theme-story/Story.ts')), + isEntry: false + }, + emitFile + ) + + expect(resolve).toHaveBeenCalledWith('@theme/index', undefined, { + isEntry: true + }) + expect([...bridgeModuleIds].sort()).toEqual( + [indexId, componentId, sharedId, virtualId].sort() + ) + expect(emitFile).toHaveBeenCalledTimes(3) + for (const id of [componentId, sharedId, virtualId]) { + expect(emitFile).toHaveBeenCalledWith({ + type: 'chunk', + id, + name: expect.stringMatching(/^site-runtime-[a-f\d]{16}$/), + preserveSignature: 'strict' + }) + } +}) + +test('runtime facades preserve local and virtual singleton identity', async () => { + const root = await mkdtemp(path.join(tmpdir(), 'vitepress-runtime-bridge-')) + const themeDir = path.join(root, '.vitepress/theme') + const dependencyDir = path.join(root, 'node_modules/runtime-dependency') + const outDir = path.join(root, 'out') + const appId = path.join(root, 'app.js') + const themeId = path.join(themeDir, 'index.js') + const sharedId = path.join(root, 'shared.js') + const virtualId = '\0test:virtual-singleton' + + try { + await Promise.all([ + mkdir(themeDir, { recursive: true }), + mkdir(dependencyDir, { recursive: true }) + ]) + await Promise.all([ + writeFile(appId, `export * from '@theme/index'`), + writeFile( + themeId, + [ + `export { localSingleton } from '../../shared.js'`, + `export { virtualSingleton } from 'virtual:singleton'`, + `export { dependencySingleton } from 'runtime-dependency'`, + `export { types as nativeTypes } from 'node:util'` + ].join('\n') + ), + writeFile(sharedId, `export const localSingleton = { local: true }`), + writeFile( + path.join(dependencyDir, 'package.json'), + JSON.stringify({ type: 'module', exports: './index.js' }) + ), + writeFile( + path.join(dependencyDir, 'index.js'), + `export const dependencySingleton = { dependency: true }` + ) + ]) + + const bridgeModuleIds = new Set() + const result = (await viteBuild({ + root, + configFile: false, + logLevel: 'silent', + resolve: { + alias: { '@theme/index': themeId } + }, + plugins: [ + { + name: 'test:virtual-singleton', + resolveId(id) { + if (id === 'virtual:singleton') return virtualId + }, + load(id) { + if (id === virtualId) { + return `export const virtualSingleton = { virtual: true }` + } + } + }, + createSsrRuntimeBridgePlugin(bridgeModuleIds) + ], + build: { + ssr: true, + outDir, + minify: false, + rolldownOptions: { + input: { app: appId, 'site-theme': '@theme/index' }, + preserveEntrySignatures: 'strict', + output: { + entryFileNames: '[name].mjs', + chunkFileNames: 'chunks/[name]-[hash].mjs' + } + } + } + })) as Rolldown.RolldownOutput + + const normalizedThemeId = [...bridgeModuleIds].find((id) => + id.endsWith('/.vitepress/theme/index.js') + ) + const normalizedSharedId = [...bridgeModuleIds].find((id) => + id.endsWith('/shared.js') + ) + expect(normalizedThemeId).toBeDefined() + expect(normalizedSharedId).toBeDefined() + expect([...bridgeModuleIds].sort()).toEqual( + [normalizedThemeId!, normalizedSharedId!, virtualId].sort() + ) + + const bridges = collectSsrRuntimeBridges(result, outDir, bridgeModuleIds) + const appChunk = result.output.find( + (output): output is Rolldown.OutputChunk => + output.type === 'chunk' && output.isEntry && output.name === 'app' + ) + expect(appChunk).toBeDefined() + + const externalImports = result.output.flatMap((output) => + output.type === 'chunk' ? output.imports : [] + ) + expect(externalImports).toContain('runtime-dependency') + expect(externalImports).toContain('node:util') + + const runtime = await import( + pathToFileURL(path.resolve(outDir, appChunk!.fileName)).href + ) + const localBridge = await import( + pathToFileURL(bridges[normalizedSharedId!]).href + ) + const virtualBridge = await import(pathToFileURL(bridges[virtualId]).href) + + expect(runtime.localSingleton).toBe(localBridge.localSingleton) + expect(runtime.virtualSingleton).toBe(virtualBridge.virtualSingleton) + expect(runtime.dependencySingleton.dependency).toBe(true) + expect(runtime.nativeTypes.isNativeError).toBeTypeOf('function') + } finally { + await rm(root, { recursive: true, force: true }) + } +}) + +test('collects only recorded runtime facades and rejects missing ones', () => { + const outDir = path.join(process.cwd(), '.temp/runtime') + const vitepressId = normalizePath(path.join(process.cwd(), 'client/index.js')) + const themeId = normalizePath(path.join(process.cwd(), 'theme/index.ts')) + const unrelatedId = normalizePath( + path.join(process.cwd(), 'theme/Widget.story.ts') + ) + const result = { + output: [ + { + type: 'chunk', + isEntry: true, + name: 'vitepress', + facadeModuleId: vitepressId, + fileName: 'vitepress.js' + }, + { + type: 'chunk', + isEntry: true, + name: 'site-theme', + facadeModuleId: themeId, + fileName: 'site-theme.js' + }, + { + type: 'chunk', + isEntry: true, + name: 'unrelated', + facadeModuleId: unrelatedId, + fileName: 'unrelated.js' + } + ] + } as unknown as Rolldown.RolldownOutput + + const bridges = collectSsrRuntimeBridges( + result, + outDir, + new Set([vitepressId, themeId]) + ) + expect(bridges).toEqual({ + [vitepressId]: path.resolve(outDir, 'vitepress.js'), + [themeId]: path.resolve(outDir, 'site-theme.js') + }) + expect(bridges[unrelatedId]).toBeUndefined() + + const missingId = normalizePath(path.join(process.cwd(), 'theme/missing.ts')) + expect(() => + collectSsrRuntimeBridges( + result, + outDir, + new Set([vitepressId, themeId, missingId]) + ) + ).toThrow(missingId) +}) diff --git a/__tests__/unit/node/build/ssr/vueDescriptorMemory.test.ts b/__tests__/unit/node/build/ssr/vueDescriptorMemory.test.ts new file mode 100644 index 000000000000..f074eaced0e0 --- /dev/null +++ b/__tests__/unit/node/build/ssr/vueDescriptorMemory.test.ts @@ -0,0 +1,176 @@ +import { createVueDescriptorMemoryPlugin } from 'node/build/ssr/vueDescriptorMemory' +import type { Plugin } from 'vite' + +describe('node/build/ssr/vueDescriptorMemory', () => { + test('compacts tracked compiler results without mutating the shared compiler', () => { + const { compiler, parseCacheClear } = createCompiler() + const vuePlugin = createVuePlugin(compiler) + const plugin = createVueDescriptorMemoryPlugin(vuePlugin) + const originalParse = compiler.parse + const originalCompileScript = compiler.compileScript + + getBuildStart(plugin).call({}) + + const facade = (vuePlugin as any).api.options.compiler + expect(facade).not.toBe(compiler) + expect(compiler.parse).toBe(originalParse) + expect(compiler.compileScript).toBe(originalCompileScript) + + const parsed = facade.parse('', { + filename: '/one.md' + }) + const script = facade.compileScript(parsed.descriptor) + expect(plugin.api.retainedFiles).toBe(1) + + getHook(plugin.buildEnd).call({}, undefined) + + expect(plugin.api.retainedFiles).toBe(0) + expect(parsed.descriptor.source).toBe('') + expect(parsed.descriptor.template.content).toBe('') + expect(parsed.descriptor.template.ast).toBeUndefined() + expect(script.content).toBe('') + expect(script.scriptAst).toBeUndefined() + expect(parseCacheClear).toHaveBeenCalledTimes(1) + expect((vuePlugin as any).api.options.compiler).toBe(facade) + + getHook(plugin.closeBundle).call({}) + expect((vuePlugin as any).api.options.compiler).toBe(compiler) + expect(compiler.parse).toBe(originalParse) + expect(compiler.compileScript).toBe(originalCompileScript) + }) + + test('isolates concurrent plugin instances that share compiler-sfc', () => { + const { compiler } = createCompiler() + const vueA = createVuePlugin(compiler) + const vueB = createVuePlugin(compiler) + const pluginA = createVueDescriptorMemoryPlugin(vueA) + const pluginB = createVueDescriptorMemoryPlugin(vueB) + + getBuildStart(pluginA).call({}) + getBuildStart(pluginB).call({}) + + const facadeA = (vueA as any).api.options.compiler + const facadeB = (vueB as any).api.options.compiler + expect(facadeA).not.toBe(facadeB) + expect(compiler.parse).not.toBe(facadeA.parse) + expect(compiler.parse).not.toBe(facadeB.parse) + + const descriptorA = facadeA.parse('source a', { + filename: '/shared.md' + }).descriptor + const descriptorB = facadeB.parse('source b', { + filename: '/shared.md' + }).descriptor + + pluginA.api.release(['/shared.md']) + expect(descriptorA.source).toBe('') + expect(descriptorB.source).toBe('source b') + + getHook(pluginA.closeBundle).call({}) + expect((vueA as any).api.options.compiler).toBe(compiler) + expect((vueB as any).api.options.compiler).toBe(facadeB) + + getHook(pluginB.closeBundle).call({}) + expect((vueB as any).api.options.compiler).toBe(compiler) + }) + + test('leases compiler-sfc parse-cache results across concurrent builds', () => { + const { compiler } = createCompiler() + const sharedDescriptor = compiler.parse('shared source', { + filename: '/shared.md' + }).descriptor + compiler.parse.mockReturnValue({ descriptor: sharedDescriptor }) + const vueA = createVuePlugin(compiler) + const vueB = createVuePlugin(compiler) + const pluginA = createVueDescriptorMemoryPlugin(vueA) + const pluginB = createVueDescriptorMemoryPlugin(vueB) + + getBuildStart(pluginA).call({}) + getBuildStart(pluginB).call({}) + ;(vueA as any).api.options.compiler.parse('shared source', { + filename: '/shared.md' + }) + ;(vueB as any).api.options.compiler.parse('shared source', { + filename: '/shared.md' + }) + + pluginA.api.release(['/shared.md']) + expect(sharedDescriptor.source).toBe('shared source') + + pluginB.api.release(['/shared.md']) + expect(sharedDescriptor.source).toBe('') + + getHook(pluginA.closeBundle).call({}) + getHook(pluginB.closeBundle).call({}) + }) + + test('restores its compiler facade when graph construction fails', () => { + const { compiler } = createCompiler() + const vuePlugin = createVuePlugin(compiler) + const plugin = createVueDescriptorMemoryPlugin(vuePlugin) + + getBuildStart(plugin).call({}) + expect((vuePlugin as any).api.options.compiler).not.toBe(compiler) + + getHook(plugin.buildEnd).call({}, new Error('build failed')) + expect((vuePlugin as any).api.options.compiler).toBe(compiler) + }) +}) + +function createCompiler() { + const parseCacheClear = vi.fn() + const compiler = { + parse: vi.fn((source: string, options: { filename: string }) => ({ + descriptor: { + filename: options.filename, + source, + template: { + content: source, + ast: { source }, + map: { source }, + loc: { source } + }, + script: null, + scriptSetup: null, + styles: [], + customBlocks: [] + } + })), + compileScript: vi.fn((descriptor: { filename: string }) => ({ + content: `compiled ${descriptor.filename}`, + scriptAst: { filename: descriptor.filename }, + scriptSetupAst: { filename: descriptor.filename }, + deps: [descriptor.filename], + imports: { value: true }, + bindings: { value: true } + })), + parseCache: { clear: parseCacheClear } + } + return { compiler, parseCacheClear } +} + +function createVuePlugin( + compiler: ReturnType['compiler'] +): Plugin { + return { + name: 'vite:vue', + api: { + options: { compiler } + } + } as Plugin +} + +function getBuildStart(plugin: Plugin): (...args: any[]) => any { + const hook = plugin.buildStart + if (!hook || typeof hook === 'function') { + throw new Error('Expected an object buildStart hook.') + } + return hook.handler +} + +function getHook any>( + hook: T | { handler: T } | undefined +): T { + if (!hook) throw new Error('Expected plugin hook.') + return typeof hook === 'function' ? hook : hook.handler +} diff --git a/__tests__/unit/node/build/ssr/worker/pool.test.ts b/__tests__/unit/node/build/ssr/worker/pool.test.ts new file mode 100644 index 000000000000..88463ceff4f4 --- /dev/null +++ b/__tests__/unit/node/build/ssr/worker/pool.test.ts @@ -0,0 +1,56 @@ +import { createWorkerExecArgv } from 'node/build/ssr/worker/pool' + +const inspectorOverrides = [ + '--no-inspect', + '--no-inspect-brk', + '--no-inspect-wait' +].filter((flag) => process.allowedNodeEnvironmentFlags.has(flag)) + +test('omits inspector flags and overrides NODE_OPTIONS', () => { + expect( + createWorkerExecArgv([ + '--enable-source-maps', + '--inspect', + '--inspect-brk=127.0.0.1:9230', + '--inspect-port', + '9231', + '--loader', + 'tsx', + '--inspect-publish-uid', + 'stderr,http', + '--conditions=development' + ]) + ).toEqual([ + '--enable-source-maps', + '--loader', + 'tsx', + '--conditions=development', + ...inspectorOverrides + ]) +}) + +test('omits parent entrypoint modes', () => { + expect( + createWorkerExecArgv([ + '--enable-source-maps', + '--', + '-e', + 'build()', + '--input-type', + 'module', + '--test', + '--watch-path', + 'src', + '--test-coverage-include', + 'src/**/*.ts', + '--watch-kill-signal=SIGTERM', + '-pe', + 'process.version', + '--conditions=development' + ]) + ).toEqual([ + '--enable-source-maps', + '--conditions=development', + ...inspectorOverrides + ]) +}) diff --git a/__tests__/unit/node/build/ssr/worker/protocol.test.ts b/__tests__/unit/node/build/ssr/worker/protocol.test.ts new file mode 100644 index 000000000000..225f7bbc747c --- /dev/null +++ b/__tests__/unit/node/build/ssr/worker/protocol.test.ts @@ -0,0 +1,20 @@ +import { serializeSsrRenderWorkerResult } from 'node/build/ssr/worker/protocol' + +test('explains the batching constraint for non-transferable context', () => { + expect(() => + serializeSsrRenderWorkerResult({ + pages: [ + { + page: 'guide.md', + pageData: {} as any, + hasCustom404: true, + context: { + content: '
Guide
', + vpSocialIcons: [], + customCallback: () => undefined + } as any + } + ] + }) + ).toThrow(/SSGContext must be structured-cloneable/) +}) diff --git a/__tests__/unit/node/markdown/plugins/highlight.test.ts b/__tests__/unit/node/markdown/plugins/highlight.test.ts index 7454dcc16a2a..3a7fcbf0a76a 100644 --- a/__tests__/unit/node/markdown/plugins/highlight.test.ts +++ b/__tests__/unit/node/markdown/plugins/highlight.test.ts @@ -1,6 +1,37 @@ import { highlight } from 'node/markdown/plugins/highlight' describe('node/markdown/plugins/highlight', () => { + test('initializes lazily and deduplicates highlights in memory', async () => { + let setupCalls = 0 + let highlightCalls = 0 + const [render, dispose] = await highlight('github-light', { + async shikiSetup(highlighter) { + setupCalls++ + const codeToHtml = highlighter.codeToHtml.bind(highlighter) + highlighter.codeToHtml = ((...args: Parameters) => { + highlightCalls++ + return codeToHtml(...args) + }) as typeof highlighter.codeToHtml + } + }) + + expect(setupCalls).toBe(0) + try { + const [first, second] = await Promise.all([ + render('const value = true', 'js', ''), + render('const value = true', 'js', '') + ]) + const third = await render('const value = true', 'js', '') + + expect(first).toBe(second) + expect(third).toBe(first) + expect(setupCalls).toBe(1) + expect(highlightCalls).toBe(1) + } finally { + dispose() + } + }) + test('passes color replacements through markdown options', async () => { const [render, dispose] = await highlight( { light: 'github-light', dark: 'github-dark' }, diff --git a/__tests__/unit/node/markdownToVue.test.ts b/__tests__/unit/node/markdownToVue.test.ts index 1d675f3112c3..ee622ea87cf9 100644 --- a/__tests__/unit/node/markdownToVue.test.ts +++ b/__tests__/unit/node/markdownToVue.test.ts @@ -1,5 +1,6 @@ import { resolveConfig } from 'node/config' import { createMarkdownToVueRenderFn } from 'node/markdownToVue' +import { PageArtifactStore } from 'node/build/artifacts/store' import { mkdtemp, rm, writeFile } from 'node:fs/promises' import { tmpdir } from 'node:os' import path from 'node:path' @@ -14,35 +15,8 @@ describe('node/markdownToVue', () => { } }) - test('records source line numbers for dead links', async () => { + test('records source line numbers for dead links after frontmatter', async () => { root = await mkdtemp(path.join(tmpdir(), 'vitepress-dead-link-')) - - const file = path.join(root, 'index.md') - const src = '# Home\n\nIntro\n\n[Missing](./missing.md)\n' - await writeFile(file, src) - - const siteConfig = await resolveConfig(root, 'build', 'production') - const render = await createMarkdownToVueRenderFn( - siteConfig.srcDir, - { cache: false }, - '/', - false, - false, - siteConfig - ) - - const result = await render(src, file) - - expect(result.deadLinks).toContainEqual({ - url: './missing', - file, - line: 5 - }) - }) - - test('records source line numbers after frontmatter', async () => { - root = await mkdtemp(path.join(tmpdir(), 'vitepress-dead-link-')) - const file = path.join(root, 'index.md') const src = '---\ntitle: Home\n---\n# Home\n\nIntro\n\n[Missing](./missing.md)\n' @@ -58,9 +32,7 @@ describe('node/markdownToVue', () => { siteConfig ) - const result = await render(src, file) - - expect(result.deadLinks).toContainEqual({ + expect((await render(src, file)).deadLinks).toContainEqual({ url: './missing', file, line: 8 @@ -69,7 +41,6 @@ describe('node/markdownToVue', () => { test('selects included heading sections after frontmatter', async () => { root = await mkdtemp(path.join(tmpdir(), 'vitepress-include-')) - const file = path.join(root, 'index.md') const source = path.join(root, 'source.md') await writeFile( @@ -82,10 +53,6 @@ describe('node/markdownToVue', () => { '', 'intro text', '', - '## Shared', - '', - 'shared before target', - '', '## Target', '', 'target text', @@ -94,10 +61,9 @@ describe('node/markdownToVue', () => { '', 'child text', '', - '## Shared', + '## Other', '', - 'shared after target', - '' + 'other text' ].join('\n') ) const src = '' @@ -112,7 +78,6 @@ describe('node/markdownToVue', () => { false, siteConfig ) - const result = await render(src, file) expect(result.vueSrc).toContain('

target text

') @@ -120,13 +85,11 @@ describe('node/markdownToVue', () => { expect(result.vueSrc).toContain('

child text

') expect(result.vueSrc).not.toContain('Source description') expect(result.vueSrc).not.toContain('intro text') - expect(result.vueSrc).not.toContain('shared before target') - expect(result.vueSrc).not.toContain('shared after target') + expect(result.vueSrc).not.toContain('other text') }) test('applies rewrites with mismatched Windows drive letter case', async () => { root = await mkdtemp(path.join(tmpdir(), 'vitepress-rewrite-')) - const file = path.join(root, 'index.md') await writeFile(file, '# Home\n') @@ -148,8 +111,99 @@ describe('node/markdownToVue', () => { siteConfig ) - const result = await render('# Home\n', 'C:/site/docs/en/index.md') + expect( + (await render('# Home\n', 'C:/site/docs/en/index.md')).pageData + .relativePath + ).toBe('index.md') + }) + + test('refreshes page data after hooks without mutating the base artifact', async () => { + root = await mkdtemp(path.join(tmpdir(), 'vitepress-page-data-')) + const file = path.join(root, 'index.md') + const src = '---\nnested:\n value: 1\n---\n# Original\n' + await writeFile(file, src) + + const siteConfig = await resolveConfig(root, 'build', 'production') + siteConfig.transformPageData = vi.fn(async (pageData) => { + ;(pageData.frontmatter.nested as { value: number }).value = 2 + return { title: 'Current title', relativePath: 'current.md' } + }) + const render = await createMarkdownToVueRenderFn( + siteConfig.srcDir, + { cache: false }, + '/', + false, + false, + siteConfig, + false, + true, + true + ) + + const base = await render(src, file) + const finalized = await render.finalize(base, file) - expect(result.pageData.relativePath).toBe('index.md') + expect(siteConfig.transformPageData).toHaveBeenCalledTimes(1) + expect(base.pageData).toMatchObject({ + title: 'Original', + relativePath: 'index.md', + frontmatter: { nested: { value: 1 } } + }) + expect(finalized.pageData).toMatchObject({ + title: 'Current title', + relativePath: 'current.md', + frontmatter: { nested: { value: 2 } } + }) + expect(readEmbeddedPageData(finalized.vueSrc)).toEqual(finalized.pageData) + expect(finalized.vueSrc).toContain('export default {name:"current.md"}') + }) + + test('shares and finalizes a Markdown artifact within the current build', async () => { + root = await mkdtemp(path.join(tmpdir(), 'vitepress-page-artifact-')) + const file = path.join(root, 'index.md') + const src = '# Original\n' + await writeFile(file, src) + + const siteConfig = await resolveConfig(root, 'build', 'production') + siteConfig.transformPageData = vi.fn(() => ({ title: 'Finalized' })) + const render = await createMarkdownToVueRenderFn( + siteConfig.srcDir, + { cache: false }, + '/', + false, + false, + siteConfig, + false, + true, + true + ) + const compile = vi.fn(() => render(src, file)) + const store = new PageArtifactStore(path.join(root, '.artifacts')) + + const first = await store.getOrCreate( + 'index.md', + src, + compile, + (artifact) => render.finalize(artifact, file) + ) + const second = await store.getOrCreate( + 'index.md', + src, + compile, + (artifact) => render.finalize(artifact, file) + ) + + expect(compile).toHaveBeenCalledTimes(1) + expect(siteConfig.transformPageData).toHaveBeenCalledTimes(1) + expect(first.pageData.title).toBe('Finalized') + expect(second).toEqual(first) }) }) + +function readEmbeddedPageData(vueSrc: string) { + const encoded = vueSrc.match( + /export const __pageData = JSON\.parse\(("(?:[^"\\]|\\.)*")\)/ + )?.[1] + expect(encoded).toBeTruthy() + return JSON.parse(JSON.parse(encoded!)) +} diff --git a/__tests__/unit/node/plugin.test.ts b/__tests__/unit/node/plugin.test.ts new file mode 100644 index 000000000000..1d19f198c7e3 --- /dev/null +++ b/__tests__/unit/node/plugin.test.ts @@ -0,0 +1,182 @@ +import { resolveConfig } from 'node/config' +import { disposeMdItInstance } from 'node/markdown/markdown' +import { PageArtifactStore } from 'node/build/artifacts/store' +import { createVitePressPlugin } from 'node/plugin' +import { mkdtemp, rm, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import path from 'node:path' +import { normalizePath, type Plugin } from 'vite' + +describe('node/plugin coordinator client', () => { + let root: string | undefined + + afterEach(async () => { + disposeMdItInstance() + if (root) { + await rm(root, { recursive: true, force: true }) + root = undefined + } + }) + + test('initializes Markdown and preloads resolved pages through the client graph', async () => { + root = await mkdtemp(path.join(tmpdir(), 'vitepress-client-preload-')) + await Promise.all([ + writeFile(path.join(root, 'one.md'), '# One\n'), + writeFile(path.join(root, 'two.md'), '# Two\n') + ]) + + const siteConfig = await resolveConfig(root, 'build', 'production') + const configureMarkdown = vi.fn() + const userPostBuildStart = vi.fn() + const userPlugin: Plugin = { + name: 'test:user-post-build-start', + enforce: 'post', + buildStart: { + order: 'post', + handler: userPostBuildStart + } + } + siteConfig.markdown = { cache: false, config: configureMarkdown } + siteConfig.vite = { plugins: [userPlugin] } + siteConfig.buildConcurrency = 1 + const store = new PageArtifactStore(path.join(root, '.artifacts')) + const plugins = await createVitePressPlugin( + siteConfig, + false, + undefined, + undefined, + undefined, + undefined, + { + coordinatorClient: true, + pageArtifactStore: store, + skipGitScan: true + } + ) + const vitePressPlugin = plugins[0] as Plugin + const configResolved = getHookHandler(vitePressPlugin.configResolved) + await configResolved.call(undefined, { + base: '/', + command: 'build', + publicDir: siteConfig.publicDir + } as any) + + // Renderer setup hooks run before coordinator preloading starts. + expect(configureMarkdown).toHaveBeenCalledTimes(1) + + const preloadPlugin = plugins.at(-1) as Plugin + expect(preloadPlugin.name).toBe('vitepress:coordinator-page-preload') + expect(preloadPlugin.enforce).toBe('post') + expect(plugins.indexOf(userPlugin)).toBeLessThan( + plugins.indexOf(preloadPlugin) + ) + const buildStart = preloadPlugin.buildStart as unknown as { + order: string + sequential: boolean + handler: (...args: any[]) => Promise + } + expect(buildStart.order).toBe('post') + expect(buildStart.sequential).toBe(true) + + let activeLoads = 0 + let peakLoads = 0 + const resolve = vi.fn(async (id: string) => ({ id })) + const load = vi.fn( + async (_options: { id: string; resolveDependencies: boolean }) => { + expect(userPostBuildStart).toHaveBeenCalledTimes(1) + activeLoads++ + peakLoads = Math.max(peakLoads, activeLoads) + await Promise.resolve() + activeLoads-- + return {} as any + } + ) + await getHookHandler(userPlugin.buildStart).call({}) + await buildStart.handler.call({ resolve, load }) + + const expectedIds = siteConfig.pages.map((page) => + normalizePath(path.resolve(siteConfig.srcDir, page)) + ) + expect(resolve.mock.calls.map(([id]) => id)).toEqual(expectedIds) + expect(load.mock.calls.map(([options]) => options)).toEqual( + expectedIds.map((id) => ({ id, resolveDependencies: true })) + ) + expect(peakLoads).toBe(1) + }) + + test('does not let an isolated SSR phase replace the client public directory', async () => { + root = await mkdtemp(path.join(tmpdir(), 'vitepress-ssr-public-dir-')) + await writeFile(path.join(root, 'index.md'), '# Page\n') + + const siteConfig = await resolveConfig(root, 'build', 'production') + const clientPublicDir = siteConfig.publicDir + const plugins = await createVitePressPlugin( + siteConfig, + true, + undefined, + undefined, + undefined, + undefined, + { isSsrBatch: true, skipGitScan: true } + ) + const vitePressPlugin = plugins[0] as Plugin + const configResolved = getHookHandler(vitePressPlugin.configResolved) + await configResolved.call(undefined, { + base: '/', + command: 'build', + publicDir: path.join(root, 'runtime-public') + } as any) + + expect(siteConfig.publicDir).toBe(clientPublicDir) + }) + + test('reuses artifacts while transforming the physical Markdown module', async () => { + root = await mkdtemp(path.join(tmpdir(), 'vitepress-physical-artifact-')) + const file = path.join(root, 'index.md') + const source = '# Page\n' + await writeFile(file, source) + + const siteConfig = await resolveConfig(root, 'build', 'production') + siteConfig.markdown = { cache: false } + siteConfig.transformPageData = vi.fn(() => ({ title: 'Finalized' })) + const store = new PageArtifactStore(path.join(root, '.artifacts')) + const plugins = await createVitePressPlugin( + siteConfig, + false, + undefined, + undefined, + undefined, + undefined, + { + coordinatorClient: true, + pageArtifactStore: store, + skipGitScan: true + } + ) + const vitePressPlugin = plugins[0] as Plugin + await getHookHandler(vitePressPlugin.configResolved).call(undefined, { + base: '/', + command: 'build', + publicDir: siteConfig.publicDir + } as any) + + const transform = getHookHandler(vitePressPlugin.transform as any) + const context = { + addWatchFile() {}, + environment: { mode: 'build', name: 'client' } + } + const first = await transform.call(context, source, file) + const second = await transform.call(context, source, file) + + expect(siteConfig.transformPageData).toHaveBeenCalledTimes(1) + expect(first).toBe(second) + expect(first).toContain('