dsh-gufa-code 0.0.0-stage → 1.0.1
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/LICENSE +21 -0
- package/README.md +93 -2
- package/client.js +11548 -0
- package/cordis.patch.yml +8 -0
- package/docs/icon-180.png +0 -0
- package/docs/icon.svg +18 -0
- package/docs/market-submission.md +32 -0
- package/docs/screenshot-ide.jpg +0 -0
- package/host/assets.js +31 -0
- package/host/chat.js +523 -0
- package/host/context.js +438 -0
- package/host/dev.js +35 -0
- package/host/fs.js +306 -0
- package/host/git.js +532 -0
- package/host/project.js +305 -0
- package/host/ssh.js +54 -0
- package/host/terminal-pty.js +237 -0
- package/host/terminal.js +947 -0
- package/icon.svg +18 -0
- package/index.js +63 -0
- package/locale/en.json +121 -0
- package/locale/zh.json +121 -0
- package/package.json +67 -4
- package/screenshots.json +3 -0
- package/scripts/build-client.mjs +38 -0
- package/scripts/selftest-client.mjs +457 -0
- package/scripts/selftest-host.mjs +197 -0
- package/scripts/selftest-http.mjs +284 -0
- package/scripts/selftest-router.mjs +101 -0
- package/scripts/selftest-terminal-client.mjs +314 -0
- package/scripts/selftest-terminal-pty.mjs +190 -0
- package/scripts/selftest-terminal.mjs +351 -0
- package/scripts/test-chat-render.mjs +702 -0
- package/scripts/test-chat.mjs +761 -0
- package/scripts/test-editor.mjs +516 -0
- package/scripts/verify-workspace-sessions.mjs +135 -0
- package/src/client/00-core.js +1686 -0
- package/src/client/10-icons.js +205 -0
- package/src/client/20-editor.js +1318 -0
- package/src/client/30-terminal.js +1510 -0
- package/src/client/40-chat.js +1184 -0
- package/src/client/46-gitgraph.js +278 -0
- package/src/client/50-panels.js +916 -0
- package/src/client/55-backdrop.js +508 -0
- package/src/client/60-dock.js +662 -0
- package/src/client/85-shortcuts.js +516 -0
- package/src/client/90-app.js +2272 -0
- package/src/client/99-main.js +449 -0
- package/vendor/monaco/LICENSE +21 -0
- package/vendor/monaco/ThirdPartyNotices.txt +448 -0
- package/vendor/monaco/vs/base/browser/ui/codicons/codicon/codicon.ttf +0 -0
- package/vendor/monaco/vs/base/worker/workerMain.js +31 -0
- package/vendor/monaco/vs/basic-languages/abap/abap.js +10 -0
- package/vendor/monaco/vs/basic-languages/apex/apex.js +10 -0
- package/vendor/monaco/vs/basic-languages/azcli/azcli.js +10 -0
- package/vendor/monaco/vs/basic-languages/bat/bat.js +10 -0
- package/vendor/monaco/vs/basic-languages/bicep/bicep.js +11 -0
- package/vendor/monaco/vs/basic-languages/cameligo/cameligo.js +10 -0
- package/vendor/monaco/vs/basic-languages/clojure/clojure.js +10 -0
- package/vendor/monaco/vs/basic-languages/coffee/coffee.js +10 -0
- package/vendor/monaco/vs/basic-languages/cpp/cpp.js +10 -0
- package/vendor/monaco/vs/basic-languages/csharp/csharp.js +10 -0
- package/vendor/monaco/vs/basic-languages/csp/csp.js +10 -0
- package/vendor/monaco/vs/basic-languages/css/css.js +12 -0
- package/vendor/monaco/vs/basic-languages/cypher/cypher.js +10 -0
- package/vendor/monaco/vs/basic-languages/dart/dart.js +10 -0
- package/vendor/monaco/vs/basic-languages/dockerfile/dockerfile.js +10 -0
- package/vendor/monaco/vs/basic-languages/ecl/ecl.js +10 -0
- package/vendor/monaco/vs/basic-languages/elixir/elixir.js +10 -0
- package/vendor/monaco/vs/basic-languages/flow9/flow9.js +10 -0
- package/vendor/monaco/vs/basic-languages/freemarker2/freemarker2.js +12 -0
- package/vendor/monaco/vs/basic-languages/fsharp/fsharp.js +10 -0
- package/vendor/monaco/vs/basic-languages/go/go.js +10 -0
- package/vendor/monaco/vs/basic-languages/graphql/graphql.js +10 -0
- package/vendor/monaco/vs/basic-languages/handlebars/handlebars.js +10 -0
- package/vendor/monaco/vs/basic-languages/hcl/hcl.js +10 -0
- package/vendor/monaco/vs/basic-languages/html/html.js +10 -0
- package/vendor/monaco/vs/basic-languages/ini/ini.js +10 -0
- package/vendor/monaco/vs/basic-languages/java/java.js +10 -0
- package/vendor/monaco/vs/basic-languages/javascript/javascript.js +10 -0
- package/vendor/monaco/vs/basic-languages/julia/julia.js +10 -0
- package/vendor/monaco/vs/basic-languages/kotlin/kotlin.js +10 -0
- package/vendor/monaco/vs/basic-languages/less/less.js +11 -0
- package/vendor/monaco/vs/basic-languages/lexon/lexon.js +10 -0
- package/vendor/monaco/vs/basic-languages/liquid/liquid.js +10 -0
- package/vendor/monaco/vs/basic-languages/lua/lua.js +10 -0
- package/vendor/monaco/vs/basic-languages/m3/m3.js +10 -0
- package/vendor/monaco/vs/basic-languages/markdown/markdown.js +10 -0
- package/vendor/monaco/vs/basic-languages/mdx/mdx.js +10 -0
- package/vendor/monaco/vs/basic-languages/mips/mips.js +10 -0
- package/vendor/monaco/vs/basic-languages/msdax/msdax.js +10 -0
- package/vendor/monaco/vs/basic-languages/mysql/mysql.js +10 -0
- package/vendor/monaco/vs/basic-languages/objective-c/objective-c.js +10 -0
- package/vendor/monaco/vs/basic-languages/pascal/pascal.js +10 -0
- package/vendor/monaco/vs/basic-languages/pascaligo/pascaligo.js +10 -0
- package/vendor/monaco/vs/basic-languages/perl/perl.js +10 -0
- package/vendor/monaco/vs/basic-languages/pgsql/pgsql.js +10 -0
- package/vendor/monaco/vs/basic-languages/php/php.js +10 -0
- package/vendor/monaco/vs/basic-languages/pla/pla.js +10 -0
- package/vendor/monaco/vs/basic-languages/postiats/postiats.js +10 -0
- package/vendor/monaco/vs/basic-languages/powerquery/powerquery.js +10 -0
- package/vendor/monaco/vs/basic-languages/powershell/powershell.js +10 -0
- package/vendor/monaco/vs/basic-languages/protobuf/protobuf.js +11 -0
- package/vendor/monaco/vs/basic-languages/pug/pug.js +10 -0
- package/vendor/monaco/vs/basic-languages/python/python.js +10 -0
- package/vendor/monaco/vs/basic-languages/qsharp/qsharp.js +10 -0
- package/vendor/monaco/vs/basic-languages/r/r.js +10 -0
- package/vendor/monaco/vs/basic-languages/razor/razor.js +10 -0
- package/vendor/monaco/vs/basic-languages/redis/redis.js +10 -0
- package/vendor/monaco/vs/basic-languages/redshift/redshift.js +10 -0
- package/vendor/monaco/vs/basic-languages/restructuredtext/restructuredtext.js +10 -0
- package/vendor/monaco/vs/basic-languages/ruby/ruby.js +10 -0
- package/vendor/monaco/vs/basic-languages/rust/rust.js +10 -0
- package/vendor/monaco/vs/basic-languages/sb/sb.js +10 -0
- package/vendor/monaco/vs/basic-languages/scala/scala.js +10 -0
- package/vendor/monaco/vs/basic-languages/scheme/scheme.js +10 -0
- package/vendor/monaco/vs/basic-languages/scss/scss.js +12 -0
- package/vendor/monaco/vs/basic-languages/shell/shell.js +10 -0
- package/vendor/monaco/vs/basic-languages/solidity/solidity.js +10 -0
- package/vendor/monaco/vs/basic-languages/sophia/sophia.js +10 -0
- package/vendor/monaco/vs/basic-languages/sparql/sparql.js +10 -0
- package/vendor/monaco/vs/basic-languages/sql/sql.js +10 -0
- package/vendor/monaco/vs/basic-languages/st/st.js +10 -0
- package/vendor/monaco/vs/basic-languages/swift/swift.js +13 -0
- package/vendor/monaco/vs/basic-languages/systemverilog/systemverilog.js +10 -0
- package/vendor/monaco/vs/basic-languages/tcl/tcl.js +10 -0
- package/vendor/monaco/vs/basic-languages/twig/twig.js +10 -0
- package/vendor/monaco/vs/basic-languages/typescript/typescript.js +10 -0
- package/vendor/monaco/vs/basic-languages/typespec/typespec.js +10 -0
- package/vendor/monaco/vs/basic-languages/vb/vb.js +10 -0
- package/vendor/monaco/vs/basic-languages/wgsl/wgsl.js +307 -0
- package/vendor/monaco/vs/basic-languages/xml/xml.js +10 -0
- package/vendor/monaco/vs/basic-languages/yaml/yaml.js +10 -0
- package/vendor/monaco/vs/editor/editor.main.css +8 -0
- package/vendor/monaco/vs/editor/editor.main.js +798 -0
- package/vendor/monaco/vs/language/css/cssMode.js +13 -0
- package/vendor/monaco/vs/language/css/cssWorker.js +77 -0
- package/vendor/monaco/vs/language/html/htmlMode.js +13 -0
- package/vendor/monaco/vs/language/html/htmlWorker.js +454 -0
- package/vendor/monaco/vs/language/json/jsonMode.js +19 -0
- package/vendor/monaco/vs/language/json/jsonWorker.js +42 -0
- package/vendor/monaco/vs/language/typescript/tsMode.js +20 -0
- package/vendor/monaco/vs/language/typescript/tsWorker.js +51328 -0
- package/vendor/monaco/vs/loader.js +11 -0
- package/vendor/monaco/vs/nls.messages.de.js +21 -0
- package/vendor/monaco/vs/nls.messages.es.js +21 -0
- package/vendor/monaco/vs/nls.messages.fr.js +19 -0
- package/vendor/monaco/vs/nls.messages.it.js +19 -0
- package/vendor/monaco/vs/nls.messages.ja.js +21 -0
- package/vendor/monaco/vs/nls.messages.ko.js +19 -0
- package/vendor/monaco/vs/nls.messages.ru.js +21 -0
- package/vendor/monaco/vs/nls.messages.zh-cn.js +21 -0
- package/vendor/monaco/vs/nls.messages.zh-tw.js +19 -0
- package/vendor/xterm/LICENSE +21 -0
- package/vendor/xterm/LICENSE.addon-fit +19 -0
- package/vendor/xterm/addon-fit.js +2 -0
- package/vendor/xterm/xterm.css +285 -0
- package/vendor/xterm/xterm.js +2 -0
|
@@ -0,0 +1,702 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* 聊天面板「渲染冒烟」测试:用最小 React 运行时替身执行 40-chat.js 的组件树,
|
|
4
|
+
* 在没有浏览器的情况下捕捉渲染期错误(hook 顺序、TDZ、缺失的 G.* API 等)。
|
|
5
|
+
*
|
|
6
|
+
* 运行:node scripts/test-chat-render.mjs
|
|
7
|
+
*/
|
|
8
|
+
import assert from 'node:assert/strict'
|
|
9
|
+
import { readFileSync } from 'node:fs'
|
|
10
|
+
import { dirname, join, resolve } from 'node:path'
|
|
11
|
+
import { fileURLToPath } from 'node:url'
|
|
12
|
+
|
|
13
|
+
const root = resolve(dirname(fileURLToPath(import.meta.url)), '..')
|
|
14
|
+
|
|
15
|
+
let passed = 0
|
|
16
|
+
const failures = []
|
|
17
|
+
function test(name, fn) {
|
|
18
|
+
try {
|
|
19
|
+
fn()
|
|
20
|
+
passed += 1
|
|
21
|
+
console.log(` ✓ ${name}`)
|
|
22
|
+
} catch (error) {
|
|
23
|
+
failures.push({ name, error })
|
|
24
|
+
console.error(` ✗ ${name}\n ${error instanceof Error ? error.stack ?? error.message : String(error)}`)
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/* ── 浏览器环境替身 ─────────────────────────────────────────────────── */
|
|
29
|
+
|
|
30
|
+
const FRAGMENT = Symbol('fragment')
|
|
31
|
+
const cleanups = []
|
|
32
|
+
|
|
33
|
+
function installDom() {
|
|
34
|
+
const storage = new Map()
|
|
35
|
+
globalThis.localStorage = {
|
|
36
|
+
getItem: (key) => (storage.has(key) ? storage.get(key) : null),
|
|
37
|
+
setItem: (key, value) => storage.set(key, String(value)),
|
|
38
|
+
removeItem: (key) => storage.delete(key),
|
|
39
|
+
}
|
|
40
|
+
globalThis.window = {
|
|
41
|
+
setTimeout: (fn, ms) => setTimeout(fn, ms),
|
|
42
|
+
clearTimeout: (id) => clearTimeout(id),
|
|
43
|
+
addEventListener() {},
|
|
44
|
+
removeEventListener() {},
|
|
45
|
+
}
|
|
46
|
+
globalThis.document = {
|
|
47
|
+
querySelector: () => null,
|
|
48
|
+
createElement: () => ({
|
|
49
|
+
dataset: {},
|
|
50
|
+
style: {},
|
|
51
|
+
textContent: '',
|
|
52
|
+
setAttribute() {},
|
|
53
|
+
append() {},
|
|
54
|
+
remove() {},
|
|
55
|
+
}),
|
|
56
|
+
head: { append() {} },
|
|
57
|
+
body: { append() {} },
|
|
58
|
+
}
|
|
59
|
+
Object.defineProperty(globalThis, 'navigator', {
|
|
60
|
+
value: { clipboard: { writeText: async () => {} } },
|
|
61
|
+
configurable: true,
|
|
62
|
+
writable: true,
|
|
63
|
+
})
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/* ── 最小 React 替身 ────────────────────────────────────────────────── */
|
|
67
|
+
|
|
68
|
+
let currentHooks = null
|
|
69
|
+
let hookIndex = 0
|
|
70
|
+
let currentEffects = null
|
|
71
|
+
let currentMarkDirty = null
|
|
72
|
+
let harnessMarkDirty = null
|
|
73
|
+
|
|
74
|
+
function createElement(type, props, ...children) {
|
|
75
|
+
const normalized = { ...(props ?? {}) }
|
|
76
|
+
const list = children.flat(Infinity)
|
|
77
|
+
if (list.length > 0) {
|
|
78
|
+
const existing = normalized.children
|
|
79
|
+
normalized.children = existing === undefined ? list : [existing, ...list]
|
|
80
|
+
}
|
|
81
|
+
return { type, props: normalized }
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function useState(initial) {
|
|
85
|
+
const index = hookIndex
|
|
86
|
+
hookIndex += 1
|
|
87
|
+
if (currentHooks[index] === undefined) {
|
|
88
|
+
currentHooks[index] = { value: typeof initial === 'function' ? initial() : initial }
|
|
89
|
+
}
|
|
90
|
+
const slot = currentHooks[index]
|
|
91
|
+
return [slot.value, (next) => {
|
|
92
|
+
slot.value = typeof next === 'function' ? next(slot.value) : next
|
|
93
|
+
if (currentMarkDirty !== null) currentMarkDirty()
|
|
94
|
+
else if (harnessMarkDirty !== null) harnessMarkDirty()
|
|
95
|
+
}]
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
function useRef(initial) {
|
|
99
|
+
const index = hookIndex
|
|
100
|
+
hookIndex += 1
|
|
101
|
+
if (currentHooks[index] === undefined) currentHooks[index] = { current: initial }
|
|
102
|
+
return currentHooks[index]
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
function useMemo(factory, deps) {
|
|
106
|
+
const index = hookIndex
|
|
107
|
+
hookIndex += 1
|
|
108
|
+
const slot = currentHooks[index]
|
|
109
|
+
const changed = slot === undefined || !sameDeps(slot.deps, deps)
|
|
110
|
+
if (changed) {
|
|
111
|
+
currentHooks[index] = {
|
|
112
|
+
value: factory(),
|
|
113
|
+
deps: deps === undefined ? undefined : [...deps],
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
return currentHooks[index].value
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
function useCallback(fn) {
|
|
120
|
+
hookIndex += 1
|
|
121
|
+
return fn
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
function useEffect(fn, deps) {
|
|
125
|
+
hookIndex += 1
|
|
126
|
+
if (currentEffects !== null && typeof fn === 'function') currentEffects.push({ fn, deps })
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
function useLayoutEffect(fn) {
|
|
130
|
+
useEffect(fn)
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
function renderComponent(component, props) {
|
|
134
|
+
const hooks = []
|
|
135
|
+
const effects = []
|
|
136
|
+
const previousHooks = currentHooks
|
|
137
|
+
const previousIndex = hookIndex
|
|
138
|
+
const previousEffects = currentEffects
|
|
139
|
+
currentHooks = hooks
|
|
140
|
+
hookIndex = 0
|
|
141
|
+
currentEffects = effects
|
|
142
|
+
let output
|
|
143
|
+
try {
|
|
144
|
+
output = component(props)
|
|
145
|
+
} finally {
|
|
146
|
+
currentHooks = previousHooks
|
|
147
|
+
hookIndex = previousIndex
|
|
148
|
+
currentEffects = previousEffects
|
|
149
|
+
}
|
|
150
|
+
for (const effect of effects) {
|
|
151
|
+
const cleanup = effect.fn()
|
|
152
|
+
if (typeof cleanup === 'function') cleanups.push(cleanup)
|
|
153
|
+
}
|
|
154
|
+
return output
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/** 依赖数组浅比较(用于带状态的根组件重渲染时决定 effect 是否重跑)。 */
|
|
158
|
+
function sameDeps(a, b) {
|
|
159
|
+
if (a === undefined || b === undefined) return false
|
|
160
|
+
if (a.length !== b.length) return false
|
|
161
|
+
for (let index = 0; index < a.length; index += 1) {
|
|
162
|
+
if (!Object.is(a[index], b[index])) return false
|
|
163
|
+
}
|
|
164
|
+
return true
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/**
|
|
168
|
+
* 带状态的根组件挂载器:根组件保留 hook 槽位,setState 后由测试手动 flush 重渲染。
|
|
169
|
+
* 子组件每次重渲染重新挂载(只用于展示型子组件)。
|
|
170
|
+
*/
|
|
171
|
+
function createHarness(component, props) {
|
|
172
|
+
const instance = { hooks: [], effectSlots: [] }
|
|
173
|
+
let dirty = false
|
|
174
|
+
let nodes = []
|
|
175
|
+
let tree = null
|
|
176
|
+
let unmounted = false
|
|
177
|
+
|
|
178
|
+
const renderBody = () => {
|
|
179
|
+
const hooks = instance.hooks
|
|
180
|
+
const effects = []
|
|
181
|
+
const previousHooks = currentHooks
|
|
182
|
+
const previousIndex = hookIndex
|
|
183
|
+
const previousEffects = currentEffects
|
|
184
|
+
currentHooks = hooks
|
|
185
|
+
hookIndex = 0
|
|
186
|
+
currentEffects = effects
|
|
187
|
+
currentMarkDirty = markDirty
|
|
188
|
+
let output
|
|
189
|
+
try {
|
|
190
|
+
output = component(props)
|
|
191
|
+
} finally {
|
|
192
|
+
currentHooks = previousHooks
|
|
193
|
+
hookIndex = previousIndex
|
|
194
|
+
currentEffects = previousEffects
|
|
195
|
+
currentMarkDirty = null
|
|
196
|
+
}
|
|
197
|
+
for (let index = 0; index < effects.length; index += 1) {
|
|
198
|
+
const effect = effects[index]
|
|
199
|
+
const slot = instance.effectSlots[index]
|
|
200
|
+
const changed = slot === undefined || !sameDeps(slot.deps, effect.deps)
|
|
201
|
+
if (!changed) continue
|
|
202
|
+
if (slot !== undefined && typeof slot.cleanup === 'function') slot.cleanup()
|
|
203
|
+
const cleanup = effect.fn()
|
|
204
|
+
instance.effectSlots[index] = { deps: effect.deps === undefined ? undefined : [...effect.deps], cleanup }
|
|
205
|
+
}
|
|
206
|
+
return output
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
const render = () => {
|
|
210
|
+
nodes = []
|
|
211
|
+
tree = resolveNode(renderBody(), nodes)
|
|
212
|
+
dirty = false
|
|
213
|
+
return { nodes, tree, text: textOf(tree) }
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
// 把 setState 变成「标记 dirty」,等待测试 flush。
|
|
217
|
+
const markDirty = () => { dirty = true }
|
|
218
|
+
harnessMarkDirty = markDirty
|
|
219
|
+
const root = {
|
|
220
|
+
props,
|
|
221
|
+
markDirty,
|
|
222
|
+
flush() {
|
|
223
|
+
if (unmounted || !dirty) return { nodes, tree, text: tree === null ? '' : textOf(tree) }
|
|
224
|
+
return render()
|
|
225
|
+
},
|
|
226
|
+
rerender() {
|
|
227
|
+
dirty = true
|
|
228
|
+
return root.flush()
|
|
229
|
+
},
|
|
230
|
+
unmount() {
|
|
231
|
+
unmounted = true
|
|
232
|
+
if (harnessMarkDirty === markDirty) harnessMarkDirty = null
|
|
233
|
+
for (const slot of instance.effectSlots) {
|
|
234
|
+
if (slot !== undefined && typeof slot.cleanup === 'function') {
|
|
235
|
+
try {
|
|
236
|
+
slot.cleanup()
|
|
237
|
+
} catch {
|
|
238
|
+
/* ignore */
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
instance.effectSlots = []
|
|
243
|
+
},
|
|
244
|
+
}
|
|
245
|
+
render()
|
|
246
|
+
return { root, instance, markDirty }
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
/** 把元素树解析成「宿主元素树」(函数组件就地展开),同时收集宿主节点。 */
|
|
250
|
+
function resolveNode(node, nodes) {
|
|
251
|
+
if (node === null || node === undefined || typeof node === 'boolean') return null
|
|
252
|
+
if (typeof node === 'string' || typeof node === 'number') return node
|
|
253
|
+
if (Array.isArray(node)) return node.map((child) => resolveNode(child, nodes))
|
|
254
|
+
if (typeof node !== 'object') return null
|
|
255
|
+
const { type, props } = node
|
|
256
|
+
if (type === FRAGMENT) return resolveNode(props.children, nodes)
|
|
257
|
+
if (typeof type === 'function') return resolveNode(renderComponent(type, props), nodes)
|
|
258
|
+
const resolved = { type, props: { ...props, children: resolveNode(props.children, nodes) } }
|
|
259
|
+
nodes.push(resolved)
|
|
260
|
+
return resolved
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
/** 渲染整棵树;返回宿主节点列表、解析后的树和全部文本。 */
|
|
264
|
+
function renderTree(element) {
|
|
265
|
+
const nodes = []
|
|
266
|
+
const tree = resolveNode(element, nodes)
|
|
267
|
+
return { nodes, tree, text: textOf(tree) }
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
/** 在渲染树里按 className 找节点。 */
|
|
271
|
+
function findNodes(nodes, className) {
|
|
272
|
+
return nodes.filter((node) => {
|
|
273
|
+
const value = node.props.className
|
|
274
|
+
return typeof value === 'string' && value.split(/\s+/).includes(className)
|
|
275
|
+
})
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
/** 收集一段渲染树里的文本。 */
|
|
279
|
+
function textOf(nodes) {
|
|
280
|
+
const parts = []
|
|
281
|
+
const visit = (node) => {
|
|
282
|
+
if (node === null || node === undefined || typeof node === 'boolean') return
|
|
283
|
+
if (typeof node === 'string' || typeof node === 'number') {
|
|
284
|
+
parts.push(String(node))
|
|
285
|
+
return
|
|
286
|
+
}
|
|
287
|
+
if (Array.isArray(node)) {
|
|
288
|
+
for (const child of node) visit(child)
|
|
289
|
+
return
|
|
290
|
+
}
|
|
291
|
+
if (typeof node === 'object') visit(node.props.children)
|
|
292
|
+
}
|
|
293
|
+
visit(nodes)
|
|
294
|
+
return parts.join('')
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
/* ── 载入 40-chat.js ────────────────────────────────────────────────── */
|
|
298
|
+
|
|
299
|
+
installDom()
|
|
300
|
+
|
|
301
|
+
const DICT = {
|
|
302
|
+
'chat.title': '古法 AI',
|
|
303
|
+
'chat.newChat': '新建对话',
|
|
304
|
+
'chat.history': '历史对话',
|
|
305
|
+
'chat.noHistory': '暂无历史对话',
|
|
306
|
+
'chat.deleteChat': '删除对话',
|
|
307
|
+
'chat.inputPlaceholder': '问问 AI…(Enter 发送,Shift+Enter 换行)',
|
|
308
|
+
'chat.send': '发送',
|
|
309
|
+
'chat.stop': '停止',
|
|
310
|
+
'chat.thinking': '思考中…',
|
|
311
|
+
'chat.emptyTitle': '古法 AI 助手',
|
|
312
|
+
'chat.emptyHint': '我在这里辅助你手写代码。',
|
|
313
|
+
'chat.attachFile': '附带当前文件',
|
|
314
|
+
'chat.insert': '插入到编辑器',
|
|
315
|
+
'chat.copy': '复制',
|
|
316
|
+
'chat.copied': '已复制',
|
|
317
|
+
'chat.error': '请求失败',
|
|
318
|
+
'chat.quickExplain': '解释当前代码',
|
|
319
|
+
'chat.quickBug': '查找潜在问题',
|
|
320
|
+
'chat.quickComment': '补充注释',
|
|
321
|
+
'chat.quickRefactor': '重构建议',
|
|
322
|
+
'chat.contextLabel': '上下文',
|
|
323
|
+
'chat.ctxFile': '当前文件',
|
|
324
|
+
'chat.ctxSelection': '选中代码',
|
|
325
|
+
'chat.clear': '清空对话',
|
|
326
|
+
'chat.model': '模型',
|
|
327
|
+
'common.close': '关闭',
|
|
328
|
+
'common.loading': '加载中…',
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
const apiCalls = []
|
|
332
|
+
const streamCalls = []
|
|
333
|
+
const abortCalls = []
|
|
334
|
+
|
|
335
|
+
const G = {
|
|
336
|
+
ChatPanel: null,
|
|
337
|
+
t: (key, params) => (params === undefined ? DICT[key] ?? key : String(DICT[key] ?? key).replace(/\{(\w+)\}/g, (m, name) => (name in params ? String(params[name]) : m))),
|
|
338
|
+
useT: () => {
|
|
339
|
+
useState(0)
|
|
340
|
+
useEffect(() => undefined, [])
|
|
341
|
+
return G.t
|
|
342
|
+
},
|
|
343
|
+
api: async (method) => {
|
|
344
|
+
apiCalls.push(method)
|
|
345
|
+
if (method === 'chat.info') return { provider: 'deepseek-official', model: 'deepseek-flash', ready: true }
|
|
346
|
+
return null
|
|
347
|
+
},
|
|
348
|
+
stream: (method, body, onEvent) => {
|
|
349
|
+
streamCalls.push({ method, body, onEvent })
|
|
350
|
+
return () => {
|
|
351
|
+
abortCalls.push(true)
|
|
352
|
+
}
|
|
353
|
+
},
|
|
354
|
+
basename: (path) => String(path ?? '').split(/[\\/]/).filter(Boolean).at(-1) ?? '',
|
|
355
|
+
errorText: (error) => (error !== null && typeof error === 'object' && typeof error.message === 'string' ? error.message : String(error ?? '')),
|
|
356
|
+
Icon: (props) => createElement('svg', { 'data-icon': props.name }),
|
|
357
|
+
IconButton: (props) => createElement('button', { className: 'gufa-iconbtn', 'data-title': props.title, onClick: props.onClick }, props.children),
|
|
358
|
+
Button: (props) => createElement('button', { className: 'gufa-btn', 'data-title': props.title, onClick: props.onClick }, props.children),
|
|
359
|
+
Spinner: () => createElement('span', { className: 'gufa-spinner' }),
|
|
360
|
+
Empty: (props) => createElement('div', { className: 'gufa-empty' }, props.title ?? null, props.hint ?? null),
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
const source = readFileSync(join(root, 'src', 'client', '40-chat.js'), 'utf8')
|
|
364
|
+
const factory = new Function(
|
|
365
|
+
'G',
|
|
366
|
+
'h',
|
|
367
|
+
'React',
|
|
368
|
+
'useState',
|
|
369
|
+
'useEffect',
|
|
370
|
+
'useRef',
|
|
371
|
+
'useMemo',
|
|
372
|
+
'useCallback',
|
|
373
|
+
'useLayoutEffect',
|
|
374
|
+
`${source}\nreturn {
|
|
375
|
+
ChatPanel: G.ChatPanel,
|
|
376
|
+
GufaChatMessageView,
|
|
377
|
+
GufaChatMarkdownText,
|
|
378
|
+
GufaChatCode,
|
|
379
|
+
GufaChatAssistantBody,
|
|
380
|
+
}\n`,
|
|
381
|
+
)
|
|
382
|
+
const client = factory(G, createElement, { Fragment: FRAGMENT }, useState, useEffect, useRef, useMemo, useCallback, useLayoutEffect)
|
|
383
|
+
|
|
384
|
+
/* ── 用例 ───────────────────────────────────────────────────────────── */
|
|
385
|
+
|
|
386
|
+
console.log('\n[render] ChatPanel 冒烟')
|
|
387
|
+
|
|
388
|
+
test('G.ChatPanel 渲染空状态(无项目)', () => {
|
|
389
|
+
const { nodes } = renderTree(createElement(client.ChatPanel, {
|
|
390
|
+
project: null,
|
|
391
|
+
activeFile: null,
|
|
392
|
+
selection: null,
|
|
393
|
+
theme: 'dark',
|
|
394
|
+
onInsertCode: () => {},
|
|
395
|
+
}))
|
|
396
|
+
assert.ok(nodes.length > 10, '应渲染出宿主节点')
|
|
397
|
+
assert.equal(findNodes(nodes, 'gufa-chat-context-hint').length, 1, '无上下文时应显示提示')
|
|
398
|
+
assert.equal(findNodes(nodes, 'gufa-chat-quick-btn').length, 4, '4 个快捷提问按钮')
|
|
399
|
+
assert.equal(findNodes(nodes, 'gufa-chat-msgs').length, 1)
|
|
400
|
+
assert.equal(findNodes(nodes, 'gufa-chat-input').length, 1)
|
|
401
|
+
// 快捷提问在没有文件/选中代码时禁用
|
|
402
|
+
for (const node of findNodes(nodes, 'gufa-chat-quick-btn')) assert.equal(node.props.disabled, true)
|
|
403
|
+
})
|
|
404
|
+
|
|
405
|
+
test('G.ChatPanel 渲染项目 + 当前文件 + 选中代码', () => {
|
|
406
|
+
const { nodes, text } = renderTree(createElement(client.ChatPanel, {
|
|
407
|
+
project: { root: '/repo', name: 'repo' },
|
|
408
|
+
activeFile: { path: 'src/a.js', content: 'const a = 1', language: 'javascript' },
|
|
409
|
+
selection: { text: 'const a = 1', path: 'src/a.js', line: 2, column: 1 },
|
|
410
|
+
theme: 'light',
|
|
411
|
+
onInsertCode: (code) => { globalThis.__inserted = code },
|
|
412
|
+
}))
|
|
413
|
+
const chips = findNodes(nodes, 'gufa-chat-chip')
|
|
414
|
+
assert.equal(chips.length, 2, '应显示「当前文件」「选中代码」两个 chip,且都可开关')
|
|
415
|
+
assert.equal(findNodes(nodes, 'gufa-chat-context-hint').length, 0)
|
|
416
|
+
for (const node of findNodes(nodes, 'gufa-chat-quick-btn')) assert.notEqual(node.props.disabled, true)
|
|
417
|
+
assert.match(text, /选中代码/)
|
|
418
|
+
const chipTitles = chips.map((chip) => String(chip.props.title ?? '')).join('\n')
|
|
419
|
+
assert.match(chipTitles, /当前文件/)
|
|
420
|
+
assert.ok(chipTitles.includes('src/a.js'), 'chip title 应带完整文件路径')
|
|
421
|
+
})
|
|
422
|
+
|
|
423
|
+
test('G.ChatPanel 头部显示模型信息 / 新建 / 历史按钮', () => {
|
|
424
|
+
const { nodes } = renderTree(createElement(client.ChatPanel, {
|
|
425
|
+
project: null,
|
|
426
|
+
activeFile: null,
|
|
427
|
+
selection: null,
|
|
428
|
+
theme: 'dark',
|
|
429
|
+
onInsertCode: () => {},
|
|
430
|
+
}))
|
|
431
|
+
const heads = findNodes(nodes, 'gufa-chat-head')
|
|
432
|
+
assert.equal(heads.length, 1)
|
|
433
|
+
const titles = nodes.filter((node) => node.type === 'button').map((node) => node.props['data-title']).filter(Boolean)
|
|
434
|
+
assert.ok(titles.includes('历史对话'))
|
|
435
|
+
assert.ok(titles.includes('新建对话'))
|
|
436
|
+
assert.equal(findNodes(nodes, 'gufa-chat-model').length, 1)
|
|
437
|
+
})
|
|
438
|
+
|
|
439
|
+
test('GufaChatMessageView 渲染用户气泡(纯文本)', () => {
|
|
440
|
+
const { nodes, text } = renderTree(createElement(client.GufaChatMessageView, {
|
|
441
|
+
message: { id: 'u1', role: 'user', content: '帮我看看 **这段** 代码' },
|
|
442
|
+
streaming: false,
|
|
443
|
+
thinking: false,
|
|
444
|
+
}))
|
|
445
|
+
assert.equal(findNodes(nodes, 'gufa-chat-usertext').length, 1)
|
|
446
|
+
assert.match(text, /帮我看看/)
|
|
447
|
+
assert.equal(findNodes(nodes, 'gufa-chat-code').length, 0, '用户消息不解析 markdown 代码块')
|
|
448
|
+
})
|
|
449
|
+
|
|
450
|
+
test('GufaChatMessageView 渲染 AI 消息:markdown-lite + 代码块按钮', () => {
|
|
451
|
+
const { nodes, text } = renderTree(createElement(client.GufaChatMessageView, {
|
|
452
|
+
message: {
|
|
453
|
+
id: 'a1',
|
|
454
|
+
role: 'assistant',
|
|
455
|
+
content: '可以这样写 **注意** 行内 `map`:\n```javascript\nconst a = 1\n```\n结束',
|
|
456
|
+
},
|
|
457
|
+
streaming: true,
|
|
458
|
+
thinking: false,
|
|
459
|
+
onInsert: (code) => { globalThis.__inserted = code },
|
|
460
|
+
}))
|
|
461
|
+
assert.equal(findNodes(nodes, 'gufa-chat-avatar').length, 1)
|
|
462
|
+
assert.equal(findNodes(nodes, 'gufa-chat-code').length, 1)
|
|
463
|
+
assert.equal(findNodes(nodes, 'gufa-chat-code-body').length, 1)
|
|
464
|
+
const codeButtons = findNodes(nodes, 'gufa-chat-code-btn')
|
|
465
|
+
assert.equal(codeButtons.length, 2, '代码块应有「复制」「插入到编辑器」两个按钮')
|
|
466
|
+
assert.match(text, /插入到编辑器/)
|
|
467
|
+
assert.match(text, /复制/)
|
|
468
|
+
// 粗体与行内代码
|
|
469
|
+
assert.ok(nodes.some((node) => node.type === 'strong'))
|
|
470
|
+
assert.ok(nodes.some((node) => node.type === 'code'))
|
|
471
|
+
// 正在流式输出时显示光标
|
|
472
|
+
assert.equal(findNodes(nodes, 'gufa-chat-caret').length, 1)
|
|
473
|
+
})
|
|
474
|
+
|
|
475
|
+
test('GufaChatMessageView 渲染思考中 / 错误状态', () => {
|
|
476
|
+
const { nodes: thinkingNodes, text: thinkingText } = renderTree(createElement(client.GufaChatMessageView, {
|
|
477
|
+
message: { id: 'a2', role: 'assistant', content: '' },
|
|
478
|
+
streaming: true,
|
|
479
|
+
thinking: true,
|
|
480
|
+
}))
|
|
481
|
+
assert.equal(findNodes(thinkingNodes, 'gufa-chat-thinking').length, 1)
|
|
482
|
+
assert.match(thinkingText, /思考中/)
|
|
483
|
+
|
|
484
|
+
const { nodes: errorNodes, text: errorText } = renderTree(createElement(client.GufaChatMessageView, {
|
|
485
|
+
message: { id: 'a3', role: 'assistant', content: '部分内容', error: 'API Key 无效' },
|
|
486
|
+
streaming: false,
|
|
487
|
+
thinking: false,
|
|
488
|
+
}))
|
|
489
|
+
assert.equal(findNodes(errorNodes, 'gufa-chat-error').length, 1)
|
|
490
|
+
assert.match(errorText, /API Key 无效/)
|
|
491
|
+
})
|
|
492
|
+
|
|
493
|
+
test('GufaChatMarkdownText 空行 / 行内代码 / 粗体', () => {
|
|
494
|
+
const { nodes, text } = renderTree(createElement(client.GufaChatMarkdownText, {
|
|
495
|
+
text: '第一行 **粗体** 和 `code`\n\n第三行',
|
|
496
|
+
}))
|
|
497
|
+
assert.ok(findNodes(nodes, 'gufa-chat-gap').length >= 1)
|
|
498
|
+
assert.ok(nodes.some((node) => node.type === 'strong'))
|
|
499
|
+
assert.ok(nodes.some((node) => node.type === 'code'))
|
|
500
|
+
assert.match(text, /第一行/)
|
|
501
|
+
})
|
|
502
|
+
|
|
503
|
+
test('渲染过程登记了 chat.info 请求', () => {
|
|
504
|
+
assert.ok(apiCalls.includes('chat.info'), '挂载后应请求模型信息')
|
|
505
|
+
})
|
|
506
|
+
|
|
507
|
+
/* ── 交互流程(有状态根组件) ───────────────────────────────────────── */
|
|
508
|
+
|
|
509
|
+
/** 找到渲染树里第一个 className 节点。 */
|
|
510
|
+
function firstNode(nodes, className) {
|
|
511
|
+
return findNodes(nodes, className)[0] ?? null
|
|
512
|
+
}
|
|
513
|
+
|
|
514
|
+
/** 找到「停止」按钮(带 data-stop 的发送按钮)。 */
|
|
515
|
+
function findStopButton(nodes) {
|
|
516
|
+
return nodes.find((node) => node.type === 'button' && node.props.className === 'gufa-chat-send' && node.props['data-stop'] !== undefined) ?? null
|
|
517
|
+
}
|
|
518
|
+
|
|
519
|
+
/** 挂载 ChatPanel 并输入文本 + 按 Enter 发送,返回 harness、流式请求与控制辅助函数。 */
|
|
520
|
+
function mountAndSend(options) {
|
|
521
|
+
localStorage.removeItem('gufa-code:chat:v1:/repo')
|
|
522
|
+
streamCalls.length = 0
|
|
523
|
+
abortCalls.length = 0
|
|
524
|
+
const harness = createHarness(G.ChatPanel, {
|
|
525
|
+
project: { root: '/repo', name: 'repo' },
|
|
526
|
+
activeFile: { path: 'src/a.js', content: 'const a = 1', language: 'javascript' },
|
|
527
|
+
selection: { text: 'const a = 1', path: 'src/a.js', line: 1 },
|
|
528
|
+
theme: 'dark',
|
|
529
|
+
onInsertCode: () => {},
|
|
530
|
+
})
|
|
531
|
+
let view = harness.root.flush()
|
|
532
|
+
let textarea = firstNode(view.nodes, 'gufa-chat-input')
|
|
533
|
+
assert.ok(textarea !== null, '应找到输入框')
|
|
534
|
+
textarea.props.onChange({ target: { value: options.text } })
|
|
535
|
+
view = harness.root.flush()
|
|
536
|
+
textarea = firstNode(view.nodes, 'gufa-chat-input')
|
|
537
|
+
textarea.props.onKeyDown({
|
|
538
|
+
key: 'Enter',
|
|
539
|
+
shiftKey: false,
|
|
540
|
+
nativeEvent: { isComposing: false },
|
|
541
|
+
preventDefault() {},
|
|
542
|
+
})
|
|
543
|
+
view = harness.root.flush()
|
|
544
|
+
assert.equal(streamCalls.length, 1, 'Enter 应触发一次 chat.stream')
|
|
545
|
+
return {
|
|
546
|
+
harness,
|
|
547
|
+
stream: streamCalls[0],
|
|
548
|
+
get view() {
|
|
549
|
+
return view
|
|
550
|
+
},
|
|
551
|
+
flush() {
|
|
552
|
+
view = harness.root.flush()
|
|
553
|
+
return view
|
|
554
|
+
},
|
|
555
|
+
unmount() {
|
|
556
|
+
harness.root.unmount()
|
|
557
|
+
},
|
|
558
|
+
}
|
|
559
|
+
}
|
|
560
|
+
|
|
561
|
+
console.log('\n[interaction] 发送 / 流式 / 停止 / 错误')
|
|
562
|
+
|
|
563
|
+
test('Enter 发送:请求体带上下文与会话历史,输入框清空并进入流式状态', () => {
|
|
564
|
+
const flow = mountAndSend({ text: '解释这段代码' })
|
|
565
|
+
try {
|
|
566
|
+
const body = flow.stream.body
|
|
567
|
+
assert.equal(flow.stream.method, 'chat.stream')
|
|
568
|
+
assert.equal(body.messages[body.messages.length - 1].content, '解释这段代码')
|
|
569
|
+
assert.equal(body.context.root, '/repo')
|
|
570
|
+
assert.equal(body.context.path, 'src/a.js')
|
|
571
|
+
assert.equal(body.context.content, 'const a = 1')
|
|
572
|
+
assert.equal(body.context.selection, 'const a = 1')
|
|
573
|
+
assert.match(flow.view.text, /解释这段代码/)
|
|
574
|
+
assert.equal(firstNode(flow.view.nodes, 'gufa-chat-input').props.value, '')
|
|
575
|
+
assert.ok(findStopButton(flow.view.nodes) !== null, '流式期间应显示停止按钮')
|
|
576
|
+
assert.equal(findNodes(flow.view.nodes, 'gufa-chat-thinking').length, 1, '应显示思考中')
|
|
577
|
+
} finally {
|
|
578
|
+
flow.unmount()
|
|
579
|
+
}
|
|
580
|
+
})
|
|
581
|
+
|
|
582
|
+
test('流式 delta 追加 / done 收尾并落盘历史', () => {
|
|
583
|
+
const flow = mountAndSend({ text: '这段代码做什么' })
|
|
584
|
+
try {
|
|
585
|
+
flow.stream.onEvent({ type: 'thinking' })
|
|
586
|
+
flow.flush()
|
|
587
|
+
assert.equal(findNodes(flow.view.nodes, 'gufa-chat-thinking').length, 1)
|
|
588
|
+
|
|
589
|
+
flow.stream.onEvent({ type: 'delta', text: '这段代码' })
|
|
590
|
+
flow.flush()
|
|
591
|
+
flow.stream.onEvent({ type: 'delta', text: '定义了常量 a。' })
|
|
592
|
+
flow.flush()
|
|
593
|
+
assert.match(flow.view.text, /这段代码定义了常量 a。/)
|
|
594
|
+
assert.equal(findNodes(flow.view.nodes, 'gufa-chat-thinking').length, 0, '收到增量后思考状态结束')
|
|
595
|
+
|
|
596
|
+
flow.stream.onEvent({ type: 'done' })
|
|
597
|
+
flow.flush()
|
|
598
|
+
assert.ok(findStopButton(flow.view.nodes) === null, '完成后恢复发送按钮')
|
|
599
|
+
|
|
600
|
+
const store = JSON.parse(localStorage.getItem('gufa-code:chat:v1:/repo'))
|
|
601
|
+
assert.equal(store.conversations.length, 1)
|
|
602
|
+
const messages = store.conversations[0].messages
|
|
603
|
+
assert.equal(messages.length, 2)
|
|
604
|
+
assert.equal(messages[0].role, 'user')
|
|
605
|
+
assert.equal(messages[1].role, 'assistant')
|
|
606
|
+
assert.equal(messages[1].content, '这段代码定义了常量 a。')
|
|
607
|
+
} finally {
|
|
608
|
+
flow.unmount()
|
|
609
|
+
}
|
|
610
|
+
})
|
|
611
|
+
|
|
612
|
+
test('停止按钮:中止请求并保留已收到的部分内容', () => {
|
|
613
|
+
const flow = mountAndSend({ text: '写个排序' })
|
|
614
|
+
try {
|
|
615
|
+
flow.stream.onEvent({ type: 'delta', text: '正在写' })
|
|
616
|
+
flow.flush()
|
|
617
|
+
const stopButton = findStopButton(flow.view.nodes)
|
|
618
|
+
assert.ok(stopButton !== null)
|
|
619
|
+
stopButton.props.onClick({})
|
|
620
|
+
flow.flush()
|
|
621
|
+
assert.equal(abortCalls.length, 1, '停止应调用 G.stream 返回的 abort')
|
|
622
|
+
assert.ok(findStopButton(flow.view.nodes) === null)
|
|
623
|
+
assert.match(flow.view.text, /正在写/)
|
|
624
|
+
const store = JSON.parse(localStorage.getItem('gufa-code:chat:v1:/repo'))
|
|
625
|
+
const assistant = store.conversations[0].messages[1]
|
|
626
|
+
assert.equal(assistant.content, '正在写')
|
|
627
|
+
assert.equal(assistant.stopped, true)
|
|
628
|
+
} finally {
|
|
629
|
+
flow.unmount()
|
|
630
|
+
}
|
|
631
|
+
})
|
|
632
|
+
|
|
633
|
+
test('error 事件:错误文案写入消息气泡并结束流式状态', () => {
|
|
634
|
+
const flow = mountAndSend({ text: '你好' })
|
|
635
|
+
try {
|
|
636
|
+
flow.stream.onEvent({ type: 'delta', text: '部分回答' })
|
|
637
|
+
flow.flush()
|
|
638
|
+
flow.stream.onEvent({ type: 'error', message: 'API Key 无效,请检查 DSH 设置。' })
|
|
639
|
+
flow.flush()
|
|
640
|
+
assert.match(flow.view.text, /API Key 无效/)
|
|
641
|
+
assert.ok(findStopButton(flow.view.nodes) === null)
|
|
642
|
+
assert.equal(findNodes(flow.view.nodes, 'gufa-chat-error').length, 1)
|
|
643
|
+
} finally {
|
|
644
|
+
flow.unmount()
|
|
645
|
+
}
|
|
646
|
+
})
|
|
647
|
+
|
|
648
|
+
test('Shift+Enter 不发送;空输入 Enter 不发送', () => {
|
|
649
|
+
localStorage.removeItem('gufa-code:chat:v1:/repo')
|
|
650
|
+
streamCalls.length = 0
|
|
651
|
+
const harness = createHarness(G.ChatPanel, {
|
|
652
|
+
project: null,
|
|
653
|
+
activeFile: null,
|
|
654
|
+
selection: null,
|
|
655
|
+
theme: 'dark',
|
|
656
|
+
onInsertCode: () => {},
|
|
657
|
+
})
|
|
658
|
+
try {
|
|
659
|
+
const view = harness.root.flush()
|
|
660
|
+
const textarea = firstNode(view.nodes, 'gufa-chat-input')
|
|
661
|
+
textarea.props.onChange({ target: { value: '换行内容' } })
|
|
662
|
+
harness.root.flush()
|
|
663
|
+
firstNode(harness.root.flush().nodes, 'gufa-chat-input').props.onKeyDown({
|
|
664
|
+
key: 'Enter',
|
|
665
|
+
shiftKey: true,
|
|
666
|
+
nativeEvent: { isComposing: false },
|
|
667
|
+
preventDefault() {},
|
|
668
|
+
})
|
|
669
|
+
harness.root.flush()
|
|
670
|
+
assert.equal(streamCalls.length, 0, 'Shift+Enter 不应发送')
|
|
671
|
+
// 清空后再按 Enter
|
|
672
|
+
firstNode(harness.root.flush().nodes, 'gufa-chat-input').props.onChange({ target: { value: ' ' } })
|
|
673
|
+
harness.root.flush()
|
|
674
|
+
firstNode(harness.root.flush().nodes, 'gufa-chat-input').props.onKeyDown({
|
|
675
|
+
key: 'Enter',
|
|
676
|
+
shiftKey: false,
|
|
677
|
+
nativeEvent: { isComposing: false },
|
|
678
|
+
preventDefault() {},
|
|
679
|
+
})
|
|
680
|
+
harness.root.flush()
|
|
681
|
+
assert.equal(streamCalls.length, 0, '空输入不应发送')
|
|
682
|
+
} finally {
|
|
683
|
+
harness.root.unmount()
|
|
684
|
+
}
|
|
685
|
+
})
|
|
686
|
+
|
|
687
|
+
/* ── 收尾 ───────────────────────────────────────────────────────────── */
|
|
688
|
+
|
|
689
|
+
for (const cleanup of cleanups.splice(0)) {
|
|
690
|
+
try {
|
|
691
|
+
cleanup()
|
|
692
|
+
} catch {
|
|
693
|
+
/* ignore */
|
|
694
|
+
}
|
|
695
|
+
}
|
|
696
|
+
|
|
697
|
+
console.log('')
|
|
698
|
+
if (failures.length > 0) {
|
|
699
|
+
console.error(`✗ 渲染冒烟失败:${failures.length} 个,${passed} 个通过`)
|
|
700
|
+
process.exit(1)
|
|
701
|
+
}
|
|
702
|
+
console.log(`✓ 渲染冒烟全部通过:${passed} 个用例`)
|