From 7358ea6395da06c45c5c617502b725a4c3c5bdcc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=9D=8E=E5=B2=A9=E5=B2=A9?= Date: Wed, 22 Jul 2026 09:31:39 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E9=87=8D=E6=9E=84terminal?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/apps/terminal/Terminal.vue | 237 ++++++++++++++++++++++++- src/apps/terminal/index.ts | 129 +------------- tests/cases/18-terminal.test.ts | 306 ++++++++++++++++++++++++++++++++ 3 files changed, 546 insertions(+), 126 deletions(-) create mode 100644 tests/cases/18-terminal.test.ts diff --git a/src/apps/terminal/Terminal.vue b/src/apps/terminal/Terminal.vue index 0ada1d8..e6953a5 100644 --- a/src/apps/terminal/Terminal.vue +++ b/src/apps/terminal/Terminal.vue @@ -1 +1,236 @@ - + + + + diff --git a/src/apps/terminal/index.ts b/src/apps/terminal/index.ts index 009b332..78290a8 100644 --- a/src/apps/terminal/index.ts +++ b/src/apps/terminal/index.ts @@ -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) diff --git a/tests/cases/18-terminal.test.ts b/tests/cases/18-terminal.test.ts new file mode 100644 index 0000000..79e5bbe --- /dev/null +++ b/tests/cases/18-terminal.test.ts @@ -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) + }) + }) +})