返回 DeepSeek-Reasonix
desktop_test.go
根目录 / internal / bot / desktop_test.go
1 package bot
2
3 import (
4 "context"
5 "fmt"
6 "strings"
7 "testing"
8
9 "reasonix/internal/event"
10 )
11
12 type fakeDesktopBridge struct {
13 sessions []DesktopSessionInfo
14 watching map[string]bool
15 approved []string
16 denied []string
17 answered map[string][]event.AskAnswer
18 questions map[string][]event.AskQuestion
19 takeovers map[string]string // routeKey -> tabID
20 driven []string
21 driveErr error
22 watchErr error
23 }
24
25 func newFakeDesktopBridge() *fakeDesktopBridge {
26 return &fakeDesktopBridge{
27 watching: make(map[string]bool),
28 answered: make(map[string][]event.AskAnswer),
29 questions: make(map[string][]event.AskQuestion),
30 takeovers: make(map[string]string),
31 }
32 }
33
34 func (f *fakeDesktopBridge) Sessions() []DesktopSessionInfo { return f.sessions }
35 func (f *fakeDesktopBridge) SetWatch(route DesktopWatchRoute, enable bool) error {
36 f.watching[route.Key()] = enable
37 return f.watchErr
38 }
39 func (f *fakeDesktopBridge) Watching(route DesktopWatchRoute) bool {
40 return f.watching[route.Key()]
41 }
42 func (f *fakeDesktopBridge) Approve(id string, allow bool) (string, error) {
43 if id == "gone" {
44 return "", fmt.Errorf("未找到待处理的审批 %s", id)
45 }
46 if allow {
47 f.approved = append(f.approved, id)
48 } else {
49 f.denied = append(f.denied, id)
50 }
51 return "已提交", nil
52 }
53 func (f *fakeDesktopBridge) AskQuestions(id string) ([]event.AskQuestion, bool) {
54 qs, ok := f.questions[id]
55 return qs, ok
56 }
57 func (f *fakeDesktopBridge) Answer(id string, answers []event.AskAnswer) (string, error) {
58 f.answered[id] = answers
59 return "已提交回答", nil
60 }
61
62 func (f *fakeDesktopBridge) Takeover(route DesktopWatchRoute, tabID string) (string, error) {
63 for _, s := range f.sessions {
64 if s.TabID == tabID {
65 f.takeovers[route.Key()] = tabID
66 return "已接管", nil
67 }
68 }
69 return "", fmt.Errorf("未找到会话 %s", tabID)
70 }
71
72 func (f *fakeDesktopBridge) Release(route DesktopWatchRoute) (string, error) {
73 if _, ok := f.takeovers[route.Key()]; !ok {
74 return "", fmt.Errorf("本聊天当前没有接管任何桌面会话。")
75 }
76 delete(f.takeovers, route.Key())
77 return "已解除接管", nil
78 }
79
80 func (f *fakeDesktopBridge) TakeoverTab(route DesktopWatchRoute) string {
81 return f.takeovers[route.Key()]
82 }
83
84 func (f *fakeDesktopBridge) DriveInput(route DesktopWatchRoute, text string) (string, error) {
85 if f.driveErr != nil {
86 return "", f.driveErr
87 }
88 f.driven = append(f.driven, text)
89 return "", nil
90 }
91
92 func desktopTestMessage(text string) InboundMessage {
93 return InboundMessage{
94 Platform: PlatformFeishu,
95 ConnectionID: "feishu-main",
96 Domain: "feishu",
97 ChatType: ChatDM,
98 ChatID: "chat-god",
99 UserID: "admin-user",
100 Text: text,
101 }
102 }
103
104 func TestHandleDesktopCommandWithoutBridge(t *testing.T) {
105 gw := &BotGateway{cfg: GatewayConfig{}}
106 got := gw.handleDesktopCommand(desktopTestMessage("/desktop status"))
107 if !strings.Contains(got, "未运行在桌面端进程内") {
108 t.Fatalf("reply = %q, want standalone-mode notice", got)
109 }
110 }
111
112 func TestHandleDesktopCommandStatusListsSessions(t *testing.T) {
113 bridge := newFakeDesktopBridge()
114 bridge.sessions = []DesktopSessionInfo{
115 {TabID: "tab-1", Label: "修复登录", Workspace: "blade", Running: true, Ready: true},
116 {TabID: "tab-2", Label: "", Topic: "周报", Ready: true, PendingPrompt: true},
117 }
118 gw := &BotGateway{cfg: GatewayConfig{Desktop: bridge}}
119
120 got := gw.handleDesktopCommand(desktopTestMessage("/desktop status"))
121 for _, want := range []string{"2 个", "修复登录", "▶️ 执行中", "周报", "⚠️ 等待审批/回答", "tab-1", "blade"} {
122 if !strings.Contains(got, want) {
123 t.Fatalf("status reply = %q, want it to contain %q", got, want)
124 }
125 }
126 }
127
128 func TestHandleDesktopCommandStatusListsPendingIDs(t *testing.T) {
129 bridge := newFakeDesktopBridge()
130 bridge.sessions = []DesktopSessionInfo{{
131 TabID: "tab-1", Label: "修复登录", Ready: true, PendingPrompt: true,
132 Pending: []DesktopPendingInfo{{ID: "appr-9", Kind: "approval", Tool: "bash"}},
133 }}
134 gw := &BotGateway{cfg: GatewayConfig{Desktop: bridge}}
135 got := gw.handleDesktopCommand(desktopTestMessage("/desktop status"))
136 // The pending id must be visible so a user whose push was dropped can still
137 // run /desktop approve <id>.
138 if !strings.Contains(got, "appr-9") {
139 t.Fatalf("status = %q, want it to list the pending approval id", got)
140 }
141 }
142
143 func TestHandleDesktopCommandStatusEmpty(t *testing.T) {
144 gw := &BotGateway{cfg: GatewayConfig{Desktop: newFakeDesktopBridge()}}
145 got := gw.handleDesktopCommand(desktopTestMessage("/desktop"))
146 if !strings.Contains(got, "没有 live 会话") {
147 t.Fatalf("reply = %q, want empty-sessions notice", got)
148 }
149 }
150
151 func TestHandleDesktopCommandWatchLifecycle(t *testing.T) {
152 bridge := newFakeDesktopBridge()
153 gw := &BotGateway{cfg: GatewayConfig{Desktop: bridge}}
154 msg := desktopTestMessage("/desktop watch on")
155
156 got := gw.handleDesktopCommand(msg)
157 if !strings.Contains(got, "已订阅") {
158 t.Fatalf("watch on reply = %q", got)
159 }
160 route := desktopRouteFromMessage(msg)
161 if !bridge.watching[route.Key()] {
162 t.Fatal("watch on did not subscribe the message route")
163 }
164
165 msg.Text = "/desktop watch off"
166 got = gw.handleDesktopCommand(msg)
167 if !strings.Contains(got, "已退订") {
168 t.Fatalf("watch off reply = %q", got)
169 }
170 if bridge.watching[route.Key()] {
171 t.Fatal("watch off did not unsubscribe the message route")
172 }
173 }
174
175 func TestHandleDesktopCommandWatchReportsPersistenceFailure(t *testing.T) {
176 bridge := newFakeDesktopBridge()
177 bridge.watchErr = fmt.Errorf("disk unavailable")
178 gw := &BotGateway{cfg: GatewayConfig{Desktop: bridge}}
179 msg := desktopTestMessage("/desktop watch on")
180
181 got := gw.handleDesktopCommand(msg)
182 if !strings.Contains(got, "本次运行中订阅") || !strings.Contains(got, "保存订阅失败") {
183 t.Fatalf("watch persistence failure reply = %q", got)
184 }
185 if !bridge.Watching(desktopRouteFromMessage(msg)) {
186 t.Fatal("runtime subscription should remain active after persistence failure")
187 }
188 }
189
190 func TestHandleDesktopCommandApproveAndDeny(t *testing.T) {
191 bridge := newFakeDesktopBridge()
192 gw := &BotGateway{cfg: GatewayConfig{Desktop: bridge}}
193
194 if got := gw.handleDesktopCommand(desktopTestMessage("/desktop approve appr-1")); !strings.Contains(got, "已提交") {
195 t.Fatalf("approve reply = %q", got)
196 }
197 if got := gw.handleDesktopCommand(desktopTestMessage("/desktop deny appr-2")); !strings.Contains(got, "已提交") {
198 t.Fatalf("deny reply = %q", got)
199 }
200 if len(bridge.approved) != 1 || bridge.approved[0] != "appr-1" {
201 t.Fatalf("approved = %v, want [appr-1]", bridge.approved)
202 }
203 if len(bridge.denied) != 1 || bridge.denied[0] != "appr-2" {
204 t.Fatalf("denied = %v, want [appr-2]", bridge.denied)
205 }
206
207 if got := gw.handleDesktopCommand(desktopTestMessage("/desktop approve gone")); !strings.Contains(got, "未找到") {
208 t.Fatalf("missing-approval reply = %q", got)
209 }
210 if got := gw.handleDesktopCommand(desktopTestMessage("/desktop approve")); got != desktopCommandUsage {
211 t.Fatalf("missing-arg reply = %q, want usage", got)
212 }
213 }
214
215 func TestHandleDesktopCommandAnswerParsesSelection(t *testing.T) {
216 bridge := newFakeDesktopBridge()
217 bridge.questions["ask-1"] = []event.AskQuestion{{
218 ID: "q1",
219 Prompt: "选一个",
220 Options: []event.AskOption{{Label: "方案 A"}, {Label: "方案 B"}},
221 }}
222 gw := &BotGateway{cfg: GatewayConfig{Desktop: bridge}}
223
224 got := gw.handleDesktopCommand(desktopTestMessage("/desktop answer ask-1 2"))
225 if !strings.Contains(got, "已提交回答") {
226 t.Fatalf("answer reply = %q", got)
227 }
228 answers := bridge.answered["ask-1"]
229 if len(answers) != 1 || answers[0].QuestionID != "q1" {
230 t.Fatalf("answers = %+v, want one answer for q1", answers)
231 }
232 if len(answers[0].Selected) != 1 || answers[0].Selected[0] != "方案 B" {
233 t.Fatalf("selected = %v, want numeric index resolved to 方案 B", answers[0].Selected)
234 }
235
236 if got := gw.handleDesktopCommand(desktopTestMessage("/desktop answer ask-gone 1")); !strings.Contains(got, "未找到") {
237 t.Fatalf("missing-ask reply = %q", got)
238 }
239 }
240
241 func TestHandleDesktopCommandTakeoverAndRelease(t *testing.T) {
242 bridge := newFakeDesktopBridge()
243 bridge.sessions = []DesktopSessionInfo{{TabID: "tab-1", Label: "会话一"}}
244 gw := &BotGateway{cfg: GatewayConfig{Desktop: bridge}}
245 msg := desktopTestMessage("/desktop takeover tab-1")
246
247 if got := gw.handleDesktopCommand(msg); !strings.Contains(got, "已接管") {
248 t.Fatalf("takeover reply = %q", got)
249 }
250 route := desktopRouteFromMessage(msg)
251 if bridge.takeovers[route.Key()] != "tab-1" {
252 t.Fatalf("takeovers = %v, want route bound to tab-1", bridge.takeovers)
253 }
254
255 msg.Text = "/desktop release"
256 if got := gw.handleDesktopCommand(msg); !strings.Contains(got, "已解除") {
257 t.Fatalf("release reply = %q", got)
258 }
259 if got := gw.handleDesktopCommand(desktopTestMessage("/desktop takeover missing")); !strings.Contains(got, "未找到") {
260 t.Fatalf("missing-tab reply = %q", got)
261 }
262 }
263
264 func TestDivertToDesktopTakeover(t *testing.T) {
265 bridge := newFakeDesktopBridge()
266 bridge.sessions = []DesktopSessionInfo{{TabID: "tab-1", Label: "会话一"}}
267 gw := &BotGateway{
268 cfg: GatewayConfig{Desktop: bridge},
269 logger: discardLogger(),
270 adapterHealth: map[string]*AdapterHealthSnapshot{},
271 }
272 adapter := newFakeAdapter(PlatformFeishu, "fake-feishu")
273 msg := desktopTestMessage("帮我跑一下测试")
274
275 // 未接管:不分流。
276 if gw.divertToDesktopTakeover(context.Background(), adapter, msg) {
277 t.Fatal("message should not divert without a takeover binding")
278 }
279
280 route := desktopRouteFromMessage(msg)
281 bridge.takeovers[route.Key()] = "tab-1"
282 msg.Text = "/desktop status"
283 if gw.divertToDesktopTakeover(context.Background(), adapter, msg) {
284 t.Fatal("slash commands must remain in the bot command path during takeover")
285 }
286 msg.Text = "帮我跑一下测试"
287 if !gw.divertToDesktopTakeover(context.Background(), adapter, msg) {
288 t.Fatal("message should divert to the taken-over session")
289 }
290 if len(bridge.driven) != 1 || bridge.driven[0] != "帮我跑一下测试" {
291 t.Fatalf("driven = %v, want the plain message text", bridge.driven)
292 }
293
294 // 驱动失败:错误文案回给用户。
295 bridge.driveErr = fmt.Errorf("会话正在执行中")
296 if !gw.divertToDesktopTakeover(context.Background(), adapter, msg) {
297 t.Fatal("drive failure should still consume the message")
298 }
299 sent := adapter.sentMessages()
300 if len(sent) == 0 || !strings.Contains(sent[len(sent)-1].Text, "正在执行中") {
301 t.Fatalf("sent = %+v, want drive error relayed to the chat", sent)
302 }
303 }
304
305 func TestDivertToDesktopTakeoverRevokesFormerAdmin(t *testing.T) {
306 bridge := newFakeDesktopBridge()
307 bridge.sessions = []DesktopSessionInfo{{TabID: "tab-1", Label: "会话一"}}
308 gw := &BotGateway{
309 cfg: GatewayConfig{
310 Desktop: bridge,
311 Allowlist: AllowlistConfig{Admins: map[Platform][]string{
312 PlatformFeishu: {"current-admin"},
313 }},
314 },
315 logger: discardLogger(),
316 adapterHealth: map[string]*AdapterHealthSnapshot{},
317 }
318 adapter := newFakeAdapter(PlatformFeishu, "fake-feishu")
319 msg := desktopTestMessage("run tests")
320 route := desktopRouteFromMessage(msg)
321 bridge.takeovers[route.Key()] = "tab-1"
322
323 if !gw.divertToDesktopTakeover(context.Background(), adapter, msg) {
324 t.Fatal("revoked takeover message should be consumed with an explanation")
325 }
326 if bridge.TakeoverTab(route) != "" || len(bridge.driven) != 0 {
327 t.Fatalf("revoked takeover remained active or drove input: tab=%q driven=%v", bridge.TakeoverTab(route), bridge.driven)
328 }
329 sent := adapter.sentMessages()
330 if len(sent) == 0 || !strings.Contains(sent[len(sent)-1].Text, "不再具有") {
331 t.Fatalf("sent = %+v, want admin-revocation explanation", sent)
332 }
333 }
334
334 lines GO