test: cover resolved SSR artifact plugin safety

pull/5349/head
Calum H. (IMB11) 1 day ago
parent 515933bc5f
commit fe6c1ff2e9

@ -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('<div id="app">')
expect(dynamicHtml).toContain('<title>Foo - transformed | Example</title>')
expect(dynamicHtml).toContain('name="ssr-batch-hook-state"')
expect(dynamicHtml).toContain(
'data-ssr-batch-transform="dynamic-routes/foo.md"'
)
expect(staticHtml).toContain('<title>Static batching page | Example</title>')
expect(staticHtml).toContain('<h1 id="static-batching-page"')
expect(staticHtml).toContain(
'<p data-static-batch-marker="preserved">Static HTML marker</p>'
)
expect(staticHtml).toContain(
'<span class="VPBadge warning">static badge</span>'
)
expect(staticHtml).toContain('<!-- static comment preserved -->')
expect(staticHtml).toContain(
'<img data-static-public-asset src="/batch-public.txt" alt="Static public asset">'
)
expect(scopedHtml).toContain('Scoped module identity')
expect(scopedHtml).toMatch(/class="scoped-batch-marker" data-v-[\da-f]+/)
expect(staticHtml).toMatch(
/<meta name="ssr-batch-hook-state" content="\d+:ssr-static\.md">/
)
expect(staticHtml).toContain(
'<meta name="ssr-batch-after-config-resolve" content="coordinator mutation retained">'
)
expect(staticHtml).toContain('data-ssr-batch-transform="ssr-static.md"')
expect(lastBatchHtml).toContain('<h1 id="text-literals"')
expect(dynamicHtml).toContain('<pre class="params">')
expect(dynamicHtml).toContain('&quot;id&quot;: &quot;foo&quot;')
expect(dynamicHtml).not.toContain('{{ $params }}')
expect(notFoundHtml).toContain('<title>404 | Example</title>')
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(
'<p data-resolved-load-environment="ssr">physical Markdown load hook</p>'
)
expect(html).toContain(
'<p data-resolved-transform-mode="server">environment-sensitive Markdown transform</p>'
)
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)')
})

@ -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`.

@ -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<p data-resolved-load-environment="${environmentName}">physical Markdown load hook</p>`
}
},
transform: {
filter: { id: artifactSafetyPageRE },
handler(code, _id, options) {
const mode = options?.ssr ? 'server' : 'client'
return `${code}\n<p data-resolved-transform-mode="${mode}">environment-sensitive Markdown transform</p>`
}
}
}
}
}
]
})

@ -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
)
})
})

@ -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<T extends (...args: any[]) => any>(
hook: T | { handler: T } | undefined
): T {
if (!hook) throw new Error('Expected plugin hook.')
return typeof hook === 'function' ? hook : hook.handler
}
Loading…
Cancel
Save