feat: 重构terminal

This commit is contained in:
李岩岩 2026-07-22 09:31:39 +08:00
parent 6c2950730e
commit 7358ea6395
3 changed files with 546 additions and 126 deletions

View File

@ -1 +1,236 @@
<template><div></div></template> <template>
<div class="term-body" @click="focusInput">
<div ref="outEl" class="term-out">
<div v-for="(line, i) in lines" :key="i" :class="'term-line ' + line.cls">{{ line.text }}</div>
</div>
<div class="term-input-row">
<span class="term-prompt">{{ promptText }}</span>
<input
ref="inputEl"
class="term-input"
spellcheck="false"
autocomplete="off"
aria-label="终端输入"
v-model="inputValue"
@keydown="onKeydown"
>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, reactive, computed, onMounted, nextTick } from 'vue'
import { fs } from '../../composables/useFS'
import { Apps } from '../../composables/useApps'
const props = defineProps<{ win: any }>()
// ============ ============
const cwd = ref(fs.HOME)
const hist = ref<string[]>([])
const hi = ref(-1)
const inputValue = ref('')
const lines = reactive<{ text: string; cls: string }[]>([])
const outEl = ref<HTMLElement>()
const inputEl = ref<HTMLInputElement>()
// ============ ============
const shortPath = computed(() => cwd.value.replace(fs.HOME, '~') || '/')
const promptText = computed(() => `guest@MacBook-Pro ${shortPath.value} % `)
// menus()
props.win.appState = { clear: () => { lines.length = 0 } }
// ============ ============
function print(text: string, cls = '') {
lines.push({ text, cls })
nextTick(() => {
if (outEl.value) outEl.value.scrollTop = outEl.value.scrollHeight
})
}
// ============ ============
function resolve(p: string): string {
if (!p) return cwd.value
if (p.startsWith('~')) p = fs.HOME + p.slice(1)
return fs.normalize(p.startsWith('/') ? p : cwd.value + '/' + p)
}
// ============ ============
function tokenize(s: string): string[] {
const out: string[] = []
let cur = ''
let q: string | null = null
let esc = false
let started = false
for (const ch of s) {
if (esc) { cur += ch; esc = false; started = true; continue }
if (q === "'") { if (ch === "'") q = null; else { cur += ch; continue } }
if (q === '"') { if (ch === '"') q = null; else if (ch === '\\') esc = true; else { cur += ch; continue } }
if (ch === '\\') { esc = true; started = true; continue }
if (ch === "'" || ch === '"') { q = ch; started = true; continue }
if (/\s/.test(ch)) { if (started) { out.push(cur); cur = ''; started = false }; continue }
cur += ch; started = true
}
if (esc) cur += '\\'
if (q) throw new Error('未闭合的引号')
if (started) out.push(cur)
return out
}
// ============ ============
function run(cmdline: string) {
print(promptText.value + cmdline, 'term-echo')
let args: string[]
try { args = tokenize(cmdline.trim()) } catch (te: any) { print('zsh: ' + te.message, 'term-err'); return }
if (!args.length) return
const [cmd, ...rest] = args
const err = (m: string) => print(m, 'term-err')
const flagsOf = () => rest.filter(a => a.startsWith('-')).join('')
const operands = () => rest.filter(a => !a.startsWith('-'))
try {
switch (cmd) {
case 'help':
print([
'可用命令:',
' help pwd ls [路径] cd <路径> cat <文件>',
' touch <文件> mkdir <目录> rm [-r] <路径> mv <源> <目标> cp [-r] <源> <目标>',
' open <路径|应用> echo <文本> clear date whoami uname',
'路径支持 ~ 、相对路径与引号(如 cd "~/Sample Folder"),与访达共享同一文件系统。',
].join('\n'))
break
case 'pwd': print(cwd.value); break
case 'whoami': print('guest'); break
case 'uname': print('Darwin MacBook-Pro.local 24.5.0 Darwin Kernel Version 24.5.0 (Web) arm64'); break
case 'date': print(new Date().toLocaleString('zh-CN', { hour12: false })); break
case 'clear': lines.length = 0; break
case 'echo': print(rest.join(' ')); break
case 'ls': {
const p = resolve(operands()[0])
const items = fs.list(p, { showHidden: rest.includes('-a') })
if (rest.includes('-l')) {
items.forEach(it => print(`${it.node.t === 'd' ? 'd' : '-'}rw-r--r-- ${it.node.t === 'f' ? String((it.node.data || '').length).padStart(6) : ' -'} ${it.name}`))
} else {
print(items.map(it => it.name + (it.node.t === 'd' ? '/' : '')).join(' ') || '')
}
break
}
case 'cd': {
const p = resolve(operands()[0] || fs.HOME)
if (!fs.isDir(p)) return err(`cd: 不是目录: ${operands()[0] || ''}`)
cwd.value = p
break
}
case 'cat': {
if (!operands()[0]) return err('cat: 缺少文件')
print(fs.read(resolve(operands()[0])))
break
}
case 'touch': {
if (!operands()[0]) return err('touch: 缺少文件')
const p = resolve(operands()[0])
if (fs.exists(p)) { const n = fs.node(p)!; n.mtime = Date.now(); fs.save() }
else fs.write(p, '')
break
}
case 'mkdir': {
if (!operands()[0]) return err('mkdir: 缺少目录')
fs.mkdir(resolve(operands()[0]))
break
}
case 'rm': {
const ops = operands()
if (!ops.length) return err('rm: 缺少路径')
const recursive = flagsOf().includes('r') || flagsOf().includes('R')
const force = flagsOf().includes('f')
for (const t of ops) {
const rp = resolve(t)
try {
if (!fs.exists(rp)) { if (!force) err(`rm: ${t}: 没有那个文件或目录`); continue }
if (fs.isDir(rp) && !recursive) { err(`rm: ${t}: 是一个目录(递归删除请用 rm -r`); continue }
fs.remove(rp)
} catch (e2: any) { err(`rm: ${t}: ${e2.message}`) }
}
break
}
case 'mv': {
const ops = operands()
if (ops.length < 2) return err('mv: 用法 mv <源> <目标>')
const s = resolve(ops[0]), d = resolve(ops[1])
try {
if (!fs.exists(s)) return err(`mv: ${ops[0]}: 没有那个文件或目录`)
if (fs.isDir(d)) fs.move(s, d)
else {
if (fs.exists(d)) return err(`mv: ${ops[1]}: 目标已存在`)
const dir = fs.dirName(d)
if (!fs.isDir(dir)) return err(`mv: ${ops[1]}: 目标目录不存在`)
const np = fs.move(s, dir)
fs.rename(np, fs.baseName(d))
}
} catch (e2: any) { err(`mv: ${e2.message}`) }
break
}
case 'cp': {
const ops = operands()
if (ops.length < 2) return err('cp: 用法 cp [-r] <源> <目标>')
const s = resolve(ops[0]), d = resolve(ops[1])
try {
if (!fs.exists(s)) return err(`cp: ${ops[0]}: 没有那个文件或目录`)
if (fs.isDir(s) && !(flagsOf().includes('r') || flagsOf().includes('R'))) return err(`cp: ${ops[0]} 是一个目录(复制目录请用 cp -r`)
if (fs.isDir(d)) fs.copy(s, d)
else {
if (fs.exists(d)) return err(`cp: ${ops[1]}: 目标已存在`)
const dir = fs.dirName(d)
if (!fs.isDir(dir)) return err(`cp: ${ops[1]}: 目标目录不存在`)
const np = fs.copy(s, dir)
fs.rename(np, fs.baseName(d))
}
} catch (e2: any) { err(`cp: ${e2.message}`) }
break
}
case 'open': {
if (!operands()[0]) return err('open: 缺少路径')
const p = resolve(operands()[0])
if (fs.exists(p)) Apps.openPath(p)
else if (Apps.get(operands()[0])) Apps.open(operands()[0])
else err(`open: 找不到: ${operands()[0]}`)
break
}
default: err(`zsh: command not found: ${cmd}`)
}
} catch (e2: any) { err(`${cmd}: ${e2.message}`) }
}
// ============ ============
function onKeydown(e: KeyboardEvent) {
e.stopPropagation()
if (e.key === 'Enter') {
const v = inputValue.value
inputValue.value = ''
if (v.trim()) { hist.value.push(v); hi.value = hist.value.length }
run(v)
} else if (e.key === 'ArrowUp') {
e.preventDefault()
if (hi.value > 0) { hi.value--; inputValue.value = hist.value[hi.value] || '' }
} else if (e.key === 'ArrowDown') {
e.preventDefault()
if (hi.value < hist.value.length - 1) { hi.value++; inputValue.value = hist.value[hi.value] }
else { hi.value = hist.value.length; inputValue.value = '' }
} else if (e.ctrlKey && e.key.toLowerCase() === 'l') {
e.preventDefault(); lines.length = 0
} else if (e.ctrlKey && e.key.toLowerCase() === 'c') {
print(promptText.value + inputValue.value + ' ^C', 'term-echo')
inputValue.value = ''
}
}
function focusInput() { inputEl.value?.focus() }
// ============ ============
onMounted(() => {
print('Last login: ' + new Date().toLocaleString('zh-CN') + ' on ttys000', 'term-dim')
setTimeout(() => inputEl.value?.focus(), 60)
})
</script>

View File

@ -1,7 +1,5 @@
// 终端应用 — Vue 组件化
import { h, render } from 'vue' import { h, render } from 'vue'
// 终端应用 — 从 js/apps.js 手动转写
import { $, $$, el } from '../../utils'
import { fs as FS } from '../../composables/useFS'
import { Apps, stdMenus } from '../../composables/useApps' import { Apps, stdMenus } from '../../composables/useApps'
import TerminalComponent from './Terminal.vue' import TerminalComponent from './Terminal.vue'
@ -16,127 +14,8 @@ export const terminalApp = {
}) })
}, },
render(win: any) { render(win: any) {
const st = win.appState = { cwd: FS.HOME, hist: [] as string[], hi: -1 } const vnode = h(TerminalComponent, { win })
win.body.classList.add('term-body') render(vnode, win.body)
const out = el('div', { class: 'term-out' }) },
const inputRow = el('div', { class: 'term-input-row' })
const promptEl = el('span', { class: 'term-prompt' })
const input = el('input', { class: 'term-input', spellcheck: 'false', autocomplete: 'off', 'aria-label': '终端输入' }) as HTMLInputElement
inputRow.append(promptEl, input)
win.body.append(out, inputRow)
const short = (p: string) => p.replace(FS.HOME, '~') || '/'
const setPrompt = () => { promptEl.textContent = `guest@MacBook-Pro ${short(st.cwd)} % ` }
const print = (text: string, cls = '') => { out.append(el('div', { class: 'term-line ' + cls, text })); out.scrollTop = out.scrollHeight }
st.clear = () => { out.innerHTML = '' }
const resolve = (p: string) => {
if (!p) return st.cwd
if (p.startsWith('~')) p = FS.HOME + p.slice(1)
return FS.normalize(p.startsWith('/') ? p : st.cwd + '/' + p)
}
const tokenize = (s: string) => {
const out: string[] = []; let cur = ''; let q: string | null = null; let esc = false; let started = false
for (const ch of s) {
if (esc) { cur += ch; esc = false; started = true; continue }
if (q === "'") { if (ch === "'") { q = null } else cur += ch; continue }
if (q === '"') { if (ch === '"') q = null; else if (ch === '\\') esc = true; else cur += ch; continue }
if (ch === '\\') { esc = true; started = true; continue }
if (ch === "'" || ch === '"') { q = ch; started = true; continue }
if (/\s/.test(ch)) { if (started) { out.push(cur); cur = ''; started = false } continue }
cur += ch; started = true
}
if (esc) cur += '\\'
if (q) throw new Error('未闭合的引号')
if (started) out.push(cur)
return out
}
const run = (cmdline: string) => {
print(promptEl.textContent + cmdline, 'term-echo')
let args: string[]
try { args = tokenize(cmdline.trim()) } catch (te: any) { print('zsh: ' + te.message, 'term-err'); return }
if (!args.length) return
const [cmd, ...rest] = args
const err = (m: string) => print(m, 'term-err')
const flagsOf = () => rest.filter(a => a.startsWith('-')).join('')
const operands = () => rest.filter(a => !a.startsWith('-'))
try {
switch (cmd) {
case 'help': print(['可用命令:', ' help pwd ls [路径] cd <路径> cat <文件>', ' touch <文件> mkdir <目录> rm [-r] <路径> mv <源> <目标> cp [-r] <源> <目标>', ' open <路径|应用> echo <文本> clear date whoami uname', '路径支持 ~ 、相对路径与引号(如 cd "~/Sample Folder"),与访达共享同一文件系统。'].join('\n')); break
case 'pwd': print(st.cwd); break
case 'whoami': print('guest'); break
case 'uname': print('Darwin MacBook-Pro.local 24.5.0 Darwin Kernel Version 24.5.0 (Web) arm64'); break
case 'date': print(new Date().toLocaleString('zh-CN', { hour12: false })); break
case 'clear': st.clear(); break
case 'echo': print(rest.join(' ')); break
case 'ls': {
const p = resolve(operands()[0])
const items = FS.list(p, { showHidden: rest.includes('-a') })
if (rest.includes('-l')) items.forEach(it => print(`${it.node.t === 'd' ? 'd' : '-'}rw-r--r-- ${it.node.t === 'f' ? String((it.node.data || '').length).padStart(6) : ' -'} ${it.name}`))
else print(items.map(it => it.name + (it.node.t === 'd' ? '/' : '')).join(' ') || '')
break
}
case 'cd': { const p = resolve(operands()[0] || FS.HOME); if (!FS.isDir(p)) return err(`cd: 不是目录: ${operands()[0] || ''}`); st.cwd = p; setPrompt(); break }
case 'cat': { if (!operands()[0]) return err('cat: 缺少文件'); print(FS.read(resolve(operands()[0]))); break }
case 'touch': { if (!operands()[0]) return err('touch: 缺少文件'); const p = resolve(operands()[0]); if (FS.exists(p)) { const n = FS.node(p)!; n.mtime = Date.now(); FS.save() } else FS.write(p, ''); break }
case 'mkdir': { if (!operands()[0]) return err('mkdir: 缺少目录'); FS.mkdir(resolve(operands()[0])); break }
case 'rm': {
const ops = operands(); if (!ops.length) return err('rm: 缺少路径')
const recursive = flagsOf().includes('r') || flagsOf().includes('R')
const force = flagsOf().includes('f')
for (const t of ops) {
const rp = resolve(t)
try {
if (!FS.exists(rp)) { if (!force) err(`rm: ${t}: 没有那个文件或目录`); continue }
if (FS.isDir(rp) && !recursive) { err(`rm: ${t}: 是一个目录(递归删除请用 rm -r`); continue }
FS.remove(rp)
} catch (e2: any) { err(`rm: ${t}: ${e2.message}`) }
}
break
}
case 'mv': {
const ops = operands(); if (ops.length < 2) return err('mv: 用法 mv <源> <目标>')
const s = resolve(ops[0]), d = resolve(ops[1])
try {
if (!FS.exists(s)) return err(`mv: ${ops[0]}: 没有那个文件或目录`)
if (FS.isDir(d)) FS.move(s, d)
else { if (FS.exists(d)) return err(`mv: ${ops[1]}: 目标已存在`); const dir = FS.dirName(d); if (!FS.isDir(dir)) return err(`mv: ${ops[1]}: 目标目录不存在`); const np = FS.move(s, dir); FS.rename(np, FS.baseName(d)) }
} catch (e2: any) { err(`mv: ${e2.message}`) }
break
}
case 'cp': {
const ops = operands(); if (ops.length < 2) return err('cp: 用法 cp [-r] <源> <目标>')
const s = resolve(ops[0]), d = resolve(ops[1])
try {
if (!FS.exists(s)) return err(`cp: ${ops[0]}: 没有那个文件或目录`)
if (FS.isDir(s) && !(flagsOf().includes('r') || flagsOf().includes('R'))) return err(`cp: ${ops[0]} 是一个目录(复制目录请用 cp -r`)
if (FS.isDir(d)) FS.copy(s, d)
else { if (FS.exists(d)) return err(`cp: ${ops[1]}: 目标已存在`); const dir = FS.dirName(d); if (!FS.isDir(dir)) return err(`cp: ${ops[1]}: 目标目录不存在`); const np = FS.copy(s, dir); FS.rename(np, FS.baseName(d)) }
} catch (e2: any) { err(`cp: ${e2.message}`) }
break
}
case 'open': {
if (!operands()[0]) return err('open: 缺少路径')
const p = resolve(operands()[0])
if (FS.exists(p)) Apps.openPath(p)
else if (Apps.get(operands()[0])) Apps.open(operands()[0])
else err(`open: 找不到: ${operands()[0]}`)
break
}
default: err(`zsh: command not found: ${cmd}`)
}
} catch (e2: any) { err(`${cmd}: ${e2.message}`) }
}
input.addEventListener('keydown', (e: KeyboardEvent) => {
e.stopPropagation()
if (e.key === 'Enter') { const v = input.value; input.value = ''; if (v.trim()) { st.hist.push(v); st.hi = st.hist.length }; run(v) }
else if (e.key === 'ArrowUp') { e.preventDefault(); if (st.hi > 0) { st.hi--; input.value = st.hist[st.hi] || '' } }
else if (e.key === 'ArrowDown') { e.preventDefault(); if (st.hi < st.hist.length - 1) { st.hi++; input.value = st.hist[st.hi] } else { st.hi = st.hist.length; input.value = '' } }
else if (e.ctrlKey && e.key.toLowerCase() === 'l') { e.preventDefault(); st.clear() }
else if (e.ctrlKey && e.key.toLowerCase() === 'c') { print(promptEl.textContent + input.value + ' ^C', 'term-echo'); input.value = '' }
})
win.body.addEventListener('click', () => input.focus())
print('Last login: ' + new Date().toLocaleString('zh-CN') + ' on ttys000', 'term-dim')
setPrompt()
setTimeout(() => input.focus(), 60)
}
} }
Apps.register(terminalApp) Apps.register(terminalApp)

View File

@ -0,0 +1,306 @@
/**
* 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)
})
})
})