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