Build and Release / Build & Release DEB Package (push) Successful in 3m27s
- 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
1421 lines
57 KiB
JavaScript
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('点击标签关闭按钮 (×) 正确关闭标签', 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);
|
|
});
|