Files
hatty/test/test-runner.cjs
zzzjy 3589e5c927
Build and Release / Build & Release DEB Package (push) Successful in 3m27s
feat: add agent panel, file panel, context menu, fix agy cursor, and bump to v1.1.0
- Agent panel: dedicated panel per tab with compact icon-only agent tabs (agy with Google colors, codex with ChatGPT icon, claude)
- Agent lifecycle: auto-close tab and panel when agent process exits without leaving a shell
- Fix cursor jumping: disable convertEol and ensure precise viewport size calculation
- File browser panel: single instance per tab, directory navigation, hidden files toggle
- Context menu: terminal right-click menu with split options, agent launcher, and close pane
- Terminal shortcuts: Ctrl+Shift+C / Ctrl+Shift+V copy and paste support
- Test suite: 35 automated end-to-end tests covering all new features
2026-09-25 01:45:51 +08:00

1421 lines
57 KiB
JavaScript

/**
* 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('点击标签关闭按钮 (&times;) 正确关闭标签', 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);
});