macos-web/tests/cases/18-terminal.test.ts
2026-07-22 09:31:39 +08:00

307 lines
11 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* 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)
})
})
})