/** * 18-terminal: Terminal Vue 组件化测试 * * 覆盖功能点: * 初始化 — prompt、登录消息 * 命令 — help/pwd/ls/cd/cat/touch/mkdir/rm/mv/cp/open/echo/clear/date/whoami/uname * 键盘 — Enter/ArrowUp/Down/Ctrl+L/Ctrl+C * tokenize — 引号、转义 * 路径 — ~ 展开、相对路径、错误处理 * win.appState — clear 暴露 */ import { describe, it, expect, beforeEach, afterEach } from 'vitest' import { h, render, nextTick } from 'vue' import TerminalComponent from '../../src/apps/terminal/Terminal.vue' import { fs } from '../../src/composables/useFS' import { setupDOM } from '../helpers' function makeMockWin() { const el = document.createElement('div') el.className = 'window' document.getElementById('window-layer')?.appendChild(el) const body = document.createElement('div') body.className = 'win-body' el.appendChild(body) return { id: 'test-win', appId: 'terminal', el, body, timers: [] as any[], data: {} } } function getLines(body: HTMLElement): string[] { return [...body.querySelectorAll('.term-line')].map(e => e.textContent || '') } function promptText(body: HTMLElement): string { return body.querySelector('.term-prompt')?.textContent || '' } describe('18-terminal — Terminal Vue 组件化', () => { let win: any beforeEach(() => { localStorage.clear(); (fs as any).root = null fs.init() setupDOM() win = makeMockWin() }) afterEach(() => { render(null, win.body) win.el.remove() }) function mount() { const vnode = h(TerminalComponent, { win }) render(vnode, win.body) } /** 模拟输入命令:设置 value + 触发 input + 触发 Enter 键盘 */ function exec(cmd: string) { const inp = win.body.querySelector('.term-input') as HTMLInputElement if (!inp) return // 设置值 inp.value = cmd // 触发 input 事件让 v-model 同步 inp.dispatchEvent(new Event('input', { bubbles: true })) // 触发 Enter inp.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', keyCode: 13, bubbles: true, cancelable: true })) } /** 模拟按键(不上屏) */ function pressKey(key: string, opts: any = {}) { const inp = win.body.querySelector('.term-input') as HTMLInputElement if (!inp) return inp.dispatchEvent(new KeyboardEvent('keydown', { key, keyCode: key.charCodeAt(0), bubbles: true, cancelable: true, ...opts })) } // ==================== 初始化 ==================== describe('初始化', () => { it('挂载后显示登录消息和提示符', async () => { mount() await nextTick() const lines = getLines(win.body) const allText = lines.join(' ') expect(allText).toContain('Last login:') expect(allText).toContain('ttys000') }) it('提示符显示 guest@MacBook-Pro ~ %', () => { mount() expect(promptText(win.body)).toContain('guest@MacBook-Pro') expect(promptText(win.body)).toContain('%') }) it('终端有输入框', () => { mount() expect(win.body.querySelector('.term-input')).toBeTruthy() }) }) // ==================== 命令执行 ==================== describe('命令执行', () => { it('pwd 输出当前目录', async () => { mount(); exec('pwd'); await nextTick() expect(getLines(win.body).some(l => l.includes(fs.HOME))).toBe(true) }) it('whoami 输出 guest', async () => { mount(); exec('whoami'); await nextTick() expect(getLines(win.body).some(l => l === 'guest')).toBe(true) }) it('echo 回显参数', async () => { mount(); exec('echo hello'); await nextTick() expect(getLines(win.body).some(l => l === 'hello')).toBe(true) }) it('help 输出命令列表', async () => { mount(); exec('help'); await nextTick() expect(getLines(win.body).some(l => l.includes('pwd'))).toBe(true) }) it('clear 清空终端', async () => { mount(); exec('echo visible'); await nextTick() expect(getLines(win.body).some(l => l === 'visible')).toBe(true) exec('clear'); await nextTick() // clear 后只有登录消息保留在 term-dim 中 const afterClear = getLines(win.body).filter(l => l !== 'Last login:' && !l.includes('Last login')) expect(afterClear.every(l => !l.includes('visible'))).toBe(true) }) it('未知命令报 command not found', async () => { mount(); exec('zzz_nonexist'); await nextTick() expect(getLines(win.body).some(l => l.includes('command not found'))).toBe(true) }) }) // ==================== 文件系统命令 ==================== describe('FS 命令', () => { it('ls 列出目录内容', async () => { mount(); exec(`cd ~/Desktop`); await nextTick() exec('ls'); await nextTick() const hasContent = getLines(win.body).some(l => l.includes('welcome.txt')) expect(hasContent).toBe(true) }) it('ls -l 显示详细信息', async () => { mount(); exec('cd ~/Desktop'); await nextTick() exec('ls -l'); await nextTick() expect(getLines(win.body).some(l => /^[-d]rw/.test(l))).toBe(true) }) it('cd 切换目录并更新提示符', async () => { mount(); exec('cd ~/Desktop'); await nextTick() expect(promptText(win.body)).toContain('Desktop') }) it('cd 到无效目录报错', async () => { mount(); exec('cd /nonexistent'); await nextTick() expect(getLines(win.body).some(l => l.includes('不是目录'))).toBe(true) }) it('cat 显示文件内容', async () => { mount(); exec('cat ~/Desktop/welcome.txt'); await nextTick() expect(getLines(win.body).some(l => l.includes('欢迎'))).toBe(true) }) it('touch 创建文件', async () => { mount(); exec('touch ~/Desktop/_t.txt'); await nextTick() expect(fs.exists(fs.HOME + '/Desktop/_t.txt')).toBe(true) fs.remove(fs.HOME + '/Desktop/_t.txt') }) it('mkdir 创建目录', async () => { mount(); exec('mkdir ~/Desktop/_td'); await nextTick() expect(fs.isDir(fs.HOME + '/Desktop/_td')).toBe(true) fs.remove(fs.HOME + '/Desktop/_td') }) it('mkdir 已存在目录报错', async () => { mount(); exec('mkdir ~/Desktop'); await nextTick() expect(getLines(win.body).some(l => l.includes('已存在'))).toBe(true) }) it('rm 删除文件', async () => { fs.write(fs.HOME + '/Desktop/_rm.txt', 'x') mount(); exec('rm ~/Desktop/_rm.txt'); await nextTick() expect(fs.exists(fs.HOME + '/Desktop/_rm.txt')).toBe(false) }) it('rm -r 递归删除目录', async () => { fs.mkdir(fs.HOME + '/Desktop/_rmdir') mount(); exec('rm -r ~/Desktop/_rmdir'); await nextTick() expect(fs.exists(fs.HOME + '/Desktop/_rmdir')).toBe(false) }) it('mv 移动文件', async () => { fs.write(fs.HOME + '/Desktop/_mv.txt', 'x') mount(); exec('mv ~/Desktop/_mv.txt ~/Documents/'); await nextTick() expect(fs.exists(fs.HOME + '/Desktop/_mv.txt')).toBe(false) expect(fs.exists(fs.HOME + '/Documents/_mv.txt')).toBe(true) fs.remove(fs.HOME + '/Documents/_mv.txt') }) it('cp 复制文件', async () => { mount(); exec('cp ~/Desktop/welcome.txt ~/Documents/_cp.txt'); await nextTick() expect(fs.exists(fs.HOME + '/Documents/_cp.txt')).toBe(true) fs.remove(fs.HOME + '/Documents/_cp.txt') }) }) // ==================== 键盘交互 ==================== describe('键盘交互', () => { it('ArrowUp 调出历史命令', async () => { mount(); exec('echo one'); await nextTick() exec('echo two'); await nextTick() const inp = win.body.querySelector('.term-input') as HTMLInputElement pressKey('ArrowUp') await nextTick() expect(inp.value).toBe('echo two') pressKey('ArrowUp') await nextTick() expect(inp.value).toBe('echo one') }) it('ArrowDown 回到空白', async () => { mount(); exec('echo test'); await nextTick() const inp = win.body.querySelector('.term-input') as HTMLInputElement pressKey('ArrowUp'); await nextTick() pressKey('ArrowDown'); await nextTick() expect(inp.value).toBe('') }) it('Ctrl+L 清屏', async () => { mount(); exec('echo visible'); await nextTick() pressKey('l', { ctrlKey: true }); await nextTick() const lines = getLines(win.body).filter(l => l.cls !== 'term-dim' || !l.includes('Last login')) // 检查没有 visible 行 expect(getLines(win.body).some(l => l === 'visible')).toBe(false) }) it('Ctrl+C 取消当前行', async () => { mount() const inp = win.body.querySelector('.term-input') as HTMLInputElement inp.value = 'partial'; inp.dispatchEvent(new Event('input', { bubbles: true })) await nextTick() pressKey('c', { ctrlKey: true }); await nextTick() expect(inp.value).toBe('') expect(getLines(win.body).some(l => l.includes('^C'))).toBe(true) }) }) // ==================== 路径解析 ==================== describe('路径解析', () => { it('~ 展开为 HOME', async () => { mount(); exec('cd ~/Documents'); await nextTick() expect(getLines(win.body).filter(l => l.includes('term-err')).length).toBe(0) }) it('相对路径基于 cwd', async () => { mount(); exec('cd ~'); await nextTick(); await nextTick() // 从 HOME 导航到 Desktop(相对路径) exec('cd Desktop'); await nextTick(); await nextTick() expect(promptText(win.body)).toContain('Desktop') }) }) // ==================== 分词 ==================== describe('tokenize', () => { it('双引号保护空格', async () => { mount(); exec('echo "hello world"'); await nextTick(); await nextTick() const allText = getLines(win.body).join('|') expect(allText).toContain('hello world') }) it('转义字符处理', async () => { mount(); exec('echo hello\\ world'); await nextTick() expect(getLines(win.body).some(l => l === 'hello world')).toBe(true) }) }) // ==================== 错误处理 ==================== describe('错误处理', () => { it('cat 缺少参数报错', async () => { mount(); exec('cat'); await nextTick() expect(getLines(win.body).some(l => l.includes('缺少文件'))).toBe(true) }) it('mv 缺少参数提示用法', async () => { mount(); exec('mv x'); await nextTick() expect(getLines(win.body).some(l => l.includes('用法'))).toBe(true) }) }) // ==================== win.appState ==================== describe('win.appState', () => { it('暴露 clear 方法给菜单', async () => { mount(); exec('echo keep'); await nextTick() expect(getLines(win.body).some(l => l === 'keep')).toBe(true) expect(typeof win.appState.clear).toBe('function') win.appState.clear() await nextTick() expect(getLines(win.body).some(l => l === 'keep')).toBe(false) }) }) })