@mzzsfy/dsh-shell-select 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +88 -0
- package/cordis.patch.yml +30 -0
- package/package.json +70 -0
- package/src/api.mjs +109 -0
- package/src/apply-state.mjs +21 -0
- package/src/client.js +757 -0
- package/src/config.mjs +118 -0
- package/src/denylist.mjs +36 -0
- package/src/executor.mjs +530 -0
- package/src/guard-config.mjs +64 -0
- package/src/guard-state.mjs +62 -0
- package/src/guard.js +256 -0
- package/src/render.mjs +59 -0
- package/src/resolve.mjs +128 -0
- package/src/sandbox-classify.mjs +82 -0
- package/src/tool.mjs +350 -0
- package/test/client-card.test.mjs +106 -0
- package/test/client-drift.test.mjs +27 -0
- package/test/client-id.test.mjs +68 -0
- package/test/config-entry.test.mjs +93 -0
- package/test/config.test.mjs +106 -0
- package/test/deny-config.test.mjs +33 -0
- package/test/deny-tool.test.mjs +26 -0
- package/test/denylist.test.mjs +39 -0
- package/test/env.test.mjs +81 -0
- package/test/executor-seam.test.mjs +136 -0
- package/test/executor.test.mjs +363 -0
- package/test/guard-entry.test.mjs +157 -0
- package/test/guard-retry.test.mjs +79 -0
- package/test/guard-state.test.mjs +111 -0
- package/test/render.test.mjs +73 -0
- package/test/resolve.test.mjs +131 -0
- package/test/sandbox-classify.test.mjs +67 -0
- package/test/switch-guard.test.mjs +37 -0
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
// guard 哨兵(桩 loader):BDD 场景见 docs/progress/shell-select-plan.md「guard」。
|
|
2
|
+
|
|
3
|
+
import { test } from 'node:test'
|
|
4
|
+
import assert from 'node:assert/strict'
|
|
5
|
+
import { detectDeadState, rowState, effectiveDisabled, MAIN_ROW_ID, OFFICIAL_ROW_IDS } from '../src/guard-state.mjs'
|
|
6
|
+
import { officialRowConfig } from '../src/guard-config.mjs'
|
|
7
|
+
|
|
8
|
+
// --- 桩 loader / 行 ---
|
|
9
|
+
|
|
10
|
+
function row({ id, disabled = false, running = false }) {
|
|
11
|
+
return {
|
|
12
|
+
options: { id },
|
|
13
|
+
get disabled() {
|
|
14
|
+
if (disabled === 'throw') throw new Error('expr eval failed')
|
|
15
|
+
return disabled
|
|
16
|
+
},
|
|
17
|
+
fiber: running ? { uid: 'f1' } : undefined,
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function loader(rows) {
|
|
22
|
+
const map = new Map(rows.map((entry) => [entry.options.id, entry]))
|
|
23
|
+
return {
|
|
24
|
+
resolve(id) {
|
|
25
|
+
if (map.has(id)) return map.get(id)
|
|
26
|
+
throw new Error('not found')
|
|
27
|
+
},
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
const MAIN = MAIN_ROW_ID
|
|
32
|
+
const [OFF_TOOL, OFF_EXEC] = OFFICIAL_ROW_IDS
|
|
33
|
+
|
|
34
|
+
test('rowState:缺席/在场禁用/在场启用/运行中 四态', () => {
|
|
35
|
+
assert.deepEqual(rowState(loader([]), MAIN), { present: false, disabled: false, running: false })
|
|
36
|
+
assert.deepEqual(rowState(loader([row({ id: MAIN, disabled: true })]), MAIN), { present: true, disabled: true, running: false })
|
|
37
|
+
assert.deepEqual(rowState(loader([row({ id: MAIN })]), MAIN), { present: true, disabled: false, running: false })
|
|
38
|
+
assert.deepEqual(rowState(loader([row({ id: MAIN, running: true })]), MAIN), { present: true, disabled: false, running: true })
|
|
39
|
+
})
|
|
40
|
+
|
|
41
|
+
test('effectiveDisabled:disabled getter 抛错按未禁处理(让位安全向)', () => {
|
|
42
|
+
assert.equal(effectiveDisabled(row({ id: 'x', disabled: 'throw' })), false)
|
|
43
|
+
})
|
|
44
|
+
|
|
45
|
+
test('死态:主行禁用停稳(或旗标 inactive)且官方两行禁用停稳', () => {
|
|
46
|
+
const dead = loader([
|
|
47
|
+
row({ id: MAIN, disabled: true }),
|
|
48
|
+
row({ id: OFF_TOOL, disabled: true }),
|
|
49
|
+
row({ id: OFF_EXEC, disabled: true }),
|
|
50
|
+
])
|
|
51
|
+
assert.equal(detectDeadState(dead, { applyState: () => 'active' }), true)
|
|
52
|
+
})
|
|
53
|
+
|
|
54
|
+
test('非死态:主行活着 / 主行禁但官方任一未禁 / 行运行中', () => {
|
|
55
|
+
const alive = loader([
|
|
56
|
+
row({ id: MAIN }),
|
|
57
|
+
row({ id: OFF_TOOL, disabled: true }),
|
|
58
|
+
row({ id: OFF_EXEC, disabled: true }),
|
|
59
|
+
])
|
|
60
|
+
assert.equal(detectDeadState(alive, { applyState: () => 'active' }), false)
|
|
61
|
+
|
|
62
|
+
const officialAlive = loader([
|
|
63
|
+
row({ id: MAIN, disabled: true }),
|
|
64
|
+
row({ id: OFF_TOOL, disabled: false }),
|
|
65
|
+
row({ id: OFF_EXEC, disabled: true }),
|
|
66
|
+
])
|
|
67
|
+
assert.equal(detectDeadState(officialAlive, { applyState: () => 'active' }), false)
|
|
68
|
+
|
|
69
|
+
const running = loader([
|
|
70
|
+
row({ id: MAIN, disabled: true, running: true }),
|
|
71
|
+
row({ id: OFF_TOOL, disabled: true }),
|
|
72
|
+
row({ id: OFF_EXEC, disabled: true }),
|
|
73
|
+
])
|
|
74
|
+
assert.equal(detectDeadState(running, { applyState: () => 'active' }), false)
|
|
75
|
+
})
|
|
76
|
+
|
|
77
|
+
test('apply 旗标 inactive 等价主行功能性停摆(行未禁也判死)', () => {
|
|
78
|
+
const zombie = loader([
|
|
79
|
+
row({ id: MAIN }),
|
|
80
|
+
row({ id: OFF_TOOL, disabled: true }),
|
|
81
|
+
row({ id: OFF_EXEC, disabled: true }),
|
|
82
|
+
])
|
|
83
|
+
assert.equal(detectDeadState(zombie, { applyState: () => 'inactive' }), true)
|
|
84
|
+
})
|
|
85
|
+
|
|
86
|
+
test('apply 旗标 pending(崩溃中)且行运行中:保守不判死', () => {
|
|
87
|
+
const rows = loader([
|
|
88
|
+
row({ id: MAIN, running: true }),
|
|
89
|
+
row({ id: OFF_TOOL, disabled: true }),
|
|
90
|
+
row({ id: OFF_EXEC, disabled: true }),
|
|
91
|
+
])
|
|
92
|
+
assert.equal(detectDeadState(rows, { applyState: () => 'pending' }), false)
|
|
93
|
+
})
|
|
94
|
+
|
|
95
|
+
test('主行禁用停稳时旗标不阻断判定(行确已停,残留旗标无意义)', () => {
|
|
96
|
+
const rows = loader([
|
|
97
|
+
row({ id: MAIN, disabled: true }),
|
|
98
|
+
row({ id: OFF_TOOL, disabled: true }),
|
|
99
|
+
row({ id: OFF_EXEC, disabled: true }),
|
|
100
|
+
])
|
|
101
|
+
assert.equal(detectDeadState(rows, { applyState: () => 'pending' }), true)
|
|
102
|
+
})
|
|
103
|
+
|
|
104
|
+
test('officialRowConfig:读 settings.yaml shell 节,缺失返回空对象', async () => {
|
|
105
|
+
const section = await officialRowConfig(async () => ({ shell: { pwshPath: 'C:\\x\\pwsh.exe' } }))
|
|
106
|
+
assert.deepEqual(section, { pwshPath: 'C:\\x\\pwsh.exe' })
|
|
107
|
+
assert.deepEqual(await officialRowConfig(async () => ({})), {})
|
|
108
|
+
assert.deepEqual(await officialRowConfig(async () => {
|
|
109
|
+
throw new Error('no file')
|
|
110
|
+
}), {})
|
|
111
|
+
})
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
// render 工具渲染镜像:BDD 场景见 docs/progress/shell-select-plan.md「模块 render」。
|
|
2
|
+
// 期望文本与官方 dsh-tool-pwsh 渲染逐字对齐,terminal 卡解析(parseExitStatus)才能复原退出 pill。
|
|
3
|
+
|
|
4
|
+
import { test } from 'node:test'
|
|
5
|
+
import assert from 'node:assert/strict'
|
|
6
|
+
import { renderResult, renderProcessRead } from '../src/render.mjs'
|
|
7
|
+
|
|
8
|
+
const NO_ESCALATION = []
|
|
9
|
+
|
|
10
|
+
test('干净退出:仅 stdout,无标记', () => {
|
|
11
|
+
const text = renderResult({
|
|
12
|
+
stdout: { text: 'hello\n', truncated: false },
|
|
13
|
+
stderr: { text: '', truncated: false },
|
|
14
|
+
exitCode: 0, signal: null, timedOut: false, sandbox: undefined,
|
|
15
|
+
}, NO_ESCALATION)
|
|
16
|
+
assert.equal(text, 'hello\n')
|
|
17
|
+
})
|
|
18
|
+
|
|
19
|
+
test('stderr 段 + 非零退出标记次序', () => {
|
|
20
|
+
const text = renderResult({
|
|
21
|
+
stdout: { text: 'out\n', truncated: false },
|
|
22
|
+
stderr: { text: 'err\n', truncated: false },
|
|
23
|
+
exitCode: 2, signal: null, timedOut: false, sandbox: undefined,
|
|
24
|
+
}, NO_ESCALATION)
|
|
25
|
+
assert.equal(text, 'out\n[stderr]\nerr\n[exit code: 2]')
|
|
26
|
+
})
|
|
27
|
+
|
|
28
|
+
test('超时与信号标记', () => {
|
|
29
|
+
const text = renderResult({
|
|
30
|
+
stdout: { text: '', truncated: false },
|
|
31
|
+
stderr: { text: '', truncated: false },
|
|
32
|
+
exitCode: null, signal: 'SIGTERM', timedOut: true, timeoutMs: 1000, sandbox: undefined,
|
|
33
|
+
}, NO_ESCALATION)
|
|
34
|
+
assert.equal(text, '(no output)\n[timed out after 1000ms]\n[killed by signal: SIGTERM]')
|
|
35
|
+
})
|
|
36
|
+
|
|
37
|
+
test('截断输出追加 spill 提示', () => {
|
|
38
|
+
const text = renderResult({
|
|
39
|
+
stdout: { text: 'partial', truncated: true, spillPath: 'C:\\spill.txt' },
|
|
40
|
+
stderr: { text: '', truncated: false },
|
|
41
|
+
exitCode: 0, signal: null, timedOut: false, sandbox: undefined,
|
|
42
|
+
}, NO_ESCALATION)
|
|
43
|
+
assert.equal(text, 'partial\n[output truncated; full output: C:\\spill.txt]')
|
|
44
|
+
})
|
|
45
|
+
|
|
46
|
+
test('沙箱拒绝:拒绝标记 + 升权提示', () => {
|
|
47
|
+
const text = renderResult({
|
|
48
|
+
stdout: { text: '', truncated: false },
|
|
49
|
+
stderr: { text: '', truncated: false },
|
|
50
|
+
exitCode: 1, signal: null, timedOut: false,
|
|
51
|
+
sandbox: { mode: 'workspace-write', denied: true },
|
|
52
|
+
}, ['read-only', 'workspace-write'])
|
|
53
|
+
assert.match(text, /\[sandbox: file access denied under workspace-write mode\]/)
|
|
54
|
+
assert.match(text, /sandbox_permissions/)
|
|
55
|
+
})
|
|
56
|
+
|
|
57
|
+
test('后台 read:lossy 丢弃提示带 spill 路径', () => {
|
|
58
|
+
const text = renderProcessRead({
|
|
59
|
+
delta: 'chunk',
|
|
60
|
+
lossy: true,
|
|
61
|
+
stdoutSpillPath: 'C:\\out.txt',
|
|
62
|
+
stderrSpillPath: 'C:\\err.txt',
|
|
63
|
+
}, undefined, NO_ESCALATION)
|
|
64
|
+
assert.match(text, /some output was dropped from memory; full output: C:\\out\.txt, C:\\err\.txt/)
|
|
65
|
+
})
|
|
66
|
+
|
|
67
|
+
test('后台 read:runnerFailed 提示优先于 denial', () => {
|
|
68
|
+
const text = renderProcessRead({ delta: '', lossy: false }, {
|
|
69
|
+
mode: 'read-only', runnerFailed: true, denied: false,
|
|
70
|
+
}, ['read-only'])
|
|
71
|
+
assert.match(text, /sandbox runner itself failed/)
|
|
72
|
+
assert.doesNotMatch(text, /file access denied/)
|
|
73
|
+
})
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
// resolve 候选探测:BDD 场景见 docs/progress/shell-select-plan.md「模块 resolve」。
|
|
2
|
+
// 平台差异经注入 env/platform 消除,测试不依赖真机安装布局。
|
|
3
|
+
|
|
4
|
+
import { test } from 'node:test'
|
|
5
|
+
import assert from 'node:assert/strict'
|
|
6
|
+
import { candidatePaths, resolveEntryPath, detectCandidates } from '../src/resolve.mjs'
|
|
7
|
+
|
|
8
|
+
// 桩 lstat:按路径集合判定存在,文件形态命中
|
|
9
|
+
function stubExists(existing) {
|
|
10
|
+
const set = new Set(existing)
|
|
11
|
+
return (candidate) => set.has(candidate)
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
const ENV = {
|
|
15
|
+
ProgramFiles: 'C:\\PF',
|
|
16
|
+
'ProgramFiles(x86)': 'C:\\PF86',
|
|
17
|
+
LocalAppData: 'C:\\LAD',
|
|
18
|
+
SystemRoot: 'C:\\WINDOWS',
|
|
19
|
+
PATH: 'C:\\one;C:\\two ;"C:\\three"',
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
const ENV_WITH_SYSTEM32_PATH = {
|
|
23
|
+
...ENV,
|
|
24
|
+
PATH: 'C:\\WINDOWS\\System32;C:\\one',
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
test('pwsh 候选顺序:PowerShell 7 → PATH 各项 → System32 5.1', () => {
|
|
28
|
+
const candidates = candidatePaths('pwsh', ENV)
|
|
29
|
+
assert.deepEqual(candidates, [
|
|
30
|
+
'C:\\PF\\PowerShell\\7\\pwsh.exe',
|
|
31
|
+
'C:\\one\\pwsh.exe',
|
|
32
|
+
'C:\\two\\pwsh.exe',
|
|
33
|
+
'C:\\three\\pwsh.exe',
|
|
34
|
+
'C:\\WINDOWS\\System32\\WindowsPowerShell\\v1.0\\powershell.exe',
|
|
35
|
+
])
|
|
36
|
+
})
|
|
37
|
+
|
|
38
|
+
test('bash 候选顺序:Git 三常见位置 → msys2 真实 bash → PATH', () => {
|
|
39
|
+
const candidates = candidatePaths('bash', ENV)
|
|
40
|
+
assert.deepEqual(candidates, [
|
|
41
|
+
'C:\\PF\\Git\\bin\\bash.exe',
|
|
42
|
+
'C:\\PF86\\Git\\bin\\bash.exe',
|
|
43
|
+
'C:\\LAD\\Programs\\Git\\bin\\bash.exe',
|
|
44
|
+
'C:\\msys64\\usr\\bin\\bash.exe',
|
|
45
|
+
'C:\\msys64\\bin\\bash.exe',
|
|
46
|
+
'C:\\one\\bash.exe',
|
|
47
|
+
'C:\\two\\bash.exe',
|
|
48
|
+
'C:\\three\\bash.exe',
|
|
49
|
+
])
|
|
50
|
+
})
|
|
51
|
+
|
|
52
|
+
test('bash 候选排除 SystemRoot 下的 PATH 条目(WSL forwarder)', () => {
|
|
53
|
+
const candidates = candidatePaths('bash', ENV_WITH_SYSTEM32_PATH)
|
|
54
|
+
assert.deepEqual(candidates, [
|
|
55
|
+
'C:\\PF\\Git\\bin\\bash.exe',
|
|
56
|
+
'C:\\PF86\\Git\\bin\\bash.exe',
|
|
57
|
+
'C:\\LAD\\Programs\\Git\\bin\\bash.exe',
|
|
58
|
+
'C:\\msys64\\usr\\bin\\bash.exe',
|
|
59
|
+
'C:\\msys64\\bin\\bash.exe',
|
|
60
|
+
'C:\\one\\bash.exe',
|
|
61
|
+
])
|
|
62
|
+
})
|
|
63
|
+
|
|
64
|
+
test('bash 候选排除 SystemRoot 下的正斜杠 PATH 条目', () => {
|
|
65
|
+
const env = { ...ENV, PATH: 'C:/WINDOWS/System32;C:\\one' }
|
|
66
|
+
const candidates = candidatePaths('bash', env)
|
|
67
|
+
assert.ok(!candidates.some((candidate) => candidate.toLowerCase().startsWith('c:\\windows')))
|
|
68
|
+
assert.ok(candidates.includes('C:\\one\\bash.exe'))
|
|
69
|
+
})
|
|
70
|
+
|
|
71
|
+
test('SystemRoot 带尾分隔符时排除规则仍命中', () => {
|
|
72
|
+
const env = { ...ENV, SystemRoot: 'C:\\WINDOWS\\', PATH: 'C:\\WINDOWS\\System32;C:\\one' }
|
|
73
|
+
const candidates = candidatePaths('bash', env)
|
|
74
|
+
assert.ok(!candidates.some((candidate) => candidate.toLowerCase().startsWith('c:\\windows')), '尾分隔符 SystemRoot 不得让 forwarder 入候选')
|
|
75
|
+
assert.ok(candidates.includes('C:\\one\\bash.exe'))
|
|
76
|
+
})
|
|
77
|
+
|
|
78
|
+
test('LocalAppData 缺省:LAD 锚位跳过,其余候选不受影响', () => {
|
|
79
|
+
const env = { ProgramFiles: 'C:\\PF', 'ProgramFiles(x86)': 'C:\\PF86', LocalAppData: '', PATH: '' }
|
|
80
|
+
const candidates = candidatePaths('bash', env)
|
|
81
|
+
assert.ok(!candidates.some((candidate) => candidate.startsWith('\\\\')), 'LocalAppData 为空不得产出空根拼接候选')
|
|
82
|
+
assert.ok(!candidates.some((candidate) => candidate.startsWith('undefined')))
|
|
83
|
+
assert.ok(candidates.includes('C:\\PF\\Git\\bin\\bash.exe'))
|
|
84
|
+
assert.ok(candidates.includes('C:\\msys64\\usr\\bin\\bash.exe'))
|
|
85
|
+
})
|
|
86
|
+
|
|
87
|
+
test('bash 候选永不含 msys2.exe 启动器(管道下静默失败)', () => {
|
|
88
|
+
for (const candidate of candidatePaths('bash', ENV)) {
|
|
89
|
+
assert.ok(!candidate.toLowerCase().includes('msys2.exe'), candidate)
|
|
90
|
+
}
|
|
91
|
+
})
|
|
92
|
+
|
|
93
|
+
test('cmd/wsl 候选:仅 System32 单点', () => {
|
|
94
|
+
assert.deepEqual(candidatePaths('cmd', ENV), ['C:\\WINDOWS\\System32\\cmd.exe'])
|
|
95
|
+
assert.deepEqual(candidatePaths('wsl', ENV), ['C:\\WINDOWS\\System32\\wsl.exe'])
|
|
96
|
+
})
|
|
97
|
+
|
|
98
|
+
test('显式路径原样使用,不探测', () => {
|
|
99
|
+
const resolved = resolveEntryPath({ kind: 'bash', path: 'D:\\tools\\my-bash.exe' }, stubExists([]))
|
|
100
|
+
assert.equal(resolved, 'D:\\tools\\my-bash.exe')
|
|
101
|
+
})
|
|
102
|
+
|
|
103
|
+
test('自动解析命中首个存在候选', () => {
|
|
104
|
+
const resolved = resolveEntryPath({ kind: 'bash', path: '' }, stubExists([
|
|
105
|
+
'C:\\one\\bash.exe',
|
|
106
|
+
'C:\\PF\\Git\\bin\\bash.exe',
|
|
107
|
+
]), ENV)
|
|
108
|
+
assert.equal(resolved, 'C:\\PF\\Git\\bin\\bash.exe')
|
|
109
|
+
})
|
|
110
|
+
|
|
111
|
+
test('无候选存在返回 undefined', () => {
|
|
112
|
+
assert.equal(resolveEntryPath({ kind: 'pwsh', path: '' }, stubExists([]), ENV), undefined)
|
|
113
|
+
})
|
|
114
|
+
|
|
115
|
+
test('detect 返回全部命中并去重', () => {
|
|
116
|
+
const found = detectCandidates(['bash', 'cmd'], ENV, stubExists([
|
|
117
|
+
'C:\\PF\\Git\\bin\\bash.exe',
|
|
118
|
+
'C:\\three\\bash.exe',
|
|
119
|
+
'C:\\WINDOWS\\System32\\cmd.exe',
|
|
120
|
+
]))
|
|
121
|
+
assert.deepEqual(found, [
|
|
122
|
+
{ kind: 'bash', path: 'C:\\PF\\Git\\bin\\bash.exe' },
|
|
123
|
+
{ kind: 'bash', path: 'C:\\three\\bash.exe' },
|
|
124
|
+
{ kind: 'cmd', path: 'C:\\WINDOWS\\System32\\cmd.exe' },
|
|
125
|
+
])
|
|
126
|
+
})
|
|
127
|
+
|
|
128
|
+
test('detect 同路径跨 kind 不重复收录', () => {
|
|
129
|
+
const found = detectCandidates(['pwsh', 'bash'], ENV, stubExists(['C:\\one\\bash.exe', 'C:\\one\\pwsh.exe']))
|
|
130
|
+
assert.equal(found.length, 2)
|
|
131
|
+
})
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
// sandbox-classify 官方镜像:BDD 场景见 docs/progress/shell-select-plan.md「模块 sandbox-classify」。
|
|
2
|
+
// 逐项对照 dsh-pwsh-sandbox lib/index.js helpers,签名词表对齐官方 dsh-sandbox-local。
|
|
3
|
+
|
|
4
|
+
import { test } from 'node:test'
|
|
5
|
+
import assert from 'node:assert/strict'
|
|
6
|
+
import { classifyDenial, classifyRunnerFailure, isRunnerSpawnFailure, isUsableWorkdir } from '../src/sandbox-classify.mjs'
|
|
7
|
+
|
|
8
|
+
const SIG = ['file access denied', 'EPERM: operation not permitted']
|
|
9
|
+
|
|
10
|
+
test('denial:非零退出 + stderr 命中签名(大小写不敏感)', () => {
|
|
11
|
+
assert.equal(classifyDenial({ exitCode: 1, stderr: { text: 'bash: /x: File Access Denied\r\n' } }, SIG), true)
|
|
12
|
+
})
|
|
13
|
+
|
|
14
|
+
test('denial:零退出或信号死亡不判拒绝', () => {
|
|
15
|
+
assert.equal(classifyDenial({ exitCode: 0, stderr: { text: 'file access denied' } }, SIG), false)
|
|
16
|
+
assert.equal(classifyDenial({ exitCode: null, stderr: { text: 'file access denied' } }, SIG), false)
|
|
17
|
+
})
|
|
18
|
+
|
|
19
|
+
const RULES = [
|
|
20
|
+
{
|
|
21
|
+
allowedExitCodes: [126],
|
|
22
|
+
informationalLines: ['info: harmless'],
|
|
23
|
+
fatalSignatures: ['cannot run runner'],
|
|
24
|
+
},
|
|
25
|
+
{ fatalSignatures: ['runner died'] },
|
|
26
|
+
]
|
|
27
|
+
|
|
28
|
+
test('runnerFailure:允许码外 + 致命行命中;信息行豁免;非允许码跳过', () => {
|
|
29
|
+
assert.deepEqual(
|
|
30
|
+
classifyRunnerFailure(126, 'info: harmless\ncannot run runner: boom\n', RULES),
|
|
31
|
+
{ detail: 'cannot run runner: boom' },
|
|
32
|
+
)
|
|
33
|
+
assert.equal(classifyRunnerFailure(999, 'nothing fatal here\n', RULES), undefined)
|
|
34
|
+
assert.deepEqual(
|
|
35
|
+
classifyRunnerFailure(2, 'some prefix runner died\n', RULES),
|
|
36
|
+
{ detail: 'some prefix runner died' },
|
|
37
|
+
)
|
|
38
|
+
})
|
|
39
|
+
|
|
40
|
+
test('runnerFailure:零退出与信号死亡不判失败', () => {
|
|
41
|
+
assert.equal(classifyRunnerFailure(0, 'runner died\n', RULES), undefined)
|
|
42
|
+
assert.equal(classifyRunnerFailure(null, 'runner died\n', RULES), undefined)
|
|
43
|
+
})
|
|
44
|
+
|
|
45
|
+
test('runnerSpawnFailure:ENOENT/EACCES + spawn 系统调用 + argv0 溯源', () => {
|
|
46
|
+
const workdir = process.cwd()
|
|
47
|
+
assert.equal(isRunnerSpawnFailure(
|
|
48
|
+
{ code: 'ENOENT', syscall: 'spawn C:\\runner\\wrap.exe', path: 'C:\\runner\\wrap.exe' },
|
|
49
|
+
'C:\\runner\\wrap.exe', workdir,
|
|
50
|
+
), true)
|
|
51
|
+
assert.equal(isRunnerSpawnFailure(
|
|
52
|
+
{ code: 'EACCES', syscall: 'spawn C:\\runner\\wrap.exe' },
|
|
53
|
+
'C:\\runner\\wrap.exe', workdir,
|
|
54
|
+
), true)
|
|
55
|
+
})
|
|
56
|
+
|
|
57
|
+
test('runnerSpawnFailure:其他码/其他路径/坏 cwd 不判 runner 失败', () => {
|
|
58
|
+
const workdir = process.cwd()
|
|
59
|
+
assert.equal(isRunnerSpawnFailure({ code: 'EACCES', syscall: 'spawn' }, undefined, workdir), false)
|
|
60
|
+
assert.equal(isRunnerSpawnFailure({ code: 'ENOENT', syscall: 'spawn', path: 'C:\\other.exe' }, 'C:\\runner\\wrap.exe', workdir), false)
|
|
61
|
+
assert.equal(isRunnerSpawnFailure({ code: 'EACCES', syscall: 'spawn x' }, 'C:\\runner\\wrap.exe', 'Z:\\definitely\\missing'), false)
|
|
62
|
+
})
|
|
63
|
+
|
|
64
|
+
test('isUsableWorkdir:存在目录 true,缺失 false', () => {
|
|
65
|
+
assert.equal(isUsableWorkdir(process.cwd()), true)
|
|
66
|
+
assert.equal(isUsableWorkdir('Z:\\definitely\\missing'), false)
|
|
67
|
+
})
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
// 开关样式守卫 BDD:裸 checkbox 直接呈现违反仓库 client 规约(DEVELOPMENT/plugin-client-conventions.md),
|
|
2
|
+
// 静态断言锁死 sls-switch 结构(参照 mce-switch/tn-switch/cb-switch 同款守卫)。
|
|
3
|
+
import { readFileSync } from 'node:fs'
|
|
4
|
+
import { dirname, join } from 'node:path'
|
|
5
|
+
import { fileURLToPath } from 'node:url'
|
|
6
|
+
import test from 'node:test'
|
|
7
|
+
import assert from 'node:assert/strict'
|
|
8
|
+
|
|
9
|
+
const PKG_ROOT = join(dirname(fileURLToPath(import.meta.url)), '..')
|
|
10
|
+
const source = readFileSync(join(PKG_ROOT, 'src', 'client.js'), 'utf8')
|
|
11
|
+
|
|
12
|
+
test('switch 隐藏规则以 input[type="checkbox"] 精确匹配', () => {
|
|
13
|
+
assert.match(source, /\.sls-switch input\[type="checkbox"\] \{ position:absolute/)
|
|
14
|
+
})
|
|
15
|
+
|
|
16
|
+
test('switch 状态选择器禁止裸 input 锚定(防误伤同 label 的文本输入)', () => {
|
|
17
|
+
const bare = source.match(/\.sls-switch input:(?!\[type)[a-z-]+/g)
|
|
18
|
+
assert.equal(bare, null, `裸 input 状态选择器: ${bare}`)
|
|
19
|
+
})
|
|
20
|
+
|
|
21
|
+
test('checkbox 仅允许出现在 switchToggle 工厂内,禁止裸 checkbox 直出', () => {
|
|
22
|
+
const occurrences = [...source.matchAll(/type: 'checkbox'/g)].map((match) => match.index)
|
|
23
|
+
assert.equal(occurrences.length, 1, `checkbox 字面量出现 ${occurrences.length} 次`)
|
|
24
|
+
const factoryStart = source.indexOf('function switchToggle')
|
|
25
|
+
const factoryEnd = source.indexOf('}', source.indexOf('__thumb', factoryStart))
|
|
26
|
+
assert.ok(occurrences[0] > factoryStart && occurrences[0] < factoryEnd, 'checkbox 字面量不在 switchToggle 工厂内')
|
|
27
|
+
})
|
|
28
|
+
|
|
29
|
+
test('switchToggle 产出顺序为 input 在前 track 在后', () => {
|
|
30
|
+
const factory = source.slice(source.indexOf('function switchToggle'))
|
|
31
|
+
assert.ok(factory.indexOf("h('input'") < factory.indexOf('sls-switch__track'), '工厂内 input 必须先于 track')
|
|
32
|
+
})
|
|
33
|
+
|
|
34
|
+
test('checked/focus-visible 两态均以 checkbox 锚定', () => {
|
|
35
|
+
assert.match(source, /\.sls-switch input\[type="checkbox"\]:checked \+/)
|
|
36
|
+
assert.match(source, /\.sls-switch input\[type="checkbox"\]:focus-visible \+/)
|
|
37
|
+
})
|