/** * 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', })); ipcMain.handle('clipboard:read', () => { const { clipboard } = require('electron'); return clipboard.readText(); }); ipcMain.on('clipboard:write', (e, text) => { const { clipboard } = require('electron'); clipboard.writeText(text || ''); }); ipcMain.handle('pty:get-cwd', (e, { id }) => { return ptyManager ? ptyManager.getCwd({ id }) : null; }); function findExecutable(command) { const isWindows = process.platform === 'win32'; const exts = isWindows ? (process.env.PATHEXT || '.EXE;.CMD;.BAT').split(';') : ['']; const dirs = (process.env.PATH || '').split(path.delimiter); const extraDirs = [ path.join(os.homedir(), '.local/bin'), path.join(os.homedir(), '.npm-global/bin'), path.join(os.homedir(), '.cargo/bin'), '/usr/local/bin', '/usr/bin', '/bin', ]; const allDirs = [...new Set([...dirs, ...extraDirs])]; for (const dir of allDirs) { for (const ext of exts) { const fullPath = path.join(dir, command + ext); try { if (fs.existsSync(fullPath) && fs.statSync(fullPath).isFile()) { return fullPath; } } catch (_) {} } } return null; } ipcMain.handle('app:get-installed-agents', () => { const agentDefs = [ { id: 'agy', name: 'agy', cmd: 'agy', desc: 'Antigravity CLI Agent' }, { id: 'codex', name: 'codex', cmd: 'codex', desc: 'OpenAI CodeX CLI' }, { id: 'claude', name: 'claude', cmd: 'claude', desc: 'Claude Code Agent' }, ]; return agentDefs.map((agent) => { const execPath = findExecutable(agent.cmd); return { ...agent, path: execPath, installed: !!execPath, }; }); }); ipcMain.handle('fs:read-dir', async (e, { dirPath, showHidden = false } = {}) => { try { let targetPath = dirPath || '~'; if (targetPath === '~' || targetPath.startsWith('~/')) { targetPath = path.join(os.homedir(), targetPath.slice(targetPath === '~' ? 1 : 2)); } targetPath = path.resolve(targetPath); if (!fs.existsSync(targetPath)) { return { success: false, error: '目录不存在: ' + targetPath, dirPath: targetPath }; } const stat = await fs.promises.stat(targetPath); if (!stat.isDirectory()) { return { success: false, error: '路径不是有效目录: ' + targetPath, dirPath: targetPath }; } const dirents = await fs.promises.readdir(targetPath, { withFileTypes: true }); const entries = []; for (const d of dirents) { const isHidden = d.name.startsWith('.'); if (!showHidden && isHidden) continue; let isDirectory = d.isDirectory(); let isFile = d.isFile(); let isSymbolicLink = d.isSymbolicLink(); let size = 0; let mtime = 0; const fullItemPath = path.join(targetPath, d.name); if (isSymbolicLink) { try { const realStat = await fs.promises.stat(fullItemPath); isDirectory = realStat.isDirectory(); isFile = realStat.isFile(); size = realStat.size; mtime = realStat.mtimeMs; } catch (_) {} } else { try { if (isFile) { const fStat = await fs.promises.stat(fullItemPath); size = fStat.size; mtime = fStat.mtimeMs; } } catch (_) {} } entries.push({ name: d.name, path: fullItemPath, isDirectory, isFile, isSymbolicLink, isHidden, size, mtime, }); } entries.sort((a, b) => { if (a.isDirectory && !b.isDirectory) return -1; if (!a.isDirectory && b.isDirectory) return 1; return a.name.localeCompare(b.name, undefined, { sensitivity: 'base', numeric: true }); }); const parentPath = path.dirname(targetPath); return { success: true, dirPath: targetPath, parentPath: parentPath !== targetPath ? parentPath : null, entries, }; } catch (err) { return { success: false, error: err.message, dirPath }; } }); // ========================================== // 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: Merged Titlebar & Workspace 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('顶栏和标签页栏合二为一 (Merged Titlebar and Tab Bar)', async () => { const res = await win.webContents.executeJavaScript(` (() => { const titlebar = document.querySelector('.titlebar'); const tabBarContainer = document.getElementById('tab-bar-container'); const tabBar = document.getElementById('tab-bar'); const newTabBtn = document.getElementById('btn-new-tab'); const viewport = document.getElementById('main-viewport'); const hasOldStage = !!document.querySelector('.workspace-header'); return { titlebarHasContainer: titlebar?.contains(tabBarContainer), containerHasTabs: tabBarContainer?.contains(tabBar), containerHasBtn: tabBarContainer?.contains(newTabBtn), hasViewport: !!viewport, hasOldHeader: hasOldStage }; })() `); if (!res.titlebarHasContainer) throw new Error('#tab-bar-container must be inside .titlebar'); if (!res.containerHasTabs) throw new Error('#tab-bar must be inside #tab-bar-container'); if (!res.containerHasBtn) throw new Error('#btn-new-tab must be inside #tab-bar-container'); if (!res.hasViewport) throw new Error('Missing #main-viewport'); if (res.hasOldHeader) throw new Error('.workspace-header should be removed (merged into titlebar)'); }); await runner.test('程序启动时工作区侧边栏默认处于折叠状态', async () => { const isCollapsed = await win.webContents.executeJavaScript(` document.getElementById('workspace-sidebar').classList.contains('collapsed') && document.querySelector('.app-body').classList.contains('sidebar-collapsed') `); if (!isCollapsed) { throw new Error('Expected workspace sidebar to be collapsed by default on app launch'); } }); await runner.test('工作区展开时标签页和终端窗口左边像素级对齐', async () => { const res = await win.webContents.executeJavaScript(` (async () => { const sb = document.getElementById('workspace-sidebar'); if (sb) sb.style.transition = 'none'; window.terminalApp?.workspaceManager?.setCollapsed(false); await new Promise((r) => setTimeout(r, 50)); const tRect = document.getElementById('tab-bar-container').getBoundingClientRect(); const vRect = document.getElementById('main-viewport').getBoundingClientRect(); if (sb) sb.style.transition = ''; return { tabLeft: tRect.left, viewportLeft: vRect.left, leftDiff: Math.abs(tRect.left - vRect.left) }; })() `); if (res.leftDiff >= 1) { throw new Error(`Left mismatch: tabLeft=${res.tabLeft}, viewportLeft=${res.viewportLeft}`); } }); await runner.test('工作区关闭时标签页位于左侧且不强求与终端左边对齐', async () => { const res = await win.webContents.executeJavaScript(` (async () => { // 折叠侧边栏 window.terminalApp?.workspaceManager?.setCollapsed(true); await new Promise((r) => setTimeout(r, 260)); const tRect = document.getElementById('tab-bar-container').getBoundingClientRect(); const titlebarLeft = document.getElementById('titlebar-left').getBoundingClientRect(); const vRect = document.getElementById('main-viewport').getBoundingClientRect(); return { tabLeft: tRect.left, controlsRight: titlebarLeft.right, viewportLeft: vRect.left, distFromControls: Math.abs(tRect.left - titlebarLeft.right) }; })() `); if (res.distFromControls >= 2) { throw new Error(`Tab bar not immediately beside titlebar-left controls when collapsed: tabLeft=${res.tabLeft}, controlsRight=${res.controlsRight}`); } }); // ========================================== // 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}`); } }); // ========================================== // Suite 7: Terminal Copy & Paste Shortcuts // ========================================== runner.suite('7. 终端复制与粘贴快捷键 (Ctrl+Shift+C / Ctrl+Shift+V)'); await runner.test('Ctrl+Shift+C 能够成功复制终端选中的文本到剪贴板', async () => { const res = await win.webContents.executeJavaScript(` (async () => { const term = window.terminalApp?.tabManager?.getActiveTerminal(); if (!term) return { ok: false, error: 'No active terminal' }; term.xterm.write('TestSelectionText'); await new Promise((r) => setTimeout(r, 80)); term.xterm.selectAll(); const copied = await term.copySelection(); const clipboardText = await window.terminalAPI.readClipboard(); return { ok: copied, clipboardText }; })() `); if (!res.ok) throw new Error('copySelection returned false'); if (!res.clipboardText.includes('TestSelectionText')) { throw new Error(`Expected clipboard to contain 'TestSelectionText', got '${res.clipboardText}'`); } }); await runner.test('Ctrl+Shift+V 能够将剪贴板内容成功粘贴至终端', async () => { const res = await win.webContents.executeJavaScript(` (async () => { const term = window.terminalApp?.tabManager?.getActiveTerminal(); if (!term) return { ok: false, error: 'No active terminal' }; const testPasteString = 'echo "hello_clipboard"\\n'; window.terminalAPI.writeClipboard(testPasteString); let pastedData = ''; const disposable = term.xterm.onData((data) => { pastedData += data; }); const pasted = await term.pasteFromClipboard(); await new Promise((r) => setTimeout(r, 80)); disposable.dispose(); return { ok: pasted, pastedData }; })() `); if (!res.ok) throw new Error('pasteFromClipboard returned false'); if (!res.pastedData.includes('hello_clipboard')) { throw new Error(`Expected pastedData to include 'hello_clipboard', got '${res.pastedData}'`); } }); // ========================================== // Suite 8: 终端右键菜单与 Agent 分屏启动 // ========================================== runner.suite('8. 终端右键上下文菜单与 Agent 分屏'); await runner.test('在终端内点击右键能够正常弹出上下文菜单并展示已安装的 Agent', async () => { const res = await win.webContents.executeJavaScript(` (async () => { const term = window.terminalApp?.tabManager?.getActiveTerminal(); if (!term) return { ok: false, error: 'No active terminal' }; // 模拟在终端上触发 contextmenu term.terminalWrapper.dispatchEvent(new MouseEvent('contextmenu', { bubbles: true, cancelable: true, clientX: 300, clientY: 300, })); // 等待异步查询已安装 agent 并渲染 DOM await new Promise((r) => setTimeout(r, 120)); const menu = document.getElementById('terminal-context-menu'); const isActive = menu && menu.classList.contains('active') && menu.style.display !== 'none'; const copyItem = document.getElementById('ctx-copy'); const pasteItem = document.getElementById('ctx-paste'); const splitHItem = document.getElementById('ctx-split-h'); const splitVItem = document.getElementById('ctx-split-v'); const closeItem = document.getElementById('ctx-close-term'); const agyItem = document.getElementById('ctx-open-agy'); const codexItem = document.getElementById('ctx-open-codex'); const claudeItem = document.getElementById('ctx-open-claude'); return { ok: isActive, hasCopy: !!copyItem, hasPaste: !!pasteItem, hasSplitH: !!splitHItem, hasSplitV: !!splitVItem, hasClose: !!closeItem, hasAgy: !!agyItem, hasCodex: !!codexItem, hasClaude: !!claudeItem, }; })() `); if (!res.ok) throw new Error('Context menu is not visible after contextmenu event'); if (!res.hasCopy || !res.hasPaste || !res.hasSplitH || !res.hasSplitV || !res.hasClose) { throw new Error('Basic context menu items missing'); } // 检查已安装的 Agent (agy, codex, claude 中至少探测到安装的项) if (!res.hasAgy && !res.hasCodex && !res.hasClaude) { throw new Error('No installed agents appeared in context menu'); } }); await runner.test('点击已安装的 Agent 菜单项能够在右侧水平分屏新终端并启动该 Agent', async () => { const res = await win.webContents.executeJavaScript(` (async () => { const tab = window.terminalApp?.tabManager?.getActiveTab(); if (!tab) return { ok: false, error: 'No active tab' }; const initialTermCount = tab.splitTree.terminals.size; // 找到任一已展示的 Agent 按钮(优先 agy) const agentBtn = document.getElementById('ctx-open-agy') || document.getElementById('ctx-open-codex') || document.getElementById('ctx-open-claude'); if (!agentBtn) return { ok: false, error: 'No agent button found in context menu' }; // 点击打开 Agent agentBtn.click(); // 等待分屏创建及聚焦 await new Promise((r) => setTimeout(r, 200)); const newTermCount = tab.splitTree.terminals.size; const activeTerm = tab.splitTree.activeTerminal; // 验证父容器为 horizontal 分屏(即右侧分屏) const activeInfo = tab.splitTree.terminals.get(activeTerm?.id); const paneElement = activeInfo?.paneElement || activeTerm?.element?.closest('.split-pane'); const parentContainer = paneElement?.parentElement; const isHorizontal = parentContainer && parentContainer.classList.contains('horizontal'); // 验证新建窗口竖向占满整个视图高度(左右整体分屏) const tabViewRect = tab.viewElement.getBoundingClientRect(); const paneRect = paneElement ? paneElement.getBoundingClientRect() : { height: 0 }; const isFullHeight = Math.abs(paneRect.height - tabViewRect.height) <= 2; // 验证菜单已自动关闭 const menu = document.getElementById('terminal-context-menu'); const menuClosed = !menu.classList.contains('active') || menu.style.display === 'none'; return { ok: newTermCount === initialTermCount + 1, isHorizontal, isFullHeight, menuClosed, activeTitle: activeTerm?.title, initialTermCount, newTermCount, paneHeight: paneRect.height, viewHeight: tabViewRect.height, }; })() `); if (!res.ok) { throw new Error(`Terminal count did not increase: initial=${res.initialTermCount}, new=${res.newTermCount}`); } if (!res.isHorizontal) { throw new Error('Expected new agent terminal split container to have horizontal layout (split to the right)'); } if (!res.isFullHeight) { throw new Error(`Expected agent terminal to vertically fill the entire view height: paneHeight=${res.paneHeight}, viewHeight=${res.viewHeight}`); } if (!res.menuClosed) { throw new Error('Context menu should close after item click'); } }); await runner.test('右键菜单中的“关闭当前终端”在分屏状态下可正常点击并关闭该分屏终端', async () => { const res = await win.webContents.executeJavaScript(` (async () => { const tab = window.terminalApp?.tabManager?.getActiveTab(); if (!tab) return { ok: false, error: 'No active tab' }; const beforeTermCount = tab.splitTree.terminals.size; if (beforeTermCount <= 1) return { ok: false, error: 'Tab is not split' }; const activeTerm = tab.splitTree.activeTerminal; const closingTermId = activeTerm.id; // 在当前分屏终端上触发右键菜单 activeTerm.terminalWrapper.dispatchEvent(new MouseEvent('contextmenu', { bubbles: true, cancelable: true, clientX: 300, clientY: 300, })); await new Promise((r) => setTimeout(r, 120)); const closeBtn = document.getElementById('ctx-close-term'); if (!closeBtn) return { ok: false, error: 'ctx-close-term not found' }; if (closeBtn.classList.contains('disabled')) { return { ok: false, error: 'ctx-close-term is disabled in split mode' }; } // 点击关闭当前终端 closeBtn.click(); await new Promise((r) => setTimeout(r, 200)); const afterTermCount = tab.splitTree.terminals.size; const stillHasClosedTerm = tab.splitTree.terminals.has(closingTermId); return { ok: afterTermCount === beforeTermCount - 1 && !stillHasClosedTerm, beforeTermCount, afterTermCount, }; })() `); if (!res.ok) { throw new Error(`Failed to close split terminal via context menu: before=${res.beforeTermCount}, after=${res.afterTermCount}`); } }); await runner.test('右键菜单中的“关闭当前终端”在非分屏(单个终端)状态下处于禁用状态且不可关闭', async () => { const res = await win.webContents.executeJavaScript(` (async () => { const tab = window.terminalApp?.tabManager?.getActiveTab(); if (!tab) return { ok: false, error: 'No active tab' }; const beforeTermCount = tab.splitTree.terminals.size; const activeTerm = tab.splitTree.activeTerminal; // 在单个终端上触发右键菜单 activeTerm.terminalWrapper.dispatchEvent(new MouseEvent('contextmenu', { bubbles: true, cancelable: true, clientX: 300, clientY: 300, })); await new Promise((r) => setTimeout(r, 120)); const closeBtn = document.getElementById('ctx-close-term'); if (!closeBtn) return { ok: false, error: 'ctx-close-term not found' }; const isDisabled = closeBtn.classList.contains('disabled'); // 尝试点击禁用的关闭项 closeBtn.click(); await new Promise((r) => setTimeout(r, 100)); const afterTermCount = tab.splitTree.terminals.size; return { ok: isDisabled && afterTermCount === beforeTermCount, isDisabled, beforeTermCount, afterTermCount, }; })() `); if (!res.ok) { throw new Error(`Expected close item to be disabled and not close single terminal: isDisabled=${res.isDisabled}`); } }); // ========================================== // Suite 9: Agent 专属特化面板、多 Agent 小标签页与分屏按钮联动 // ========================================== runner.suite('9. Agent 专属特化面板、多 Agent 小标签页与分屏按钮联动'); await runner.test('Agent 专属特化面板:每个大标签页独占一个,多个 agent 在该面板内部以小标签页展示', async () => { const res = await win.webContents.executeJavaScript(` (async () => { const tab = window.terminalApp?.tabManager?.getActiveTab(); if (!tab) return { ok: false, error: 'No active tab' }; // 1. 打开第一个 Agent (agy) const term1 = tab.splitTree.openAgent({ id: 'agy', name: 'agy', cmd: 'agy' }); await new Promise((r) => setTimeout(r, 150)); const panel1Count = tab.viewElement.querySelectorAll('.agent-panel').length; const tabs1Count = tab.viewElement.querySelectorAll('.agent-tab').length; // 2. 打开第二个 Agent (codex) const term2 = tab.splitTree.openAgent({ id: 'codex', name: 'codex', cmd: 'codex' }); await new Promise((r) => setTimeout(r, 150)); const panel2Count = tab.viewElement.querySelectorAll('.agent-panel').length; const tabs2 = Array.from(tab.viewElement.querySelectorAll('.agent-tab')); const tabs2Count = tabs2.length; // 验证小标签页展示对应 agent 图标与隐藏名称 const hasIcons = tabs2.every((t) => !!t.querySelector('.agent-tab-icon svg')); const nameEls = Array.from(tab.viewElement.querySelectorAll('.agent-tab-name')); const nameHidden = nameEls.every((el) => window.getComputedStyle(el).display === 'none'); // 验证可以通过点击小标签页切换 active agent const agyTab = tabs2.find((t) => t.dataset.agentId === 'agy'); agyTab?.click(); await new Promise((r) => setTimeout(r, 50)); const activeAgentId = tab.splitTree.agentPanel?.activeAgentId; return { ok: panel1Count === 1 && panel2Count === 1 && tabs1Count === 1 && tabs2Count === 2, panel1Count, panel2Count, tabs1Count, tabs2Count, hasIcons, nameHidden, activeAgentId, }; })() `); if (!res.ok) { throw new Error(`Agent panel count or tabs count mismatch: panel1=${res.panel1Count}, panel2=${res.panel2Count}, tabs1=${res.tabs1Count}, tabs2=${res.tabs2Count}`); } if (!res.hasIcons) { throw new Error('Agent tabs must display agent svg icons'); } if (!res.nameHidden) { throw new Error('Agent tab name should be hidden (display: none) for compact icon-only representation'); } if (res.activeAgentId !== 'agy') { throw new Error(`Expected activeAgentId to be agy after click, got ${res.activeAgentId}`); } }); await runner.test('Agent 终端光标位置保持稳定:在输入与状态刷新时不发生意外前跳 (convertEol: false 验证)', async () => { const res = await win.webContents.executeJavaScript(` (async () => { const tab = window.terminalApp?.tabManager?.getActiveTab(); if (!tab || !tab.splitTree.agentPanel) return { ok: false, error: 'Agent panel missing' }; const agentTerm = tab.splitTree.agentPanel.getActiveTerminal(); if (!agentTerm) return { ok: false, error: 'No active agent terminal' }; // 模拟 agy 经典光标输入与状态重绘序列 agentTerm.xterm.reset(); await new Promise((r) => agentTerm.xterm.write('> abc', r)); const beforeX = agentTerm.xterm.buffer.active.cursorX; // agy 发送的状态行临时下移与相对光标回移序列 const delta = Math.min(10, Math.max(1, agentTerm.xterm.cols - 1 - beforeX)); await new Promise((r) => agentTerm.xterm.write('\\x1b[?25l\\n\\n\\x1b[' + delta + 'C\\x1b[1K\\x1b[2A\\x1b[' + delta + 'D\\x1b[?25h', r)); const afterX = agentTerm.xterm.buffer.active.cursorX; return { ok: beforeX === 5 && afterX === 5, beforeX, afterX, cols: agentTerm.xterm.cols, }; })() `); if (!res.ok) { throw new Error(`Cursor jumped incorrectly: expected position 5, but got before=${res.beforeX}, after=${res.afterX}`); } }); await runner.test('选中 Agent 面板时,顶栏分屏按钮变灰不可点击,分屏快捷键被拦截', async () => { const res = await win.webContents.executeJavaScript(` (async () => { const tab = window.terminalApp?.tabManager?.getActiveTab(); if (!tab || !tab.splitTree.agentPanel) return { ok: false, error: 'Agent panel missing' }; // 聚焦 Agent 终端 const agentTerm = tab.splitTree.agentPanel.getActiveTerminal(); tab.splitTree.setActiveTerminal(agentTerm); agentTerm.focus(); await new Promise((r) => setTimeout(r, 100)); const btnH = document.getElementById('btn-split-h'); const btnV = document.getElementById('btn-split-v'); const isHDisabled = btnH.disabled && btnH.classList.contains('disabled'); const isVDisabled = btnV.disabled && btnV.classList.contains('disabled'); // 尝试点击分屏按钮及触发快捷键 const initialCount = tab.splitTree.terminals.size; btnH.click(); btnV.click(); window.dispatchEvent(new KeyboardEvent('keydown', { key: 'e', ctrlKey: true, shiftKey: true, bubbles: true })); window.dispatchEvent(new KeyboardEvent('keydown', { key: 'o', ctrlKey: true, shiftKey: true, bubbles: true })); await new Promise((r) => setTimeout(r, 100)); const countAfterAttempt = tab.splitTree.terminals.size; // 切换聚焦回常规终端 const regInfo = Array.from(tab.splitTree.terminals.values()).find((t) => !t.termView.isAgent); if (regInfo) { tab.splitTree.setActiveTerminal(regInfo.termView); regInfo.termView.focus(); } await new Promise((r) => setTimeout(r, 100)); const isHRestored = !btnH.disabled && !btnH.classList.contains('disabled'); const isVRestored = !btnV.disabled && !btnV.classList.contains('disabled'); return { ok: isHDisabled && isVDisabled && countAfterAttempt === initialCount && isHRestored && isVRestored, isHDisabled, isVDisabled, initialCount, countAfterAttempt, isHRestored, isVRestored, }; })() `); if (!res.isHDisabled || !res.isVDisabled) { throw new Error(`Split buttons should be disabled when agent is focused: H=${res.isHDisabled}, V=${res.isVDisabled}`); } if (res.countAfterAttempt !== res.initialCount) { throw new Error('Terminal count increased despite split buttons/shortcuts being disabled'); } if (!res.isHRestored || !res.isVRestored) { throw new Error('Split buttons should be re-enabled when regular terminal is focused'); } }); await runner.test('Agent 面板内右键菜单特化:仅有关闭窗口功能,无分屏和打开终端功能', async () => { const res = await win.webContents.executeJavaScript(` (async () => { const tab = window.terminalApp?.tabManager?.getActiveTab(); if (!tab || !tab.splitTree.agentPanel) return { ok: false, error: 'Agent panel missing' }; const agentTerm = tab.splitTree.agentPanel.getActiveTerminal(); tab.splitTree.setActiveTerminal(agentTerm); // 在 Agent 终端上触发右键菜单 agentTerm.terminalWrapper.dispatchEvent(new MouseEvent('contextmenu', { bubbles: true, cancelable: true, clientX: 400, clientY: 300, })); await new Promise((r) => setTimeout(r, 120)); const menu = document.getElementById('terminal-context-menu'); const isMenuOpen = menu && menu.classList.contains('active') && menu.style.display !== 'none'; // 验证无向右分屏与向下分屏 const splitH = document.getElementById('ctx-split-h'); const splitV = document.getElementById('ctx-split-v'); // 验证无已安装 Agent 打开按钮或标题 const header = menu?.querySelector('.ctx-menu-header'); const agyBtn = document.getElementById('ctx-open-agy'); const codexBtn = document.getElementById('ctx-open-codex'); const claudeBtn = document.getElementById('ctx-open-claude'); // 验证有关闭窗口按钮且 label 为 "关闭窗口" const closeBtn = document.getElementById('ctx-close-term'); const closeLabel = closeBtn?.querySelector('.ctx-menu-label')?.textContent?.trim(); const closeDisabled = closeBtn?.classList.contains('disabled'); // 验证无清空当前屏幕 const clearBtn = document.getElementById('ctx-clear'); return { ok: isMenuOpen && !splitH && !splitV && !header && !agyBtn && !codexBtn && !claudeBtn && !!closeBtn && !closeDisabled && !clearBtn, isMenuOpen, hasSplitH: !!splitH, hasSplitV: !!splitV, hasHeader: !!header, hasAgy: !!agyBtn, hasCodex: !!codexBtn, hasClaude: !!claudeBtn, hasClose: !!closeBtn, hasClear: !!clearBtn, closeLabel, closeDisabled, }; })() `); if (!res.isMenuOpen) throw new Error('Context menu did not open on agent terminal'); if (res.hasSplitH || res.hasSplitV) { throw new Error('Agent terminal context menu must NOT contain split options (向右水平分屏/向下垂直分屏)'); } if (res.hasHeader || res.hasAgy || res.hasCodex || res.hasClaude) { throw new Error('Agent terminal context menu must NOT contain open agent/terminal options'); } if (res.hasClear) { throw new Error('Agent terminal context menu must NOT contain clear screen option (清空当前屏幕)'); } if (!res.hasClose || res.closeDisabled) { throw new Error('Agent terminal context menu must contain enabled close window item'); } if (res.closeLabel !== '关闭窗口') { throw new Error(`Expected close item label to be "关闭窗口", got "${res.closeLabel}"`); } }); await runner.test('在 Agent 面板中关闭 Agent 标签页,全部关闭后自动销毁面板并恢复主界面', async () => { const res = await win.webContents.executeJavaScript(` (async () => { const tab = window.terminalApp?.tabManager?.getActiveTab(); if (!tab || !tab.splitTree.agentPanel) return { ok: false, error: 'Agent panel missing' }; // 1. 关闭当前激活的 agent (通过右键菜单的关闭窗口) const closeBtn = document.getElementById('ctx-close-term'); closeBtn?.click(); await new Promise((r) => setTimeout(r, 200)); const tabsCountAfter1 = tab.viewElement.querySelectorAll('.agent-tab').length; const panelPresent1 = tab.viewElement.querySelectorAll('.agent-panel').length === 1; // 2. 关闭剩余的最后一个 agent (通过小标签上的关闭按钮) const lastCloseBtn = tab.viewElement.querySelector('.agent-tab .agent-tab-close'); lastCloseBtn?.click(); await new Promise((r) => setTimeout(r, 200)); const panelPresent2 = tab.viewElement.querySelectorAll('.agent-panel').length; const isAgentPanelNull = tab.splitTree.agentPanel === null; // 3. 验证分屏按钮已自动恢复可用 const btnH = document.getElementById('btn-split-h'); const btnV = document.getElementById('btn-split-v'); const isHRestored = !btnH.disabled && !btnH.classList.contains('disabled'); const isVRestored = !btnV.disabled && !btnV.classList.contains('disabled'); return { ok: tabsCountAfter1 === 1 && panelPresent1 && panelPresent2 === 0 && isAgentPanelNull && isHRestored && isVRestored, tabsCountAfter1, panelPresent1, panelPresent2, isAgentPanelNull, isHRestored, isVRestored, }; })() `); if (!res.ok) { throw new Error(`Failed to close agent tabs or destroy agent panel: tabsAfter1=${res.tabsCountAfter1}, panel1=${res.panelPresent1}, panel2=${res.panelPresent2}, isNull=${res.isAgentPanelNull}, H=${res.isHRestored}, V=${res.isVRestored}`); } }); await runner.test('Agent 进程退出后,对应 Agent 标签页/面板自动关闭且不残留普通终端', async () => { const res = await win.webContents.executeJavaScript(` (async () => { const tab = window.terminalApp?.tabManager?.getActiveTab(); if (!tab) return { ok: false, error: 'No active tab' }; // 1. 打开两个 Agent:一个长驻进程,一个快速退出进程 const stayTerm = tab.splitTree.openAgent( { id: 'stay-agent', name: 'stay-agent', cmd: 'stay-agent' }, { args: ['-c', 'exec sleep 10'] } ); const exitTerm = tab.splitTree.openAgent( { id: 'exit-agent', name: 'exit-agent', cmd: 'exit-agent' }, { args: ['-c', 'exec sleep 0.15'] } ); await new Promise((r) => setTimeout(r, 100)); const initialTabsCount = tab.splitTree.agentPanel?.tabs.size; const panelPresentBefore = !!tab.viewElement.querySelector('.agent-panel'); // 2. 等待快速退出的 agent 结束 (sleep 0.15s 退出,触发 pty:exit) await new Promise((r) => setTimeout(r, 450)); const tabsCountAfterFirstExit = tab.splitTree.agentPanel?.tabs.size; const hasExitAgent = tab.splitTree.agentPanel?.tabs.has('exit-agent'); const hasStayAgent = tab.splitTree.agentPanel?.tabs.has('stay-agent'); const activeAfterFirstExit = tab.splitTree.agentPanel?.activeAgentId; // 3. 结束长驻 agent 的进程,触发最后一块 agent 关闭 window.terminalAPI.killPty(stayTerm.id); await new Promise((r) => setTimeout(r, 250)); const panelPresentAfter = !!tab.viewElement.querySelector('.agent-panel'); const agentPanelIsNull = tab.splitTree.agentPanel === null; const activeTermIsNormal = tab.splitTree.activeTerminal && !tab.splitTree.activeTerminal.isAgent; return { ok: initialTabsCount === 2 && panelPresentBefore && tabsCountAfterFirstExit === 1 && !hasExitAgent && hasStayAgent && activeAfterFirstExit === 'stay-agent' && !panelPresentAfter && agentPanelIsNull && activeTermIsNormal, initialTabsCount, panelPresentBefore, tabsCountAfterFirstExit, hasExitAgent, hasStayAgent, activeAfterFirstExit, panelPresentAfter, agentPanelIsNull, activeTermIsNormal, }; })() `); if (!res.ok) { throw new Error(`Agent panel did not auto-close on process exit: ${JSON.stringify(res)}`); } }); // ========================================== // Suite 10: 文件浏览面板、默认终端同目录与显隐隐藏文件开关 // ========================================== runner.suite('10. 文件浏览面板、默认终端同目录与显隐隐藏文件开关'); await runner.test('点击顶栏文件浏览按钮 (#btn-file-browser) 能打开文件面板,默认展示终端同目录', async () => { const res = await win.webContents.executeJavaScript(` (async () => { const tab = window.terminalApp?.tabManager?.getActiveTab(); if (!tab) return { ok: false, error: 'No active tab' }; const term = tab.splitTree.activeTerminal; const termCwd = term?.cwd || ''; // 点击顶栏 #btn-file-browser 按钮 const btn = document.getElementById('btn-file-browser'); if (!btn) return { ok: false, error: 'btn-file-browser not found' }; btn.click(); await new Promise((r) => setTimeout(r, 250)); const panel = tab.viewElement.querySelector('.file-panel'); if (!panel) return { ok: false, error: 'file-panel not found in DOM' }; const pathEl = panel.querySelector('.file-panel-path'); const currentPath = pathEl ? pathEl.textContent.trim() : ''; const items = Array.from(panel.querySelectorAll('.file-item')); const dirItems = Array.from(panel.querySelectorAll('.file-item.is-directory')); const fileItems = Array.from(panel.querySelectorAll('.file-item.is-file')); return { ok: !!panel && items.length > 0, hasPanel: !!panel, currentPath, termCwd, totalItems: items.length, dirCount: dirItems.length, fileCount: fileItems.length, }; })() `); if (!res.ok) { throw new Error(`File panel failed to open or load items: hasPanel=${res.hasPanel}, items=${res.totalItems}, path=${res.currentPath}`); } }); await runner.test('文件浏览面板每个标签页唯一 (独占一个且多标签页独立)', async () => { const res = await win.webContents.executeJavaScript(` (async () => { const tm = window.terminalApp?.tabManager; const tab1 = tm?.getActiveTab(); if (!tab1) return { ok: false, error: 'No active tab' }; // 验证 Tab 1 内部只有 1 个 file-panel const panelCountTab1 = tab1.viewElement.querySelectorAll('.file-panel').length; // 再次调用 openFilePanel,验证依然只有 1 个 file-panel tab1.splitTree.openFilePanel(); const panelCountTab1After = tab1.viewElement.querySelectorAll('.file-panel').length; // 新建 Tab 2 const tab2Id = tm.createTab({ title: 'Tab 2' }); await new Promise((r) => setTimeout(r, 200)); const tab2 = tm.tabs.get(tab2Id); // 验证 Tab 2 初始无 file-panel const panelCountTab2Initial = tab2.viewElement.querySelectorAll('.file-panel').length; // 在 Tab 2 中打开文件面板 tab2.splitTree.openFilePanel(); await new Promise((r) => setTimeout(r, 200)); const panelCountTab2After = tab2.viewElement.querySelectorAll('.file-panel').length; // 切换回 Tab 1 tm.switchTab(tab1.tabId); await new Promise((r) => setTimeout(r, 100)); const panelCountTab1Final = tab1.viewElement.querySelectorAll('.file-panel').length; return { ok: panelCountTab1 === 1 && panelCountTab1After === 1 && panelCountTab2Initial === 0 && panelCountTab2After === 1 && panelCountTab1Final === 1, panelCountTab1, panelCountTab1After, panelCountTab2Initial, panelCountTab2After, panelCountTab1Final, }; })() `); if (!res.ok) { throw new Error(`File panel uniqueness check failed: Tab1=${res.panelCountTab1}->${res.panelCountTab1After}, Tab2=${res.panelCountTab2Initial}->${res.panelCountTab2After}, Tab1Final=${res.panelCountTab1Final}`); } }); await runner.test('显隐隐藏文件开关 (#file-toggle-hidden) 能够正确切换隐藏文件显示状态', async () => { const res = await win.webContents.executeJavaScript(` (async () => { const tab = window.terminalApp?.tabManager?.getActiveTab(); if (!tab || !tab.splitTree.filePanel) return { ok: false, error: 'File panel not active' }; const fp = tab.splitTree.filePanel; const toggleBtn = fp.paneElement.querySelector('.file-toggle-hidden'); if (!toggleBtn) return { ok: false, error: 'file-toggle-hidden button missing' }; // 初始状态:不显示隐藏文件 const initialShowHidden = fp.showHidden; const initialHiddenItems = fp.paneElement.querySelectorAll('.file-item.is-hidden').length; // 点击切换开关:开启显示隐藏文件 toggleBtn.click(); await new Promise((r) => setTimeout(r, 200)); const stateAfterShow = fp.showHidden; const btnHasActiveClass = toggleBtn.classList.contains('active'); const hiddenItemsAfterShow = fp.paneElement.querySelectorAll('.file-item.is-hidden').length; // 再次点击切换开关:关闭显示隐藏文件 toggleBtn.click(); await new Promise((r) => setTimeout(r, 200)); const stateAfterHide = fp.showHidden; const btnHasNoActive = !toggleBtn.classList.contains('active'); const hiddenItemsAfterHide = fp.paneElement.querySelectorAll('.file-item.is-hidden').length; return { ok: !initialShowHidden && initialHiddenItems === 0 && stateAfterShow && btnHasActiveClass && hiddenItemsAfterShow > 0 && !stateAfterHide && btnHasNoActive && hiddenItemsAfterHide === 0, initialShowHidden, initialHiddenItems, stateAfterShow, btnHasActiveClass, hiddenItemsAfterShow, stateAfterHide, btnHasNoActive, hiddenItemsAfterHide, }; })() `); if (!res.ok) { throw new Error(`Hidden files toggle test failed: initial=${res.initialShowHidden}, afterShow=${res.stateAfterShow}(items=${res.hiddenItemsAfterShow}), afterHide=${res.stateAfterHide}(items=${res.hiddenItemsAfterHide})`); } }); await runner.test('文件面板支持进入子目录与返回上级目录导航', async () => { const res = await win.webContents.executeJavaScript(` (async () => { const tab = window.terminalApp?.tabManager?.getActiveTab(); if (!tab || !tab.splitTree.filePanel) return { ok: false, error: 'File panel not active' }; const fp = tab.splitTree.filePanel; const initialPath = fp.currentPath; // 找到一个文件夹项 (例如 src 或 test) const dirItem = fp.paneElement.querySelector('.file-item.is-directory'); if (!dirItem) return { ok: false, error: 'No directory item found in list' }; const targetDirName = dirItem.querySelector('.file-item-name')?.textContent?.trim(); // 点击文件夹项进入该子目录 dirItem.click(); await new Promise((r) => setTimeout(r, 200)); const subPath = fp.currentPath; const pathMatchesSub = subPath.endsWith(targetDirName); // 点击“返回上一级”按钮 const upBtn = fp.paneElement.querySelector('.file-up-btn'); upBtn?.click(); await new Promise((r) => setTimeout(r, 200)); const restoredPath = fp.currentPath; return { ok: pathMatchesSub && restoredPath === initialPath, initialPath, subPath, restoredPath, targetDirName, }; })() `); if (!res.ok) { throw new Error(`Directory navigation failed: initial=${res.initialPath}, sub=${res.subPath}, restored=${res.restoredPath}`); } }); await runner.test('关闭文件面板与快捷键 (Ctrl+Shift+U) 能够切换显隐并恢复布局', async () => { const res = await win.webContents.executeJavaScript(` (async () => { const tab = window.terminalApp?.tabManager?.getActiveTab(); if (!tab || !tab.splitTree.filePanel) return { ok: false, error: 'File panel not active' }; const fp = tab.splitTree.filePanel; // 点击面板右上角关闭按钮 const closeBtn = fp.paneElement.querySelector('.file-panel-close-btn'); closeBtn?.click(); await new Promise((r) => setTimeout(r, 150)); const panelPresentAfterClose = tab.viewElement.querySelectorAll('.file-panel').length; const isNullAfterClose = tab.splitTree.filePanel === null; // 使用快捷键 Ctrl+Shift+U 再次打开 window.dispatchEvent(new KeyboardEvent('keydown', { key: 'u', ctrlKey: true, shiftKey: true, bubbles: true })); await new Promise((r) => setTimeout(r, 200)); const panelPresentAfterShortcut = tab.viewElement.querySelectorAll('.file-panel').length; // 再次使用快捷键 Ctrl+Shift+U 关闭 window.dispatchEvent(new KeyboardEvent('keydown', { key: 'u', ctrlKey: true, shiftKey: true, bubbles: true })); await new Promise((r) => setTimeout(r, 150)); const panelPresentFinal = tab.viewElement.querySelectorAll('.file-panel').length; return { ok: panelPresentAfterClose === 0 && isNullAfterClose && panelPresentAfterShortcut === 1 && panelPresentFinal === 0, panelPresentAfterClose, isNullAfterClose, panelPresentAfterShortcut, panelPresentFinal, }; })() `); if (!res.ok) { throw new Error(`File panel toggle & shortcut failed: afterClose=${res.panelPresentAfterClose}, afterShortcut=${res.panelPresentAfterShortcut}, final=${res.panelPresentFinal}`); } }); // 运行总结 const exitCode = runner.summary(); ptyManager.killAll(); app.quit(); process.exit(exitCode); });