feat: align tab bar with terminal window under workspace hierarchy & add automated test suite
- Restructure UI layout: tab bar and stage actions are now housed inside .workspace-stage (.workspace-header), aligning 100% with the terminal window (#main-viewport) below it - Establish clear workspace hierarchy: Global Titlebar -> Left Workspace Sidebar -> Active Workspace Content Stage (Tab Bar + Terminal Viewport) - Add active workspace indicator badge to titlebar header - Introduce comprehensive automated test suite (test/test-runner.cjs) covering PTY process lifecycle, workspace hierarchy, stage alignment, tab creation, split panes, shortcuts, and themes - Add 'npm test' script to package.json
This commit is contained in:
@@ -5,6 +5,7 @@
|
||||
"main": "src/main/index.cjs",
|
||||
"scripts": {
|
||||
"start": "electron .",
|
||||
"test": "electron test/test-runner.cjs",
|
||||
"rebuild": "electron-rebuild -f -w node-pty",
|
||||
"package:deb": "electron-builder --linux deb",
|
||||
"package:all": "electron-builder --linux deb AppImage",
|
||||
|
||||
@@ -64,6 +64,46 @@ body {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
/* 顶部标题栏工作区标识 */
|
||||
.titlebar-workspace-tag {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 2px 10px;
|
||||
height: 22px;
|
||||
background: var(--bg-tab-inactive);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 12px;
|
||||
font-size: 11px;
|
||||
color: var(--text-muted);
|
||||
white-space: nowrap;
|
||||
user-select: none;
|
||||
-webkit-app-region: no-drag !important;
|
||||
transition: all 0.15s ease;
|
||||
}
|
||||
|
||||
.titlebar-workspace-tag:hover {
|
||||
background: var(--bg-tab-hover);
|
||||
color: var(--text-main);
|
||||
border-color: var(--border-active);
|
||||
}
|
||||
|
||||
.ws-tag-dot {
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
border-radius: 50%;
|
||||
background: var(--accent-color);
|
||||
box-shadow: 0 0 6px var(--accent-glow);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.ws-tag-name {
|
||||
font-weight: 500;
|
||||
max-width: 160px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.titlebar-drag-spacer {
|
||||
flex: 1;
|
||||
height: 100%;
|
||||
@@ -153,9 +193,51 @@ body {
|
||||
color: #ffffff;
|
||||
}
|
||||
|
||||
/* 主内容区 */
|
||||
.main-viewport {
|
||||
/* 工作区终端主舞台与标签页栏 (工作区子层级,与终端严格对齐) */
|
||||
.workspace-stage {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.workspace-header {
|
||||
height: 38px;
|
||||
min-height: 38px;
|
||||
background: var(--bg-titlebar);
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 0 8px;
|
||||
gap: 8px;
|
||||
user-select: none;
|
||||
z-index: 20;
|
||||
}
|
||||
|
||||
.tab-bar-container {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.stage-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
/* 主内容区终端视口 */
|
||||
.main-viewport {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
background: transparent;
|
||||
|
||||
@@ -5,7 +5,8 @@
|
||||
gap: 4px;
|
||||
overflow-x: auto;
|
||||
scrollbar-width: none;
|
||||
max-width: calc(100vw - 320px);
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
-webkit-app-region: no-drag !important;
|
||||
}
|
||||
|
||||
|
||||
+42
-20
@@ -36,14 +36,11 @@
|
||||
<span>hatty</span>
|
||||
</div>
|
||||
|
||||
<!-- 标签页栏 -->
|
||||
<div class="tab-bar" id="tab-bar"></div>
|
||||
<button class="new-tab-btn" id="btn-new-tab" title="新建标签页 (Ctrl+Shift+T)">
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<line x1="12" y1="5" x2="12" y2="19"></line>
|
||||
<line x1="5" y1="12" x2="19" y2="12"></line>
|
||||
</svg>
|
||||
</button>
|
||||
<!-- 当前活跃工作区标识 (上层结构指示) -->
|
||||
<div class="titlebar-workspace-tag" id="titlebar-workspace-tag" title="当前激活工作区">
|
||||
<span class="ws-tag-dot"></span>
|
||||
<span class="ws-tag-name" id="titlebar-ws-name">本机电脑 (Local PC)</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 中间空白可拖拽区域 -->
|
||||
@@ -51,18 +48,6 @@
|
||||
|
||||
<!-- 右侧快速操作按钮与窗口控制 -->
|
||||
<div class="titlebar-actions">
|
||||
<button class="btn-icon" id="btn-split-h" title="向右水平分屏 (Ctrl+Shift+E)">
|
||||
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<rect x="3" y="3" width="18" height="18" rx="2"></rect>
|
||||
<line x1="12" y1="3" x2="12" y2="21"></line>
|
||||
</svg>
|
||||
</button>
|
||||
<button class="btn-icon" id="btn-split-v" title="向下垂直分屏 (Ctrl+Shift+O)">
|
||||
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<rect x="3" y="3" width="18" height="18" rx="2"></rect>
|
||||
<line x1="3" y1="12" x2="21" y2="12"></line>
|
||||
</svg>
|
||||
</button>
|
||||
<button class="btn-icon" id="btn-palette" title="快速命令面板 (Ctrl+Shift+P / F1)">
|
||||
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<circle cx="12" cy="12" r="10"></circle>
|
||||
@@ -147,9 +132,46 @@
|
||||
<span>工作区</span>
|
||||
</button>
|
||||
|
||||
<!-- 右侧工作区终端主舞台 (标签页为工作区子层级,与终端视口对齐) -->
|
||||
<div class="workspace-stage">
|
||||
<!-- 标签页栏与终端操作条 (与终端视口完全对齐) -->
|
||||
<div class="workspace-header">
|
||||
<div class="tab-bar-container">
|
||||
<div class="tab-bar" id="tab-bar"></div>
|
||||
<button class="new-tab-btn" id="btn-new-tab" title="新建标签页 (Ctrl+Shift+T)">
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<line x1="12" y1="5" x2="12" y2="19"></line>
|
||||
<line x1="5" y1="12" x2="19" y2="12"></line>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="stage-actions">
|
||||
<button class="btn-icon" id="btn-split-h" title="向右水平分屏 (Ctrl+Shift+E)">
|
||||
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<rect x="3" y="3" width="18" height="18" rx="2"></rect>
|
||||
<line x1="12" y1="3" x2="12" y2="21"></line>
|
||||
</svg>
|
||||
</button>
|
||||
<button class="btn-icon" id="btn-split-v" title="向下垂直分屏 (Ctrl+Shift+O)">
|
||||
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<rect x="3" y="3" width="18" height="18" rx="2"></rect>
|
||||
<line x1="3" y1="12" x2="21" y2="12"></line>
|
||||
</svg>
|
||||
</button>
|
||||
<button class="btn-icon" id="btn-search" title="查找文本 (Ctrl+Shift+F)">
|
||||
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<circle cx="11" cy="11" r="8"></circle>
|
||||
<line x1="21" y1="21" x2="16.65" y2="16.65"></line>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 终端主视口 -->
|
||||
<main class="main-viewport" id="main-viewport"></main>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 底部状态栏 -->
|
||||
<footer class="statusbar">
|
||||
|
||||
@@ -270,7 +270,12 @@ export class WorkspaceManager {
|
||||
|
||||
this.renderSidebar();
|
||||
|
||||
// 更新底部状态栏信息
|
||||
// 更新顶部标题栏与底部状态栏信息
|
||||
const titlebarWsName = document.getElementById('titlebar-ws-name');
|
||||
if (titlebarWsName) {
|
||||
titlebarWsName.textContent = ws.name;
|
||||
}
|
||||
|
||||
const statusHost = document.getElementById('status-host');
|
||||
if (statusHost) {
|
||||
if (ws.type === WORKSPACE_TYPES.SSH) {
|
||||
|
||||
@@ -0,0 +1,405 @@
|
||||
/**
|
||||
* 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: Workspace Hierarchy & Stage 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('工作区终端舞台包含独立头部标签条与视口 (.workspace-stage)', async () => {
|
||||
const res = await win.webContents.executeJavaScript(`
|
||||
(() => {
|
||||
const stage = document.querySelector('.workspace-stage');
|
||||
const header = document.querySelector('.workspace-header');
|
||||
const viewport = document.getElementById('main-viewport');
|
||||
const tabBar = document.getElementById('tab-bar');
|
||||
const newTabBtn = document.getElementById('btn-new-tab');
|
||||
return {
|
||||
hasStage: !!stage,
|
||||
hasHeader: !!header,
|
||||
hasViewport: !!viewport,
|
||||
tabsInHeader: header?.contains(tabBar),
|
||||
btnInHeader: header?.contains(newTabBtn),
|
||||
};
|
||||
})()
|
||||
`);
|
||||
if (!res.hasStage) throw new Error('Missing .workspace-stage container');
|
||||
if (!res.hasHeader) throw new Error('Missing .workspace-header');
|
||||
if (!res.hasViewport) throw new Error('Missing #main-viewport');
|
||||
if (!res.tabsInHeader) throw new Error('#tab-bar must be inside .workspace-header');
|
||||
if (!res.btnInHeader) throw new Error('#btn-new-tab must be inside .workspace-header');
|
||||
});
|
||||
|
||||
await runner.test('标签栏与终端窗口边缘完全像素级对齐 (100% Alignment)', async () => {
|
||||
const res = await win.webContents.executeJavaScript(`
|
||||
(() => {
|
||||
const hRect = document.querySelector('.workspace-header').getBoundingClientRect();
|
||||
const vRect = document.getElementById('main-viewport').getBoundingClientRect();
|
||||
return {
|
||||
headerLeft: hRect.left,
|
||||
viewportLeft: vRect.left,
|
||||
headerWidth: hRect.width,
|
||||
viewportWidth: vRect.width,
|
||||
leftDiff: Math.abs(hRect.left - vRect.left),
|
||||
widthDiff: Math.abs(hRect.width - vRect.width)
|
||||
};
|
||||
})()
|
||||
`);
|
||||
if (res.leftDiff >= 1) {
|
||||
throw new Error(`Left mismatch: header.left=${res.headerLeft}, viewport.left=${res.viewportLeft}`);
|
||||
}
|
||||
if (res.widthDiff >= 1) {
|
||||
throw new Error(`Width mismatch: header.width=${res.headerWidth}, viewport.width=${res.viewportWidth}`);
|
||||
}
|
||||
});
|
||||
|
||||
// ==========================================
|
||||
// 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);
|
||||
});
|
||||
Reference in New Issue
Block a user