307 lines
11 KiB
TypeScript
307 lines
11 KiB
TypeScript
/**
|
||
* 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)
|
||
})
|
||
})
|
||
})
|