dsh-prompt-customizer 0.9.2 → 2.1.2

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.
Files changed (64) hide show
  1. package/README.en.md +49 -234
  2. package/README.md +45 -310
  3. package/build.mjs +27 -10
  4. package/client/client.js +2446 -1364
  5. package/client/client.js.map +4 -4
  6. package/docs/adr/0001-remove-catalog-pool.md +27 -0
  7. package/docs/adr/0002-hide-identity-name-source.md +23 -0
  8. package/docs/adr/0003-destructive-updates-allowed.md +18 -0
  9. package/docs/adr/0004-generalized-filter-model.md +17 -0
  10. package/docs/adr/0005-universal-prompt-takeover.md +109 -0
  11. package/docs/design/prompt-control-consensus.md +103 -0
  12. package/docs/guide.md +280 -0
  13. package/docs/images/prompt.png +0 -0
  14. package/docs/images/tool.png +0 -0
  15. package/docs/testing/feature-matrix.md +142 -0
  16. package/docs/upgrades/1.0.0-sqlite-breaking.md +26 -0
  17. package/docs/upgrades/1.4.0-generalized-model.md +23 -0
  18. package/docs/upgrades/1.4.1-web-preview-fixes.md +15 -0
  19. package/docs/upgrades/2.0.0-remove-workspace-level.md +16 -0
  20. package/docs/upgrades/2.0.1-context-fixes.md +6 -0
  21. package/docs/upgrades/2.0.10-import-and-skill.md +6 -0
  22. package/docs/upgrades/2.0.11-regression-fixes.md +7 -0
  23. package/docs/upgrades/2.0.12-generative-tool-tests.md +5 -0
  24. package/docs/upgrades/2.0.13-runtime-contract.md +8 -0
  25. package/docs/upgrades/2.0.14-tool-execution-guard.md +5 -0
  26. package/docs/upgrades/2.0.15-mask-pool-regression.md +5 -0
  27. package/docs/upgrades/2.0.16-regression-cleanup.md +6 -0
  28. package/docs/upgrades/2.0.17-unhide-source.md +5 -0
  29. package/docs/upgrades/2.0.18-unhide-cleanup.md +4 -0
  30. package/docs/upgrades/2.0.19-mcp-json-edit.md +4 -0
  31. package/docs/upgrades/2.0.2-generic-context-filter.md +6 -0
  32. package/docs/upgrades/2.0.20-preset-isolation.md +4 -0
  33. package/docs/upgrades/2.0.21-isolation-restore-test.md +4 -0
  34. package/docs/upgrades/2.0.22-source-immutability.md +3 -0
  35. package/docs/upgrades/2.0.3-skill-summary.md +6 -0
  36. package/docs/upgrades/2.0.4-tool-draft.md +5 -0
  37. package/docs/upgrades/2.0.5-tool-catalog-robustness.md +13 -0
  38. package/docs/upgrades/2.0.6-tool-restrict.md +6 -0
  39. package/docs/upgrades/2.0.7-tool-stability.md +5 -0
  40. package/docs/upgrades/2.0.8-catalog-row-stability.md +4 -0
  41. package/docs/upgrades/2.0.9-tool-and-write.md +8 -0
  42. package/docs/upgrades/2.1.0-universal-prompt-takeover.md +41 -0
  43. package/docs/upgrades/2.1.1-tool-shadow-detection.md +48 -0
  44. package/docs/upgrades/2.1.2-tool-catalog-scope.md +55 -0
  45. package/docs/upgrades/README.md +298 -0
  46. package/docs/upgrades/dsh-0.2.2-rc1.md +49 -0
  47. package/docs/upgrades/rc2-migration.md +70 -0
  48. package/lib/index.js +4697 -1701
  49. package/lib/index.js.map +4 -4
  50. package/package.json +7 -5
  51. package/scripts/e2e-report.json +221 -0
  52. package/scripts/e2e-terminal.mjs +210 -0
  53. package/scripts/simulate-rescan.mjs +184 -0
  54. package/scripts/smoke-host.mjs +1 -1
  55. package/scripts/verify-apply-all.mjs +64 -0
  56. package/scripts/verify-effect.mjs +153 -0
  57. package/scripts/verify-mcp-edit-ext.mjs +48 -0
  58. package/scripts/verify-mcp-edit.mjs +45 -0
  59. package/scripts/verify-panel-browser.mjs +298 -0
  60. package/scripts/verify-workspace-mcp.mjs +62 -0
  61. package/scripts/test-mcp-layering.mjs +0 -441
  62. package/scripts/test-mcp-tool-disable.mjs +0 -420
  63. package/scripts/test-rc2-runtime.mjs +0 -42
  64. package/scripts/test-skill-toggles.mjs +0 -100
@@ -112,13 +112,13 @@ need(paths.some(p => p.startsWith('/api/prompt-customizer')), 'prompt customizer
112
112
  need(listeners.has('agent/created'), 'agent/created hooked (预设层开关与遮蔽)')
113
113
  need(listeners.has('tools/change'), 'tools/change hooked (装配过滤失效重算)')
114
114
  need(listeners.has('system-prompt/assemble'), 'system-prompt/assemble hooked (提示词段覆盖)')
115
+ need(listeners.has('session/event'), 'session/event hooked (阶段化工具目录按会话事件重算)')
115
116
 
116
117
  // 三个已移除的模块不得留下任何路由、工具或事件钩子。
117
118
  const leftovers = paths.filter(p => /^\/api\/(dsh-memory|usage-stats|triad-automation)/.test(p))
118
119
  need(leftovers.length === 0, `removed modules left no routes${leftovers.length > 0 ? `: ${leftovers.join(', ')}` : ''}`)
119
120
  need(tools.length === 0, `no model tools registered${tools.length > 0 ? `: ${tools.join(', ')}` : ''}`)
120
121
  need(!listeners.has('agent/pre-step'), 'agent/pre-step injection gone')
121
- need(!listeners.has('session/event'), 'session/event capture gone')
122
122
 
123
123
  const warns = logs.filter(([lvl]) => lvl === 'warn')
124
124
  if (warns.length > 0) {
@@ -0,0 +1,64 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * 「应用到所有预设」跨预设实测:对多个预设写入 per-preset 覆盖 → 执行应用全部 → 断言全部被清空(继承全部/全局)。
4
+ * 覆盖 SKILL(resetPreset per preset)与 MCP(/api/triad/mcp-apply-all)。写入前备份、测后恢复。
5
+ */
6
+ const argv = process.argv.slice(2)
7
+ const arg = (n, d) => { const i = argv.indexOf(n); return i >= 0 && argv[i + 1] !== undefined ? argv[i + 1] : d }
8
+ const TOKEN_URL = arg('--url'); if (!TOKEN_URL) { console.error('缺 --url'); process.exit(2) }
9
+ const ORIGIN = new URL(TOKEN_URL).origin
10
+ const results = []
11
+ const rec = (name, ok, detail) => { results.push({ name, ok, detail }); console.log(`${ok ? 'PASS' : 'FAIL'} ${name}${detail ? ` — ${detail}` : ''}`) }
12
+ let cookie = ''
13
+ async function handshake() { const r = await fetch(TOKEN_URL, { redirect: 'manual' }); const sc = r.headers.getSetCookie ? r.headers.getSetCookie() : []; cookie = sc.map((c) => c.split(';')[0]).join('; ') }
14
+ async function req(method, p, body) { const h = { accept: 'application/json' }; if (cookie) h.cookie = cookie; if (body !== undefined) h['content-type'] = 'application/json'; const r = await fetch(ORIGIN + p, { method, headers: h, body: body === undefined ? undefined : JSON.stringify(body) }); let j = null; try { j = await r.json() } catch {} return { status: r.status, json: j } }
15
+
16
+ async function main() {
17
+ await handshake()
18
+ const backup = (await req('GET', '/api/prompt-customizer/backup')).json
19
+ const presetList = (await req('GET', '/api/prompt-customizer/agent-presets')).json?.presets ?? []
20
+ const ids = presetList.filter((p) => !p.broken).map((p) => p.id).slice(0, 3)
21
+ const st0 = (await req('GET', '/api/skill-toggles/presets')).json ?? {}
22
+ const skillName = Object.keys((await req('GET', '/api/skill-toggles/status')).json?.skills ?? {})[0]
23
+ const mcp = (await req('GET', '/api/triad/mcp-status')).json ?? {}
24
+ const server = mcp.servers?.[0]?.serverName
25
+ console.log(`presets=${ids.join(',')} skill=${skillName} mcpServer=${server}`)
26
+ try {
27
+ // SKILL:给多个预设写单独开关
28
+ for (const id of ids) await req('PUT', `/api/skill-toggles/presets/${encodeURIComponent(id)}/skills/${encodeURIComponent(skillName)}`, { enabled: false })
29
+ let ov = (await req('GET', '/api/skill-toggles/presets')).json?.overrides ?? {}
30
+ const setCount = ids.filter((id) => ov[id]?.[skillName] === false).length
31
+ rec(`SKILL 多预设写入单独关闭 (${setCount}/${ids.length})`, setCount === ids.length)
32
+ // 应用全部 = 逐个 resetPreset(与 SKILL 按钮一致)
33
+ for (const id of ids) await req('POST', `/api/skill-toggles/presets/${encodeURIComponent(id)}/reset`)
34
+ ov = (await req('GET', '/api/skill-toggles/presets')).json?.overrides ?? {}
35
+ const remain = ids.filter((id) => ov[id]?.[skillName] === false).length
36
+ rec('SKILL 应用到所有预设后单预设开关被清空', remain === 0, `remaining=${remain}`)
37
+
38
+ // MCP:给多个预设写遮蔽 + 工具级覆盖
39
+ if (server) {
40
+ for (const id of ids) await req('PUT', `/api/triad/mcp-masks/${encodeURIComponent(id)}/${encodeURIComponent(server)}`, { enabled: false })
41
+ let s = (await req('GET', '/api/triad/mcp-status')).json ?? {}
42
+ const maskCount = ids.filter((id) => (s.masks?.[id]?.[server]) === false).length
43
+ rec(`MCP 多预设写入遮蔽 (${maskCount}/${ids.length})`, maskCount === ids.length)
44
+ const applied = await req('POST', '/api/triad/mcp-apply-all')
45
+ rec('MCP 应用到所有预设接口 ok', applied.status === 200 && applied.json?.ok === true, JSON.stringify(applied.json))
46
+ s = (await req('GET', '/api/triad/mcp-status')).json ?? {}
47
+ const maskRemain = ids.filter((id) => (s.masks?.[id]?.[server]) === false).length
48
+ rec('MCP 应用到所有预设后多预设遮蔽被清空', maskRemain === 0, `remaining=${maskRemain}`)
49
+ const toolRemain = ids.filter((id) => Object.keys(s.toolDisabledByPreset?.[id] ?? {}).length > 0).length
50
+ rec('MCP 应用到所有预设后多预设工具覆盖被清空', toolRemain === 0, `remaining=${toolRemain}`)
51
+ } else {
52
+ rec('MCP 应用到所有预设(无 server 跳过)', true, 'no server')
53
+ }
54
+ } finally {
55
+ const restore = await req('POST', '/api/prompt-customizer/restore', { docs: backup?.docs ?? {}, catalog: backup?.catalog ?? {} })
56
+ rec('已恢复整库备份', restore.status === 200 && restore.json?.ok === true)
57
+ }
58
+ }
59
+
60
+ try { await main() } catch (e) { rec('主流程异常', false, e instanceof Error ? (e.stack ?? e.message) : String(e)) } finally {
61
+ const p = results.filter((r) => r.ok).length
62
+ console.log(`\n==== 跨预设应用全部: ${p}/${results.length} 通过, ${results.length - p} 失败 ====`)
63
+ process.exit(results.length - p === 0 ? 0 : 1)
64
+ }
@@ -0,0 +1,153 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * 行为级验证:隐藏/修改提示词、隐藏/启用工具,**是否真的改变模型最终收到的内容**。
4
+ *
5
+ * 依据:
6
+ * - `/api/prompt-customizer/preview?phase=active&scope=<preset>`:真实跑该预设装配,
7
+ * 返回的 `text` 是最终系统提示词、`tools` 是最终交给模型的工具声明。
8
+ * - `/api/prompt-customizer/session`:活动会话(含 agent 作用域 restrict)的真实视图。
9
+ *
10
+ * 用法:node scripts/verify-effect.mjs --url "http://127.0.0.1:PORT/?token=TOKEN"
11
+ * 写入前整库备份、结束后恢复。
12
+ *
13
+ * 状态(2.1.0):本脚本的「替换段」仍用 1.4.0 已删除的 `replace` 字典,「工作区级
14
+ * 隐藏段 / 工具」仍用 2.0.0 已移除的 `level=workspace`,与当前 `/config/apply` 契约
15
+ * 不相容,不能直接当作 2.1.0 的验证结论。当前门禁是 `npm run check` 与
16
+ * `test/universal-prompt-takeover.test.mjs`(ADR-0005)。重跑前需把 `replace` 改为
17
+ * `replacements: [{name, phase, text}]`、去掉工作区级场景、隐藏名单改成 `{name, source}` 条目。
18
+ */
19
+ import fs from 'node:fs'
20
+ import os from 'node:os'
21
+ import path from 'node:path'
22
+
23
+ const argv = process.argv.slice(2)
24
+ const arg = (n, d) => { const i = argv.indexOf(n); return i >= 0 && argv[i + 1] !== undefined ? argv[i + 1] : d }
25
+ const TOKEN_URL = arg('--url')
26
+ if (TOKEN_URL === undefined) { console.error('缺 --url'); process.exit(2) }
27
+ const ORIGIN = new URL(TOKEN_URL).origin
28
+
29
+ const results = []
30
+ const rec = (name, ok, detail) => { results.push({ name, ok, detail }); console.log(`${ok ? 'PASS' : 'FAIL'} ${name}${detail ? ` — ${detail}` : ''}`) }
31
+ const names = (arr) => (Array.isArray(arr) ? arr.map((x) => (typeof x === 'string' ? x : x?.name)).filter(Boolean) : [])
32
+
33
+ let cookie = ''
34
+ async function handshake() {
35
+ const res = await fetch(TOKEN_URL, { redirect: 'manual' })
36
+ const sc = typeof res.headers.getSetCookie === 'function' ? res.headers.getSetCookie() : []
37
+ cookie = sc.map((c) => c.split(';')[0]).join('; ')
38
+ }
39
+ async function req(method, p, body) {
40
+ const headers = { accept: 'application/json' }
41
+ if (cookie) headers.cookie = cookie
42
+ if (body !== undefined) headers['content-type'] = 'application/json'
43
+ const r = await fetch(ORIGIN + p, { method, headers, body: body === undefined ? undefined : JSON.stringify(body) })
44
+ let j = null; try { j = await r.json() } catch {}
45
+ return { status: r.status, json: j }
46
+ }
47
+ const getPreview = async (phase, scope) => (await req('GET', `/api/prompt-customizer/preview?phase=${phase}&scope=${encodeURIComponent(scope)}`)).json
48
+ const getSession = async () => (await req('GET', '/api/prompt-customizer/session')).json
49
+
50
+ const serverOf = (full) => { const r = full.startsWith('mcp__') ? full.slice(5) : null; if (r === null) return null; const i = r.indexOf('__'); return i <= 0 ? null : r.slice(0, i) }
51
+
52
+ async function main() {
53
+ await handshake()
54
+ const cfg = (await req('GET', '/api/prompt-customizer/config')).json
55
+ const currentWorkspace = cfg?.currentWorkspace
56
+ const presetList = (await req('GET', '/api/prompt-customizer/agent-presets')).json?.presets ?? []
57
+ const preset = presetList.find((p) => !p.broken)?.id ?? presetList[0]?.id ?? 'standard'
58
+ console.log(`preset=${preset} currentWorkspace=${currentWorkspace ?? '(none)'}`)
59
+
60
+ const backup = (await req('GET', '/api/prompt-customizer/backup')).json
61
+ const bfile = path.join(process.cwd(), 'scripts', '.verify-backup.json')
62
+ fs.writeFileSync(bfile, JSON.stringify(backup))
63
+ const RESTORE = async () => { await req('POST', '/api/prompt-customizer/restore', { docs: backup.docs ?? {}, catalog: backup.catalog ?? {} }) }
64
+
65
+ try {
66
+ // ── 预设级:真实装配前后对比(/preview phase=active,final text + tools)──
67
+ const p0 = await getPreview('active', preset)
68
+ const s0 = names(p0?.sections)
69
+ const t0 = names(p0?.tools)
70
+ console.log(`[baseline preview] sections=${s0.length} tools=${t0.length}; e.g. sections=${s0.slice(0, 5).join(',')} tools=${t0.slice(0, 5).join(',')}`)
71
+ const sectionName = s0[0]
72
+ const origText = String(p0?.text ?? '')
73
+ rec('preview 基线可用(有段与文本)', s0.length > 0 && origText !== '', `sections=${s0.length} textLen=${origText.length}`)
74
+
75
+ // 1) 隐藏段(预设级)
76
+ await req('POST', '/api/prompt-customizer/config/apply', { target: preset, patch: { sections: [sectionName] } })
77
+ const p1 = await getPreview('active', preset)
78
+ const s1 = names(p1?.sections)
79
+ rec(`隐藏段「${sectionName}」后真实装配不再包含该段`, !s1.includes(sectionName), `before=${s0.includes(sectionName)} after=${s1.includes(sectionName)}`)
80
+ rec(`隐藏段后最终提示词文本变短`, String(p1?.text ?? '').length < origText.length, `len ${origText.length} -> ${String(p1?.text ?? '').length}`)
81
+ await req('POST', '/api/prompt-customizer/config/apply', { target: preset, patch: { sections: null } })
82
+ const p1r = await getPreview('active', preset)
83
+ rec(`还原后该段回到真实装配`, names(p1r?.sections).includes(sectionName))
84
+
85
+ // 2) 修改段文本(预设级 replace)—— 最终文本里出现替换标记
86
+ const marker = '__E2E_MODIFIED_TEXT__'
87
+ await req('POST', '/api/prompt-customizer/config/apply', { target: preset, patch: { replace: { [sectionName]: marker } } })
88
+ const p2 = await getPreview('active', preset)
89
+ rec(`替换段「${sectionName}」文本后最终提示词含替换内容`, String(p2?.text ?? '').includes(marker))
90
+ await req('POST', '/api/prompt-customizer/config/apply', { target: preset, patch: { replace: null } })
91
+
92
+ // 3) 隐藏工具(预设级,MCP 工具级禁用)—— 最终工具声明里消失
93
+ let toolName = t0.find((n) => n.startsWith('mcp__'))
94
+ if (toolName !== undefined) {
95
+ const server = serverOf(toolName)
96
+ await req('PUT', `/api/triad/mcp-tools/${encodeURIComponent(server)}`, { enabled: false, tools: [toolName], preset, source: 'inherit' })
97
+ const p3 = await getPreview('active', preset)
98
+ const t3 = names(p3?.tools)
99
+ rec(`隐藏工具「${toolName}」后最终模型工具声明不再包含它`, !t3.includes(toolName), `before=${t0.includes(toolName)} after=${t3.includes(toolName)}`)
100
+ await req('PUT', `/api/triad/mcp-tools/${encodeURIComponent(server)}`, { enabled: true, tools: [toolName], preset, source: 'inherit' })
101
+ const p3r = await getPreview('active', preset)
102
+ rec(`还原后该工具回到最终工具声明`, names(p3r?.tools).includes(toolName))
103
+ } else {
104
+ rec('隐藏工具(当前预览无 mcp 工具可测,跳过)', true, 'no mcp tool in preview')
105
+ }
106
+
107
+ // ── 工作区级:真实活动会话前后对比(/session)──
108
+ if (currentWorkspace === undefined || currentWorkspace === null || currentWorkspace === '') {
109
+ rec('工作区级真实会话验证(无活动会话,跳过)', true, 'no currentWorkspace')
110
+ return
111
+ }
112
+ const sess0 = await getSession()
113
+ const ss0 = names(sess0?.baseSections)
114
+ const st0 = names(sess0?.tools)
115
+ const stext0 = String(sess0?.text ?? '')
116
+ console.log(`[baseline session] baseSections=${ss0.length} tools=${st0.length}`)
117
+ const wsSection = ss0.find((n) => n !== '') ?? ss0[0]
118
+ if (wsSection !== undefined) {
119
+ await req('POST', '/api/prompt-customizer/config/apply', { level: 'workspace', workspace: currentWorkspace, patch: { sections: [wsSection] } })
120
+ const sess1 = await getSession()
121
+ const ss1 = names(sess1?.baseSections)
122
+ const fs1 = names(sess1?.sections)
123
+ const gone = fs1.length > 0 ? !fs1.includes(wsSection) : (String(sess1?.text ?? '').length < stext0.length)
124
+ rec(`工作区隐藏段「${wsSection}」后活动会话最终提示词不再包含该段`, gone, `finalSections=${fs1.length} textLen ${stext0.length}->${String(sess1?.text ?? '').length}`)
125
+ rec(`工作区隐藏段后会话提示词文本变短`, String(sess1?.text ?? '').length < stext0.length, `${stext0.length} -> ${String(sess1?.text ?? '').length}`)
126
+ await req('POST', '/api/prompt-customizer/config/apply', { level: 'workspace', workspace: currentWorkspace, patch: { sections: null } })
127
+ const sess1r = await getSession()
128
+ rec(`工作区还原后该段回到会话视图`, names(sess1r?.baseSections).includes(wsSection))
129
+ }
130
+ const wsTool = st0.find((n) => n.startsWith('mcp__'))
131
+ if (wsTool !== undefined) {
132
+ await req('PUT', `/api/triad/mcp-workspace-scope/workspace/${encodeURIComponent(currentWorkspace)}/tools`, { name: wsTool, enabled: false })
133
+ const sess2 = await getSession()
134
+ const st2 = names(sess2?.tools)
135
+ rec(`工作区隐藏工具「${wsTool}」后活动会话真实工具集不再包含`, !st2.includes(wsTool), `before=${st0.includes(wsTool)} after=${st2.includes(wsTool)}`)
136
+ await req('PUT', `/api/triad/mcp-workspace-scope/workspace/${encodeURIComponent(currentWorkspace)}/tools`, { name: wsTool, enabled: true })
137
+ const sess2r = await getSession()
138
+ rec(`工作区还原后该工具回到会话工具集`, names(sess2r?.tools).includes(wsTool))
139
+ } else {
140
+ rec('工作区隐藏工具(会话无 mcp 工具可测,跳过)', true, 'no mcp tool in session')
141
+ }
142
+ } finally {
143
+ await RESTORE()
144
+ rec('已恢复整库备份', true)
145
+ try { fs.unlinkSync(bfile) } catch {}
146
+ }
147
+ }
148
+
149
+ try { await main() } catch (e) { rec('主流程异常', false, e instanceof Error ? (e.stack ?? e.message) : String(e)) } finally {
150
+ const passed = results.filter((r) => r.ok).length
151
+ console.log(`\n==== 行为级验证: ${passed}/${results.length} 通过, ${results.length - passed} 失败 ====`)
152
+ process.exit(results.length - passed === 0 ? 0 : 1)
153
+ }
@@ -0,0 +1,48 @@
1
+ #!/usr/bin/env node
2
+ /** 预设专属 / 工作区 MCP 编辑实测(切换 standard/ptc;工作区用临时 .mcp.json)。 */
3
+ import fs from 'node:fs'
4
+ import os from 'node:os'
5
+ import path from 'node:path'
6
+ const argv = process.argv.slice(2)
7
+ const arg = (n, d) => { const i = argv.indexOf(n); return i >= 0 && argv[i + 1] !== undefined ? argv[i + 1] : d }
8
+ const TOKEN_URL = arg('--url'); if (!TOKEN_URL) { console.error('缺 --url'); process.exit(2) }
9
+ const ORIGIN = new URL(TOKEN_URL).origin
10
+ const results = []
11
+ const rec = (n, ok, d) => { results.push({ n, ok }); console.log(`${ok ? 'PASS' : 'FAIL'} ${n}${d ? ` — ${d}` : ''}`) }
12
+ let cookie = ''
13
+ async function handshake() { const r = await fetch(TOKEN_URL, { redirect: 'manual' }); const sc = r.headers.getSetCookie ? r.headers.getSetCookie() : []; cookie = sc.map((c) => c.split(';')[0]).join('; ') }
14
+ async function req(method, p, body) { const h = { accept: 'application/json' }; if (cookie) h.cookie = cookie; if (body !== undefined) h['content-type'] = 'application/json'; const r = await fetch(ORIGIN + p, { method, headers: h, body: body === undefined ? undefined : JSON.stringify(body) }); let j = null; try { j = await r.json() } catch {} return { status: r.status, json: j } }
15
+
16
+ async function main() {
17
+ await handshake()
18
+ // 工作区 .mcp.json 编辑(临时目录)
19
+ const ws = fs.mkdtempSync(path.join(os.tmpdir(), 'pc-mcp-edit-'))
20
+ const mcpFile = path.join(ws, '.mcp.json')
21
+ fs.writeFileSync(mcpFile, JSON.stringify({ mcpServers: { e2eprobe: { command: 'echo', args: ['a'] } } }, null, 2) + '\n')
22
+ const wg = await req('GET', `/api/triad/mcp-edit?source=workspace&workspace=${encodeURIComponent(ws)}&serverName=e2eprobe`)
23
+ rec('工作区 GET 读到 e2eprobe 配置', wg.status === 200 && wg.json?.ok === true && String(wg.json.text).includes('echo'))
24
+ const wp = await req('POST', '/api/triad/mcp-edit', { source: 'workspace', workspace: ws, serverName: 'e2eprobe', text: JSON.stringify({ command: 'echo', args: ['b'] }) })
25
+ rec('工作区 POST 保存成功', wp.status === 200 && wp.json?.ok === true, JSON.stringify(wp.json))
26
+ const wsDoc = JSON.parse(fs.readFileSync(mcpFile, 'utf8'))
27
+ rec('工作区 .mcp.json 已更新为 args=[b]', JSON.stringify(wsDoc.mcpServers.e2eprobe.args) === '["b"]')
28
+ rec('工作区 .mcp.json 生成了 .bak-last-toggle', fs.existsSync(mcpFile + '.bak-last-toggle'))
29
+ try { fs.rmSync(ws, { recursive: true, force: true }) } catch {}
30
+
31
+ // 预设专属 Server 编辑(standard / ptc 各一遍)
32
+ for (const preset of ['standard', 'ptc']) {
33
+ const add = await req('POST', `/api/triad/mcp-presets/${encodeURIComponent(preset)}/servers`, { format: 'json', text: JSON.stringify({ mcpServers: { e2eprobe: { command: 'echo', args: ['a'] } } }) })
34
+ if (!(add.status === 200 && add.json?.ok === true)) { rec(`预设 ${preset} 添加 e2eprobe`, false, JSON.stringify(add.json)); continue }
35
+ const g1 = await req('GET', `/api/triad/mcp-edit?source=preset&preset=${encodeURIComponent(preset)}&serverName=e2eprobe`)
36
+ rec(`预设 ${preset} GET 读到 e2eprobe 配置`, g1.status === 200 && g1.json?.ok === true && String(g1.json.text).includes('echo'))
37
+ const p1 = await req('POST', '/api/triad/mcp-edit', { source: 'preset', preset, serverName: 'e2eprobe', text: JSON.stringify({ command: 'echo', args: ['b'] }) })
38
+ rec(`预设 ${preset} 编辑保存成功`, p1.status === 200 && p1.json?.ok === true, JSON.stringify(p1.json))
39
+ const g2 = await req('GET', `/api/triad/mcp-edit?source=preset&preset=${encodeURIComponent(preset)}&serverName=e2eprobe`)
40
+ rec(`预设 ${preset} 编辑后读回 args=[b]`, g2.status === 200 && /"b"/.test(String(g2.json.text)))
41
+ await req('DELETE', `/api/triad/mcp-presets/${encodeURIComponent(preset)}/servers/e2eprobe`)
42
+ }
43
+ }
44
+ try { await main() } catch (e) { rec('主流程异常', false, e instanceof Error ? e.stack ?? e.message : String(e)) } finally {
45
+ const p = results.filter((r) => r.ok).length
46
+ console.log(`\n==== 预设/工作区 MCP 编辑实测: ${p}/${results.length} 通过 ====`)
47
+ process.exit(results.length - p === 0 ? 0 : 1)
48
+ }
@@ -0,0 +1,45 @@
1
+ #!/usr/bin/env node
2
+ /** MCP 编辑实测:对 codegraph 做「原样往返」编辑(改后再 .bak 还原),验证读写与还原正确。 */
3
+ import fs from 'node:fs'
4
+ import path from 'node:path'
5
+ const argv = process.argv.slice(2)
6
+ const arg = (n, d) => { const i = argv.indexOf(n); return i >= 0 && argv[i + 1] !== undefined ? argv[i + 1] : d }
7
+ const TOKEN_URL = arg('--url'); if (!TOKEN_URL) { console.error('缺 --url'); process.exit(2) }
8
+ const ORIGIN = new URL(TOKEN_URL).origin
9
+ const PATCH = arg('--patch', path.join(process.env.USERPROFILE ?? '', '.dsh', 'profiles', 'web', 'cordis.patch.yml'))
10
+ const results = []
11
+ const rec = (n, ok, d) => { results.push({ n, ok }); console.log(`${ok ? 'PASS' : 'FAIL'} ${n}${d ? ` — ${d}` : ''}`) }
12
+ let cookie = ''
13
+ async function handshake() { const r = await fetch(TOKEN_URL, { redirect: 'manual' }); const sc = r.headers.getSetCookie ? r.headers.getSetCookie() : []; cookie = sc.map((c) => c.split(';')[0]).join('; ') }
14
+ async function req(method, p, body) { const h = { accept: 'application/json' }; if (cookie) h.cookie = cookie; if (body !== undefined) h['content-type'] = 'application/json'; const r = await fetch(ORIGIN + p, { method, headers: h, body: body === undefined ? undefined : JSON.stringify(body) }); let j = null; try { j = await r.json() } catch {} return { status: r.status, json: j } }
15
+
16
+ async function main() {
17
+ await handshake()
18
+ const status = (await req('GET', '/api/triad/mcp-status')).json ?? {}
19
+ const target = (status.servers ?? []).find((s) => s?.config?.editable === true)?.serverName
20
+ if (!target) { rec('存在可编辑的全局 server', false, '无(跳过全局编辑实测)'); return }
21
+ rec('找到可编辑全局 server', true, target)
22
+ const SN = String(target)
23
+ const before = fs.existsSync(PATCH) ? fs.readFileSync(PATCH, 'utf8') : ''
24
+ const raw = await req('GET', `/api/triad/mcp-edit?serverName=${encodeURIComponent(SN)}`)
25
+ rec(`GET /mcp-edit 读到 ${SN} 当前配置原文`, raw.status === 200 && raw.json?.ok === true && typeof raw.json.text === 'string' && raw.json.text.includes(SN), `text=${JSON.stringify(raw.json?.text)?.slice(0, 140)}`)
26
+ // GET 现在直接返回 mcpServers 形态 YAML(含 codegraph 键),可直接回写。
27
+ const yaml = String(raw.json?.text ?? '')
28
+ const save = await req('POST', '/api/triad/mcp-edit', { serverName: SN, format: 'yaml', text: yaml, reload: false })
29
+ rec('POST /mcp-edit 保存(remove+append)成功', save.status === 200 && save.json?.ok === true, JSON.stringify(save.json))
30
+ const after = fs.existsSync(PATCH) ? fs.readFileSync(PATCH, 'utf8') : ''
31
+ rec('patch 文件仍包含该条目', new RegExp(`serverName:\\s*${SN}`).test(after))
32
+ rec('往返后 patch 结构仍有效(含该条目)', new RegExp(`serverName:\\s*${SN}`).test(after), after === before ? '字节不变' : `reformatted len ${before.length}->${after.length}`)
33
+ const raw2 = await req('GET', `/api/triad/mcp-edit?serverName=${encodeURIComponent(SN)}`)
34
+ rec('再次读取仍能拿到该 server 配置', raw2.status === 200 && raw2.json?.ok === true && String(raw2.json.text).includes(SN))
35
+ // 还原
36
+ const bak = `${PATCH}.bak-last-toggle`
37
+ if (fs.existsSync(bak)) { fs.copyFileSync(bak, PATCH); rec('已用 .bak 还原 patch 文件', true) } else { rec('未找到 .bak,跳过还原', false) }
38
+ const restored = fs.existsSync(PATCH) ? fs.readFileSync(PATCH, 'utf8') : ''
39
+ rec('还原后 patch 与编辑前逐字一致', restored === before, restored === before ? '' : '不一致')
40
+ }
41
+ try { await main() } catch (e) { rec('主流程异常', false, e instanceof Error ? e.stack ?? e.message : String(e)) } finally {
42
+ const p = results.filter((r) => r.ok).length
43
+ console.log(`\n==== MCP 编辑实测: ${p}/${results.length} 通过 ====`)
44
+ process.exit(results.length - p === 0 ? 0 : 1)
45
+ }
@@ -0,0 +1,298 @@
1
+ /**
2
+ * scripts/verify-panel-browser.mjs — 用真实 Chrome(CDP)点一遍「能力」面板。
3
+ *
4
+ * 为什么需要它:0.10.0 提交后回归过一次,只有真渲染才会暴露 ——
5
+ * MCP 页里 `import { IconFolderOutlineRegular } from '@deepseek-ai/dsh-client-ui-primitives'`,
6
+ * 而宿主根本没导出这个名字,JSX 组件是 `undefined`,React 抛
7
+ * `Element type is invalid`,MCP 页被 ErrorBoundary 摘掉:用户点 MCP tab 就
8
+ * **闪退回对话界面**。当时 `node --test` 全绿(构建、路由、host 逻辑都没问题),
9
+ * 因为没有任何单元测试会真的渲染这个组件树。
10
+ *
11
+ * 这个脚本补上那一步:开一个真 Chrome 窗口,加载 dsh Web UI,点「能力」入口 →
12
+ * MCP 页 → SKILL 页,断言没有未捕获异常、MCP 页真的渲染出来了、四个目录里的
13
+ * 技能/MCP 条目都出现在 DOM 里,并截图留证。
14
+ *
15
+ * Usage:
16
+ * node scripts/verify-panel-browser.mjs --url "<带 token 的 GUI URL>" \
17
+ * [--expect-skills a,b] [--expect-mcp x,y] [--out <截图目录>] [--headless]
18
+ *
19
+ * 依赖:本机装了 Chrome(默认 `%LOCALAPPDATA%\Google\Chrome\Application\chrome.exe`,
20
+ * 可用 --chrome 指定)。脚本只用 Node 内建能力(fetch + WebSocket),无第三方依赖。
21
+ */
22
+
23
+ import { spawn } from 'node:child_process'
24
+ import { existsSync, mkdirSync, mkdtempSync, writeFileSync } from 'node:fs'
25
+ import { tmpdir } from 'node:os'
26
+ import { join } from 'node:path'
27
+
28
+ const DEFAULT_CHROME = join(process.env.LOCALAPPDATA ?? '', 'Google', 'Chrome', 'Application', 'chrome.exe')
29
+ const PORT = 9333
30
+ const CDP = `http://127.0.0.1:${String(PORT)}`
31
+
32
+ /** 解析 `--key value` / `--flag` 形式的参数。 */
33
+ function parseArgs(argv) {
34
+ const out = { headless: false }
35
+ for (let i = 0; i < argv.length; i += 1) {
36
+ const token = argv[i]
37
+ if (!token.startsWith('--')) continue
38
+ const key = token.slice(2)
39
+ if (key === 'headless') { out.headless = true; continue }
40
+ const value = argv[i + 1]
41
+ if (value === undefined || value.startsWith('--')) continue
42
+ out[key] = value
43
+ i += 1
44
+ }
45
+ return out
46
+ }
47
+
48
+ const args = parseArgs(process.argv.slice(2))
49
+ if (args.url === undefined) {
50
+ console.error('缺少 --url "<带 token 的 GUI URL>"')
51
+ process.exit(2)
52
+ }
53
+ const chromePath = args.chrome ?? DEFAULT_CHROME
54
+ if (!existsSync(chromePath)) {
55
+ console.error(`找不到 Chrome:${chromePath}(用 --chrome 指定)`)
56
+ process.exit(2)
57
+ }
58
+ const expectSkills = (args['expect-skills'] ?? '').split(',').map((s) => s.trim()).filter(Boolean)
59
+ const expectMcp = (args['expect-mcp'] ?? '').split(',').map((s) => s.trim()).filter(Boolean)
60
+
61
+ const checks = []
62
+ const consoleErrors = []
63
+ const exceptions = []
64
+ function check(name, pass, detail = '') {
65
+ checks.push({ name, pass, detail })
66
+ console.log(`${pass ? 'ok ' : 'FAIL'} ${name}${detail === '' ? '' : ` — ${detail}`}`)
67
+ }
68
+
69
+ const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms))
70
+
71
+ /* ── CDP 客户端(裸 WebSocket,无依赖) ──────────────────────────────────── */
72
+
73
+ class Cdp {
74
+ constructor(socket) {
75
+ this.socket = socket
76
+ this.nextId = 1
77
+ this.pending = new Map()
78
+ socket.addEventListener('message', (event) => {
79
+ const message = JSON.parse(event.data)
80
+ if (message.id !== undefined) {
81
+ const entry = this.pending.get(message.id)
82
+ if (entry === undefined) return
83
+ this.pending.delete(message.id)
84
+ if (message.error !== undefined) entry.reject(new Error(JSON.stringify(message.error)))
85
+ else entry.resolve(message.result)
86
+ return
87
+ }
88
+ this.onEvent?.(message)
89
+ })
90
+ }
91
+
92
+ send(method, params = {}) {
93
+ const id = this.nextId++
94
+ return new Promise((resolve, reject) => {
95
+ this.pending.set(id, { resolve, reject })
96
+ this.socket.send(JSON.stringify({ id, method, params }))
97
+ })
98
+ }
99
+
100
+ /** 在页面里求值(返回 JSON 值)。 */
101
+ async evaluate(expression) {
102
+ const result = await this.send('Runtime.evaluate', {
103
+ expression, returnByValue: true, awaitPromise: true,
104
+ })
105
+ if (result.exceptionDetails !== undefined) {
106
+ throw new Error(`页面求值抛错:${result.exceptionDetails.text ?? ''} ${JSON.stringify(result.exceptionDetails.exception?.description ?? '')}`)
107
+ }
108
+ return result.result?.value
109
+ }
110
+
111
+ /** 轮询直到表达式为真(或超时)。 */
112
+ async waitFor(expression, timeoutMs = 15000, label = expression) {
113
+ const started = Date.now()
114
+ let last
115
+ while (Date.now() - started < timeoutMs) {
116
+ try { last = await this.evaluate(expression) } catch (error) { last = `err:${String(error)}` }
117
+ if (last === true) return true
118
+ await sleep(200)
119
+ }
120
+ throw new Error(`等待超时(${label}),最后一次取值:${JSON.stringify(last)}`)
121
+ }
122
+ }
123
+
124
+ async function connect(wsUrl) {
125
+ const socket = new WebSocket(wsUrl)
126
+ await new Promise((resolve, reject) => {
127
+ socket.addEventListener('open', resolve, { once: true })
128
+ socket.addEventListener('error', () => { reject(new Error(`WebSocket 连接失败:${wsUrl}`)) }, { once: true })
129
+ })
130
+ return new Cdp(socket)
131
+ }
132
+
133
+ /* ── 主流程 ──────────────────────────────────────────────────────────────── */
134
+
135
+ const profileDir = mkdtempSync(join(tmpdir(), 'pc-verify-chrome-'))
136
+ const browser = spawn(chromePath, [
137
+ `--remote-debugging-port=${String(PORT)}`,
138
+ `--user-data-dir=${profileDir}`,
139
+ '--no-first-run', '--no-default-browser-check', '--disable-extensions',
140
+ '--window-size=1440,1000',
141
+ ...(args.headless ? ['--headless=new', '--disable-gpu'] : []),
142
+ 'about:blank',
143
+ ], { stdio: 'ignore' })
144
+
145
+ let cdp
146
+ let failed = false
147
+ try {
148
+ // 1. 等 DevTools 端点起来
149
+ const started = Date.now()
150
+ while (Date.now() - started < 30000) {
151
+ try {
152
+ const response = await fetch(`${CDP}/json/version`)
153
+ if (response.ok) break
154
+ } catch { /* 还没起来 */ }
155
+ await sleep(300)
156
+ }
157
+
158
+ // 2. 新开一个标签页(新版 Chrome 只接受 PUT)
159
+ const created = await fetch(`${CDP}/json/new?${encodeURIComponent(args.url)}`, { method: 'PUT' })
160
+ if (!created.ok) throw new Error(`开标签页失败:HTTP ${String(created.status)}`)
161
+ const target = await created.json()
162
+
163
+ cdp = await connect(target.webSocketDebuggerUrl)
164
+ cdp.onEvent = (message) => {
165
+ if (message.method === 'Runtime.exceptionThrown') {
166
+ exceptions.push(message.params?.exceptionDetails?.exception?.description
167
+ ?? message.params?.exceptionDetails?.text ?? 'unknown exception')
168
+ }
169
+ if (message.method === 'Runtime.consoleAPICalled' && message.params?.type === 'error') {
170
+ consoleErrors.push((message.params.args ?? []).map((arg) => arg.value ?? arg.description ?? '').join(' '))
171
+ }
172
+ }
173
+ await cdp.send('Runtime.enable')
174
+ await cdp.send('Page.enable')
175
+
176
+ // 3. 等前端外壳 + 本插件注入的侧栏入口
177
+ await cdp.waitFor("document.querySelector('.dsh-nav-btn') !== null", 30000, '侧栏「能力」入口出现')
178
+ check('侧栏「能力」入口已注入', true)
179
+
180
+ // 4. 打开面板
181
+ await cdp.evaluate(`(() => {
182
+ const button = [...document.querySelectorAll('.dsh-nav-btn')]
183
+ .find((node) => (node.getAttribute('aria-label') ?? node.textContent ?? '').includes('能力'))
184
+ ?? document.querySelector('.dsh-nav-btn')
185
+ button.click()
186
+ return true
187
+ })()`)
188
+ try {
189
+ // 面板是弹层:根容器是 .skm-hub(.skm-panel 是样式表里的旧键,组件已不用)。
190
+ await cdp.waitFor("document.querySelector('.skm-hub') !== null", 15000, '能力面板打开')
191
+ } catch (error) {
192
+ // 失败时把现场 dump 出来:类名、入口按钮、有没有 skm-* 容器,便于定位是
193
+ // 「面板没打开」还是「客户端插件没加载」还是「DOM 变了」。
194
+ const dump = await cdp.evaluate(`(() => ({
195
+ navButtons: [...document.querySelectorAll('.dsh-nav-btn')].map((n) => ({
196
+ label: n.getAttribute('aria-label'), open: n.getAttribute('data-open'), text: (n.textContent ?? '').slice(0, 20),
197
+ })),
198
+ skmClasses: [...new Set([...document.querySelectorAll('[class*=skm]')].map((n) => n.className))].slice(0, 20),
199
+ hasNavHost: document.querySelector('#dsh-prompt-customizer-nav-host') !== null,
200
+ bodyHead: (document.body.innerHTML ?? '').slice(0, 400),
201
+ }))()`)
202
+ const outDir = args.out ?? join(process.cwd(), 'verify-shots')
203
+ mkdirSync(outDir, { recursive: true })
204
+ const shot = await cdp.send('Page.captureScreenshot', { format: 'png' })
205
+ writeFileSync(join(outDir, 'panel-open-failed.png'), Buffer.from(shot.data, 'base64'))
206
+ throw new Error(`${error instanceof Error ? error.message : String(error)}\n诊断:${JSON.stringify(dump, null, 2)}`)
207
+ }
208
+ check('「能力」面板已打开', true)
209
+
210
+ // 5. 点 MCP tab —— 这一步就是历史上闪退的地方
211
+ const openedMcp = await cdp.evaluate(`(() => {
212
+ const tab = [...document.querySelectorAll('[role="tab"]')].find((n) => (n.textContent ?? '').trim() === 'MCP')
213
+ if (tab === undefined) return false
214
+ tab.click()
215
+ return true
216
+ })()`)
217
+ check('找到并点击 MCP tab', openedMcp === true)
218
+ await sleep(1200)
219
+
220
+ // MCP 页没有独立的根容器(样式表里的 skm-mcp-view-root 组件并未使用);
221
+ // 「还挂着」的判据是:MCP tab 处于选中态 + 面板仍在 + 面板里能看到 MCP 文案。
222
+ const mcpActive = await cdp.evaluate(`(() => {
223
+ const tab = [...document.querySelectorAll('[role="tab"]')].find((n) => (n.textContent ?? '').trim() === 'MCP')
224
+ return tab?.getAttribute('aria-selected') === 'true'
225
+ })()`)
226
+ check('MCP tab 处于选中态(点击生效,没有闪退回对话界面)', mcpActive === true)
227
+
228
+ const panelStillThere = await cdp.evaluate("document.querySelector('.skm-hub') !== null")
229
+ check('面板没有被 ErrorBoundary 摘掉', panelStillThere === true)
230
+
231
+ const mcpVisible = await cdp.evaluate(`(() => {
232
+ const hub = document.querySelector('.skm-hub')
233
+ return (hub?.textContent ?? '').includes('MCP')
234
+ })()`)
235
+ check('MCP 页渲染出内容', mcpVisible === true)
236
+
237
+ // 6. mcps 目录分组里的 server 都在 DOM 里
238
+ const mcpText = await cdp.evaluate("document.querySelector('.skm-hub')?.textContent ?? ''")
239
+ for (const name of expectMcp) {
240
+ check(`MCP 列表出现 ${name}`, mcpText.includes(name))
241
+ }
242
+ if (expectMcp.length > 0) {
243
+ check('mcps 目录分组标题出现', mcpText.includes('mcps 目录'))
244
+ }
245
+
246
+ // 7. SKILL tab:四个目录里的技能都要在,且同名时 .dsh 胜出(描述来自 .dsh 那份)
247
+ const openedSkill = await cdp.evaluate(`(() => {
248
+ const tab = [...document.querySelectorAll('[role="tab"]')].find((n) => (n.textContent ?? '').trim() === 'SKILL')
249
+ if (tab === undefined) return false
250
+ tab.click()
251
+ return true
252
+ })()`)
253
+ check('找到并点击 SKILL tab', openedSkill === true)
254
+ await sleep(1200)
255
+ // 散装技能区块默认折叠:先展开它,技能名才会进 DOM(不展开时只有计数)。
256
+ const expandedLoose = await cdp.evaluate(`(() => {
257
+ const header = [...document.querySelectorAll('.skm-bundle-row')]
258
+ .find((node) => (node.textContent ?? '').includes('散装技能'))
259
+ if (header === undefined) return false
260
+ if (header.closest('[data-open="true"]') === null) header.click()
261
+ return true
262
+ })()`)
263
+ check('找到散装技能区块并展开', expandedLoose === true)
264
+ await sleep(800)
265
+ const skillText = await cdp.evaluate("document.querySelector('.skm-hub')?.textContent ?? ''")
266
+ for (const name of expectSkills) {
267
+ check(`技能列表出现 ${name}`, skillText.includes(name))
268
+ }
269
+ if (args['expect-dup-winner'] !== undefined) {
270
+ check(`同名技能采用 ${args['expect-dup-winner']} 那份(.dsh 优先)`,
271
+ skillText.includes(args['expect-dup-winner']))
272
+ }
273
+
274
+ // 8. 截图留证
275
+ const outDir = args.out ?? join(process.cwd(), 'verify-shots')
276
+ mkdirSync(outDir, { recursive: true })
277
+ const shot = await cdp.send('Page.captureScreenshot', { format: 'png' })
278
+ const shotPath = join(outDir, 'panel-skill.png')
279
+ writeFileSync(shotPath, Buffer.from(shot.data, 'base64'))
280
+ check('截图已保存', existsSync(shotPath), shotPath)
281
+
282
+ // 9. 全程无未捕获异常 / console.error(过滤掉与本页无关的资源噪声)
283
+ const realExceptions = exceptions.filter((text) => !text.includes('ResizeObserver'))
284
+ check('浏览器无未捕获异常', realExceptions.length === 0, realExceptions.slice(0, 3).join(' | '))
285
+ check('MCP 页没有 Element type is invalid(历史上就是它导致闪退)',
286
+ !exceptions.some((text) => text.includes('Element type is invalid')))
287
+ } catch (error) {
288
+ check('验证流程执行完成', false, error instanceof Error ? error.message : String(error))
289
+ } finally {
290
+ failed = checks.some((item) => !item.pass)
291
+ try { await cdp?.send('Browser.close') } catch { /* 忽略 */ }
292
+ await sleep(500)
293
+ browser.kill()
294
+ }
295
+
296
+ console.log(`\n${failed ? 'PANEL BROWSER VERIFY FAILED' : 'PANEL BROWSER VERIFY PASSED'}(${checks.filter((c) => c.pass).length}/${checks.length})`)
297
+ if (consoleErrors.length > 0) console.log(`(console.error ${consoleErrors.length} 条,首条:${consoleErrors[0].slice(0, 200)})`)
298
+ process.exit(failed ? 1 : 0)