241 lines
13 KiB
TypeScript
241 lines
13 KiB
TypeScript
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<BlockView[]> {
|
||
const { blocks } = await readBlockViews(runtime, bash, bashZhTranslator, 'shellscript', source, { commandRule: commandRuleLookup(RULES, text => reports.push(text)) })
|
||
return blocks
|
||
}
|
||
|
||
async function readLines(source: string): Promise<string[]> {
|
||
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/<target>/{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',
|
||
' 其他 | 打印 "用法: 脚本路径 <x86_64-pc-windows-msvc|aarch64-apple-darwin>"(打印到错误输出) ⏎ 退出,退出码 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('坏了')])
|
||
})
|
||
})
|