Files
hatty/test/test-runner.cjs
T
zzzjy 2db388c2f2 feat: merge titlebar and tab bar into single header with dynamic workspace alignment
- 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
2026-09-24 22:57:47 +08:00

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('点击标签关闭按钮 (&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}`);
}
});
// 运行总结
const exitCode = runner.summary();
ptyManager.killAll();
app.quit();
process.exit(exitCode);
});