diff --git a/playwright.config.ts b/playwright.config.ts new file mode 100644 index 0000000..2f0916c --- /dev/null +++ b/playwright.config.ts @@ -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', + }, + }, + ], +}) diff --git a/tests/core.test.ts b/tests/core.test.ts new file mode 100644 index 0000000..70f3347 --- /dev/null +++ b/tests/core.test.ts @@ -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'), ` + +
+ +`, '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' }, + }, + ]) + }) +}) diff --git a/tests/e2e/presenter.spec.ts b/tests/e2e/presenter.spec.ts new file mode 100644 index 0000000..e284e9a --- /dev/null +++ b/tests/e2e/presenter.spec.ts @@ -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(``)}` + 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') + } +}) diff --git a/vitest.config.ts b/vitest.config.ts new file mode 100644 index 0000000..80095ff --- /dev/null +++ b/vitest.config.ts @@ -0,0 +1,8 @@ +import { defineConfig } from 'vitest/config' + +export default defineConfig({ + test: { + include: ['tests/**/*.test.ts'], + exclude: ['tests/e2e/**'], + }, +})