feat: 重构terminal
This commit is contained in:
parent
6c2950730e
commit
7358ea6395
@ -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>
|
||||
|
||||
|
||||
@ -1,7 +1,5 @@
|
||||
// 终端应用 — 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 TerminalComponent from './Terminal.vue'
|
||||
|
||||
@ -16,127 +14,8 @@ export const terminalApp = {
|
||||
})
|
||||
},
|
||||
render(win: any) {
|
||||
const st = win.appState = { cwd: FS.HOME, hist: [] as string[], hi: -1 }
|
||||
win.body.classList.add('term-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)
|
||||
}
|
||||
const vnode = h(TerminalComponent, { win })
|
||||
render(vnode, win.body)
|
||||
},
|
||||
}
|
||||
Apps.register(terminalApp)
|
||||
|
||||
306
tests/cases/18-terminal.test.ts
Normal file
306
tests/cases/18-terminal.test.ts
Normal 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)
|
||||
})
|
||||
})
|
||||
})
|
||||
Loading…
x
Reference in New Issue
Block a user