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'); await page.waitForTimeout(1000); const invoke = (ch: string, args: any) => page.evaluate(({ ch, args }) => (globalThis as any).dsh.invoke(ch, args), { ch, args }); return { app, page, invoke }; } // 回归:文件列表缩进逐级递进 + 每行带 SVG 图标(FR-反馈1) test('回归:文件列表缩进递进且带图标', async () => { const tmp = await fs.mkdtemp(nodePath.join(os.tmpdir(), 'hn-reg1-')); const userData = nodePath.join(tmp, 'ud'); const vault = nodePath.join(tmp, 'v'); 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 a.invoke('file:create', { basePath: '', name: '一层', isFolder: true }); await a.invoke('file:create', { basePath: '一层', name: '二层', isFolder: true }); await a.invoke('file:create', { basePath: '一层/二层', name: '深层文档.md', isFolder: false }); await a.invoke('file:create', { basePath: '', name: '根文档.md', isFolder: false }); await a.page.evaluate(() => (globalThis as any).__STORE__?.getState?.().loadTree()); await a.page.waitForTimeout(1200); const rows = await a.page.evaluate(() => { const ft = document.querySelector('[data-testid="filetree"]'); const out: { padLeft: string; svgs: number; text: string }[] = []; for (const el of ft?.querySelectorAll('div') ?? []) { const cs = getComputedStyle(el); if (cs.cursor === 'pointer' && cs.position === 'relative') { out.push({ padLeft: cs.paddingLeft, svgs: el.querySelectorAll('svg').length, text: el.textContent.trim().slice(0, 20) }); } } return out; }); const pad = rows.map(r => parseInt(r.padLeft, 10)); // 根文档(0级) vs 一层(0级) vs 二层(1级) vs 深层文档(2级) expect(rows.length).toBeGreaterThanOrEqual(4); const rootPad = pad[0]; const deepRow = rows.find(r => r.text.includes('深层文档')); const l2Row = rows.find(r => r.text.includes('二层')); expect(deepRow).toBeTruthy(); expect(l2Row).toBeTruthy(); expect(parseInt(deepRow!.padLeft, 10)).toBeGreaterThan(parseInt(l2Row!.padLeft, 10)); expect(parseInt(l2Row!.padLeft, 10)).toBeGreaterThan(rootPad); // 每个文件夹/文档行都有 SVG 图标 for (const r of rows) expect(r.svgs).toBeGreaterThan(0); await a.app.close(); }); // 回归:代码块复制应为纯代码内容,不附带 ```language 围栏(用户反馈修正) test('回归:复制代码块为纯内容(无围栏)', async () => { const tmp = await fs.mkdtemp(nodePath.join(os.tmpdir(), 'hn-reg3-')); const userData = nodePath.join(tmp, 'ud'); const vault = nodePath.join(tmp, 'v'); await fs.mkdir(userData, { recursive: true }); await fs.mkdir(vault, { recursive: true }); const a = await launch(userData); await a.invoke('vault:switch', { root: vault }); const body = '# 复制测试\n\n```bash\necho hello\nls -la\n```'; await a.invoke('doc:write', { path: 'copy.md', meta: {}, body }); await a.page.evaluate((p: string) => (globalThis as any).__STORE__?.getState?.().openDoc(p), 'copy.md'); await a.page.waitForTimeout(1500); // 拦截剪贴板写入 await a.page.evaluate(() => { (globalThis as any).__cap = []; navigator.clipboard.writeText = (t: string) => { (globalThis as any).__cap.push(t); return Promise.resolve(); }; }); const copyBtn = a.page.locator('.ProseMirror button', { hasText: '复制' }).first(); await expect(copyBtn).toBeVisible({ timeout: 10000 }); await copyBtn.click(); await a.page.waitForTimeout(400); const text = await a.page.evaluate(() => (globalThis as any).__cap?.[0] ?? null); // 纯代码内容,不带 ``` 围栏 expect(text).toBe('echo hello\nls -la'); await a.app.close(); }); // 回归:长文档底部书写时光标不贴底(FR-反馈4) test('回归:长文档底部书写光标距底留白', async () => { const tmp = await fs.mkdtemp(nodePath.join(os.tmpdir(), 'hn-reg4-')); const userData = nodePath.join(tmp, 'ud'); const vault = nodePath.join(tmp, 'v'); await fs.mkdir(userData, { recursive: true }); await fs.mkdir(vault, { recursive: true }); const a = await launch(userData); await a.invoke('vault:switch', { root: vault }); const paras: string[] = []; for (let i = 1; i <= 600; i++) paras.push('段落 ' + i + ' 内容内容内容内容内容内容内容内容内容内容内容内容内容内容'); const body = '# 长文档\n\n' + paras.join('\n\n'); await a.invoke('doc:write', { path: 'long.md', meta: {}, body }); await a.page.evaluate((p: string) => (globalThis as any).__STORE__?.getState?.().openDoc(p), 'long.md'); await a.page.waitForTimeout(1500); const editor = a.page.locator('.ProseMirror'); await editor.click(); await a.page.keyboard.press('Control+End'); await a.page.waitForTimeout(400); await a.page.keyboard.type('底部输入内容'); await a.page.waitForTimeout(700); const m = await a.page.evaluate(() => { const prose = document.querySelector('.ProseMirror'); const sc = prose?.closest('div[style]') as HTMLElement | null; if (!sc) return null; const rect = sc.getBoundingClientRect(); const sel = window.getSelection(); const range = sel && sel.rangeCount ? sel.getRangeAt(0) : null; const caret = range ? range.getBoundingClientRect() : null; return caret ? Math.round(rect.bottom - caret.bottom) : null; }); expect(m).not.toBeNull(); // 光标至少距底部 30px 留白(修复前为 0~5px 贴底) expect(m!).toBeGreaterThanOrEqual(30); await a.app.close(); }); // 回归:代码块语法高亮不再出现红色(FR-反馈2) test('回归:代码块语法高亮无红色', async () => { const tmp = await fs.mkdtemp(nodePath.join(os.tmpdir(), 'hn-reg2-')); const userData = nodePath.join(tmp, 'ud'); const vault = nodePath.join(tmp, 'v'); await fs.mkdir(userData, { recursive: true }); await fs.mkdir(vault, { recursive: true }); const a = await launch(userData); await a.invoke('vault:switch', { root: vault }); const body = '# 高亮\n\n```bash\ntime for in\n# 中文注释\n```\n\n```markdown\n## 标题\n- 列表项\n```'; await a.invoke('doc:write', { path: 'hl.md', meta: {}, body }); await a.page.evaluate((p: string) => (globalThis as any).__STORE__?.getState?.().openDoc(p), 'hl.md'); await a.page.waitForTimeout(1500); const reds = await a.page.evaluate(() => { const prose = document.querySelector('.ProseMirror'); const bad: { cls: string; color: string; text: string }[] = []; for (const el of prose?.querySelectorAll('.hljs-keyword, .hljs-section, .hljs-tag, .hljs-doctag, .hljs-deletion') ?? []) { const c = getComputedStyle(el).color; const mm = c.match(/rgba?\((\d+),\s*(\d+),\s*(\d+)/); if (!mm) continue; const r = +mm[1], g = +mm[2], b = +mm[3]; if (r > 130 && r > g * 1.4 && r > b * 1.4) { bad.push({ cls: String(el.className), color: c, text: (el.textContent || '').slice(0, 20) }); } } return bad; }); expect(reds).toEqual([]); await a.app.close(); });