- Merge titlebar and tab bar into a single unified top bar (.titlebar) - Remove redundant floating workspace button that caused overlap on collapsed sidebar - Ensure tab bar left aligns with terminal window when workspace sidebar is expanded - Allow tab bar to sit naturally at leftmost position when workspace sidebar is collapsed - Update test runner suite to cover merged titlebar and alignment behavior
424 lines
17 KiB
JavaScript
424 lines
17 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',
|
|
}));
|
|
|
|
// ==========================================
|
|
// 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 res = await win.webContents.executeJavaScript(`
|
|
(() => {
|
|
const tRect = document.getElementById('tab-bar-container').getBoundingClientRect();
|
|
const vRect = document.getElementById('main-viewport').getBoundingClientRect();
|
|
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(`
|
|
(() => {
|
|
// 折叠侧边栏
|
|
window.terminalApp?.workspaceManager?.setCollapsed(true);
|
|
const tRect = document.getElementById('tab-bar-container').getBoundingClientRect();
|
|
const titlebarLeft = document.getElementById('titlebar-left').getBoundingClientRect();
|
|
const vRect = document.getElementById('main-viewport').getBoundingClientRect();
|
|
// 恢复展开状态供后续测试
|
|
window.terminalApp?.workspaceManager?.setCollapsed(false);
|
|
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}`);
|
|
}
|
|
});
|
|
|
|
// 运行总结
|
|
const exitCode = runner.summary();
|
|
ptyManager.killAll();
|
|
app.quit();
|
|
process.exit(exitCode);
|
|
});
|