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 setupVault(a: { page: any; invoke: any }, docs: { name: string; body?: string; tags?: string[]; links?: string[] }[] = []) { const tmp = await fs.mkdtemp(nodePath.join(os.tmpdir(), 'hn-btn-')); const ud = nodePath.join(tmp, 'ud'); const v = nodePath.join(tmp, 'v'); await fs.mkdir(ud, { recursive: true }); await fs.mkdir(v, { recursive: true }); await a.invoke('vault:switch', { root: v }); for (const d of docs) { await a.invoke('doc:write', { path: d.name + '.md', meta: { title: d.name, tags: d.tags, links: d.links }, body: d.body ?? ('# ' + d.name) }); } await a.page.getByTitle('刷新').click(); await a.page.waitForTimeout(300); return { tmp, v }; } // === TitleBar 按钮验证 === test('TitleBar: 所有按钮可点击', async () => { const a = await launch(nodePath.join(os.tmpdir(), 'hn-tb-' + Date.now())); const { v } = await setupVault(a); // 侧边栏开关 await a.page.getByTitle('侧边栏开关').click(); await a.page.waitForTimeout(200); await a.page.getByTitle('侧边栏开关').click(); await a.page.waitForTimeout(200); // 外观定制 await a.page.getByTitle('外观定制').click(); await expect(a.page.getByText('外观定制', { exact: true }).first()).toBeVisible({ timeout: 3000 }); await a.page.keyboard.press('Escape'); await a.page.waitForTimeout(300); // 刷新 await a.page.getByTitle('刷新').click(); await a.page.waitForTimeout(200); // 切换编辑器/图谱 await a.page.getByTitle('切换 编辑器/图谱').click(); await a.page.waitForTimeout(300); await expect(a.page.getByRole('button', { name: '知识图谱' })).toBeVisible({ timeout: 3000 }); await a.page.getByTitle('切换 编辑器/图谱').click(); await a.page.waitForTimeout(300); // 标签栏开关 await a.page.getByTestId('tabbar-toggle').click(); await a.page.waitForTimeout(200); await a.page.getByTestId('tabbar-toggle').click(); await a.page.waitForTimeout(200); // 极简模式 await a.page.getByTitle('极简模式').click(); await a.page.waitForTimeout(200); await a.page.getByTitle('极简模式').click(); await a.page.waitForTimeout(200); // 设置 await a.page.getByTitle('设置').click(); await expect(a.page.getByText('设置', { exact: true }).first()).toBeVisible({ timeout: 3000 }); await a.page.keyboard.press('Escape'); await a.page.waitForTimeout(300); await a.app.close(); }); // === Toolbar 按钮验证 === test('Toolbar: 新建文档+新建文件夹可用', async () => { const a = await launch(nodePath.join(os.tmpdir(), 'hn-tb2-' + Date.now())); const { v } = await setupVault(a); // 新建文档 await a.page.getByRole('button', { name: /新建文档/ }).click(); await a.page.getByPlaceholder(/输入文档标题/).fill('BtnTest1'); await a.page.getByPlaceholder(/输入文档标题/).press('Enter'); await expect(a.page.getByTestId('tabbar').getByText('BtnTest1', { exact: true })).toBeVisible({ timeout: 5000 }); // 新建文件夹 await a.page.getByRole('button', { name: '新建文件夹' }).click(); await a.page.getByPlaceholder(/文件夹名称/).fill('TestFolder'); await a.page.getByPlaceholder(/文件夹名称/).press('Enter'); await a.page.waitForTimeout(300); await expect(a.page.getByTestId('filetree').getByText('TestFolder', { exact: true })).toBeVisible({ timeout: 3000 }); await a.app.close(); }); // === EditorToolbar 按钮验证 === test('EditorToolbar: 格式按钮可点击生效', async () => { const a = await launch(nodePath.join(os.tmpdir(), 'hn-et-' + Date.now())); const { v } = await setupVault(a, [{ name: 'FmtTest', body: 'Hello World' }]); await a.page.getByTestId('filetree').getByText('FmtTest').click(); await a.page.waitForTimeout(500); await a.page.locator('.ProseMirror').click(); await a.page.keyboard.type('test text'); // 星标 await a.page.getByTitle('星标').click(); await a.page.waitForTimeout(200); // 加粗 await a.page.getByTitle('加粗 Ctrl+B').click(); await a.page.waitForTimeout(100); // 斜体 await a.page.getByTitle('斜体 Ctrl+I').click(); await a.page.waitForTimeout(100); // 删除线 await a.page.getByTitle('删除线 Alt+Shift+5').click(); await a.page.waitForTimeout(100); // 行内代码 await a.page.getByTitle('行内代码 Ctrl+E').click(); await a.page.waitForTimeout(100); // 标题 await a.page.getByTitle('一级标题 Ctrl+1').click(); await a.page.waitForTimeout(100); await a.page.getByTitle('二级标题 Ctrl+2').click(); await a.page.waitForTimeout(100); await a.page.getByTitle('三级标题 Ctrl+3').click(); await a.page.waitForTimeout(100); // 列表 await a.page.getByTitle('无序列表 Ctrl+L').click(); await a.page.waitForTimeout(100); await a.page.getByTitle('有序列表 Ctrl+Shift+O').click(); await a.page.waitForTimeout(100); // 代码块 await a.page.getByTitle('代码块 Ctrl+Shift+M').click(); await a.page.waitForTimeout(100); // 撤销/重做 await a.page.getByTitle('撤销 Ctrl+Z').click(); await a.page.waitForTimeout(100); await a.page.getByTitle('重做 Ctrl+Y').click(); await a.page.waitForTimeout(100); // 验证编辑器有内容(按钮没崩溃) const html = await a.page.locator('.ProseMirror').innerHTML(); expect(html.length).toBeGreaterThan(0); await a.app.close(); }); // === EditorToolbar: 链接/图片按钮 === test('EditorToolbar: 链接气泡按钮可用', async () => { const a = await launch(nodePath.join(os.tmpdir(), 'hn-lb-' + Date.now())); const { v } = await setupVault(a, [{ name: 'LinkTest', body: 'some text here' }]); await a.page.getByTestId('filetree').getByText('LinkTest').click(); await a.page.waitForTimeout(500); await a.page.locator('.ProseMirror').click(); await a.page.keyboard.type('linktext'); // 选中文字 await a.page.keyboard.press('Shift+Home'); // 点击链接按钮 await a.page.getByTitle('链接').click(); await a.page.waitForTimeout(300); // 应弹出输入对话框 await expect(a.page.getByText('插入链接')).toBeVisible({ timeout: 3000 }); await a.page.getByPlaceholder('https://example.com').fill('https://test.com'); await a.page.getByPlaceholder('https://example.com').press('Enter'); await a.page.waitForTimeout(500); // 验证链接已插入 const html = await a.page.locator('.ProseMirror').innerHTML(); expect(html).toContain('test.com'); await a.app.close(); }); // === FindReplace 按钮验证 === test('FindReplace: 查找/替换按钮可用', async () => { const a = await launch(nodePath.join(os.tmpdir(), 'hn-fr-' + Date.now())); const { v } = await setupVault(a, [{ name: 'FindTest', body: 'find me find me find me' }]); await a.page.getByTestId('filetree').getByText('FindTest').click(); await a.page.waitForTimeout(500); // Ctrl+F 呼出查找 await a.page.keyboard.press('Control+f'); await a.page.waitForTimeout(300); await a.page.getByPlaceholder('查找').fill('find'); await a.page.waitForTimeout(300); // 查找按钮 await a.page.getByRole('button', { name: '查找' }).click(); await a.page.waitForTimeout(200); // 上一个/下一个 await a.page.getByTitle('下一个').click(); await a.page.waitForTimeout(100); await a.page.getByTitle('上一个').click(); await a.page.waitForTimeout(100); // 替换 await a.page.getByPlaceholder('替换').fill('replaced'); await a.page.getByRole('button', { name: '替换', exact: true }).click(); await a.page.waitForTimeout(200); // 全部替换 await a.page.getByRole('button', { name: '全部替换' }).click(); await a.page.waitForTimeout(200); await a.app.close(); }); // === MoreActions 按钮验证 === test('MoreActions: 菜单+复制可用', async () => { const a = await launch(nodePath.join(os.tmpdir(), 'hn-ma-' + Date.now())); const { v } = await setupVault(a, [{ name: 'MoreTest', body: 'copy this' }]); await a.page.getByTestId('filetree').getByText('MoreTest').click(); await a.page.waitForTimeout(500); // 点击更多操作 await a.page.getByTitle('更多操作').click(); await a.page.waitForTimeout(300); await expect(a.page.getByText('复制 Markdown')).toBeVisible({ timeout: 3000 }); // 复制 Markdown await a.page.getByText('复制 Markdown').click(); await a.page.waitForTimeout(500); // 应显示成功提示 await expect(a.page.getByText('已复制 Markdown')).toBeVisible({ timeout: 3000 }); await a.app.close(); }); // === DocInfoPanel 按钮验证 === test('DocInfoPanel: 星标+标签+关联可用', async () => { const a = await launch(nodePath.join(os.tmpdir(), 'hn-di-' + Date.now())); const { v } = await setupVault(a, [{ name: 'DocA', body: 'doc a' }, { name: 'DocB', body: 'doc b' }]); await a.page.getByTestId('filetree').getByText('DocA').click(); await a.page.waitForTimeout(500); // 切换到信息面板 await a.page.getByRole('button', { name: '信息', exact: true }).click(); await a.page.waitForTimeout(300); // 星标(信息面板内——第2个星标按钮,用 evaluate 精确点击) await a.page.evaluate(() => { const stars = document.querySelectorAll('button[title="星标"]'); if (stars.length >= 2) (stars[1] as HTMLElement).click(); }); await a.page.waitForTimeout(200); // 添加标签 await a.page.getByPlaceholder('添加标签').fill('testtag'); await a.page.getByPlaceholder('添加标签').press('Enter'); await a.page.waitForTimeout(300); await expect(a.page.getByText('testtag')).toBeVisible({ timeout: 3000 }); // 关联文档 const select = a.page.locator('select').first(); await select.selectOption('DocB.md'); await a.page.getByRole('button', { name: '关联' }).click(); await a.page.waitForTimeout(300); await expect(a.page.getByText('DocB.md')).toBeVisible({ timeout: 3000 }); await a.app.close(); }); // === SettingsPanel 按钮验证 === test('SettingsPanel: 页签+开关+按钮可用', async () => { const a = await launch(nodePath.join(os.tmpdir(), 'hn-sp-' + Date.now())); await setupVault(a); await a.page.getByTitle('设置').click(); await a.page.waitForTimeout(300); // 页签切换 await a.page.getByRole('button', { name: '快捷键' }).click(); await a.page.waitForTimeout(200); await a.page.getByRole('button', { name: '编辑器' }).click(); await a.page.waitForTimeout(200); await a.page.getByRole('button', { name: '路径' }).click(); await a.page.waitForTimeout(200); await a.page.getByRole('button', { name: '其他' }).click(); await a.page.waitForTimeout(200); // 编辑器页:自动换行 toggle(用 evaluate 找到标签旁的按钮) await a.page.getByRole('button', { name: '编辑器' }).click(); await a.page.waitForTimeout(200); await a.page.evaluate(() => { const labels = Array.from(document.querySelectorAll('span')); const lbl = labels.find(s => s.textContent === '自动换行'); if (lbl) { const row = lbl.parentElement; const btn = row?.querySelector('button'); (btn as HTMLElement)?.click(); } }); await a.page.waitForTimeout(200); // 其他页:性能优先 toggle await a.page.getByRole('button', { name: '其他' }).click(); await a.page.getByTestId('perf-toggle').click(); await a.page.waitForTimeout(200); await a.page.getByTestId('perf-toggle').click(); await a.page.waitForTimeout(200); // 快捷键页:重置按钮 await a.page.getByRole('button', { name: '快捷键' }).click(); await a.page.getByRole('button', { name: '重置默认' }).click(); await a.page.waitForTimeout(200); await a.app.close(); }); // === FileTree hover buttons + context menu === test('FileTree: 悬停按钮+右键菜单可用', async () => { const a = await launch(nodePath.join(os.tmpdir(), 'hn-ft-' + Date.now())); const { v } = await setupVault(a, [{ name: 'FTDoc', body: 'ft test' }]); await a.invoke('file:create', { basePath: '', name: 'FTFolder', isFolder: true }); await a.page.getByTitle('刷新').click(); await a.page.waitForTimeout(300); // 悬停文件夹行显示按钮——用 mouse.move 物理触发 hover const folderSpan = a.page.getByTestId('filetree').getByText('FTFolder', { exact: true }); const box = await folderSpan.boundingBox(); if (box) { await a.page.mouse.move(box.x + box.width / 2, box.y + box.height / 2); await a.page.waitForTimeout(300); } // 新建文档按钮(此时 hover 状态已激活,按钮已渲染) await a.page.getByTestId('filetree').getByTitle('新建文档').click(); await a.page.getByPlaceholder(/文档标题/).fill('SubDoc'); await a.page.getByPlaceholder(/文档标题/).press('Enter'); await a.page.waitForTimeout(300); // 右键菜单 await a.page.getByTestId('filetree').getByText('FTDoc', { exact: true }).click({ button: 'right' }); await a.page.waitForTimeout(300); await expect(a.page.getByText('重命名')).toBeVisible({ timeout: 3000 }); await a.page.keyboard.press('Escape'); await a.app.close(); }); // === Graph controls + tabs === test('Graph: 控件+模式切换可用', async () => { const a = await launch(nodePath.join(os.tmpdir(), 'hn-gc-' + Date.now())); const { v } = await setupVault(a, [{ name: 'G1', body: 'g1' }]); await a.page.getByTitle('切换 编辑器/图谱').click(); await a.page.waitForTimeout(300); // 思维导图/知识图谱 切换 await a.page.getByRole('button', { name: '知识图谱' }).click(); await a.page.waitForTimeout(1000); await expect(a.page.getByTestId('graph-controls')).toBeVisible({ timeout: 5000 }); // 放大/缩小/适配(用 evaluate 点击——测试窗口小,控件在视口边缘) await a.page.evaluate(() => { (document.querySelector('button[title="放大"]') as HTMLElement)?.click(); }); await a.page.waitForTimeout(200); await a.page.evaluate(() => { (document.querySelector('button[title="缩小"]') as HTMLElement)?.click(); }); await a.page.waitForTimeout(200); await a.page.evaluate(() => { (document.querySelector('[data-testid="fit-view-btn"]') as HTMLElement)?.click(); }); await a.page.waitForTimeout(200); // 切换回思维导图 await a.page.getByRole('button', { name: '思维导图' }).click(); await a.page.waitForTimeout(300); await a.app.close(); }); // === TabBar pin/close === test('TabBar: 固定+关闭可用', async () => { const a = await launch(nodePath.join(os.tmpdir(), 'hn-tab-' + Date.now())); const { v } = await setupVault(a, [{ name: 'Tab1', body: 't1' }, { name: 'Tab2', body: 't2' }]); await a.page.getByTestId('filetree').getByText('Tab1').click(); await a.page.waitForTimeout(300); await a.page.getByTestId('filetree').getByText('Tab2').click(); await a.page.waitForTimeout(300); // 固定 Tab1(pin 按钮是 span[title="固定"]●) const tab1 = a.page.getByTestId('tabbar').locator('div').filter({ hasText: 'Tab1' }).first(); await tab1.locator('span[title="固定"]').click(); await a.page.waitForTimeout(300); // 取消固定 await tab1.locator('span[title="取消固定"]').click(); await a.page.waitForTimeout(300); // 关闭 Tab2(close 按钮是 span 文本 x) const tab2 = a.page.getByTestId('tabbar').locator('div').filter({ hasText: 'Tab2' }).first(); await tab2.locator('span').filter({ hasText: 'x' }).click(); await a.page.waitForTimeout(300); await expect(a.page.getByTestId('tabbar').getByText('Tab2', { exact: true })).toHaveCount(0, { timeout: 3000 }); await a.app.close(); });