/** * hatty 自动化测试套件 (Automated Test Suite Runner) * 运行方式: npm test 或 electron test/test-runner.cjs */ const { app, BrowserWindow, ipcMain } = require('electron'); const path = require('path'); const os = require('os'); const fs = require('fs'); const baseDir = path.resolve(__dirname, '..'); const { PtyManager } = require(path.join(baseDir, 'src/main/pty-manager.cjs')); const colors = { reset: '\x1b[0m', bold: '\x1b[1m', green: '\x1b[32m', red: '\x1b[31m', yellow: '\x1b[33m', cyan: '\x1b[36m', gray: '\x1b[90m', }; class TestRunner { constructor() { this.passed = 0; this.failed = 0; this.currentSuite = ''; this.startTime = Date.now(); } suite(name) { this.currentSuite = name; console.log(`\n${colors.bold}${colors.cyan}▶ ${name}${colors.reset}`); } async test(name, fn) { const t0 = Date.now(); try { await fn(); const elapsed = Date.now() - t0; this.passed++; console.log(` ${colors.green}✔${colors.reset} ${name} ${colors.gray}(${elapsed}ms)${colors.reset}`); } catch (err) { const elapsed = Date.now() - t0; this.failed++; console.log(` ${colors.red}✖${colors.reset} ${name} ${colors.gray}(${elapsed}ms)${colors.reset}`); console.error(` ${colors.red}${err.message}${colors.reset}`); if (err.stack) { const stackLine = err.stack.split('\n')[1] || ''; console.error(` ${colors.gray}${stackLine.trim()}${colors.reset}`); } } } summary() { const totalTime = ((Date.now() - this.startTime) / 1000).toFixed(2); const total = this.passed + this.failed; console.log(`\n${colors.bold}───────────────────────────────────────────────${colors.reset}`); if (this.failed === 0) { console.log( `${colors.bold}${colors.green}测试全部通过! ${this.passed}/${total} 个用例成功 ${colors.reset}${colors.gray}(耗时 ${totalTime}s)${colors.reset}\n` ); return 0; } else { console.log( `${colors.bold}${colors.red}测试失败: ${this.failed} 失败, ${this.passed} 通过, 共 ${total} 个用例 ${colors.reset}${colors.gray}(耗时 ${totalTime}s)${colors.reset}\n` ); return 1; } } } app.whenReady().then(async () => { const runner = new TestRunner(); // 创建离屏/测试窗口 const win = new BrowserWindow({ show: false, width: 1100, height: 700, webPreferences: { preload: path.join(baseDir, 'src/preload/index.cjs'), contextIsolation: true, nodeIntegration: false, }, }); const ptyManager = new PtyManager(win); ipcMain.handle('pty:create', (e, opt) => ptyManager.create(opt)); ipcMain.on('pty:write', (e, opt) => ptyManager.write(opt)); ipcMain.on('pty:resize', (e, opt) => ptyManager.resize(opt)); ipcMain.on('pty:kill', (e, opt) => ptyManager.kill(opt)); ipcMain.handle('app:get-system-info', () => ({ platform: process.platform, shell: process.env.SHELL || '/bin/bash', })); // ========================================== // Suite 1: PTY Engine & Path Resolution // ========================================== runner.suite('1. PTY 底层进程引擎与路径解析'); await runner.test('PtyManager 应当正确将 ~ 展开为当前系统用户目录', async () => { let resolvedCwd = '~'; if (resolvedCwd === '~') resolvedCwd = os.homedir(); if (resolvedCwd !== os.homedir()) throw new Error(`Expected ${os.homedir()}, got ${resolvedCwd}`); }); await runner.test('PtyManager 应当正确展开 ~/子目录', async () => { const input = '~/projects'; const expected = path.join(os.homedir(), 'projects'); const resolved = path.join(os.homedir(), input.slice(2)); if (resolved !== expected) throw new Error(`Expected ${expected}, got ${resolved}`); }); await runner.test('PtyManager 对不存在路径安全回退到用户家目录或当前目录', async () => { const nonExistent = '/non/existent/path/for/hatty/testing'; let target = nonExistent; if (!fs.existsSync(target)) { target = os.homedir() || process.cwd(); } if (!fs.existsSync(target)) throw new Error('Target path does not exist'); }); await runner.test('PtyManager 能够成功创建 PTY 终端会话并返回 pid', async () => { const res = ptyManager.create({ id: 'test_term_spawn', cols: 80, rows: 24, cwd: os.homedir(), shell: '/bin/bash', args: [], }); if (!res || !res.success || !res.pid) { throw new Error(`Failed to create PTY: ${JSON.stringify(res)}`); } ptyManager.kill({ id: 'test_term_spawn' }); }); // 加载渲染层页面 await win.loadFile(path.join(baseDir, 'src/renderer/index.html')); await new Promise((r) => setTimeout(r, 600)); // 重置 localStorage 状态确保纯净测试环境 await win.webContents.executeJavaScript(` localStorage.removeItem('hatty:sidebar-collapsed'); localStorage.removeItem('hatty:active-workspace'); localStorage.removeItem('hatty:workspaces'); `); // ========================================== // Suite 2: Workspace Hierarchy & Stage Alignment // ========================================== runner.suite('2. 工作区层级结构与视口对齐验证 (UI Architecture)'); await runner.test('标题栏与工作区标识层级正确 (Titlebar Header)', async () => { const res = await win.webContents.executeJavaScript(` (() => { const titlebar = document.querySelector('.titlebar'); const tag = document.getElementById('titlebar-workspace-tag'); const name = document.getElementById('titlebar-ws-name')?.textContent; return { hasTitlebar: !!titlebar, hasTag: !!tag, wsName: name }; })() `); if (!res.hasTitlebar) throw new Error('Missing .titlebar'); if (!res.hasTag) throw new Error('Missing #titlebar-workspace-tag'); if (!res.wsName) throw new Error('Active workspace tag name is empty'); }); await runner.test('工作区终端舞台包含独立头部标签条与视口 (.workspace-stage)', async () => { const res = await win.webContents.executeJavaScript(` (() => { const stage = document.querySelector('.workspace-stage'); const header = document.querySelector('.workspace-header'); const viewport = document.getElementById('main-viewport'); const tabBar = document.getElementById('tab-bar'); const newTabBtn = document.getElementById('btn-new-tab'); return { hasStage: !!stage, hasHeader: !!header, hasViewport: !!viewport, tabsInHeader: header?.contains(tabBar), btnInHeader: header?.contains(newTabBtn), }; })() `); if (!res.hasStage) throw new Error('Missing .workspace-stage container'); if (!res.hasHeader) throw new Error('Missing .workspace-header'); if (!res.hasViewport) throw new Error('Missing #main-viewport'); if (!res.tabsInHeader) throw new Error('#tab-bar must be inside .workspace-header'); if (!res.btnInHeader) throw new Error('#btn-new-tab must be inside .workspace-header'); }); await runner.test('标签栏与终端窗口边缘完全像素级对齐 (100% Alignment)', async () => { const res = await win.webContents.executeJavaScript(` (() => { const hRect = document.querySelector('.workspace-header').getBoundingClientRect(); const vRect = document.getElementById('main-viewport').getBoundingClientRect(); return { headerLeft: hRect.left, viewportLeft: vRect.left, headerWidth: hRect.width, viewportWidth: vRect.width, leftDiff: Math.abs(hRect.left - vRect.left), widthDiff: Math.abs(hRect.width - vRect.width) }; })() `); if (res.leftDiff >= 1) { throw new Error(`Left mismatch: header.left=${res.headerLeft}, viewport.left=${res.viewportLeft}`); } if (res.widthDiff >= 1) { throw new Error(`Width mismatch: header.width=${res.headerWidth}, viewport.width=${res.viewportWidth}`); } }); // ========================================== // Suite 3: Tabs & Workspace Isolation // ========================================== runner.suite('3. 标签页创建、切换、关闭与工作区隔离'); await runner.test('真实鼠标点击 #btn-new-tab 能成功新建标签页', async () => { const coords = await win.webContents.executeJavaScript(` (() => { const btn = document.getElementById('btn-new-tab'); const r = btn.getBoundingClientRect(); return { x: Math.round(r.x + r.width / 2), y: Math.round(r.y + r.height / 2) }; })() `); const initial = await win.webContents.executeJavaScript(`document.getElementById('tab-bar').children.length`); win.webContents.sendInputEvent({ type: 'mouseMove', x: coords.x, y: coords.y }); await new Promise((r) => setTimeout(r, 40)); win.webContents.sendInputEvent({ type: 'mouseDown', x: coords.x, y: coords.y, button: 'left', clickCount: 1 }); await new Promise((r) => setTimeout(r, 40)); win.webContents.sendInputEvent({ type: 'mouseUp', x: coords.x, y: coords.y, button: 'left', clickCount: 1 }); await new Promise((r) => setTimeout(r, 500)); const after = await win.webContents.executeJavaScript(`document.getElementById('tab-bar').children.length`); if (after !== initial + 1) { throw new Error(`Expected tab count to increase from ${initial} to ${initial + 1}, but got ${after}`); } }); await runner.test('快捷键 Ctrl+Shift+T 触发新建标签页', async () => { const initial = await win.webContents.executeJavaScript(`document.getElementById('tab-bar').children.length`); win.webContents.sendInputEvent({ type: 'keyDown', keyCode: 'T', modifiers: ['control', 'shift'] }); await new Promise((r) => setTimeout(r, 40)); win.webContents.sendInputEvent({ type: 'keyUp', keyCode: 'T', modifiers: ['control', 'shift'] }); await new Promise((r) => setTimeout(r, 500)); const after = await win.webContents.executeJavaScript(`document.getElementById('tab-bar').children.length`); if (after !== initial + 1) { throw new Error(`Expected tab count ${initial + 1}, got ${after}`); } }); await runner.test('点击不同标签项能正确切换活跃状态', async () => { const res = await win.webContents.executeJavaScript(` (() => { const tabs = document.querySelectorAll('.tab-item'); if (tabs.length < 2) return { ok: false, reason: 'insufficient tabs' }; tabs[0].click(); return { ok: true, tab0Active: tabs[0].classList.contains('active'), tab1Active: tabs[1].classList.contains('active') }; })() `); if (!res.ok) throw new Error(res.reason); if (!res.tab0Active || res.tab1Active) { throw new Error('Tab 0 was not activated or tab 1 remained active'); } }); await runner.test('点击标签关闭按钮 (×) 正确关闭标签', async () => { const beforeCount = await win.webContents.executeJavaScript(`document.getElementById('tab-bar').children.length`); const afterCount = await win.webContents.executeJavaScript(` (() => { const tabs = document.querySelectorAll('.tab-item'); const closeBtn = tabs[tabs.length - 1].querySelector('.tab-close'); closeBtn.click(); return document.getElementById('tab-bar').children.length; })() `); if (afterCount !== beforeCount - 1) { throw new Error(`Expected ${beforeCount - 1} tabs after close, got ${afterCount}`); } }); // ========================================== // Suite 4: Sidebar & Workspace Switching // ========================================== runner.suite('4. 工作区切换与侧边栏折叠'); await runner.test('Ctrl+B 能够顺畅折叠与展开左侧栏', async () => { const initialCollapsed = await win.webContents.executeJavaScript(` document.getElementById('workspace-sidebar').classList.contains('collapsed') `); // Toggle once win.webContents.sendInputEvent({ type: 'keyDown', keyCode: 'B', modifiers: ['control'] }); await new Promise((r) => setTimeout(r, 40)); win.webContents.sendInputEvent({ type: 'keyUp', keyCode: 'B', modifiers: ['control'] }); await new Promise((r) => setTimeout(r, 300)); const toggled = await win.webContents.executeJavaScript(` document.getElementById('workspace-sidebar').classList.contains('collapsed') `); if (toggled === initialCollapsed) { throw new Error(`Sidebar collapse state did not toggle (was ${initialCollapsed})`); } // Toggle back win.webContents.sendInputEvent({ type: 'keyDown', keyCode: 'B', modifiers: ['control'] }); await new Promise((r) => setTimeout(r, 40)); win.webContents.sendInputEvent({ type: 'keyUp', keyCode: 'B', modifiers: ['control'] }); await new Promise((r) => setTimeout(r, 300)); }); await runner.test('侧边栏展现 3 个默认工作区 (PC、SSH、Serial)', async () => { const count = await win.webContents.executeJavaScript(` document.getElementById('sidebar-workspace-list').children.length `); if (count !== 3) { throw new Error(`Expected 3 workspaces in sidebar, found ${count}`); } }); // ========================================== // Suite 5: Terminal Split Screens // ========================================== runner.suite('5. 终端分屏快捷操作 (Split Panes)'); await runner.test('向右水平分屏按钮 (#btn-split-h) 能够正确生成并列窗格', async () => { const initialPanes = await win.webContents.executeJavaScript(` document.querySelectorAll('.tab-view.active .split-pane').length `); await win.webContents.executeJavaScript(` document.getElementById('btn-split-h').click(); `); await new Promise((r) => setTimeout(r, 400)); const afterPanes = await win.webContents.executeJavaScript(` document.querySelectorAll('.tab-view.active .split-pane').length `); if (afterPanes !== initialPanes + 1) { throw new Error(`Expected ${initialPanes + 1} panes after split, got ${afterPanes}`); } }); await runner.test('快捷键 Ctrl+Shift+W 能够关闭当前聚焦窗格', async () => { const initialPanes = await win.webContents.executeJavaScript(` document.querySelectorAll('.tab-view.active .split-pane').length `); win.webContents.sendInputEvent({ type: 'keyDown', keyCode: 'W', modifiers: ['control', 'shift'] }); await new Promise((r) => setTimeout(r, 40)); win.webContents.sendInputEvent({ type: 'keyUp', keyCode: 'W', modifiers: ['control', 'shift'] }); await new Promise((r) => setTimeout(r, 300)); const afterPanes = await win.webContents.executeJavaScript(` document.querySelectorAll('.tab-view.active .split-pane').length `); if (afterPanes !== initialPanes - 1) { throw new Error(`Expected ${initialPanes - 1} panes after Ctrl+Shift+W, got ${afterPanes}`); } }); // ========================================== // Suite 6: Theme Switching & Status Bar // ========================================== runner.suite('6. 主题切换与状态栏响应'); await runner.test('支持切换浅色和暗色主题并正确设置 document[data-theme]', async () => { const result = await win.webContents.executeJavaScript(` (() => { const doc = document.documentElement; const initial = doc.getAttribute('data-theme'); // 动态切换到 catppuccin-latte doc.setAttribute('data-theme', 'catppuccin-latte'); const updated = doc.getAttribute('data-theme'); return { initial, updated }; })() `); if (result.updated !== 'catppuccin-latte') { throw new Error(`Failed to update data-theme attribute: ${result.updated}`); } }); // 运行总结 const exitCode = runner.summary(); ptyManager.killAll(); app.quit(); process.exit(exitCode); });