350 lines
18 KiB
TypeScript
350 lines
18 KiB
TypeScript
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+Shift+`').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+Shift+]').click(); await a.page.waitForTimeout(100);
|
||
await a.page.getByTitle('有序列表 Ctrl+Shift+[').click(); await a.page.waitForTimeout(100);
|
||
// 代码块
|
||
await a.page.getByTitle('代码块 Ctrl+Shift+K').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 });
|
||
// 关联文档(快速双向关联:下拉为文档名,按钮 title 建立双向关联)
|
||
const select = a.page.locator('select').first();
|
||
await select.selectOption('DocB');
|
||
await a.page.getByRole('button', { name: '关联', exact: true }).click();
|
||
await a.page.waitForTimeout(300);
|
||
await expect(a.page.getByTitle('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 close ===
|
||
test('TabBar: 关闭标签可用(pin 点已移除)', 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);
|
||
// 关闭 Tab2(close 按钮是 span 文本 x;pin 点按钮已按 UI 需求移除)
|
||
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();
|
||
});
|
||
|
||
test('TabBar: 关闭全部标签后编辑器应显示空状态', async () => {
|
||
const a = await launch(nodePath.join(os.tmpdir(), 'hn-close-all-' + Date.now()));
|
||
await setupVault(a, [{ name: 'CloseAllTest', body: '# 关闭全部测试\n正文内容' }]);
|
||
// 打开文档
|
||
await a.page.getByTestId('filetree').getByText('CloseAllTest', { exact: true }).click();
|
||
await expect(a.page.getByTestId('tabbar').getByText('CloseAllTest', { exact: true })).toBeVisible();
|
||
// 编辑器应显示文档内容
|
||
await expect(a.page.locator('.ProseMirror')).toContainText('关闭全部测试');
|
||
// 关闭标签页(span 文本 x)
|
||
const tab = a.page.getByTestId('tabbar').locator('div').filter({ hasText: 'CloseAllTest' }).first();
|
||
await tab.locator('span').filter({ hasText: 'x' }).click();
|
||
await a.page.waitForTimeout(500);
|
||
// 标签栏为空 → 当前文档应重置,编辑器显示空状态提示
|
||
await expect.poll(() => a.page.evaluate(() => (globalThis as any).__STORE__?.getState?.().currentPath), { timeout: 5000 }).toBeNull();
|
||
await expect(a.page.getByText('从左侧选择一篇笔记')).toBeVisible({ timeout: 5000 });
|
||
await a.app.close();
|
||
});
|
||
test('OutlinePanel: 切换文档后大纲自动更新', async () => {
|
||
const a = await launch(nodePath.join(os.tmpdir(), 'hn-outline-' + Date.now()));
|
||
await setupVault(a, [
|
||
{ name: 'DocA', body: '# Title A\n## Sub A' },
|
||
{ name: 'DocB', body: '# Title B\n## Sub B\n### Deep B' },
|
||
]);
|
||
await a.page.getByTestId('filetree').getByText('DocA', { exact: true }).click();
|
||
await a.page.waitForTimeout(500);
|
||
await expect(a.page.locator('.outline-item', { hasText: 'Title A' })).toBeVisible({ timeout: 5000 });
|
||
await expect(a.page.locator('.outline-item', { hasText: 'Sub A' })).toBeVisible({ timeout: 5000 });
|
||
await a.page.getByTestId('filetree').getByText('DocB', { exact: true }).click();
|
||
await a.page.waitForTimeout(800);
|
||
await expect(a.page.locator('.outline-item', { hasText: 'Title B' })).toBeVisible({ timeout: 5000 });
|
||
await expect(a.page.locator('.outline-item', { hasText: 'Deep B' })).toBeVisible({ timeout: 5000 });
|
||
await a.app.close();
|
||
}); |