From 5a032b13ec1caaed651ff45d2c7fa2ef113068ec Mon Sep 17 00:00:00 2001 From: zzzjy Date: Thu, 24 Sep 2026 01:54:02 +0800 Subject: [PATCH] feat: align tab bar with terminal window under workspace hierarchy & add automated test suite - Restructure UI layout: tab bar and stage actions are now housed inside .workspace-stage (.workspace-header), aligning 100% with the terminal window (#main-viewport) below it - Establish clear workspace hierarchy: Global Titlebar -> Left Workspace Sidebar -> Active Workspace Content Stage (Tab Bar + Terminal Viewport) - Add active workspace indicator badge to titlebar header - Introduce comprehensive automated test suite (test/test-runner.cjs) covering PTY process lifecycle, workspace hierarchy, stage alignment, tab creation, split panes, shortcuts, and themes - Add 'npm test' script to package.json --- package.json | 1 + src/renderer/css/app.css | 86 +++++- src/renderer/css/tabs.css | 3 +- src/renderer/index.html | 66 +++-- src/renderer/js/workspace-manager.js | 7 +- test/test-runner.cjs | 405 +++++++++++++++++++++++++++ 6 files changed, 542 insertions(+), 26 deletions(-) create mode 100644 test/test-runner.cjs diff --git a/package.json b/package.json index 18aafd7..398fc7c 100644 --- a/package.json +++ b/package.json @@ -5,6 +5,7 @@ "main": "src/main/index.cjs", "scripts": { "start": "electron .", + "test": "electron test/test-runner.cjs", "rebuild": "electron-rebuild -f -w node-pty", "package:deb": "electron-builder --linux deb", "package:all": "electron-builder --linux deb AppImage", diff --git a/src/renderer/css/app.css b/src/renderer/css/app.css index 04f106f..64dd3d8 100644 --- a/src/renderer/css/app.css +++ b/src/renderer/css/app.css @@ -64,6 +64,46 @@ body { flex-shrink: 0; } +/* 顶部标题栏工作区标识 */ +.titlebar-workspace-tag { + display: inline-flex; + align-items: center; + gap: 6px; + padding: 2px 10px; + height: 22px; + background: var(--bg-tab-inactive); + border: 1px solid var(--border-color); + border-radius: 12px; + font-size: 11px; + color: var(--text-muted); + white-space: nowrap; + user-select: none; + -webkit-app-region: no-drag !important; + transition: all 0.15s ease; +} + +.titlebar-workspace-tag:hover { + background: var(--bg-tab-hover); + color: var(--text-main); + border-color: var(--border-active); +} + +.ws-tag-dot { + width: 6px; + height: 6px; + border-radius: 50%; + background: var(--accent-color); + box-shadow: 0 0 6px var(--accent-glow); + flex-shrink: 0; +} + +.ws-tag-name { + font-weight: 500; + max-width: 160px; + overflow: hidden; + text-overflow: ellipsis; +} + .titlebar-drag-spacer { flex: 1; height: 100%; @@ -153,9 +193,51 @@ body { color: #ffffff; } -/* 主内容区 */ -.main-viewport { +/* 工作区终端主舞台与标签页栏 (工作区子层级,与终端严格对齐) */ +.workspace-stage { flex: 1; + min-width: 0; + display: flex; + flex-direction: column; + position: relative; + overflow: hidden; + background: transparent; +} + +.workspace-header { + height: 38px; + min-height: 38px; + background: var(--bg-titlebar); + border-bottom: 1px solid var(--border-color); + display: flex; + align-items: center; + justify-content: space-between; + padding: 0 8px; + gap: 8px; + user-select: none; + z-index: 20; +} + +.tab-bar-container { + display: flex; + align-items: center; + gap: 6px; + flex: 1; + min-width: 0; + overflow: hidden; +} + +.stage-actions { + display: flex; + align-items: center; + gap: 4px; + flex-shrink: 0; +} + +/* 主内容区终端视口 */ +.main-viewport { + flex: 1; + min-height: 0; position: relative; overflow: hidden; background: transparent; diff --git a/src/renderer/css/tabs.css b/src/renderer/css/tabs.css index 9734e05..c8c57ab 100644 --- a/src/renderer/css/tabs.css +++ b/src/renderer/css/tabs.css @@ -5,7 +5,8 @@ gap: 4px; overflow-x: auto; scrollbar-width: none; - max-width: calc(100vw - 320px); + flex: 1; + min-width: 0; -webkit-app-region: no-drag !important; } diff --git a/src/renderer/index.html b/src/renderer/index.html index ff7bf94..9dc6186 100644 --- a/src/renderer/index.html +++ b/src/renderer/index.html @@ -36,14 +36,11 @@ hatty - -
- + +
+ + 本机电脑 (Local PC) +
@@ -51,18 +48,6 @@
- - - -
+ +
+ +
+
+
+ +
+ +
+ + + +
+
+ + +
+
diff --git a/src/renderer/js/workspace-manager.js b/src/renderer/js/workspace-manager.js index c8d9fc4..6645fea 100644 --- a/src/renderer/js/workspace-manager.js +++ b/src/renderer/js/workspace-manager.js @@ -270,7 +270,12 @@ export class WorkspaceManager { this.renderSidebar(); - // 更新底部状态栏信息 + // 更新顶部标题栏与底部状态栏信息 + const titlebarWsName = document.getElementById('titlebar-ws-name'); + if (titlebarWsName) { + titlebarWsName.textContent = ws.name; + } + const statusHost = document.getElementById('status-host'); if (statusHost) { if (ws.type === WORKSPACE_TYPES.SSH) { diff --git a/test/test-runner.cjs b/test/test-runner.cjs new file mode 100644 index 0000000..235b867 --- /dev/null +++ b/test/test-runner.cjs @@ -0,0 +1,405 @@ +/** + * 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); +});