test: add CLI and presenter coverage

This commit is contained in:
2026-08-29 18:59:49 +08:00
parent 5224d45994
commit 0cf1f51745
4 changed files with 974 additions and 0 deletions
+420
View File
@@ -0,0 +1,420 @@
import { mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import path from 'node:path'
import { describe, expect, it } from 'vitest'
import { checkProject } from '../scripts/check.ts'
import { loadProjectConfig, normalizeConfig } from '../scripts/lib/config.ts'
import { discoverDecks, extractMarkdownImages, parseAttributes, readDeck, resolveDeck, validateDeck } from '../scripts/lib/content.ts'
import { startDevOverflowAudit } from '../scripts/lib/dev-overflow-audit.ts'
import { joinBase, normalizeSiteBase } from '../scripts/lib/site-base.ts'
import { StaticOverflowAuditor, auditStaticDecks } from '../scripts/lib/static-overflow.ts'
import { hasPresenterToken, hashPresenterToken } from '../scripts/lib/token.ts'
import { auditBuiltDecks } from '../scripts/lib/overflow-audit.ts'
import { resolveImageSource } from '../packages/slidev-theme-easy-jyy/utils/resolve-image.ts'
function overflowingList(title: string, count: number) {
return `# ${title}\n\n${Array.from({ length: count }, (_, index) => `- ${index + 1} `).join('\n')}\n`
}
describe('theme defaults', () => {
it('uses a blue 4:3 canvas and exposes the content size presets', async () => {
const [packageSource, themeCss, contentCss, styleEntry, layoutCss, imageAutoSource, controlsSource, scrollFallbackSource, buildSource, coverSource] = await Promise.all([
readFile(path.join(process.cwd(), 'packages/slidev-theme-easy-jyy/package.json'), 'utf8'),
readFile(path.join(process.cwd(), 'packages/slidev-theme-easy-jyy/styles/base.css'), 'utf8'),
readFile(path.join(process.cwd(), 'packages/slidev-theme-easy-jyy/styles/content.css'), 'utf8'),
readFile(path.join(process.cwd(), 'packages/slidev-theme-easy-jyy/styles/index.ts'), 'utf8'),
readFile(path.join(process.cwd(), 'packages/slidev-theme-easy-jyy/styles/layouts.css'), 'utf8'),
readFile(path.join(process.cwd(), 'packages/slidev-theme-easy-jyy/layouts/image-auto.vue'), 'utf8'),
readFile(path.join(process.cwd(), 'packages/slidev-theme-easy-jyy/components/ContentScaleControls.vue'), 'utf8'),
readFile(path.join(process.cwd(), 'packages/slidev-theme-easy-jyy/components/SlideScrollFallback.vue'), 'utf8'),
readFile(path.join(process.cwd(), 'scripts/build.ts'), 'utf8'),
readFile(path.join(process.cwd(), 'examples/content-repo/slides/getting-started/assets/cover.svg'), 'utf8'),
])
const themePackage = JSON.parse(packageSource)
expect(themePackage.slidev.defaults).toMatchObject({
aspectRatio: '4/3',
canvasWidth: 1024,
})
expect(themePackage.dependencies['@slidev/client']).toBe('52.19.1')
expect(themeCss).toContain('--easy-accent: #1d4ed8')
expect(themeCss.slice(0, themeCss.indexOf('.easy-table-sm'))).not.toContain('--easy-code-height')
expect(themeCss).toContain('var(--easy-code-auto-height, auto)')
expect(themeCss).toContain('--easy-code-scaled-font-size')
expect(themeCss).toContain('overflow-x: hidden')
expect(themeCss).toContain('white-space: pre-wrap')
expect(themeCss).toContain('.easy-table-lg')
expect(themeCss).toContain('.easy-code-height-lg')
expect(styleEntry).toContain("import './content.css'")
expect(contentCss).toContain('.slidev-layout .outline .current')
expect(contentCss).toContain('.slidev-layout .course-table')
expect(contentCss).toContain('.easy-layout-cover .cover-identity')
expect(layoutCss).toContain('.easy-layout-image-auto.easy-image-bottom')
expect(layoutCss).toContain('.easy-content-scale-toolbar')
expect(layoutCss).toContain('grid-template-rows: minmax(0, 1fr)')
expect(imageAutoSource).toContain('image.naturalWidth / image.naturalHeight >= 1.6')
expect(imageAutoSource).toContain("type ImagePlacement = 'auto' | 'left' | 'right' | 'top' | 'bottom'")
expect(controlsSource).toContain('BroadcastChannel')
expect(controlsSource).toContain('Math.min(1.6, Math.max(0.6')
expect(controlsSource).toContain("overlay.setAttribute('aria-label', '图片全屏预览')")
expect(controlsSource).toContain("type ScalableKind = 'table' | 'code'")
expect(controlsSource).toContain('easySlidesImagePreview')
expect(controlsSource).toContain('sharedState')
expect(controlsSource).toContain('}, 500)')
expect(scrollFallbackSource).toContain('easy-slide-scroll-fallback')
expect(scrollFallbackSource).not.toContain('内容溢出')
expect(layoutCss).not.toContain('easy-slide-overflow-warning')
expect(buildSource).toContain('auditBuiltDecks')
expect(buildSource).toContain('#1d4ed8')
expect(coverSource).toContain('viewBox="0 0 1600 1200"')
for (const source of [themeCss, buildSource, coverSource])
expect(source).not.toMatch(/#(?:6a005f|581050|4c3249|f0edf0|f0ebef|f2edf1|ddd4dc)/i)
})
})
describe('compiled slide overflow audit', () => {
it('reports the rendered slide number, heading, and overflow distance', async () => {
const root = await mkdtemp(path.join(tmpdir(), 'easy-slides-overflow-'))
const overview = path.join(root, 'demo', 'overview')
try {
await mkdir(overview, { recursive: true })
await writeFile(path.join(root, 'demo', 'index.html'), `<!doctype html>
<style>
.slidev-layout { box-sizing: border-box; width: 120px; height: 100px; overflow: hidden; }
.content { width: 180px; height: 240px; }
</style>
<div class="slidev-page-3"><main class="slidev-layout"><h2>Overflow example</h2><div class="content"></div></main></div>
<script>
if (location.hash === '#/overview') document.body.dataset.overview = 'true'
</script>
`, 'utf8')
const result = await auditBuiltDecks({
root,
siteBase: '/',
decks: [{ entry: path.join(root, 'demo', 'slides.md'), slug: 'demo' }],
})
expect(result.errors).toEqual([])
expect(result.issues).toEqual([
expect.objectContaining({
heading: 'Overflow example',
horizontal: 60,
slide: 3,
vertical: expect.any(Number),
}),
])
expect(result.issues[0].vertical).toBeGreaterThan(140)
}
finally {
await rm(root, { recursive: true, force: true })
}
})
it('reports development overflow statically and rechecks after content changes', async () => {
const root = await mkdtemp(path.join(tmpdir(), 'easy-slides-dev-overflow-'))
const entry = path.join(root, 'slides.md')
const output: string[] = []
await writeFile(entry, overflowingList('Development overflow', 16), 'utf8')
const controller = startDevOverflowAudit({
cwd: root,
debounceMs: 50,
deck: {
author: '',
date: '',
description: '',
directory: root,
draft: false,
entry,
slug: 'demo',
tags: [],
title: 'Demo',
},
output: line => output.push(line),
})
try {
await controller.ready
expect(output.find(line => line.includes('第 1 页(Development overflow)存在静态纵向溢出风险'))).toBeTruthy()
await writeFile(entry, '# Development overflow fixed\n', 'utf8')
await expect.poll(() => output.filter(line => line.startsWith('静态内容检查通过')).length, { timeout: 5_000 }).toBe(1)
}
finally {
await controller.close()
await rm(root, { recursive: true, force: true })
}
})
})
describe('static slide overflow analysis', () => {
it('keeps the development checker independent from browsers and preview routes', async () => {
const source = await readFile(path.join(process.cwd(), 'scripts/lib/dev-overflow-audit.ts'), 'utf8')
expect(source).not.toMatch(/playwright|chromium|page\.goto|networkidle|origin:/i)
expect(source).toContain('StaticOverflowAuditor')
})
it('estimates theme layouts without treating auto-height code as page overflow', async () => {
const root = await mkdtemp(path.join(tmpdir(), 'easy-slides-static-overflow-'))
const entry = path.join(root, 'slides.md')
const longCode = Array.from({ length: 80 }, (_, index) => `const value${index} = ${index}`).join('\n')
const tableRows = Array.from({ length: 12 }, (_, index) => `| ${index + 1} | 一段表格内容 |`).join('\n')
try {
await writeFile(entry, `---
theme: easy-jyy
title: Static demo
---
# Static demo
短内容。
---
${overflowingList('Long list', 16)}
---
## Auto-height code
\`\`\`ts
${longCode}
---
\`\`\`
<!-- ${Array.from({ length: 30 }, () => '演讲备注不参与布局').join('\n')} -->
---
class: easy-code-height-lg
---
## Fixed-height code
${Array.from({ length: 6 }, (_, index) => `- ${index + 1}`).join('\n')}
\`\`\`ts
const fixed = true
\`\`\`
---
layout: two-cols
---
## Two columns
::left::
- 简短左栏
::right::
${Array.from({ length: 16 }, (_, index) => `- ${index + 1}`).join('\n')}
---
## Large table
| 编号 | 内容 |
| ---: | --- |
${tableRows}
---
## Image
![示例](./image.png){max-height="60vh"}
---
## Wide token
${'a'.repeat(220)}
`, 'utf8')
const deck = testDeck(root, entry)
const result = await auditStaticDecks([deck])
expect(result.errors).toEqual([])
expect(result.issues.map(issue => issue.slide)).toEqual([2, 4, 5, 6, 8])
expect(result.issues.find(issue => issue.slide === 8)?.directions).toContain('horizontal')
expect(result.issues.find(issue => issue.slide === 3)).toBeUndefined()
}
finally {
await rm(root, { recursive: true, force: true })
}
})
it('reuses unchanged slide estimates and only analyzes changed pages', async () => {
const root = await mkdtemp(path.join(tmpdir(), 'easy-slides-static-cache-'))
const entry = path.join(root, 'slides.md')
const analyzed: number[] = []
try {
await writeFile(entry, '# First\n\n---\n\n# Second\n', 'utf8')
const deck = testDeck(root, entry)
const auditor = new StaticOverflowAuditor({ onAnalyzeSlide: (_deck, slide) => analyzed.push(slide) })
await auditor.audit([deck])
expect(analyzed).toEqual([1, 2])
analyzed.length = 0
await writeFile(entry, '# First\n\n---\n\n# Second changed\n', 'utf8')
await auditor.audit([deck])
expect(analyzed).toEqual([2])
}
finally {
await rm(root, { recursive: true, force: true })
}
})
it('is reused by easy-slides check as a non-blocking warning', async () => {
const root = await mkdtemp(path.join(tmpdir(), 'easy-slides-static-check-'))
const directory = path.join(root, 'slides', 'demo')
const entry = path.join(directory, 'slides.md')
try {
await mkdir(directory, { recursive: true })
await writeFile(entry, `---\ntitle: Demo\n---\n\n${overflowingList('Check overflow', 16)}`, 'utf8')
const result = await checkProject(root)
expect(result.issues.some(issue => issue.level === 'error')).toBe(false)
expect(result.staticOverflow.issues).toEqual([
expect.objectContaining({ slide: 1, directions: ['vertical'] }),
])
}
finally {
await rm(root, { recursive: true, force: true })
}
})
})
function testDeck(root: string, entry: string) {
return {
author: '',
date: '',
description: '',
directory: root,
draft: false,
entry,
slug: 'demo',
tags: [],
title: 'Demo',
}
}
describe('site base', () => {
it('normalizes root and repository subpaths', () => {
expect(normalizeSiteBase()).toBe('/')
expect(normalizeSiteBase('easy-slides')).toBe('/easy-slides/')
expect(normalizeSiteBase('/easy-slides/')).toBe('/easy-slides/')
})
it('joins deck paths without duplicate slashes', () => {
expect(joinBase('/', 'intro')).toBe('/intro/')
expect(joinBase('/easy-slides/', '/intro/')).toBe('/easy-slides/intro/')
})
})
describe('content repository config', () => {
it('loads project-owned paths and site metadata', async () => {
const root = await mkdtemp(path.join(tmpdir(), 'easy-slides-config-'))
try {
await writeFile(path.join(root, 'easy-slides.config.mjs'), `export default { title: 'Course', description: 'Decks', slidesDir: 'talks', outDir: 'public' }\n`)
expect(await loadProjectConfig(root)).toMatchObject({
title: 'Course',
description: 'Decks',
slidesDir: 'talks',
outDir: 'public',
exportDir: 'exports',
})
}
finally {
await rm(root, { recursive: true, force: true })
}
})
it('rejects output paths outside the content repository', () => {
expect(() => normalizeConfig({ outDir: '../public' })).toThrow('outDir 必须是项目内的相对路径')
})
})
describe('presenter token', () => {
it('hashes the trimmed token without exposing plaintext', () => {
expect(hashPresenterToken(' demo-token ')).toBe('7c43ef5ae21d43ce2743f770c68e24def1a43ee2f416d2438410c8af7af2ff2c')
expect(hashPresenterToken('wrong-token')).not.toBe(hashPresenterToken('demo-token'))
expect(hasPresenterToken({ PRESENTER_TOKEN: ' token ' } as NodeJS.ProcessEnv)).toBe(true)
expect(hasPresenterToken({ PRESENTER_TOKEN: ' ' } as NodeJS.ProcessEnv)).toBe(false)
})
})
describe('deck discovery and metadata', () => {
it('reads metadata, draft state, slug, and resolves slug or file paths', async () => {
const root = await mkdtemp(path.join(tmpdir(), 'easy-slides-'))
const directory = path.join(root, 'slides', 'draft-talk')
const entry = path.join(directory, 'slides.md')
try {
await mkdir(path.join(directory, 'assets'), { recursive: true })
await writeFile(path.join(directory, 'assets', 'cover.png'), 'image')
await writeFile(entry, `---\ntitle: Draft talk\ndescription: Test deck\nauthor: Tester\ndate: 2026-08-27\ntags: [Test, Markdown]\ncover: ./assets/cover.png\ndraft: true\n---\n\n# Draft\n`)
const deck = await readDeck(entry)
expect(deck).toMatchObject({
slug: 'draft-talk',
title: 'Draft talk',
description: 'Test deck',
author: 'Tester',
date: '2026-08-27',
tags: ['Test', 'Markdown'],
draft: true,
})
expect(await discoverDecks(root)).toHaveLength(1)
expect((await resolveDeck('draft-talk', root)).entry).toBe(entry)
expect((await resolveDeck(path.relative(root, entry), root)).slug).toBe('draft-talk')
expect(await validateDeck(deck)).toEqual([])
}
finally {
await rm(root, { recursive: true, force: true })
}
})
it('reports invalid slugs, missing titles, assets, and image attributes', async () => {
const root = await mkdtemp(path.join(tmpdir(), 'easy-slides-'))
const directory = path.join(root, 'slides', 'Bad_Slug')
const entry = path.join(directory, 'slides.md')
try {
await mkdir(directory, { recursive: true })
await writeFile(entry, `---\ntags: invalid\n---\n\n![missing](./assets/nope.png){fit="stretch" max-height="huge"}\n`)
const issues = await validateDeck(await readDeck(entry))
expect(issues.map(issue => issue.message)).toEqual(expect.arrayContaining([
'目录名必须是小写 kebab-case slug',
'frontmatter 缺少必填 title',
'tags 必须是数组',
'图片不存在:./assets/nope.png',
'图片 fit 不受支持:stretch',
'图片 max-height 不合法:huge',
]))
}
finally {
await rm(root, { recursive: true, force: true })
}
})
})
describe('smart image syntax', () => {
it('resolves layout frontmatter images against the deck base', () => {
expect(resolveImageSource('./assets/diagram.png', '/course/chapter-2/')).toBe('/course/chapter-2/assets/diagram.png')
expect(resolveImageSource('assets/diagram.png', '/course/chapter-2')).toBe('/course/chapter-2/assets/diagram.png')
expect(resolveImageSource('/shared/diagram.png', '/course/chapter-2/')).toBe('/shared/diagram.png')
expect(resolveImageSource('https://example.com/diagram.png', '/course/chapter-2/')).toBe('https://example.com/diagram.png')
})
it('parses quoted and unquoted attributes', () => {
expect(parseAttributes('fit=cover position="50% 30%" max-height=55vh')).toEqual({
fit: 'cover',
position: '50% 30%',
'max-height': '55vh',
})
})
it('extracts markdown image metadata', () => {
expect(extractMarkdownImages('![cover](./assets/a.png){fit="contain" max-height="60vh"}')).toEqual([
{
source: './assets/a.png',
attributes: { fit: 'contain', 'max-height': '60vh' },
},
])
})
})