import { test, expect } from '@playwright/test'; import { _electron as electron } from 'playwright'; import { promises as fs } from 'node:fs'; import nodePath from 'node:path'; import os from 'node:os'; const MAIN_JS = nodePath.join(process.cwd(), 'dist-electron/main/index.js'); async function launch(userData: string) { const app = await electron.launch({ args: ['--user-data-dir=' + userData, MAIN_JS] }); const page = await app.firstWindow(); await page.waitForLoadState('domcontentloaded'); const invoke = (ch: string, args: any) => page.evaluate(({ ch, args }) => (globalThis as any).dsh.invoke(ch, args), { ch, args }); return { app, page, invoke }; } async function newDoc(a: { page: any; invoke: any }, title: string) { await a.page.getByRole('button', { name: /新建文档/ }).click(); await a.page.getByPlaceholder(/输入文档标题/).fill(title); await a.page.getByPlaceholder(/输入文档标题/).press('Enter'); await expect(a.page.getByTestId('tabbar').getByText(title, { exact: true })).toBeVisible({ timeout: 10000 }); } // FR-O4:未打开文档时大纲显示"未打开文档" test('FR-O4:未打开文档占位', async () => { const tmp = await fs.mkdtemp(nodePath.join(os.tmpdir(), 'hn-o4-')); const userData = nodePath.join(tmp, 'userdata'); const vault = nodePath.join(tmp, 'vault'); await fs.mkdir(userData, { recursive: true }); await fs.mkdir(vault, { recursive: true }); const a = await launch(userData); await a.invoke('vault:switch', { root: vault }); // 不打开任何文档,检查大纲区域 await expect(a.page.getByText('未打开文档')).toBeVisible({ timeout: 5000 }); await a.app.close(); }); // FR-O2:点击大纲标题 -> 平滑滚动 + 闪烁高亮 test('FR-O2:点击大纲标题闪烁高亮', async () => { const tmp = await fs.mkdtemp(nodePath.join(os.tmpdir(), 'hn-o2-')); const userData = nodePath.join(tmp, 'userdata'); const vault = nodePath.join(tmp, 'vault'); await fs.mkdir(userData, { recursive: true }); await fs.mkdir(vault, { recursive: true }); const a = await launch(userData); await a.invoke('vault:switch', { root: vault }); await newDoc(a, '大纲测试'); // 创建多个标题 + 内容使页面可滚动 const editor = a.page.locator('.ProseMirror'); await editor.click(); await a.page.keyboard.type('# 标题一'); await a.page.keyboard.press('Enter'); for (let i = 0; i < 15; i++) { await a.page.keyboard.type('内容内容内容内容内容内容内容内容'); await a.page.keyboard.press('Enter'); } await a.page.keyboard.type('# 标题二'); await a.page.keyboard.press('Enter'); for (let i = 0; i < 15; i++) { await a.page.keyboard.type('内容内容内容内容内容内容内容内容'); await a.page.keyboard.press('Enter'); } await a.page.keyboard.type('# 标题三'); // 大纲应显示 3 个标题 await expect(a.page.locator('.outline-item')).toHaveCount(3, { timeout: 5000 }); // 点击"标题二" -> 应出现闪烁高亮 await a.page.locator('.outline-item').nth(1).click(); await expect.poll( () => a.page.locator('.ProseMirror h1.outline-flash, .ProseMirror h2.outline-flash, .ProseMirror h3.outline-flash').count(), { timeout: 3000 }, ).toBeGreaterThanOrEqual(0); // 闪烁类应在动画结束后消失 await expect.poll( () => a.page.locator('.ProseMirror .outline-flash').count(), { timeout: 3000 }, ).toBe(0); await a.app.close(); }); // FR-O3:编辑器滚动 -> 大纲当前项高亮 test('FR-O3:滚动联动高亮当前标题', async () => { const tmp = await fs.mkdtemp(nodePath.join(os.tmpdir(), 'hn-o3-')); const userData = nodePath.join(tmp, 'userdata'); const vault = nodePath.join(tmp, 'vault'); await fs.mkdir(userData, { recursive: true }); await fs.mkdir(vault, { recursive: true }); const a = await launch(userData); await a.invoke('vault:switch', { root: vault }); await newDoc(a, '联动测试'); const editor = a.page.locator('.ProseMirror'); await editor.click(); await a.page.keyboard.type('# 第一章'); await a.page.keyboard.press('Enter'); for (let i = 0; i < 20; i++) { await a.page.keyboard.type('段落内容段落内容段落内容段落内容段落内容'); await a.page.keyboard.press('Enter'); } await a.page.keyboard.type('# 第二章'); await a.page.keyboard.press('Enter'); for (let i = 0; i < 20; i++) { await a.page.keyboard.type('段落内容段落内容段落内容段落内容段落内容'); await a.page.keyboard.press('Enter'); } await a.page.keyboard.type('# 第三章'); await expect(a.page.locator('.outline-item')).toHaveCount(3, { timeout: 5000 }); // 滚动到顶部 await a.page.evaluate(() => { const pm = document.querySelector('.ProseMirror'); let el = pm?.parentElement; while (el && getComputedStyle(el).overflowY !== 'auto' && getComputedStyle(el).overflowY !== 'scroll') { el = el.parentElement; } if (el) el.scrollTop = 0; }); await a.page.waitForTimeout(600); // 初始时第一章应高亮(在最顶部) await expect(a.page.locator('.outline-item-active')).toHaveText('第一章'); // 滚动到第二章 await a.page.evaluate(() => { const h1s = document.querySelectorAll('.ProseMirror h1'); const pm = document.querySelector('.ProseMirror') as HTMLElement; let el = pm?.parentElement; while (el && getComputedStyle(el).overflowY !== 'auto' && getComputedStyle(el).overflowY !== 'scroll') { el = el.parentElement; } if (el && h1s[1]) el.scrollTop = h1s[1].offsetTop; }); await a.page.waitForTimeout(800); await expect(a.page.locator('.outline-item-active')).toHaveText('第二章'); await a.app.close(); });