diff --git a/__tests__/e2e/ssr-batching.test.ts b/__tests__/e2e/ssr-batching.test.ts new file mode 100644 index 00000000..30ade433 --- /dev/null +++ b/__tests__/e2e/ssr-batching.test.ts @@ -0,0 +1,117 @@ +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).not.toContain('data-resolved-transform-mode="client"') +}) + +test('the static SSR fast path 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 00000000..379c9380 --- /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/vite.config.ts b/__tests__/e2e/vite.config.ts new file mode 100644 index 00000000..9d27b581 --- /dev/null +++ b/__tests__/e2e/vite.config.ts @@ -0,0 +1,48 @@ +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' + return `${code}\n

environment-sensitive Markdown transform

` + } + } + } + } + } + ] +}) diff --git a/__tests__/unit/node/artifactPluginSafety.test.ts b/__tests__/unit/node/artifactPluginSafety.test.ts new file mode 100644 index 00000000..bba9e04b --- /dev/null +++ b/__tests__/unit/node/artifactPluginSafety.test.ts @@ -0,0 +1,79 @@ +import { resolveConfig } from 'node/config' +import { + canCompileSsrPageArtifact, + canReuseSsrPageArtifactWithPlugins +} from 'node/markdownToVue' +import { mkdtemp, rm, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import path from 'node:path' + +describe('SSR artifact plugin safety', () => { + let root: string | undefined + + afterEach(async () => { + if (root) { + await rm(root, { recursive: true, force: true }) + root = undefined + } + }) + + test('checks plugins discovered in resolved environments and honors the explicit safety contract', async () => { + root = await mkdtemp(path.join(tmpdir(), 'vitepress-resolved-hooks-')) + const file = path.join(root, 'index.md') + await writeFile(file, '# Resolved plugin\n') + + const siteConfig = await resolveConfig(root, 'build', 'production') + siteConfig.vite = { plugins: [] } + expect(canCompileSsrPageArtifact(siteConfig, file)).toBe(true) + + const resolvedConfigFilePlugin = { + name: 'config-file-markdown-load', + load: { + filter: { id: /[.]md$/ }, + handler() { + return null + } + } + } + expect( + canReuseSsrPageArtifactWithPlugins([resolvedConfigFilePlugin], file) + ).toBe(false) + + const explicitlySafeResolvedPlugin = { + ...resolvedConfigFilePlugin, + api: { vitepress: { ssrArtifactSafe: true } } + } + expect( + canReuseSsrPageArtifactWithPlugins([explicitlySafeResolvedPlugin], file) + ).toBe(true) + }) + + test('checks the plugin produced by applyToEnvironment after resolution', async () => { + root = await mkdtemp(path.join(tmpdir(), 'vitepress-environment-hooks-')) + const file = path.join(root, 'index.md') + await writeFile(file, '# Per-environment plugin\n') + + const environmentPlugin = { + name: 'config-file-per-environment', + applyToEnvironment(environment: { name: string }) { + return { + name: `config-file-per-environment:${environment.name}`, + enforce: 'pre' as const, + transform: { + filter: { id: /[.]md$/ }, + handler(code: string, _id: string, options?: { ssr?: boolean }) { + return `${code}\n${options?.ssr ? 'server' : 'client'}` + } + } + } + } + } + const resolvedSsrPlugin = environmentPlugin.applyToEnvironment({ + name: 'ssr' + }) + + expect(canReuseSsrPageArtifactWithPlugins([resolvedSsrPlugin], file)).toBe( + false + ) + }) +}) diff --git a/__tests__/unit/node/pluginArtifactSafety.test.ts b/__tests__/unit/node/pluginArtifactSafety.test.ts new file mode 100644 index 00000000..525b4d56 --- /dev/null +++ b/__tests__/unit/node/pluginArtifactSafety.test.ts @@ -0,0 +1,104 @@ +import { resolveConfig } from 'node/config' +import { PageArtifactStore } from 'node/pageArtifacts' +import { createVitePressPlugin } from 'node/plugin' +import { mkdtemp, rm, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import path from 'node:path' +import type { Plugin } from 'vite' + +describe('resolved plugin artifact safety', () => { + let root: string | undefined + + afterEach(async () => { + if (root) { + await rm(root, { recursive: true, force: true }) + root = undefined + } + }) + + test('uses resolved-config plugins when deciding whether an artifact is reusable', async () => { + root = await mkdtemp(path.join(tmpdir(), 'vitepress-resolved-plugins-')) + const file = path.join(root, 'index.md') + const source = '# Resolved plugin safety\n' + await writeFile(file, source) + + const siteConfig = await resolveConfig(root, 'build', 'production') + siteConfig.markdown = { cache: false } + siteConfig.vite = { plugins: [] } + + const compileWithResolvedPlugin = async ( + resolvedPlugin: Plugin, + namespace: string + ) => { + const store = new PageArtifactStore(siteConfig.cacheDir, { namespace }) + 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: '/', + build: { rolldownOptions: { plugins: [] } }, + command: 'build', + plugins: [vitePressPlugin, resolvedPlugin], + publicDir: siteConfig.publicDir + } as any) + + const transform = getHookHandler(vitePressPlugin.transform as any) + await transform.call( + { + addWatchFile() {}, + environment: { mode: 'build', name: 'client' } + }, + source, + file + ) + return store.getCurrentMetadata('index.md') + } + + const resolvedConfigFilePlugin: Plugin = { + name: 'test:resolved-config-file-load', + load: { + filter: { id: /[.]md$/ }, + handler() { + return null + } + } + } + expect(siteConfig.vite.plugins).not.toContain(resolvedConfigFilePlugin) + await expect( + compileWithResolvedPlugin(resolvedConfigFilePlugin, 'resolved-unsafe') + ).resolves.toEqual({ + staticPage: false, + requiresSourceModuleIdentity: true + }) + + const explicitlySafePlugin = { + ...resolvedConfigFilePlugin, + api: { vitepress: { ssrArtifactSafe: true } } + } as Plugin + await expect( + compileWithResolvedPlugin(explicitlySafePlugin, 'resolved-safe') + ).resolves.toEqual({ + staticPage: true, + requiresSourceModuleIdentity: false + }) + }) +}) + +function getHookHandler any>( + hook: T | { handler: T } | undefined +): T { + if (!hook) throw new Error('Expected plugin hook.') + return typeof hook === 'function' ? hook : hook.handler +}