dsh-gufa-code 0.0.0-stage → 1.0.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.
Files changed (158) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +96 -2
  3. package/client.js +11548 -0
  4. package/cordis.patch.yml +8 -0
  5. package/docs/icon-180.png +0 -0
  6. package/docs/icon.svg +18 -0
  7. package/docs/market-submission.md +32 -0
  8. package/docs/screenshot-editor.jpg +0 -0
  9. package/docs/screenshot-ide.jpg +0 -0
  10. package/host/assets.js +31 -0
  11. package/host/chat.js +523 -0
  12. package/host/context.js +438 -0
  13. package/host/dev.js +35 -0
  14. package/host/fs.js +306 -0
  15. package/host/git.js +532 -0
  16. package/host/project.js +305 -0
  17. package/host/ssh.js +54 -0
  18. package/host/terminal-pty.js +237 -0
  19. package/host/terminal.js +947 -0
  20. package/icon.svg +18 -0
  21. package/index.js +63 -0
  22. package/locale/en.json +121 -0
  23. package/locale/zh.json +121 -0
  24. package/package.json +66 -4
  25. package/scripts/build-client.mjs +38 -0
  26. package/scripts/selftest-client.mjs +457 -0
  27. package/scripts/selftest-host.mjs +197 -0
  28. package/scripts/selftest-http.mjs +284 -0
  29. package/scripts/selftest-router.mjs +101 -0
  30. package/scripts/selftest-terminal-client.mjs +314 -0
  31. package/scripts/selftest-terminal-pty.mjs +190 -0
  32. package/scripts/selftest-terminal.mjs +351 -0
  33. package/scripts/test-chat-render.mjs +702 -0
  34. package/scripts/test-chat.mjs +761 -0
  35. package/scripts/test-editor.mjs +516 -0
  36. package/scripts/verify-workspace-sessions.mjs +135 -0
  37. package/src/client/00-core.js +1686 -0
  38. package/src/client/10-icons.js +205 -0
  39. package/src/client/20-editor.js +1318 -0
  40. package/src/client/30-terminal.js +1510 -0
  41. package/src/client/40-chat.js +1184 -0
  42. package/src/client/46-gitgraph.js +278 -0
  43. package/src/client/50-panels.js +916 -0
  44. package/src/client/55-backdrop.js +508 -0
  45. package/src/client/60-dock.js +662 -0
  46. package/src/client/85-shortcuts.js +516 -0
  47. package/src/client/90-app.js +2272 -0
  48. package/src/client/99-main.js +449 -0
  49. package/vendor/monaco/LICENSE +21 -0
  50. package/vendor/monaco/ThirdPartyNotices.txt +448 -0
  51. package/vendor/monaco/vs/base/browser/ui/codicons/codicon/codicon.ttf +0 -0
  52. package/vendor/monaco/vs/base/worker/workerMain.js +31 -0
  53. package/vendor/monaco/vs/basic-languages/abap/abap.js +10 -0
  54. package/vendor/monaco/vs/basic-languages/apex/apex.js +10 -0
  55. package/vendor/monaco/vs/basic-languages/azcli/azcli.js +10 -0
  56. package/vendor/monaco/vs/basic-languages/bat/bat.js +10 -0
  57. package/vendor/monaco/vs/basic-languages/bicep/bicep.js +11 -0
  58. package/vendor/monaco/vs/basic-languages/cameligo/cameligo.js +10 -0
  59. package/vendor/monaco/vs/basic-languages/clojure/clojure.js +10 -0
  60. package/vendor/monaco/vs/basic-languages/coffee/coffee.js +10 -0
  61. package/vendor/monaco/vs/basic-languages/cpp/cpp.js +10 -0
  62. package/vendor/monaco/vs/basic-languages/csharp/csharp.js +10 -0
  63. package/vendor/monaco/vs/basic-languages/csp/csp.js +10 -0
  64. package/vendor/monaco/vs/basic-languages/css/css.js +12 -0
  65. package/vendor/monaco/vs/basic-languages/cypher/cypher.js +10 -0
  66. package/vendor/monaco/vs/basic-languages/dart/dart.js +10 -0
  67. package/vendor/monaco/vs/basic-languages/dockerfile/dockerfile.js +10 -0
  68. package/vendor/monaco/vs/basic-languages/ecl/ecl.js +10 -0
  69. package/vendor/monaco/vs/basic-languages/elixir/elixir.js +10 -0
  70. package/vendor/monaco/vs/basic-languages/flow9/flow9.js +10 -0
  71. package/vendor/monaco/vs/basic-languages/freemarker2/freemarker2.js +12 -0
  72. package/vendor/monaco/vs/basic-languages/fsharp/fsharp.js +10 -0
  73. package/vendor/monaco/vs/basic-languages/go/go.js +10 -0
  74. package/vendor/monaco/vs/basic-languages/graphql/graphql.js +10 -0
  75. package/vendor/monaco/vs/basic-languages/handlebars/handlebars.js +10 -0
  76. package/vendor/monaco/vs/basic-languages/hcl/hcl.js +10 -0
  77. package/vendor/monaco/vs/basic-languages/html/html.js +10 -0
  78. package/vendor/monaco/vs/basic-languages/ini/ini.js +10 -0
  79. package/vendor/monaco/vs/basic-languages/java/java.js +10 -0
  80. package/vendor/monaco/vs/basic-languages/javascript/javascript.js +10 -0
  81. package/vendor/monaco/vs/basic-languages/julia/julia.js +10 -0
  82. package/vendor/monaco/vs/basic-languages/kotlin/kotlin.js +10 -0
  83. package/vendor/monaco/vs/basic-languages/less/less.js +11 -0
  84. package/vendor/monaco/vs/basic-languages/lexon/lexon.js +10 -0
  85. package/vendor/monaco/vs/basic-languages/liquid/liquid.js +10 -0
  86. package/vendor/monaco/vs/basic-languages/lua/lua.js +10 -0
  87. package/vendor/monaco/vs/basic-languages/m3/m3.js +10 -0
  88. package/vendor/monaco/vs/basic-languages/markdown/markdown.js +10 -0
  89. package/vendor/monaco/vs/basic-languages/mdx/mdx.js +10 -0
  90. package/vendor/monaco/vs/basic-languages/mips/mips.js +10 -0
  91. package/vendor/monaco/vs/basic-languages/msdax/msdax.js +10 -0
  92. package/vendor/monaco/vs/basic-languages/mysql/mysql.js +10 -0
  93. package/vendor/monaco/vs/basic-languages/objective-c/objective-c.js +10 -0
  94. package/vendor/monaco/vs/basic-languages/pascal/pascal.js +10 -0
  95. package/vendor/monaco/vs/basic-languages/pascaligo/pascaligo.js +10 -0
  96. package/vendor/monaco/vs/basic-languages/perl/perl.js +10 -0
  97. package/vendor/monaco/vs/basic-languages/pgsql/pgsql.js +10 -0
  98. package/vendor/monaco/vs/basic-languages/php/php.js +10 -0
  99. package/vendor/monaco/vs/basic-languages/pla/pla.js +10 -0
  100. package/vendor/monaco/vs/basic-languages/postiats/postiats.js +10 -0
  101. package/vendor/monaco/vs/basic-languages/powerquery/powerquery.js +10 -0
  102. package/vendor/monaco/vs/basic-languages/powershell/powershell.js +10 -0
  103. package/vendor/monaco/vs/basic-languages/protobuf/protobuf.js +11 -0
  104. package/vendor/monaco/vs/basic-languages/pug/pug.js +10 -0
  105. package/vendor/monaco/vs/basic-languages/python/python.js +10 -0
  106. package/vendor/monaco/vs/basic-languages/qsharp/qsharp.js +10 -0
  107. package/vendor/monaco/vs/basic-languages/r/r.js +10 -0
  108. package/vendor/monaco/vs/basic-languages/razor/razor.js +10 -0
  109. package/vendor/monaco/vs/basic-languages/redis/redis.js +10 -0
  110. package/vendor/monaco/vs/basic-languages/redshift/redshift.js +10 -0
  111. package/vendor/monaco/vs/basic-languages/restructuredtext/restructuredtext.js +10 -0
  112. package/vendor/monaco/vs/basic-languages/ruby/ruby.js +10 -0
  113. package/vendor/monaco/vs/basic-languages/rust/rust.js +10 -0
  114. package/vendor/monaco/vs/basic-languages/sb/sb.js +10 -0
  115. package/vendor/monaco/vs/basic-languages/scala/scala.js +10 -0
  116. package/vendor/monaco/vs/basic-languages/scheme/scheme.js +10 -0
  117. package/vendor/monaco/vs/basic-languages/scss/scss.js +12 -0
  118. package/vendor/monaco/vs/basic-languages/shell/shell.js +10 -0
  119. package/vendor/monaco/vs/basic-languages/solidity/solidity.js +10 -0
  120. package/vendor/monaco/vs/basic-languages/sophia/sophia.js +10 -0
  121. package/vendor/monaco/vs/basic-languages/sparql/sparql.js +10 -0
  122. package/vendor/monaco/vs/basic-languages/sql/sql.js +10 -0
  123. package/vendor/monaco/vs/basic-languages/st/st.js +10 -0
  124. package/vendor/monaco/vs/basic-languages/swift/swift.js +13 -0
  125. package/vendor/monaco/vs/basic-languages/systemverilog/systemverilog.js +10 -0
  126. package/vendor/monaco/vs/basic-languages/tcl/tcl.js +10 -0
  127. package/vendor/monaco/vs/basic-languages/twig/twig.js +10 -0
  128. package/vendor/monaco/vs/basic-languages/typescript/typescript.js +10 -0
  129. package/vendor/monaco/vs/basic-languages/typespec/typespec.js +10 -0
  130. package/vendor/monaco/vs/basic-languages/vb/vb.js +10 -0
  131. package/vendor/monaco/vs/basic-languages/wgsl/wgsl.js +307 -0
  132. package/vendor/monaco/vs/basic-languages/xml/xml.js +10 -0
  133. package/vendor/monaco/vs/basic-languages/yaml/yaml.js +10 -0
  134. package/vendor/monaco/vs/editor/editor.main.css +8 -0
  135. package/vendor/monaco/vs/editor/editor.main.js +798 -0
  136. package/vendor/monaco/vs/language/css/cssMode.js +13 -0
  137. package/vendor/monaco/vs/language/css/cssWorker.js +77 -0
  138. package/vendor/monaco/vs/language/html/htmlMode.js +13 -0
  139. package/vendor/monaco/vs/language/html/htmlWorker.js +454 -0
  140. package/vendor/monaco/vs/language/json/jsonMode.js +19 -0
  141. package/vendor/monaco/vs/language/json/jsonWorker.js +42 -0
  142. package/vendor/monaco/vs/language/typescript/tsMode.js +20 -0
  143. package/vendor/monaco/vs/language/typescript/tsWorker.js +51328 -0
  144. package/vendor/monaco/vs/loader.js +11 -0
  145. package/vendor/monaco/vs/nls.messages.de.js +21 -0
  146. package/vendor/monaco/vs/nls.messages.es.js +21 -0
  147. package/vendor/monaco/vs/nls.messages.fr.js +19 -0
  148. package/vendor/monaco/vs/nls.messages.it.js +19 -0
  149. package/vendor/monaco/vs/nls.messages.ja.js +21 -0
  150. package/vendor/monaco/vs/nls.messages.ko.js +19 -0
  151. package/vendor/monaco/vs/nls.messages.ru.js +21 -0
  152. package/vendor/monaco/vs/nls.messages.zh-cn.js +21 -0
  153. package/vendor/monaco/vs/nls.messages.zh-tw.js +19 -0
  154. package/vendor/xterm/LICENSE +21 -0
  155. package/vendor/xterm/LICENSE.addon-fit +19 -0
  156. package/vendor/xterm/addon-fit.js +2 -0
  157. package/vendor/xterm/xterm.css +285 -0
  158. package/vendor/xterm/xterm.js +2 -0
@@ -0,0 +1,457 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Client 半烟雾测试(无浏览器):
4
+ * - 在受控沙箱里执行构建产物 client.js,取出 lazy factory 并调用;
5
+ * - 用假 React / 假文档调用 apply(ctx),断言 sidebar.panellist / main / sidebar.footer.action / locale 注册;
6
+ * - 逐个执行面板组件的函数体(假 hooks),捕获明显的未定义引用/逻辑崩溃。
7
+ *
8
+ * 注意:不能替代真实浏览器渲染验证。
9
+ * 用法:node scripts/selftest-client.mjs
10
+ */
11
+ import { readFileSync } from 'node:fs'
12
+ import { dirname, join, resolve } from 'node:path'
13
+ import { fileURLToPath } from 'node:url'
14
+
15
+ const root = resolve(dirname(fileURLToPath(import.meta.url)), '..')
16
+ const code = readFileSync(join(root, 'client.js'), 'utf8')
17
+
18
+ let passed = 0
19
+ let failed = 0
20
+ function check(name, condition, detail) {
21
+ if (condition) {
22
+ passed += 1
23
+ console.log(` ok ${name}`)
24
+ } else {
25
+ failed += 1
26
+ console.error(` FAIL ${name}${detail === undefined ? '' : ` — ${detail}`}`)
27
+ }
28
+ }
29
+
30
+ /* ── 假 React ─────────────────────────────────────────────────────────── */
31
+ const Fragment = Symbol('Fragment')
32
+ const createElement = (type, props, ...children) => ({ type, props: props ?? {}, children })
33
+ const React = {
34
+ Fragment,
35
+ createElement,
36
+ useState: (initial) => [typeof initial === 'function' ? initial() : initial, () => {}],
37
+ useEffect: () => {},
38
+ useLayoutEffect: () => {},
39
+ useRef: (value) => ({ current: value }),
40
+ useMemo: (factory) => factory(),
41
+ useCallback: (fn) => fn,
42
+ memo: (fn) => fn,
43
+ }
44
+
45
+ /* ── 假 DOM / 浏览器 API ──────────────────────────────────────────────── */
46
+ const styleTags = []
47
+ const documentStub = {
48
+ body: { hasAttribute: () => false, appendChild: () => {} },
49
+ head: { append: (node) => styleTags.push(node) },
50
+ querySelector: () => null,
51
+ querySelectorAll: () => [],
52
+ createElement: (tag) => ({ tag, dataset: {}, style: {}, setAttribute() {}, remove() {}, textContent: '' }),
53
+ }
54
+ const localStorageStub = {
55
+ store: new Map(),
56
+ getItem(key) { return this.store.has(key) ? this.store.get(key) : null },
57
+ setItem(key, value) { this.store.set(key, String(value)) },
58
+ removeItem(key) { this.store.delete(key) },
59
+ }
60
+ const locationStub = { protocol: 'http:', host: '127.0.0.1:53091' }
61
+ const windowStub = {
62
+ __ModuleLoader__: { load(definition) { windowStub.__definition = definition } },
63
+ setTimeout: (fn, ms) => setTimeout(fn, ms),
64
+ addEventListener: () => {},
65
+ removeEventListener: () => {},
66
+ monaco: undefined,
67
+ require: undefined,
68
+ }
69
+ const fetchStub = async () => ({ ok: true, status: 200, json: async () => ({ ok: true, value: null }) })
70
+ class TextDecoderStub {
71
+ decode(value) { return String(value) }
72
+ }
73
+ class AbortControllerStub {
74
+ constructor() { this.signal = { aborted: false } }
75
+ abort() { this.signal.aborted = true }
76
+ }
77
+ class MutationObserverStub {
78
+ observe() {}
79
+ disconnect() {}
80
+ }
81
+
82
+ /* ── 执行 bundle,拿到 factory ────────────────────────────────────────── */
83
+ new Function(
84
+ 'window', 'document', 'localStorage', 'location', 'fetch', 'TextDecoder', 'AbortController', 'MutationObserver', 'console',
85
+ code,
86
+ )(windowStub, documentStub, localStorageStub, locationStub, fetchStub, TextDecoderStub, AbortControllerStub, MutationObserverStub, console)
87
+
88
+ check('bundle registers a module definition', windowStub.__definition !== undefined)
89
+ const definition = windowStub.__definition ?? {}
90
+ check('module id is dsh-gufa-code', definition.id === 'dsh-gufa-code', definition.id)
91
+ check('factory is a function', typeof definition.factory === 'function')
92
+
93
+ let requireError = ''
94
+ const plugin = definition.factory((name) => {
95
+ if (name === 'react') return React
96
+ requireError = `unexpected require: ${name}`
97
+ throw new Error(requireError)
98
+ })
99
+ check('factory returned a plugin', plugin !== undefined && typeof plugin.apply === 'function', String(plugin))
100
+
101
+ /* ── 假 Cordis ctx + slots ────────────────────────────────────────────── */
102
+ const registrations = []
103
+ const localeRegistrations = []
104
+ const slotApi = {
105
+ inject(owner, callback) {
106
+ const effect = callback()
107
+ return typeof effect === 'function' ? effect : () => {}
108
+ },
109
+ register(options, component) {
110
+ registrations.push({ options, component })
111
+ return () => {}
112
+ },
113
+ }
114
+ const localeApi = {
115
+ register(ns, dicts) {
116
+ localeRegistrations.push({ ns, dicts })
117
+ return () => {}
118
+ },
119
+ bind(ns) {
120
+ return (key) => key
121
+ },
122
+ subscribe() {
123
+ return () => {}
124
+ },
125
+ }
126
+ const effects = []
127
+ const ctx = {
128
+ get(name) {
129
+ if (name === 'locale') return localeApi
130
+ if (name === 'slots') return slotApi
131
+ if (name === 'layout') return { selectPanel() {} }
132
+ if (name === 'uiWorkspace') return { pickDirectory: async () => null }
133
+ return undefined
134
+ },
135
+ effect(fn) {
136
+ const dispose = fn()
137
+ effects.push(typeof dispose === 'function' ? dispose : () => {})
138
+ return () => {}
139
+ },
140
+ inject(names, callback) {
141
+ const scope = {
142
+ slots: slotApi,
143
+ get: ctx.get,
144
+ effect: ctx.effect,
145
+ }
146
+ callback(scope)
147
+ return () => {}
148
+ },
149
+ }
150
+ plugin.apply(ctx)
151
+
152
+ const mainPanel = registrations.find((item) => item.options.name === 'main')
153
+ const overlay = registrations.find((item) => item.options.name === 'shell.overlay')
154
+ check('no top-level sidebar.panellist entry', registrations.every((item) => item.options.name !== 'sidebar.panellist'))
155
+ check('no sidebar.panellist.actions entry', registrations.every((item) => item.options.name !== 'sidebar.panellist.actions'))
156
+ check('no sidebar.footer.action entry', registrations.every((item) => item.options.name !== 'sidebar.footer.action'))
157
+ check('registered main redirect', mainPanel !== undefined && mainPanel.options.key === 'gufa-code')
158
+ check('registered shell.overlay IDE host', overlay !== undefined && overlay.options.id === 'gufa-code-overlay')
159
+ check('registered locale dictionary', localeRegistrations.some((item) => item.ns === 'gufa-code' && item.dicts.zh !== undefined))
160
+ check('injected styles', styleTags.length === 1, String(styleTags.length))
161
+
162
+ /* ── 组件函数体烟雾渲染(假 hooks;不验证真实渲染结果) ─────────────────── */
163
+ function smoke(name, fn) {
164
+ try {
165
+ fn()
166
+ check(`render body: ${name}`, true)
167
+ } catch (error) {
168
+ check(`render body: ${name}`, false, error instanceof Error ? `${error.message}` : String(error))
169
+ }
170
+ }
171
+
172
+ // 调试句柄暴露了 G(也方便浏览器控制台排查)
173
+ const G = windowStub.__GUFA_CODE__?.G
174
+ check('debug handle exposes G', G !== undefined && typeof G === 'object')
175
+
176
+ check('workspace menu injector exposed', typeof G.installWorkspaceMenuEntry === 'function')
177
+ check('GufaApp is a component function', typeof mainPanel.component === 'function')
178
+
179
+ if (G !== undefined) {
180
+ const tabs = [
181
+ { path: 'src/app.ts', content: 'export const x = 1\n', language: 'typescript', revision: 'r1', dirty: false },
182
+ { path: 'README.md', content: '# hi\n', language: 'markdown', revision: 'r2', dirty: true },
183
+ ]
184
+ const file = { path: 'src/app.ts', content: 'export const x = 1\n', language: 'typescript' }
185
+ const project = { root: '/tmp/gufa-smoke', name: 'gufa-smoke', isGit: true }
186
+ smoke('Explorer', () => G.Explorer({ root: project.root, activePath: 'src/app.ts', refreshToken: 0, gitStatuses: { 'src/app.ts': 'M' }, onOpenFile() {}, onNewFile() {}, onNewFolder() {}, onRename() {}, onDelete() {} }))
187
+ smoke('Explorer (no project)', () => G.Explorer({ root: null, activePath: null, refreshToken: 0, gitStatuses: {}, onOpenFile() {}, onNewFile() {}, onNewFolder() {}, onRename() {}, onDelete() {} }))
188
+ smoke('GitPanel', () => G.GitPanel({ root: project.root, refreshToken: 0, onStatus() {}, onOpenFile() {}, onChanged() {} }))
189
+ smoke('SshPanel', () => G.SshPanel({ refreshToken: 0, onConnect() {} }))
190
+ smoke('EditorPane (file)', () => G.EditorPane({ tabs, activePath: 'src/app.ts', theme: 'dark', root: project.root, onSelectTab() {}, onCloseTab() {}, onChange() {}, onSave() {}, onCursor() {} }))
191
+ smoke('EditorPane (empty)', () => G.EditorPane({ tabs: [], activePath: null, theme: 'light', root: project.root, onSelectTab() {}, onCloseTab() {}, onChange() {}, onSave() {}, onCursor() {} }))
192
+ smoke('TerminalPane', () => G.TerminalPane({ root: project.root, visible: true, height: 220, theme: 'dark', onToggleHeight() {} }))
193
+ smoke('TerminalPane (no project)', () => G.TerminalPane({ root: null, visible: false, height: 220, theme: 'light', onToggleHeight() {} }))
194
+ smoke('ChatPanel (fallback)', () => G.ChatPanel({ project, activeFile: file, selection: { text: 'x = 1', path: 'src/app.ts', line: 1, column: 1 }, theme: 'dark', width: 360, onInsertCode() {} }))
195
+ smoke('ChatPanel (empty)', () => G.ChatPanel({ project: null, activeFile: null, selection: null, theme: 'light', width: 360, onInsertCode() {} }))
196
+ smoke('ChatDock', () => G.ChatDock({ project, activeFile: file, selection: { text: 'x = 1', path: 'src/app.ts', line: 1, column: 1 }, theme: 'dark', width: 360, active: false, onInsertCode() {}, onClose() {} }))
197
+ smoke('ChatDock (no project)', () => G.ChatDock({ project: null, activeFile: null, selection: null, theme: 'light', width: 360, active: true, onInsertCode() {} }))
198
+ smoke('OverlayHost (hidden)', () => G.OverlayHost({ useSessions: () => null }))
199
+ smoke('MainRedirect', () => G.MainRedirect({}))
200
+ check('UsageDock exists', typeof G.UsageDock === 'function')
201
+ smoke('UsageDock', () => G.UsageDock({ active: true }))
202
+ check('mode store present', typeof G.mode?.set === 'function' && typeof G.mode?.toggle === 'function' && typeof G.enterGufa === 'function')
203
+ check('mode default off', G.mode.get() === false)
204
+ G.mode.set(true)
205
+ check('mode toggle on', G.mode.get() === true && localStorageStub.getItem('gufa-code:mode:v1') === '1')
206
+ G.mode.set(false)
207
+ }
208
+
209
+ // 通过 fake hooks 直接调用主组件函数体(会执行 hooks 与首次渲染分支)
210
+ try {
211
+ mainPanel.component({
212
+ useSessions: () => null,
213
+ usePanelInfo: () => ({ activePanelId: 'gufa-code' }),
214
+ })
215
+ check('render body: GufaApp', true)
216
+ } catch (error) {
217
+ check('render body: GufaApp', false, error instanceof Error ? `${error.message}\n${error.stack}` : String(error))
218
+ }
219
+
220
+ /* ── 组件引用检查:h(SomeComponent) 必须是本文件的函数声明或 G.* 挂载 ───────── */
221
+ {
222
+ const partFiles = ['10-icons.js', '20-editor.js', '30-terminal.js', '40-chat.js', '50-panels.js', '55-backdrop.js', '60-dock.js', '90-app.js']
223
+ for (const file of partFiles) {
224
+ let source = ''
225
+ try {
226
+ source = readFileSync(join(root, 'src', 'client', file), 'utf8')
227
+ } catch {
228
+ continue
229
+ }
230
+ const names = [...source.matchAll(/h\(\s*([A-Z][A-Za-z0-9_]*)\s*[,)]/g)].map((match) => match[1])
231
+ for (const name of [...new Set(names)]) {
232
+ const declared = new RegExp(`function\\s+${name}\\b`).test(source)
233
+ const exposed = source.includes(`G.${name} =`) || source.includes(`G.${name}=`)
234
+ check(`component ref ${file}: ${name} 有定义`, declared || exposed, declared || exposed ? '' : '未定义的裸组件引用')
235
+ }
236
+ }
237
+ }
238
+
239
+ /* ── IDE 专属快捷键(参考 VS Code) ──────────────────────────────────────── */
240
+ if (windowStub.__GUFA_CODE__?.G?.shortcutAction !== undefined) {
241
+ const G0 = windowStub.__GUFA_CODE__.G
242
+ const mac = G0.isMacPlatform()
243
+ const base = { metaKey: false, ctrlKey: false, shiftKey: false, altKey: false, key: '', code: '' }
244
+ const mod = (patch) => (mac ? { ...base, metaKey: true, ...patch } : { ...base, ctrlKey: true, ...patch })
245
+ check('shortcut: Cmd/Ctrl+K = clearTerminal', G0.shortcutAction(mod({ key: 'k', code: 'KeyK' })) === 'clearTerminal')
246
+ check('shortcut: Cmd/Ctrl+S = save', G0.shortcutAction(mod({ key: 's', code: 'KeyS' })) === 'save')
247
+ check('shortcut: Cmd/Ctrl+J = toggleTerminal', G0.shortcutAction(mod({ key: 'j', code: 'KeyJ' })) === 'toggleTerminal')
248
+ check('shortcut: Cmd/Ctrl+` = toggleTerminal', G0.shortcutAction(mod({ code: 'Backquote' })) === 'toggleTerminal')
249
+ check('shortcut: Cmd/Ctrl+B = toggleSidebar', G0.shortcutAction(mod({ key: 'b', code: 'KeyB' })) === 'toggleSidebar')
250
+ check('shortcut: Cmd/Ctrl+W = closeTab', G0.shortcutAction(mod({ key: 'w', code: 'KeyW' })) === 'closeTab')
251
+ check('shortcut: Shift+Cmd/Ctrl+E/G/H = panels',
252
+ G0.shortcutAction(mod({ key: 'e', code: 'KeyE', shiftKey: true })) === 'panelExplorer' &&
253
+ G0.shortcutAction(mod({ key: 'g', code: 'KeyG', shiftKey: true })) === 'panelGit' &&
254
+ G0.shortcutAction(mod({ key: 'h', code: 'KeyH', shiftKey: true })) === 'panelSsh')
255
+ check('shortcut: Shift+Cmd/Ctrl+` = newTerminal', G0.shortcutAction(mod({ code: 'Backquote', shiftKey: true })) === 'newTerminal')
256
+ check('shortcut: Cmd/Ctrl+/ = shortcuts help', G0.shortcutAction(mod({ key: '/', code: 'Slash' })) === 'shortcuts')
257
+ check('shortcut: no modifier ignored', G0.shortcutAction({ ...base, key: 'k', code: 'KeyK' }) === null)
258
+ check('shortcut: Alt modifier ignored', G0.shortcutAction(mod({ key: 'k', code: 'KeyK', altKey: true })) === null)
259
+ check('shortcut list has clearTerminal', Array.isArray(G0.SHORTCUTS) && G0.SHORTCUTS.some((entry) => entry.action === 'clearTerminal'))
260
+ check('shortcut key text renders', mac ? G0.shortcutKeyText(G0.SHORTCUTS[0]) === '⌘S' : G0.shortcutKeyText(G0.SHORTCUTS[0]) === 'Ctrl+S', G0.shortcutKeyText(G0.SHORTCUTS[0]))
261
+ }
262
+
263
+ /* ── Git Graph 泳道布局 ─────────────────────────────────────────────────── */
264
+ if (windowStub.__GUFA_CODE__?.G?.gitGraphLayout !== undefined) {
265
+ const G0 = windowStub.__GUFA_CODE__.G
266
+ const layout = G0.gitGraphLayout([
267
+ { hash: 'c3', parents: ['c2'], subject: 'third' },
268
+ { hash: 'c2', parents: ['c1', 'b1'], subject: 'merge' },
269
+ { hash: 'c1', parents: [], subject: 'first' },
270
+ { hash: 'b1', parents: ['c1'], subject: 'branch' },
271
+ ])
272
+ check('gitGraphLayout rows', layout.rows.length === 4 && layout.laneCount >= 2)
273
+ check('gitGraphLayout hashRow', layout.hashRow.get('c1') === 2 && layout.hashRow.get('c3') === 0)
274
+ check('gitGraphLayout merge row', layout.rows[1].commit.parents.length === 2 && layout.rows[1].lane === 0)
275
+ const single = G0.gitGraphLayout([{ hash: 'a', parents: [] }])
276
+ check('gitGraphLayout single commit', single.laneCount === 1 && single.rows[0].lane === 0)
277
+ smoke('GitGraphView (no project)', () => G0.GitGraphView({ root: null, refreshToken: 0 }))
278
+ }
279
+
280
+ /* ── 代码区跟随滑动变祖配色(Monaco 动态主题 + 调色板 store) ─────────────── */
281
+ if (windowStub.__GUFA_CODE__?.G?.paletteStore !== undefined) {
282
+ const G0 = windowStub.__GUFA_CODE__.G
283
+ const seen = []
284
+ const off = G0.paletteStore.subscribe((value) => seen.push(value))
285
+ const first = { bg: 'rgb(30, 28, 24)' }
286
+ G0.paletteStore.set(first)
287
+ check('palette store publishes', seen.length === 1 && seen[0] === first)
288
+ off()
289
+ G0.paletteStore.set({ bg: 'rgb(1, 2, 3)' })
290
+ check('palette store unsubscribe', seen.length === 1)
291
+ const internals = G0.EditorPane?.internals
292
+ check('editor internals expose skinMonacoTheme', typeof internals?.skinMonacoTheme === 'function')
293
+ const darkSkin = internals.skinMonacoTheme({
294
+ bg: 'rgb(30, 28, 24)', text: 'rgb(236, 238, 241)', textDim: 'rgb(160, 160, 160)',
295
+ layer: 'rgb(50, 48, 44)', layer2: 'rgb(60, 57, 52)', accent: 'rgb(193, 154, 73)', isDark: true,
296
+ })
297
+ check('skin monaco theme dark generated', darkSkin !== null && darkSkin.data.base === 'vs-dark' && darkSkin.data.colors['editor.background'] === '#1e1c18', JSON.stringify(darkSkin?.data?.colors?.['editor.background']))
298
+ check('skin monaco theme uses accent for cursor/selection', darkSkin.data.colors['editorCursor.foreground'] === '#c19a49' && String(darkSkin.data.colors['editor.selectionBackground']).startsWith('#c19a49'), darkSkin.data.colors['editorCursor.foreground'])
299
+ check('skin monaco theme uses skin foreground', darkSkin.data.colors['editor.foreground'] === '#eceef1')
300
+ const lightSkin = internals.skinMonacoTheme({
301
+ bg: 'rgb(248, 248, 244)', text: 'rgb(24, 26, 30)', textDim: 'rgb(92, 98, 106)',
302
+ layer: 'rgb(238, 239, 235)', layer2: 'rgb(224, 224, 218)', accent: 'rgb(181, 43, 36)', isDark: false,
303
+ })
304
+ check('skin monaco theme light generated', lightSkin !== null && lightSkin.data.base === 'vs' && lightSkin.data.colors['editor.background'] === '#f8f8f4')
305
+ check('skin monaco theme rejects empty palette', internals.skinMonacoTheme(null) === null && internals.skinMonacoTheme({}) === null)
306
+ }
307
+
308
+ /* ── 梁圣皮肤背景读取(皮肤 DOM/样式 → IDE 背景层) ─────────────────────── */
309
+ if (windowStub.__GUFA_CODE__?.G?.__backdrop !== undefined) {
310
+ const B = windowStub.__GUFA_CODE__.G.__backdrop
311
+ check('backdrop unavailable without skin', B.read().available === false)
312
+ const skinImg = { src: 'https://127.0.0.1/plugins/skin/assets/portrait-source-v2/stage-02.webp', currentSrc: '' }
313
+ const skinNode = {
314
+ getAttribute(name) {
315
+ return name === 'data-media' ? 'sequence' : name === 'data-mask' ? 'on' : null
316
+ },
317
+ querySelector() {
318
+ return skinImg
319
+ },
320
+ }
321
+ documentStub.querySelector = (selector) => (selector === '.liang-skin-backdrop' ? skinNode : null)
322
+ documentStub.body.hasAttribute = (name) => name === 'data-liang-skin'
323
+ windowStub.getComputedStyle = (element) => {
324
+ if (element === skinNode) return { backgroundColor: 'rgb(24, 26, 30)', backgroundImage: 'none' }
325
+ return { display: 'block', opacity: '0.62', transform: 'matrix(1.5, 0, 0, 1.5, 0, 0)' }
326
+ }
327
+ const meta = B.read()
328
+ check('backdrop reads skin meta', meta.available === true && meta.pageColor === 'rgb(24, 26, 30)' && meta.portraitSrc.endsWith('stage-02.webp'), JSON.stringify(meta))
329
+ check('backdrop reads portrait opacity/scale', Math.abs(meta.portraitOpacity - 0.62) < 1e-6 && Math.abs(meta.portraitScale - 1.5) < 1e-6, JSON.stringify(meta))
330
+ check('backdrop reads mask flag', meta.mask === true)
331
+ const G2 = windowStub.__GUFA_CODE__.G
332
+ const rendered = G2.SkinBackdrop({ meta, enabled: true })
333
+ check('backdrop renders layer', rendered !== null && rendered.type === 'div' && rendered.props.className === 'gufa-backdrop')
334
+ check('backdrop renders portrait image', JSON.stringify(rendered.children).includes('gufa-backdrop-portrait'))
335
+ check('backdrop disabled renders nothing', G2.SkinBackdrop({ meta, enabled: false }) === null)
336
+ const strong = G2.SkinBackdrop({ meta, enabled: true, strength: 30 })
337
+ const strongImg = strong.children.find((child) => child !== null && child.props !== undefined && child.props.className === 'gufa-backdrop-portrait')
338
+ check('backdrop strength scales portrait opacity', strongImg !== undefined && Math.abs(strongImg.props.style.opacity - 0.3) < 1e-6, JSON.stringify(strongImg?.props?.style))
339
+ const slider = G2.BackdropStrengthSlider({ value: 42, onChange() {}, onReset() {} })
340
+ check('strength slider renders range input', slider.props.className === 'gufa-strength' && slider.children[1].props.type === 'range' && slider.children[1].props.value === 42)
341
+ check('strength pref persists', (() => {
342
+ G2.backdropStrengthPref.set(66)
343
+ const stored = G2.backdropStrengthPref.get()
344
+ G2.backdropStrengthPref.set(null)
345
+ return stored === 66 && G2.backdropStrengthPref.get() === null
346
+ })())
347
+ // 还原桩
348
+ documentStub.querySelector = () => null
349
+ documentStub.body.hasAttribute = () => false
350
+ delete windowStub.getComputedStyle
351
+ }
352
+
353
+ /* ── 滑动变祖「31 档细分」:只开一次、可读回、写 marker ─────────────────── */
354
+ if (windowStub.__GUFA_CODE__?.G?.ensureSkinFineStops !== undefined) {
355
+ const G3 = windowStub.__GUFA_CODE__.G
356
+ localStorageStub.removeItem('dsh-liang-intensity-skin.settings.v1')
357
+ localStorageStub.removeItem('gufa-code:skin-fine-stops:v1')
358
+ const first = G3.ensureSkinFineStops()
359
+ check('fine stops applied on first run', first.applied === true, JSON.stringify(first))
360
+ check('fine stops readable', G3.readSkinFineStops() === true)
361
+ check('fine stops keeps other defaults', (() => {
362
+ const raw = localStorageStub.getItem('dsh-liang-intensity-skin.settings.v1')
363
+ const record = raw === null ? null : JSON.parse(raw)
364
+ return record !== null && record.version === 1 && record.values.fineStopsEnabled === true && record.values.bindEffort === true && record.values.hideSlider === false
365
+ })())
366
+ const second = G3.ensureSkinFineStops()
367
+ check('fine stops applied only once', second.applied === false, JSON.stringify(second))
368
+ localStorageStub.removeItem('dsh-liang-intensity-skin.settings.v1')
369
+ localStorageStub.removeItem('gufa-code:skin-fine-stops:v1')
370
+ }
371
+
372
+ /* ── 不透明配色(皮肤把主题 token 变透明时的适配) ────────────────────── */
373
+ if (windowStub.__GUFA_CODE__?.G?.__palette !== undefined) {
374
+ const P = windowStub.__GUFA_CODE__.G.__palette
375
+ const rgb = P.parseCssColor('rgba(10, 20, 30, 0.5)')
376
+ check('palette parses rgba', rgb !== null && rgb[0] === 10 && rgb[1] === 20 && rgb[2] === 30 && Math.abs(rgb[3] - 0.5) < 1e-6, JSON.stringify(rgb))
377
+ check('palette parses transparent', (P.parseCssColor('transparent') ?? [1])[3] === 0)
378
+ check('palette parses hex', JSON.stringify(P.parseCssColor('#ff8800')) === JSON.stringify([255, 136, 0, 1]))
379
+ const mixed = P.compositeColor([255, 255, 255, 0.5], [0, 0, 0, 1])
380
+ check('palette composites translucent over opaque', mixed[0] === 128 && mixed[1] === 128 && mixed[2] === 128 && mixed[3] === 1, JSON.stringify(mixed))
381
+ const opaque = P.compositeColor([10, 20, 30, 0], [200, 100, 50, 1])
382
+ check('palette treats transparent as backdrop', opaque[0] === 200 && opaque[1] === 100 && opaque[2] === 50)
383
+ const fallback = P.resolveSolidPalette(null)
384
+ check('palette fallback returns opaque bg', P.parseCssColor(fallback.bg)?.[3] === 1 && P.parseCssColor(fallback.layer)?.[3] === 1 && P.parseCssColor(fallback.overlay)?.[3] === 1, JSON.stringify(fallback))
385
+ }
386
+
387
+ /* ── 停靠逻辑:官方会话浮到右侧 AI 栏 / 还原 / 定位修正 ─────────────────── */
388
+ if (windowStub.__GUFA_CODE__?.G?.__dock !== undefined) {
389
+ const D = windowStub.__GUFA_CODE__.G.__dock
390
+
391
+ function fakeElement(kind, rect, offset) {
392
+ const attributes = new Map()
393
+ const style = {
394
+ setProperty(name, value) {
395
+ style[name] = value
396
+ },
397
+ removeProperty(name) {
398
+ delete style[name]
399
+ },
400
+ }
401
+ const node = {
402
+ style,
403
+ getBoundingClientRect() {
404
+ if (kind === 'host') return { ...rect }
405
+ const left = Number.parseFloat(style.left || '0') + (offset?.x ?? 0)
406
+ const top = Number.parseFloat(style.top || '0') + (offset?.y ?? 0)
407
+ return { left, top, width: Number.parseFloat(style.width || String(rect.width)), height: Number.parseFloat(style.height || String(rect.height)) }
408
+ },
409
+ getAttribute(name) {
410
+ return attributes.has(name) ? attributes.get(name) : null
411
+ },
412
+ setAttribute(name, value) {
413
+ attributes.set(name, String(value))
414
+ },
415
+ removeAttribute(name) {
416
+ if (name === 'style') {
417
+ for (const key of Object.keys(style)) {
418
+ if (key !== 'setProperty' && key !== 'removeProperty') delete style[key]
419
+ }
420
+ }
421
+ attributes.delete(name)
422
+ },
423
+ }
424
+ return node
425
+ }
426
+
427
+ const conversation = fakeElement('target', { left: 0, top: 0, width: 0, height: 0 })
428
+ const host = fakeElement('host', { left: 800, top: 44, width: 360, height: 700 })
429
+ const saved = D.snapshot(conversation)
430
+ check('dock snapshot before place', saved.style === null && saved.dock === null)
431
+ check('dock placement succeeds', D.placeDockedConversation(conversation, host) === true)
432
+ check('dock uses fixed positioning', conversation.style.position === 'fixed')
433
+ check('dock aligns to host rect', conversation.style.left === '800px' && conversation.style.top === '44px' && conversation.style.width === '360px' && conversation.style.height === '700px', JSON.stringify(conversation.style))
434
+ check('dock z-index above frame overlay layer', Number(conversation.style.zIndex) > 20, conversation.style.zIndex)
435
+ check('dock narrow-width custom props', conversation.style['--dsh-chat-user-width'] === 'calc(100% - 24px)' && conversation.style['--dsh-conversation-column-width'] === '360px')
436
+ check('dock marker attribute', conversation.getAttribute('data-gufa-dock') === '1')
437
+ D.restore(conversation, saved)
438
+ check('undock removes marker', conversation.getAttribute('data-gufa-dock') === null)
439
+ check('undock clears inline styles', Object.keys(conversation.style).filter((key) => key !== 'setProperty' && key !== 'removeProperty').length === 0, JSON.stringify(Object.keys(conversation.style)))
440
+
441
+ documentStub.querySelector = (selector) => (selector === '[data-conversation-scroll]' ? { closest: () => conversation } : null)
442
+ check('finder locates conversation root', D.findConversationRoot() === conversation)
443
+ documentStub.querySelector = () => null
444
+ check('finder returns null without conversation', D.findConversationRoot() === null)
445
+
446
+ // position:fixed 被祖先 transform 影响时的补偿
447
+ const shiftedConversation = fakeElement('target', { left: 0, top: 0, width: 0, height: 0 }, { x: -120, y: -40 })
448
+ const host2 = fakeElement('host', { left: 700, top: 40, width: 380, height: 640 })
449
+ const saved2 = D.snapshot(shiftedConversation)
450
+ D.placeDockedConversation(shiftedConversation, host2)
451
+ const actual = shiftedConversation.getBoundingClientRect()
452
+ check('dock corrects transformed containing block', Math.abs(actual.left - 700) <= 1 && Math.abs(actual.top - 40) <= 1, JSON.stringify(actual))
453
+ D.restore(shiftedConversation, saved2)
454
+ }
455
+
456
+ console.log(`\n${passed} passed, ${failed} failed`)
457
+ process.exit(failed === 0 ? 0 : 1)
@@ -0,0 +1,197 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Host 半自测:不启动 Harness、不走 HTTP,直接调用各 host 模块注册的 handler。
4
+ * 覆盖:项目打开/新建、文件读写/新建/重命名/删除、Git 状态/暂存/提交、SSH 配置解析、静态资产解析。
5
+ * 用法:node scripts/selftest-host.mjs
6
+ */
7
+ import { execFileSync } from 'node:child_process'
8
+ import { mkdtempSync, rmSync, writeFileSync } from 'node:fs'
9
+ import { tmpdir } from 'node:os'
10
+ import { join } from 'node:path'
11
+
12
+ import * as assets from '../host/assets.js'
13
+ import * as chat from '../host/chat.js'
14
+ import * as fs from '../host/fs.js'
15
+ import * as git from '../host/git.js'
16
+ import * as project from '../host/project.js'
17
+ import { ProjectRegistry } from '../host/context.js'
18
+ import * as ssh from '../host/ssh.js'
19
+ import * as terminal from '../host/terminal.js'
20
+
21
+ let passed = 0
22
+ let failed = 0
23
+ function check(name, condition, detail) {
24
+ if (condition) {
25
+ passed += 1
26
+ console.log(` ok ${name}`)
27
+ } else {
28
+ failed += 1
29
+ console.error(` FAIL ${name}${detail === undefined ? '' : ` — ${detail}`}`)
30
+ }
31
+ }
32
+
33
+ function makeRouter() {
34
+ const router = {
35
+ methods: new Map(),
36
+ streams: new Map(),
37
+ sockets: [],
38
+ assets: [],
39
+ disposers: [],
40
+ method(name, fn) {
41
+ router.methods.set(name, fn)
42
+ },
43
+ stream(name, fn) {
44
+ router.streams.set(name, fn)
45
+ },
46
+ ws(channel, handler) {
47
+ router.sockets.push({ channel, handler })
48
+ },
49
+ asset(prefix, resolver) {
50
+ router.assets.push({ prefix, resolver })
51
+ },
52
+ onDispose(fn) {
53
+ router.disposers.push(fn)
54
+ },
55
+ }
56
+ return router
57
+ }
58
+
59
+ async function call(router, name, body = {}) {
60
+ const fn = router.methods.get(name)
61
+ if (fn === undefined) throw new Error(`method not registered: ${name}`)
62
+ return await fn(body, {})
63
+ }
64
+
65
+ const sandbox = mkdtempSync(join(tmpdir(), 'gufa-selftest-'))
66
+ const projectDir = join(sandbox, 'demo-project')
67
+ const router = makeRouter()
68
+ const env = { ctx: { get: () => undefined }, projects: new ProjectRegistry() }
69
+ for (const module of [assets, project, fs, git, ssh, terminal, chat]) module.register(router, env)
70
+
71
+ try {
72
+ console.log('project.create / project.open')
73
+ const created = await call(router, 'project.create', { parent: sandbox, name: 'demo-project' })
74
+ const root = created.root
75
+ check('new project root', created.root.split('/').at(-1) === 'demo-project', created.root)
76
+ check('project name', created.name === 'demo-project')
77
+ check('registered in registry', env.projects.list().length === 1)
78
+
79
+ console.log('fs.create / fs.list / fs.read / fs.write')
80
+ await call(router, 'fs.create', { root, path: 'src', kind: 'dir' })
81
+ await call(router, 'fs.create', { root, path: 'src/main.ts', kind: 'file' })
82
+ const listing = await call(router, 'fs.list', { root, path: '' })
83
+ check('root lists src dir', listing.entries.some((entry) => entry.name === 'src' && entry.kind === 'dir'))
84
+ const doc = await call(router, 'fs.read', { root, path: 'src/main.ts' })
85
+ check('read empty file', doc.content === '')
86
+ check('language detected', doc.language === 'typescript', doc.language)
87
+ const saved = await call(router, 'fs.write', { root, path: 'src/main.ts', content: 'const answer = 42\n', expectedRevision: doc.revision })
88
+ check('write returns revision', typeof saved.revision === 'string' && saved.revision.length > 0)
89
+ const reread = await call(router, 'fs.read', { root, path: 'src/main.ts' })
90
+ check('content persisted', reread.content === 'const answer = 42\n')
91
+ let conflict = false
92
+ try {
93
+ await call(router, 'fs.write', { root, path: 'src/main.ts', content: 'x', expectedRevision: doc.revision })
94
+ } catch (error) {
95
+ conflict = error.code === 'revision-conflict'
96
+ }
97
+ check('stale revision rejected', conflict)
98
+ let outside = false
99
+ try {
100
+ await call(router, 'fs.read', { root, path: '../../../etc/passwd' })
101
+ } catch (error) {
102
+ outside = error.code === 'path-outside' || error.code === 'not-found'
103
+ }
104
+ check('path traversal rejected', outside)
105
+ await call(router, 'fs.create', { root, path: 'src/util.ts', kind: 'file' })
106
+ await call(router, 'fs.rename', { root, path: 'src/util.ts', nextPath: 'src/helper.ts' })
107
+ const afterRename = await call(router, 'fs.list', { root, path: 'src' })
108
+ check('rename applied', afterRename.entries.some((entry) => entry.name === 'helper.ts'))
109
+ await call(router, 'fs.delete', { root, path: 'src/helper.ts' })
110
+ const afterDelete = await call(router, 'fs.list', { root, path: 'src' })
111
+ check('delete applied', !afterDelete.entries.some((entry) => entry.name === 'helper.ts'))
112
+
113
+ console.log('git.status / stage / commit')
114
+ execFileSync('git', ['init', '-q'], { cwd: projectDir })
115
+ execFileSync('git', ['config', 'user.email', 'selftest@example.com'], { cwd: projectDir })
116
+ execFileSync('git', ['config', 'user.name', 'gufa selftest'], { cwd: projectDir })
117
+ const status0 = await call(router, 'git.status', { root })
118
+ check('git repo detected', status0.isGit === true)
119
+ check('untracked change listed', status0.changes.some((change) => change.path === 'src/main.ts'), JSON.stringify(status0.changes))
120
+ await call(router, 'git.stage', { root, paths: ['src/main.ts'] })
121
+ const staged = await call(router, 'git.status', { root })
122
+ check('change staged', staged.changes.some((change) => change.path === 'src/main.ts' && change.staged === true), JSON.stringify(staged.changes))
123
+ await call(router, 'git.commit', { root, message: 'feat: selftest' })
124
+ const clean = await call(router, 'git.status', { root })
125
+ check('working tree clean after commit', clean.changes.length === 0, JSON.stringify(clean.changes))
126
+ const log = await call(router, 'git.log', { root, limit: 5 })
127
+ check('commit in log', log.commits.length === 1 && log.commits[0].subject === 'feat: selftest')
128
+ writeFileSync(join(projectDir, 'src/main.ts'), 'const answer = 43\n')
129
+ const diff = await call(router, 'git.diff', { root, path: 'src/main.ts' })
130
+ check('diff contains change', diff.patch.includes('43'))
131
+
132
+ console.log('workspaces')
133
+ const fakeSessions = new Map([
134
+ ['s1', { id: 's1', updatedAt: 20 }],
135
+ ['s2', { id: 's2', updatedAt: 30 }],
136
+ ])
137
+ const wsRouter = makeRouter()
138
+ const wsEnv = {
139
+ ctx: {
140
+ get(name) {
141
+ if (name === 'workspaceRegistry') {
142
+ return { list: () => [{ id: 'ws-1', path: '/tmp/project-a', title: 'Project A' }, { id: 'ws-2', path: '/tmp/project-b' }, { bad: true }] }
143
+ }
144
+ if (name === 'workingDirectory') return { defaultDirectory: '/tmp' }
145
+ if (name === 'workspaceController') {
146
+ return {
147
+ async *follow() {
148
+ yield {
149
+ type: 'baseline',
150
+ value: {
151
+ items: [
152
+ { workspaceId: 'ws-1', path: '/tmp/project-a', title: 'Project A', sessionIds: ['s1', 's2'] },
153
+ { workspaceId: 'ws-2', path: '/tmp/project-b', title: 'Project B', sessionIds: [] },
154
+ ],
155
+ },
156
+ }
157
+ },
158
+ }
159
+ }
160
+ if (name === 'sessions') return { get: (id) => fakeSessions.get(id) }
161
+ if (name === 'sessionTitle') return { get: (session) => ({ title: session.id === 's1' ? 'Grafana 搭建' : '新增 RTA' }) }
162
+ return undefined
163
+ },
164
+ },
165
+ projects: new ProjectRegistry(),
166
+ }
167
+ project.register(wsRouter, wsEnv)
168
+ const wsList = await wsRouter.methods.get('workspaces.list')({})
169
+ check('workspaces.list maps registry', wsList.available === true && wsList.workspaces.length === 2 && wsList.workspaces[0].title === 'Project A')
170
+ const wsSessions = await wsRouter.methods.get('workspaces.sessions')({ path: '/tmp/project-a' })
171
+ check('workspaces.sessions returns workspace sessions', wsSessions.available === true && wsSessions.sessions.length === 2, JSON.stringify(wsSessions))
172
+ check('workspaces.sessions membership', Array.isArray(wsSessions.sessionIds) && wsSessions.sessionIds.length === 2 && wsSessions.sessionIds[0] === 's1' && wsSessions.sessions.length === 2)
173
+ const wsGroups = await wsRouter.methods.get('workspaces.sessions')({ path: '/tmp/project-a' })
174
+ check('workspaces.sessions groups', Array.isArray(wsGroups.groups) && wsGroups.groups.length === 1 && wsGroups.groups[0].sessions.length === 2)
175
+ const wsDefault = await wsRouter.methods.get('workspaces.defaultDirectory')({})
176
+ check('workspaces.defaultDirectory', wsDefault.directory === '/tmp')
177
+ const wsRouterUnavailable = makeRouter()
178
+ project.register(wsRouterUnavailable, { ctx: {}, projects: new ProjectRegistry() })
179
+ const wsNone = await wsRouterUnavailable.methods.get('workspaces.list')({})
180
+ check('workspaces.list unavailable fallback', wsNone.available === false && wsNone.workspaces.length === 0)
181
+
182
+ console.log('ssh / assets / placeholders')
183
+ const sshHosts = await call(router, 'ssh.hosts')
184
+ check('ssh.hosts shape', Array.isArray(sshHosts.hosts))
185
+ const monaco = await router.assets.find((item) => item.prefix === 'monaco').resolver('loader.js')
186
+ check('monaco loader asset resolves', monaco !== undefined && monaco.type.includes('javascript'), JSON.stringify(monaco))
187
+ const xterm = await router.assets.find((item) => item.prefix === 'xterm').resolver('xterm.js')
188
+ check('xterm asset resolves', xterm !== undefined)
189
+ check('ws channel registered', router.sockets.some((item) => item.channel === 'terminal'))
190
+ check('chat stream registered', router.streams.has('chat.stream'))
191
+ check('api method count', router.methods.size >= 20, String(router.methods.size))
192
+ } finally {
193
+ rmSync(sandbox, { recursive: true, force: true })
194
+ }
195
+
196
+ console.log(`\n${passed} passed, ${failed} failed`)
197
+ process.exit(failed === 0 ? 0 : 1)