import type { BlockView } from '../blockView' import { describe, expect, it } from 'vitest' import { readBlockViews } from '../blockView' import { commandRuleLookup } from '../commandRules' import { packageCommandRules } from '../commands/packageCommands' import { shellCommandRules } from '../commands/shellCommands' import { systemCommandRules } from '../commands/systemCommands' import { createBashAdapter } from '../languages/bash' import { flatSegments, readFixture, runtime, viewLines, WASM_DIR } from '../testSupport' import { bashZhTranslator } from './bashZh' const bash = createBashAdapter(WASM_DIR) /** 内置的全部命令规则,和插件里注册的一样。 */ const RULES = [...shellCommandRules, ...systemCommandRules, ...packageCommandRules] async function read(source: string, reports: string[] = []): Promise { const { blocks } = await readBlockViews(runtime, bash, bashZhTranslator, 'shellscript', source, { commandRule: commandRuleLookup(RULES, text => reports.push(text)) }) return blocks } async function readLines(source: string): Promise { return viewLines(await read(source)) } /** luffy 桌面端 fetch-runtime.sh 读出来的样子(和原型 SHELL_BLOCKS 对得上,原型略去的 95-100、116-121 行也在)。 */ const FETCH_READING = [ 'interpreter 1', ' 解释器: bash', ' 怎么找: 按 PATH 去找(/usr/bin/env bash)', 'comment 2: 下载执行器自带的 git 与 python, 放到 src-tauri/resources/runtime//{git,python}。', 'options 14', ' 出错就停: 是 -e', ' 用到没设的变量就报错: 是 -u', ' 管道里任一步失败都算失败: 是 -o pipefail', 'variable 16', ' 名称: target', ' 初始值: 第 1 个参数(没给就是空)', 'variable 17', ' 名称: here', ' 初始值: 执行 〔进入 脚本所在目录/.. 然后 打印当前目录〕 的输出', 'variable 18', ' 名称: dest', ' 初始值: here/src-tauri/resources/runtime/target', 'variable 19', ' 名称: cache', ' 初始值: XDG_CACHE_HOME(没设就用 HOME/.cache)/luffy-desktop-runtime', 'const 21', ' 名称: PY_RELEASE', ' 值: 20260924', 'const 22', ' 名称: PY_VERSION', ' 值: 3.13.15', 'case 24: 按 target 分情况', ' x86_64-pc-windows-msvc | GIT_URL = https://github.com/git-for-windows/git/releases/download/v2.55.0.windows.5/MinGit-2.55.0.5-64-bit.zip ⏎ GIT_SHA256 = 56d7b226b7693196cfc71fef26568f536c4a021ab6c37ff2db4287bed908e96e ⏎ PY_SHA256 = e42fa944748a50e9ff481cbb817ef8a6e3da6fbcf0cf6f29b554e1acb8c7384d', ' aarch64-apple-darwin | GIT_URL = https://github.com/desktop/dugite-native/releases/download/v2.53.0-4/dugite-native-v2.53.0-4098283-macOS-arm64.tar.gz ⏎ GIT_SHA256 = f9dc64635a5b62fbd7ad95db73268bbb8912255ac516d65d37bf7af22fcb8ffe ⏎ PY_SHA256 = 064afb7c2fc0bbf511d886288adf98696af5105e36c138cdf2c199c0146fcf68', ' 其他 | 打印 "用法: 脚本路径 "(打印到错误输出) ⏎ 退出,退出码 2', 'variable 40', ' 名称: PY_URL', ' 初始值: https://github.com/astral-sh/python-build-standalone/releases/download/PY_RELEASE/cpython-PY_VERSION%2BPY_RELEASE-target-install_only_stripped.tar.gz', 'function 42', ' 名称: sha_ok', ' 输入参数: 2 个,按位置', ' 第 1 个 | $1', ' 第 2 个 | $2', ' 返回: 最后一句成不成功', ' if 43: 成立:文件 第 1 个参数 存在 并且 〔算 第 1 个参数 的 sha256 → 按空格切,取第 1 段〕 等于 第 2 个参数', 'function 46', ' 名称: fetch', ' 说明: 下载 (已缓存且校验通过就不重下), 回显本地文件路径', ' 输入参数: 2 个,按位置', ' 第 1 个 | url', ' 第 2 个 | sha', ' 输出: 打印 out(调用处用 $(fetch …) 拿到)', ' variable 48', ' 名称: url', ' 初始值: 第 1 个参数', ' 名称: sha', ' 初始值: 第 2 个参数', ' 名称: out', ' 初始值: 无', ' variable 49', ' 名称: out', ' 初始值: cache/〔取文件名 url(其中 %2B 全换成 +)〕', ' command 50: 建目录 cache(连同上级目录)', ' if 51: 如果 不 sha_ok out sha', ' command 52: 打印 "下载 url"(打印到错误输出)', ' command 53: 下载 url 到 out.part(失败就报错、跟着跳转走、最多重试 3 次)', ' command 54: 改名 out.part → out', ' if 56: 如果 不 sha_ok out sha', ' command 57: 打印 "sha256 不符: out"(打印到错误输出)', ' command 58: 退出,退出码 1', ' command 60: 打印 out', 'variable 63', ' 名称: git_pkg', ' 初始值: 执行 〔fetch GIT_URL GIT_SHA256〕 的输出', 'variable 64', ' 名称: py_pkg', ' 初始值: 执行 〔fetch PY_URL PY_SHA256〕 的输出', 'command 66: 删除 dest(连同里面的东西、不提示)', 'command 67: 建目录 dest/git(连同上级目录)', 'case 68: 按 git_pkg 分情况', ' 以 .zip 结尾 | 解压 zip git_pkg 到 dest/git(不打印过程)', ' 其他 | 解压 tar.gz git_pkg 到 dest/git', 'comment 72: python-build-standalone 的包里顶层目录就叫 python', 'command 73: 解压 tar.gz py_pkg 到 dest', 'comment 75: ── 去掉 Git Credential Manager ──────────────────────────────────────────────', 'case 78: 按 target 分情况', ' case 79: 情况:以 -apple-darwin 结尾', ' variable 80', ' 名称: gc', ' 初始值: dest/git/libexec/git-core', ' command 81: 删除 gc/git-credential-manager*、gc/*.dll、gc/*.dylib、gc/createdump、gc/NOTICE、gc/uninstall.sh(连同里面的东西、不提示)', ' comment 83: .NET 的各语言资源目录; git 自己的子目录只有 mergetools', ' command 84: 找出 gc 下一层的目录,除了 mergetools,逐个 删除(连同里面的东西、不提示)', ' case 86: 情况:以 -windows-msvc 结尾', ' comment 87: mingw64/bin 里 git 自己依赖的 DLL 都以 lib 开头 (外加 zlib1.dll), 其余 DLL 都属于 GCM;', ' variable 89', ' 名称: mb', ' 初始值: dest/git/mingw64/bin', ' command 90: 删除 mb/git-credential-manager.exe*、mb/libHarfBuzzSharp.dll、mb/libSkiaSharp.dll(不提示)', ' command 91: 找出 mb 这一层的 *.dll,除了 lib* 和 zlib1.dll,逐个 删除', 'comment 95: Windows 版 python 只有 python.exe; 平台脚本按 python3 这个名字再调起它时 (它的目录前置在', 'if 98: 如果 target 以 -windows-msvc 结尾', ' command 99: 复制 dest/python/python.exe 到 dest/python/python3.exe', 'comment 102: ── 去掉符号链接 ─────────────────────────────────────────────────────────────', 'if 105: 如果 target 以 -apple-darwin 结尾', ' variable 106', ' 名称: gc', ' 初始值: dest/git/libexec/git-core', ' comment 107: 内建命令的短横线别名 (git-add -> git …): git 自己调内建命令不走它们。同 git 构建选项', ' loop 109: 遍历 gc/git-* 匹配到的每个文件 f', ' if 110: 如果不是 〔f 是符号链接 并且 〔读出 f 指向的路径〕 等于 git〕,跳过这一个', ' case 111: 按 〔取文件名 f〕 分情况', ' git-upload-pack 或 git-receive-pack 或 git-upload-archive | 什么也不做', ' 其他 | 删除 f', ' comment 116: python 只留 bin/python3 这一个入口 (执行器按它找), 版本号命名的本体挪过来', ' variable 117', ' 名称: pybin', ' 初始值: dest/python/bin', ' variable 118', ' 名称: real', ' 初始值: 执行 〔读出 pybin/python3 指向的路径〕 的输出', ' command 119: 删除 pybin/python3', ' command 120: 改名 pybin/real → pybin/python3', ' command 121: 找出 dest/python 下面所有符号链接,逐个 删除', ' comment 122: 剩下的 (传输程序、git-remote-https 之类) 换成实体文件', ' pipeline 123: 找出 dest 下面所有符号链接 → 逐行读进 link', ' command 124: 复制 link 指向的实体文件 到 link.tmp', ' command 125: 改名 link.tmp → link', 'command 129: 打印 "自带运行时就绪: dest"(打印到错误输出)', 'command 130: 统计占用空间 dest/git、dest/python(按人读的单位、只看总数)(打印到错误输出)', ] describe('shell 中文直译', () => { it('fetch-runtime.sh 整份:解释器、选项、变量、分情况、函数(参数表、输出、返回)、命令规则、条件、遍历、管道', async () => { expect(await readLines(readFixture('fetchRuntime.sh'))).toEqual(FETCH_READING) }) it('变量展开:长度、去头去尾、截取、没设时、替换;特殊变量', async () => { expect(await readLines(`echo "\${#x} \${x#p} \${x%%s} \${x:1:2} $@ $? \${x:=d} \${x:+y} \${x/a/b}"\n`)).toEqual([ 'command 1: 打印 x 的长度 x(去掉开头的 p) x(去掉结尾最长的 s) x(从第 1 个字符起,取 2 个) 全部参数 上一条命令的退出码 x(没设就设成 d) y(x 设了才有) x(其中第一个 a 换成 b)', ]) expect(await readLines(`echo "\${x:?no}"\n`)).toEqual(['command 1: 打印 x(没设就报错:no)']) }) it.each([ ['[ "$a" -gt 3 ]', 'if 1: 成立:a 大于 3'], ['[[ "$x" != *.sh ]]', 'if 1: 成立:x 不以 .sh 结尾'], ['[ -f x ] && rm x', 'if 1: 如果 文件 x 存在,删除 x'], ])('条件:%s', async (source, expected) => { expect(await readLines(`${source}\n`)).toEqual([expected]) }) it.each([ ['cmd 2>/dev/null', 'cmd(丢掉错误输出)'], ['cmd >> log 2>&1', 'cmd(追加到 log)(错误输出并进输出)'], ['grep -qi error log.txt', '在 log.txt 里找含 error 的行(只看找没找到、不分大小写)'], ['sed -i \'s/a/b/g\' conf', '把 conf 里的 a 全换成 b(直接改文件)'], ['chmod +x run.sh', '给 run.sh 加上可执行权限'], ['ln -sf target link', '建符号链接 link → target(已有就替换)'], ['rm -rf --one-file-system x', '删除 x(连同里面的东西、不提示) --one-file-system'], ['foo -x "$y"', 'foo -x y'], ['mkdir -p d && cd d', '建目录 d(连同上级目录) 然后 进入 d'], ['mkdir -p d && echo hi > d/a', '建目录 d(连同上级目录) 然后 打印 "hi"(写到 d/a)'], ['curl -sSL -o f https://x', '下载 https://x 到 f(不打印进度、跟着跳转走)'], ['. /etc/os-release', '读入 /etc/os-release 里的变量和函数'], ['pg_repack --version', '打印 pg_repack 的版本'], ['cmd || exit 1', 'cmd,失败就 退出,退出码 1'], ])('命令:%s(规则不认识的选项照抄,没有规则的命令照抄命令名)', async (source, expected) => { expect(await readLines(`${source}\n`)).toEqual([`command 1: ${expected}`]) }) it('遍历:写出来的几项、全部参数、只要 / 直到', async () => { expect(await readLines('for x in a b; do echo "$x"; done\n')).toEqual(['loop 1: 遍历 a、b 中的每个 x', ' command 1: 打印 x']) expect(await readLines('for a in "$@"; do shift; done\n')).toEqual(['loop 1: 遍历 全部参数 的每个 a', ' command 1: shift']) expect(await readLines('while [ -n "$x" ]; do sleep 1; done\n')).toEqual(['loop 1: 只要 x 不为空 就一直重复', ' command 1: 等 1 秒']) expect(await readLines('until false; do break; done\n')).toEqual(['loop 1: 直到 失败 之前一直重复', ' command 1: 跳出循环']) }) it('变量的值:$(dirname "$0") 是脚本所在目录,整个值是 $(…) 读成「执行〔…〕的输出」,只读的是常量', async () => { expect(await readLines('x="$(dirname "$0")"\ny=$(pwd)\nreadonly N=3\n')).toEqual([ 'variable 1', ' 名称: x', ' 初始值: 脚本所在目录', 'variable 2', ' 名称: y', ' 初始值: 执行 〔打印当前目录〕 的输出', 'const 3', ' 名称: N', ' 值: 3', ]) }) it('着色:字符串里写死的部分是字符串色,展开的变量是变量色,全大写的名字是常量色', async () => { const [block] = await read(`PY_URL="https://x/$PY_RELEASE/c-\${PY_VERSION}-$target.tgz"\n`) const value = 'properties' in block ? block.properties.find(row => row.label === '初始值')?.value : undefined expect(value && !('markdown' in value) && flatSegments(value).map(({ text, role }) => [text, role])).toEqual([ ['https://x/', 'string'], ['PY_RELEASE', 'constant'], ['/c-', 'string'], ['PY_VERSION', 'constant'], ['-', 'string'], ['target', 'variable'], ['.tgz', 'string'], ]) expect(block.codeTokens.filter(({ role }) => role !== 'plain').map(({ text, role }) => [text, role])).toContainEqual(['PY_URL', 'constant']) }) it('命令规则出错时照抄命令并报告,别的命令不受影响', async () => { const reports: string[] = [] const broken = { ...shellCommandRules.find(rule => rule.commands.includes('rm'))!, read: () => { throw new Error('坏了') } } const { blocks } = await readBlockViews(runtime, bash, bashZhTranslator, 'shellscript', 'rm -rf x\nmkdir d\n', { commandRule: commandRuleLookup([broken, ...shellCommandRules], text => reports.push(text)), }) expect(viewLines(blocks)).toEqual(['command 1: rm -rf x', 'command 2: 建目录 d']) expect(reports).toEqual([expect.stringContaining('坏了')]) }) })