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 { createBrowserInstallCommand } from '../scripts/lib/browser-install.ts' import { resolveImageSource } from '../packages/slidev-theme-easy-jyy/utils/resolve-image.ts' describe('browser installation', () => { it('uses the Chromium-only Playwright CLI bundled with the engine', () => { const command = createBrowserInstallCommand([]) expect(command.command).toBe(process.execPath) expect(command.args.at(-3)).toMatch(/playwright-chromium[/\\]cli\.js$/) expect(command.args.slice(-2)).toEqual(['install', 'chromium']) }) it('supports Linux system dependency installation and rejects unknown options', () => { expect(createBrowserInstallCommand(['--with-deps']).args.slice(-3)).toEqual([ 'install', '--with-deps', 'chromium', ]) expect(() => createBrowserInstallCommand(['--firefox'])).toThrow('不支持参数') expect(() => createBrowserInstallCommand(['--with-deps', '--with-deps'])).toThrow('只能指定一次') }) it('includes browser installation in generated CI workflows', async () => { const source = await readFile(path.join(process.cwd(), 'scripts/init.ts'), 'utf8') expect(source).toContain('pnpm exec easy-slides install-browser --with-deps') }) }) 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('const IMAGE_MIN_SCALE = 0.1') expect(controlsSource).toContain('const IMAGE_MAX_SCALE = 4') expect(controlsSource).toContain("type: 'close'") expect(controlsSource).toContain('easy-slides:image-preview:') expect(layoutCss).toContain('.easy-image-scale-toolbar') expect(layoutCss).toContain('.easy-image-lightbox-pannable') 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'), `
`, '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} --- \`\`\` --- 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 {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{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('{fit="contain" max-height="60vh"}')).toEqual([ { source: './assets/a.png', attributes: { fit: 'contain', 'max-height': '60vh' }, }, ]) }) })