test: add CLI and presenter coverage
This commit is contained in:
@@ -0,0 +1,34 @@
|
||||
import { defineConfig } from '@playwright/test'
|
||||
|
||||
export default defineConfig({
|
||||
testDir: './tests/e2e',
|
||||
fullyParallel: false,
|
||||
timeout: 30_000,
|
||||
use: {
|
||||
baseURL: 'http://127.0.0.1:4173',
|
||||
viewport: { width: 1440, height: 900 },
|
||||
},
|
||||
webServer: [
|
||||
{
|
||||
command: 'pnpm demo:build && pnpm exec vite preview examples/content-repo --host 127.0.0.1 --port 4173',
|
||||
url: 'http://127.0.0.1:4173',
|
||||
reuseExistingServer: true,
|
||||
timeout: 120_000,
|
||||
env: {
|
||||
...process.env,
|
||||
PRESENTER_TOKEN: 'test-token',
|
||||
},
|
||||
},
|
||||
{
|
||||
command: 'pnpm demo:present -- getting-started',
|
||||
url: 'http://127.0.0.1:4174',
|
||||
reuseExistingServer: true,
|
||||
timeout: 120_000,
|
||||
env: {
|
||||
...process.env,
|
||||
PORT: '4174',
|
||||
PRESENTER_TOKEN: 'test-token',
|
||||
},
|
||||
},
|
||||
],
|
||||
})
|
||||
@@ -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
|
||||
|
||||
{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' },
|
||||
},
|
||||
])
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,512 @@
|
||||
import { expect, test } from '@playwright/test'
|
||||
|
||||
test('landing page links to a rendered deck', async ({ page }) => {
|
||||
await page.goto('/')
|
||||
await expect(page.getByRole('heading', { name: 'Engineering Slides', exact: true })).toBeVisible()
|
||||
await page.getByRole('link', { name: 'easy-slides 快速开始' }).click()
|
||||
await expect(page.getByRole('heading', { name: 'easy-slides', exact: true })).toBeVisible()
|
||||
await expect(page.getByRole('button', { name: '演示者' })).toBeVisible()
|
||||
})
|
||||
|
||||
test('presenter token unlocks, survives refresh, and can be locked', async ({ page }) => {
|
||||
await page.goto('/getting-started/presenter/')
|
||||
const gate = page.getByRole('dialog', { name: '演示者模式' })
|
||||
const tokenInput = page.getByLabel('演示者 token')
|
||||
await expect(gate).toBeVisible()
|
||||
await expect(page.locator('body')).toHaveClass(/easy-presenter-locked/)
|
||||
expect(await gate.evaluate(element => element.matches(':modal'))).toBe(true)
|
||||
await expect(tokenInput).toBeFocused()
|
||||
|
||||
const lockedUrl = page.url()
|
||||
await page.keyboard.press('ArrowRight')
|
||||
expect(page.url()).toBe(lockedUrl)
|
||||
let backgroundClickBlocked = false
|
||||
try {
|
||||
await page.getByTitle('Go to next slide').click({ timeout: 500 })
|
||||
}
|
||||
catch {
|
||||
backgroundClickBlocked = true
|
||||
}
|
||||
expect(backgroundClickBlocked).toBe(true)
|
||||
await page.keyboard.press('Escape')
|
||||
await expect(gate).toBeVisible()
|
||||
await page.keyboard.press('Tab')
|
||||
expect(await gate.evaluate(element => element.contains(document.activeElement))).toBe(true)
|
||||
|
||||
await tokenInput.fill('wrong-token')
|
||||
await page.getByRole('button', { name: '解锁' }).click()
|
||||
await expect(page.getByRole('alert')).toHaveText('Token 不正确')
|
||||
|
||||
await tokenInput.fill('test-token')
|
||||
await page.getByRole('button', { name: '解锁' }).click()
|
||||
await expect(gate).toHaveCount(0)
|
||||
await expect(page.locator('body')).not.toHaveClass(/easy-presenter-locked/)
|
||||
await expect(page.getByRole('navigation', { name: '提词器设置' })).toBeVisible()
|
||||
const notes = page.getByText('欢迎使用 easy-slides')
|
||||
await expect(notes).toBeVisible()
|
||||
const initialSize = await notes.evaluate(element => Number.parseFloat(getComputedStyle(element).fontSize))
|
||||
await page.getByRole('button', { name: 'A+' }).click()
|
||||
const enlargedSize = await notes.evaluate(element => Number.parseFloat(getComputedStyle(element).fontSize))
|
||||
expect(enlargedSize).toBeGreaterThan(initialSize)
|
||||
|
||||
await page.setViewportSize({ width: 390, height: 844 })
|
||||
const toolsBox = await page.getByRole('navigation', { name: '提词器设置' }).boundingBox()
|
||||
expect(toolsBox).not.toBeNull()
|
||||
expect(toolsBox!.x + toolsBox!.width).toBeLessThanOrEqual(390)
|
||||
await page.getByRole('button', { name: '◐' }).click()
|
||||
await expect(page.locator('body')).toHaveClass(/easy-notes-contrast/)
|
||||
|
||||
await page.reload()
|
||||
await expect(page.getByRole('navigation', { name: '提词器设置' })).toBeVisible()
|
||||
|
||||
await page.getByRole('button', { name: '锁定', exact: true }).click()
|
||||
await expect(gate).toBeVisible()
|
||||
expect(await gate.evaluate(element => element.matches(':modal'))).toBe(true)
|
||||
await expect(tokenInput).toBeFocused()
|
||||
})
|
||||
|
||||
test('presenter unlock is scoped to one browser tab', async ({ context, page }) => {
|
||||
await page.goto('/getting-started/presenter/')
|
||||
await page.getByLabel('演示者 token').fill('test-token')
|
||||
await page.getByRole('button', { name: '解锁' }).click()
|
||||
|
||||
const second = await context.newPage()
|
||||
await second.goto('/getting-started/presenter/')
|
||||
await expect(second.getByLabel('演示者 token')).toBeVisible()
|
||||
})
|
||||
|
||||
test('presenter sends image previews to every audience window', async ({ context, page }) => {
|
||||
await page.goto('/getting-started/#/4')
|
||||
const secondAudience = await context.newPage()
|
||||
await secondAudience.goto('/getting-started/#/4')
|
||||
const presenter = await context.newPage()
|
||||
await presenter.goto('/getting-started/presenter/')
|
||||
await presenter.getByLabel('演示者 token').fill('test-token')
|
||||
await presenter.getByRole('button', { name: '解锁' }).click()
|
||||
|
||||
const images = presenter.locator('.slidev-layout img[src*="cover.svg"]')
|
||||
await expect.poll(async () => {
|
||||
if (await images.count() === 0)
|
||||
return null
|
||||
return images.first().evaluate((element) => {
|
||||
const imageElement = element as HTMLImageElement
|
||||
return {
|
||||
loaded: imageElement.complete && imageElement.naturalWidth > 0,
|
||||
pathname: new URL(imageElement.currentSrc).pathname,
|
||||
}
|
||||
})
|
||||
}).toEqual({
|
||||
loaded: true,
|
||||
pathname: '/getting-started/assets/cover.svg',
|
||||
})
|
||||
|
||||
const presenterCurrent = presenter.locator('.grid-section.main')
|
||||
const presenterNext = presenter.locator('.grid-section.next')
|
||||
const imageSlideHeading = presenterCurrent.getByRole('heading', { name: '智能图片' })
|
||||
for (let step = 0; step < 12 && !(await imageSlideHeading.isVisible()); step += 1)
|
||||
await presenter.getByTitle('Go to next slide').click()
|
||||
await expect(imageSlideHeading).toBeVisible()
|
||||
|
||||
await expect(presenterCurrent.getByRole('navigation', { name: '图片缩放' })).toHaveCount(0)
|
||||
await expect(presenterNext.getByRole('navigation', { name: '图片缩放' })).toHaveCount(0)
|
||||
const currentImage = presenterCurrent.locator('img.easy-image-preview-trigger').first()
|
||||
const nextImage = presenterNext.locator('img.easy-image-preview-trigger').first()
|
||||
await expect(currentImage).toBeVisible()
|
||||
await expect(nextImage).toBeVisible()
|
||||
await currentImage.click()
|
||||
|
||||
const presenterDialog = presenter.getByRole('dialog', { name: '图片全屏预览' })
|
||||
const audienceDialog = page.getByRole('dialog', { name: '图片全屏预览' })
|
||||
const secondAudienceDialog = secondAudience.getByRole('dialog', { name: '图片全屏预览' })
|
||||
await expect(audienceDialog).toBeVisible()
|
||||
await expect(secondAudienceDialog).toBeVisible()
|
||||
await expect(presenterDialog).toBeHidden()
|
||||
await expect(audienceDialog.locator('img')).toHaveAttribute('src', /cover\.svg/)
|
||||
await page.getByRole('button', { name: '关闭图片全屏预览' }).click()
|
||||
await secondAudience.getByRole('button', { name: '关闭图片全屏预览' }).click()
|
||||
|
||||
await nextImage.press('Enter')
|
||||
await expect(audienceDialog).toBeVisible()
|
||||
await expect(secondAudienceDialog).toBeVisible()
|
||||
await expect(presenterDialog).toBeHidden()
|
||||
await page.keyboard.press('Escape')
|
||||
await secondAudience.keyboard.press('Escape')
|
||||
})
|
||||
|
||||
test('presenter opens an image locally when no audience acknowledges it', async ({ context, page }) => {
|
||||
const auditPage = await context.newPage()
|
||||
await auditPage.goto('/getting-started/?easy-slides-audit=1#/4')
|
||||
await page.goto('/getting-started/presenter/')
|
||||
await page.getByLabel('演示者 token').fill('test-token')
|
||||
await page.getByRole('button', { name: '解锁' }).click()
|
||||
|
||||
const current = page.locator('.grid-section.main')
|
||||
const imageSlideHeading = current.getByRole('heading', { name: '智能图片' })
|
||||
for (let step = 0; step < 12 && !(await imageSlideHeading.isVisible()); step += 1)
|
||||
await page.getByTitle('Go to next slide').click()
|
||||
const image = current.locator('img.easy-image-preview-trigger').first()
|
||||
await image.click()
|
||||
|
||||
const dialog = page.getByRole('dialog', { name: '图片全屏预览' })
|
||||
await expect(dialog).toBeVisible({ timeout: 2_000 })
|
||||
await expect(auditPage.getByRole('dialog', { name: '图片全屏预览' })).toBeHidden()
|
||||
await page.keyboard.press('Escape')
|
||||
await expect(dialog).toBeHidden()
|
||||
await auditPage.close()
|
||||
})
|
||||
|
||||
test('present mode sends image previews across isolated browser contexts', async ({ browser }) => {
|
||||
const audienceContext = await browser.newContext()
|
||||
const presenterContext = await browser.newContext()
|
||||
const audience = await audienceContext.newPage()
|
||||
const presenter = await presenterContext.newPage()
|
||||
try {
|
||||
await audience.goto('http://127.0.0.1:4174/4')
|
||||
await presenter.goto('http://127.0.0.1:4174/presenter/?password=easy-slides-local')
|
||||
await presenter.getByLabel('演示者 token').fill('test-token')
|
||||
await presenter.getByRole('button', { name: '解锁' }).click()
|
||||
await presenter.goto('http://127.0.0.1:4174/presenter/4?password=easy-slides-local')
|
||||
|
||||
const current = presenter.locator('.grid-section.main')
|
||||
const imageSlideHeading = current.getByRole('heading', { name: '智能图片' })
|
||||
await expect(imageSlideHeading).toBeVisible()
|
||||
await expect(audience.getByRole('heading', { name: '智能图片' })).toBeVisible()
|
||||
await current.locator('img.easy-image-preview-trigger').first().click()
|
||||
|
||||
await expect(audience.getByRole('dialog', { name: '图片全屏预览' })).toBeVisible()
|
||||
await expect(presenter.getByRole('dialog', { name: '图片全屏预览' })).toBeHidden()
|
||||
}
|
||||
finally {
|
||||
await audienceContext.close()
|
||||
await presenterContext.close()
|
||||
}
|
||||
})
|
||||
|
||||
test('presenter advances animations, switches notes, shows timing, and syncs the audience window', async ({ context, page }) => {
|
||||
await page.goto('/getting-started/#/1')
|
||||
const presenter = await context.newPage()
|
||||
await presenter.goto('/getting-started/presenter/')
|
||||
await presenter.getByLabel('演示者 token').fill('test-token')
|
||||
await presenter.getByRole('button', { name: '解锁' }).click()
|
||||
|
||||
await expect(presenter.getByText('Next', { exact: true })).toBeVisible()
|
||||
await expect(presenter.getByText('欢迎使用 easy-slides')).toBeVisible()
|
||||
await expect(presenter.locator('body')).toContainText('-:--')
|
||||
|
||||
await presenter.getByTitle('Go to next slide').click()
|
||||
await expect(page.getByRole('heading', { name: '内容优先' })).toBeVisible()
|
||||
await expect(presenter.getByText('依次介绍四个核心能力')).toBeVisible()
|
||||
await expect(presenter.getByText('2 / 8', { exact: true })).toBeVisible()
|
||||
|
||||
await presenter.getByTitle('Go to next slide').click()
|
||||
await expect(presenter.getByText('1/4', { exact: true })).toBeVisible()
|
||||
await expect(page).toHaveURL(/#\/2\?clicks=1$/)
|
||||
})
|
||||
|
||||
test('table and code controls adjust font size while code fills the available height', async ({ page }) => {
|
||||
await page.goto('/getting-started/#/7')
|
||||
const table = page.locator('.slidev-layout table').first()
|
||||
const tablePreset = table.locator('..').locator('..')
|
||||
const tableCell = table.locator('th').first()
|
||||
|
||||
const initialTableSize = await table.evaluate(element => Number.parseFloat(getComputedStyle(element).fontSize))
|
||||
await page.getByRole('button', { name: '放大表格' }).click()
|
||||
await expect.poll(() => table.evaluate(element => Number.parseFloat(getComputedStyle(element).fontSize))).toBeCloseTo(initialTableSize * 1.1, 4)
|
||||
await page.getByRole('button', { name: '重置表格缩放' }).click()
|
||||
await expect.poll(() => table.evaluate(element => Number.parseFloat(getComputedStyle(element).fontSize))).toBe(initialTableSize)
|
||||
|
||||
for (const preset of [
|
||||
{ className: '', fontSize: 30, paddingY: 10, paddingX: 14 },
|
||||
{ className: 'easy-table-sm', fontSize: 24, paddingY: 7, paddingX: 10 },
|
||||
{ className: 'easy-table-md', fontSize: 30, paddingY: 10, paddingX: 14 },
|
||||
{ className: 'easy-table-lg', fontSize: 36, paddingY: 13, paddingX: 18 },
|
||||
]) {
|
||||
await tablePreset.evaluate((element, className) => {
|
||||
element.classList.remove('easy-table-sm', 'easy-table-md', 'easy-table-lg')
|
||||
if (className)
|
||||
element.classList.add(className)
|
||||
}, preset.className)
|
||||
await expect.poll(() => table.evaluate(element => Number.parseFloat(getComputedStyle(element).fontSize))).toBe(preset.fontSize)
|
||||
const padding = await tableCell.evaluate(element => ({
|
||||
x: Number.parseFloat(getComputedStyle(element).paddingRight),
|
||||
y: Number.parseFloat(getComputedStyle(element).paddingTop),
|
||||
}))
|
||||
expect(padding).toEqual({ x: preset.paddingX, y: preset.paddingY })
|
||||
}
|
||||
|
||||
await page.goto('/getting-started/#/3')
|
||||
const layout = page.locator('.slidev-page-3 .slidev-layout')
|
||||
const code = layout.locator('pre').first()
|
||||
await expect.poll(() => code.evaluate(element => Number.parseFloat(getComputedStyle(element).fontSize))).toBe(34)
|
||||
await expect(page.getByRole('navigation', { name: '代码缩放' })).toBeVisible()
|
||||
const initialCodeSize = await code.evaluate(element => Number.parseFloat(getComputedStyle(element).fontSize))
|
||||
await page.getByRole('button', { name: '放大代码' }).click()
|
||||
await expect.poll(() => code.evaluate(element => Number.parseFloat(getComputedStyle(element).fontSize))).toBeCloseTo(initialCodeSize * 1.1, 4)
|
||||
await page.getByRole('button', { name: '重置代码缩放' }).click()
|
||||
await expect.poll(() => code.evaluate(element => Number.parseFloat(getComputedStyle(element).fontSize))).toBe(initialCodeSize)
|
||||
|
||||
await expect.poll(() => code.evaluate((element) => {
|
||||
const region = element.closest('.easy-two-cols-grid > div')!
|
||||
const layout = element.closest('.slidev-layout') as HTMLElement
|
||||
const scale = layout.getBoundingClientRect().height / layout.clientHeight
|
||||
const contentBottom = Math.min(
|
||||
region.getBoundingClientRect().bottom - Number.parseFloat(getComputedStyle(region).paddingBottom) * scale,
|
||||
layout.getBoundingClientRect().bottom - Number.parseFloat(getComputedStyle(layout).paddingBottom) * scale,
|
||||
)
|
||||
return Math.abs(contentBottom - element.getBoundingClientRect().bottom) / scale
|
||||
})).toBeLessThanOrEqual(3)
|
||||
const overflow = await code.evaluate(element => ({
|
||||
horizontal: getComputedStyle(element).overflowX,
|
||||
lineHeight: Number.parseFloat(getComputedStyle(element).lineHeight),
|
||||
scrollable: element.scrollHeight > element.clientHeight,
|
||||
vertical: getComputedStyle(element).overflowY,
|
||||
whiteSpace: getComputedStyle(element).whiteSpace,
|
||||
}))
|
||||
expect(overflow).toMatchObject({ horizontal: 'hidden', scrollable: true, vertical: 'auto', whiteSpace: 'pre-wrap' })
|
||||
expect(overflow.lineHeight).toBeGreaterThan(34)
|
||||
expect(await code.evaluate(element => element.scrollWidth - element.clientWidth)).toBeLessThanOrEqual(1)
|
||||
expect(await code.evaluate((element) => {
|
||||
element.scrollTop = element.scrollHeight
|
||||
return element.scrollTop
|
||||
})).toBeGreaterThan(0)
|
||||
|
||||
for (const preset of [
|
||||
{ className: '', fontSize: 28 },
|
||||
{ className: 'easy-code-sm', fontSize: 22 },
|
||||
{ className: 'easy-code-md', fontSize: 28 },
|
||||
{ className: 'easy-code-lg', fontSize: 34 },
|
||||
]) {
|
||||
await layout.evaluate((element, className) => {
|
||||
element.classList.remove('easy-code-sm', 'easy-code-md', 'easy-code-lg')
|
||||
if (className)
|
||||
element.classList.add(className)
|
||||
}, preset.className)
|
||||
await expect.poll(() => code.evaluate(element => Number.parseFloat(getComputedStyle(element).fontSize))).toBe(preset.fontSize)
|
||||
}
|
||||
|
||||
for (const preset of [
|
||||
{ className: 'easy-code-height-sm', height: 180 },
|
||||
{ className: 'easy-code-height-md', height: 300 },
|
||||
{ className: 'easy-code-height-lg', height: 420 },
|
||||
]) {
|
||||
await layout.evaluate((element, className) => {
|
||||
element.classList.remove('easy-code-height-sm', 'easy-code-height-md', 'easy-code-height-lg')
|
||||
if (className)
|
||||
element.classList.add(className)
|
||||
}, preset.className)
|
||||
await expect.poll(() => code.evaluate(element => Number.parseFloat(getComputedStyle(element).height))).toBe(preset.height)
|
||||
}
|
||||
|
||||
await layout.evaluate((element) => {
|
||||
element.classList.remove('easy-code-height-sm', 'easy-code-height-md', 'easy-code-height-lg')
|
||||
})
|
||||
await expect.poll(() => code.evaluate((element) => {
|
||||
const region = element.closest('.easy-two-cols-grid > div')!
|
||||
const layout = element.closest('.slidev-layout') as HTMLElement
|
||||
const scale = layout.getBoundingClientRect().height / layout.clientHeight
|
||||
const contentBottom = Math.min(
|
||||
region.getBoundingClientRect().bottom - Number.parseFloat(getComputedStyle(region).paddingBottom) * scale,
|
||||
layout.getBoundingClientRect().bottom - Number.parseFloat(getComputedStyle(layout).paddingBottom) * scale,
|
||||
)
|
||||
return Math.abs(contentBottom - element.getBoundingClientRect().bottom) / scale
|
||||
})).toBeLessThanOrEqual(3)
|
||||
})
|
||||
|
||||
test('image fullscreen preserves layout and table scaling stays out of non-interactive routes', async ({ page }) => {
|
||||
await page.goto('/getting-started/#/4')
|
||||
const image = page.locator('.slidev-layout img.easy-image-preview-trigger').first()
|
||||
const before = await image.evaluate(element => ({
|
||||
height: element.getBoundingClientRect().height,
|
||||
parentClass: element.parentElement?.className,
|
||||
transform: getComputedStyle(element).transform,
|
||||
width: element.getBoundingClientRect().width,
|
||||
}))
|
||||
await expect(page.getByRole('navigation', { name: '图片缩放' })).toHaveCount(0)
|
||||
await image.click()
|
||||
const dialog = page.getByRole('dialog', { name: '图片全屏预览' })
|
||||
await expect(dialog).toBeVisible()
|
||||
const viewport = page.viewportSize()
|
||||
const overlay = await dialog.boundingBox()
|
||||
expect(overlay).not.toBeNull()
|
||||
expect(overlay!.width).toBe(viewport!.width)
|
||||
expect(overlay!.height).toBe(viewport!.height)
|
||||
await page.keyboard.press('Escape')
|
||||
await expect(dialog).toBeHidden()
|
||||
const after = await image.evaluate(element => ({
|
||||
height: element.getBoundingClientRect().height,
|
||||
parentClass: element.parentElement?.className,
|
||||
transform: getComputedStyle(element).transform,
|
||||
width: element.getBoundingClientRect().width,
|
||||
}))
|
||||
expect(after).toEqual(before)
|
||||
|
||||
await image.click()
|
||||
for (const scenario of [
|
||||
{ height: 844, imageHeight: 3200, imageWidth: 120, width: 390 },
|
||||
{ height: 390, imageHeight: 120, imageWidth: 3200, width: 844 },
|
||||
]) {
|
||||
await page.setViewportSize({ width: scenario.width, height: scenario.height })
|
||||
const source = `data:image/svg+xml,${encodeURIComponent(`<svg xmlns="http://www.w3.org/2000/svg" width="${scenario.imageWidth}" height="${scenario.imageHeight}" viewBox="0 0 ${scenario.imageWidth} ${scenario.imageHeight}"><rect width="100%" height="100%" fill="#2563eb"/></svg>`)}`
|
||||
const preview = dialog.locator('img')
|
||||
await preview.evaluate((element, nextSource) => {
|
||||
;(element as HTMLImageElement).src = nextSource
|
||||
}, source)
|
||||
await expect.poll(() => preview.evaluate(element => (element as HTMLImageElement).naturalWidth)).toBe(scenario.imageWidth)
|
||||
const bounds = await dialog.evaluate((element) => {
|
||||
const overlay = element as HTMLElement
|
||||
const preview = overlay.querySelector('img')!
|
||||
const overlayRect = overlay.getBoundingClientRect()
|
||||
const previewRect = preview.getBoundingClientRect()
|
||||
const style = getComputedStyle(overlay)
|
||||
return {
|
||||
contentBottom: overlayRect.bottom - Number.parseFloat(style.paddingBottom),
|
||||
contentLeft: overlayRect.left + Number.parseFloat(style.paddingLeft),
|
||||
contentRight: overlayRect.right - Number.parseFloat(style.paddingRight),
|
||||
contentTop: overlayRect.top + Number.parseFloat(style.paddingTop),
|
||||
overlayBottom: overlayRect.bottom,
|
||||
overlayRight: overlayRect.right,
|
||||
previewBottom: previewRect.bottom,
|
||||
previewLeft: previewRect.left,
|
||||
previewRight: previewRect.right,
|
||||
previewTop: previewRect.top,
|
||||
scrollHeight: overlay.scrollHeight,
|
||||
scrollWidth: overlay.scrollWidth,
|
||||
}
|
||||
})
|
||||
expect(bounds.overlayRight).toBe(scenario.width)
|
||||
expect(bounds.overlayBottom).toBe(scenario.height)
|
||||
expect(bounds.scrollWidth).toBeLessThanOrEqual(scenario.width)
|
||||
expect(bounds.scrollHeight).toBeLessThanOrEqual(scenario.height)
|
||||
expect(bounds.previewLeft).toBeGreaterThanOrEqual(bounds.contentLeft - 1)
|
||||
expect(bounds.previewTop).toBeGreaterThanOrEqual(bounds.contentTop - 1)
|
||||
expect(bounds.previewRight).toBeLessThanOrEqual(bounds.contentRight + 1)
|
||||
expect(bounds.previewBottom).toBeLessThanOrEqual(bounds.contentBottom + 1)
|
||||
}
|
||||
await page.keyboard.press('Escape')
|
||||
await page.setViewportSize({ width: 1440, height: 900 })
|
||||
|
||||
await page.goto('/getting-started/#/7')
|
||||
const table = page.locator('.slidev-layout table').first()
|
||||
const baseFontSize = await table.evaluate(element => Number.parseFloat(getComputedStyle(element).fontSize))
|
||||
await page.getByRole('button', { name: '放大表格' }).click()
|
||||
await page.getByRole('button', { name: '放大表格' }).click()
|
||||
await expect.poll(() => table.evaluate(element => Number.parseFloat(getComputedStyle(element).fontSize))).toBeCloseTo(baseFontSize * 1.2, 4)
|
||||
await page.keyboard.press('ArrowDown')
|
||||
await page.keyboard.press('ArrowUp')
|
||||
await expect.poll(() => table.evaluate(element => Number.parseFloat(getComputedStyle(element).fontSize))).toBeCloseTo(baseFontSize * 1.2, 4)
|
||||
|
||||
await page.goto('/getting-started/overview/')
|
||||
await expect(page.locator('body')).not.toHaveClass(/easy-slide-scroll-fallback/)
|
||||
await expect(page.locator('.easy-content-scale-toolbar')).toHaveCount(0)
|
||||
await expect(page.locator('.easy-image-lightbox')).toHaveCount(0)
|
||||
await page.goto('/getting-started/export/')
|
||||
await expect(page.locator('.easy-content-scale-toolbar')).toHaveCount(0)
|
||||
await expect(page.locator('.easy-image-lightbox')).toHaveCount(0)
|
||||
await page.goto('/getting-started/editor/')
|
||||
await expect(page.locator('.easy-content-scale-toolbar')).toHaveCount(0)
|
||||
await expect(page.locator('.easy-image-lightbox')).toHaveCount(0)
|
||||
})
|
||||
|
||||
test('overflowing slides scroll as a fallback without rendering a warning UI', async ({ page }) => {
|
||||
const overflowWarnings: string[] = []
|
||||
page.on('console', (message) => {
|
||||
if (message.text().includes('内容纵向溢出'))
|
||||
overflowWarnings.push(message.text())
|
||||
})
|
||||
await page.goto('/getting-started/#/2')
|
||||
const layout = page.locator('.slidev-page-2 .slidev-layout').first()
|
||||
await expect(page.locator('body')).toHaveClass(/easy-slide-scroll-fallback/)
|
||||
await layout.evaluate((element) => {
|
||||
const filler = document.createElement('div')
|
||||
filler.dataset.testOverflow = 'true'
|
||||
filler.style.height = '900px'
|
||||
filler.style.width = '1px'
|
||||
element.append(filler)
|
||||
})
|
||||
await expect.poll(() => layout.evaluate(element => element.scrollHeight - element.clientHeight)).toBeGreaterThan(100)
|
||||
expect(await layout.evaluate(element => getComputedStyle(element).overflowY)).toBe('auto')
|
||||
expect(await layout.evaluate(element => getComputedStyle(element).overflowX)).toBe('hidden')
|
||||
expect(await layout.evaluate((element) => {
|
||||
element.scrollTop = element.scrollHeight
|
||||
return element.scrollTop
|
||||
})).toBeGreaterThan(100)
|
||||
await expect(page.locator('.easy-slide-overflow-warning')).toHaveCount(0)
|
||||
expect(overflowWarnings).toEqual([])
|
||||
})
|
||||
|
||||
test('code scaling synchronizes between audience and presenter tabs', async ({ context, page }) => {
|
||||
await page.goto('/getting-started/#/3')
|
||||
const presenter = await context.newPage()
|
||||
await presenter.goto('/getting-started/presenter/')
|
||||
await presenter.getByLabel('演示者 token').fill('test-token')
|
||||
await presenter.getByRole('button', { name: '解锁' }).click()
|
||||
|
||||
const presenterCurrent = presenter.locator('.grid-section.main')
|
||||
const heading = presenterCurrent.getByRole('heading', { name: 'Markdown 与代码' })
|
||||
for (let step = 0; step < 12 && !(await heading.isVisible()); step += 1)
|
||||
await presenter.getByTitle('Go to next slide').click()
|
||||
|
||||
const audienceCode = page.locator('.slidev-page-3 pre').first()
|
||||
const presenterCode = presenterCurrent.locator('pre').first()
|
||||
const initial = await audienceCode.evaluate(element => Number.parseFloat(getComputedStyle(element).fontSize))
|
||||
await presenterCurrent.getByRole('button', { name: '放大代码' }).click()
|
||||
await expect.poll(() => audienceCode.evaluate(element => Number.parseFloat(getComputedStyle(element).fontSize))).toBeCloseTo(initial * 1.1, 4)
|
||||
await expect.poll(() => presenterCode.evaluate(element => Number.parseFloat(getComputedStyle(element).fontSize))).toBeCloseTo(initial * 1.1, 4)
|
||||
})
|
||||
|
||||
test('stacked image layouts constrain wide images by available height', async ({ page }) => {
|
||||
await page.setViewportSize({ width: 925, height: 963 })
|
||||
await page.goto('/getting-started/#/4')
|
||||
const pane = page.locator('.slidev-layout .easy-image-pane').first()
|
||||
const image = pane.locator('img').first()
|
||||
|
||||
await expect(image).toBeVisible()
|
||||
const dimensions = await pane.evaluate((element) => {
|
||||
const imageElement = element.querySelector('img')!
|
||||
return {
|
||||
imageHeight: imageElement.offsetHeight,
|
||||
imageWidth: imageElement.offsetWidth,
|
||||
paneHeight: element.clientHeight,
|
||||
paneWidth: element.clientWidth,
|
||||
scrollHeight: element.scrollHeight,
|
||||
scrollWidth: element.scrollWidth,
|
||||
}
|
||||
})
|
||||
expect(dimensions.imageHeight).toBeLessThanOrEqual(dimensions.paneHeight)
|
||||
expect(dimensions.imageWidth).toBeLessThanOrEqual(dimensions.paneWidth)
|
||||
expect(dimensions.scrollHeight - dimensions.paneHeight).toBeLessThanOrEqual(1)
|
||||
expect(dimensions.scrollWidth - dimensions.paneWidth).toBeLessThanOrEqual(1)
|
||||
})
|
||||
|
||||
test('sample slides stay inside the 4:3 canvas', async ({ page }) => {
|
||||
await page.goto('/getting-started/#/1')
|
||||
for (let slide = 1; slide <= 8; slide += 1) {
|
||||
const layout = page.locator('.slidev-layout').first()
|
||||
await expect(layout).toBeVisible()
|
||||
const box = await layout.boundingBox()
|
||||
expect(box).not.toBeNull()
|
||||
expect(box!.width / box!.height, `slide ${slide} aspect ratio`).toBeCloseTo(4 / 3, 2)
|
||||
const overflow = await layout.evaluate(element => ({
|
||||
horizontal: element.scrollWidth - element.clientWidth,
|
||||
vertical: element.scrollHeight - element.clientHeight,
|
||||
}))
|
||||
expect(overflow.horizontal, `slide ${slide} horizontal overflow`).toBeLessThanOrEqual(1)
|
||||
expect(overflow.vertical, `slide ${slide} vertical overflow`).toBeLessThanOrEqual(1)
|
||||
for (const image of await layout.locator('img').all()) {
|
||||
const state = await image.evaluate((element) => {
|
||||
const imageElement = element as HTMLImageElement
|
||||
return {
|
||||
complete: imageElement.complete,
|
||||
naturalWidth: imageElement.naturalWidth,
|
||||
width: imageElement.getBoundingClientRect().width,
|
||||
height: imageElement.getBoundingClientRect().height,
|
||||
}
|
||||
})
|
||||
expect(state.complete, `slide ${slide} image loaded`).toBe(true)
|
||||
expect(state.naturalWidth, `slide ${slide} image has content`).toBeGreaterThan(0)
|
||||
expect(state.width, `slide ${slide} image width`).toBeGreaterThan(0)
|
||||
expect(state.height, `slide ${slide} image height`).toBeGreaterThan(0)
|
||||
}
|
||||
await page.keyboard.press('ArrowDown')
|
||||
}
|
||||
})
|
||||
@@ -0,0 +1,8 @@
|
||||
import { defineConfig } from 'vitest/config'
|
||||
|
||||
export default defineConfig({
|
||||
test: {
|
||||
include: ['tests/**/*.test.ts'],
|
||||
exclude: ['tests/e2e/**'],
|
||||
},
|
||||
})
|
||||
Reference in New Issue
Block a user