Repository navigation
Expand file tree
/
Copy pathpanel-source.spec.tsx
More file actions
286 lines (271 loc) · 19 KB
/
Copy pathpanel-source.spec.tsx
File metadata and controls
286 lines (271 loc) · 19 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
// @vitest-environment jsdom
// The Source status and settings.
import { act } from 'react'
import { expect, it, onTestFinished, vi } from 'vitest'
import { zh } from '../src/client/locales.ts'
import { usePanel, mount, remount, settle, all, find, tab, click, type, text, field, at } from './fixtures/panel.tsx'
import { server, problemOf, droppedReply } from './fixtures/panel-server.ts'
usePanel()
it('shows each Source\'s state on the Source status and opens its settings from a setup problem', async () => {
await mount()
await click(zh['tab.source'])
expect(document.querySelector('.dt-status-title')?.textContent).toBe(zh.statusTitle)
expect(all('.dt-form > .dt-list > .dt-row .dt-row-name').map(title => title.textContent)).toEqual(['飞书', 'GitHub'])
expect(all('.dt-form > .dt-list > .dt-row')[0]!.querySelector('.dt-row-meta')?.textContent).toBe('24 小时到达 12被过滤 2最近 ' + document.querySelector('.dt-row-meta time, .dt-row-meta .dt-lv:last-child .dt-lv-value')?.textContent)
expect(text()).toContain(zh['sourceState.error'])
expect(text()).toContain('socket closed')
expect(all('.dt-card')).toHaveLength(0)
server.setup = { ...server.setup, problem: problemOf('agentCliUnset') }
await remount()
await click(zh['tab.source'])
await click(zh.sourceOpenSettings)
expect(tab(zh['tab.settings']).getAttribute('aria-selected')).toBe('true')
expect(document.querySelector('.dt-settings-link[aria-current="page"]')?.textContent).toBe(zh['term.source'])
expect(all('.dt-card')[1]!.textContent).toContain('部署检查未通过:没有设置智能体的 GitHub CLI。')
})
it('folds more than six chats into a line of their names', async () => {
const chats = Array.from({ length: 7 }, (_, n) => ({ chatId: n ? `oc_c${n}` : 'oc_team', listen: 'all' as const }))
server.settings.sources.feishu = { ...server.settings.sources.feishu!, config: { ...server.settings.sources.feishu!.config, chats } }
await mount()
await click(zh['tab.source'])
await click(find(zh['tab.settings'], '.dt-status-head button'))
const summary = all('.dt-card')[0]!.querySelector('.dt-rows-summary')!
expect(summary.textContent).toBe(['团队群', 'oc_c1', 'oc_c2', 'oc_c3', 'oc_c4', 'oc_c5', 'oc_c6'].join('、'))
})
it('rereads the chats the bot has joined on request and says which are new, keeping unsaved edits', async () => {
await mount()
await click(zh['tab.source'])
await click(find(zh['tab.settings'], '.dt-status-head button'))
const feishu = () => all('.dt-card')[0]!
const button = () => [...feishu().querySelectorAll('button')].find(item => item.textContent === '刷新群列表')!
await click([...feishu().querySelectorAll('button')].find(item => item.textContent === '添加')!)
server.joining = { oc_new: '新群' }
await click(button())
expect(server.requests.find(request => request.path === 'sources/feishu/labels')).toMatchObject({ method: 'POST' })
expect(document.querySelector('.dt-toast')?.textContent).toBe('列表已更新,新增:新群')
// The row being added stays, and now offers the new chat.
const pick = feishu().querySelectorAll('.dt-rows-item')[1]!.querySelector<HTMLSelectElement>('select[aria-label="选择机器人已加入的群"]')!
expect([...pick.options].map(option => option.textContent)).toEqual(['选择机器人已加入的群', '另一个群', '新群'])
await click(button())
expect([...document.querySelectorAll('.dt-toast')].at(-1)?.textContent).toBe(zh.labelsCurrent)
server.joinError = 'lark-cli: token expired'
await click(button())
expect([...document.querySelectorAll('.dt-toast')].at(-1)?.textContent).toBe('读取失败,列表保持不变:lark-cli: token expired')
expect(button().disabled).toBe(false)
// Only a field whose key column offers a refresh shows the button.
expect([...all('.dt-card')[1]!.querySelectorAll('button')].some(item => item.textContent === '刷新群列表')).toBe(false)
})
it('renders each Source\'s settings from its kind\'s fields and saves one Source against its own revision', async () => {
await mount()
await click(zh['tab.source'])
await click(find(zh['tab.settings'], '.dt-status-head button'))
const cards = all('.dt-card')
expect(cards.map(card => card.querySelector('.dt-row-title')?.firstChild?.textContent)).toEqual(['飞书', 'GitHub'])
const feishu = cards[0]!
expect(feishu.textContent).toContain('接收的群')
await click([...feishu.querySelectorAll('button')].find(button => button.textContent === '添加')!)
const rows = feishu.querySelectorAll('.dt-rows-item')
// A saved chat shows its name; a row being added picks from the joined chats not yet configured.
expect(rows[0]!.querySelector('.dt-row-label')?.textContent).toBe('团队群')
expect(rows[0]!.querySelector('select[aria-label="选择机器人已加入的群"]')).toBeNull()
const pick = rows[1]!.querySelector<HTMLSelectElement>('select[aria-label="选择机器人已加入的群"]')!
expect([...pick.options].map(option => option.textContent)).toEqual(['选择机器人已加入的群', '另一个群'])
await type(pick, 'oc_other')
await type(rows[1]!.querySelector('select[aria-label="接收"]')!, 'all')
await click('接收私聊')
await act(async () => { feishu.dispatchEvent(new Event('submit', { bubbles: true, cancelable: true })) }); await settle()
expect(server.requests.find(request => request.path === 'settings/source:feishu')).toMatchObject({ method: 'PUT', body: { revision: 0,
value: { enabled: true, config: { chats: [{ chatId: 'oc_team', listen: 'mentions' }, { chatId: 'oc_other', listen: 'all' }],
directMessages: false } } } })
expect(server.settings.sources.feishu!.config).toMatchObject({ directMessages: false })
const github = all('.dt-card')[1]!
await click('运行来源:GitHub')
server.revisions['source:github'] = 5
await act(async () => { github.dispatchEvent(new Event('submit', { bubbles: true, cancelable: true })) }); await settle()
// Another save changed nothing this draft changed, so the draft moves onto the newer revision and saves from there.
expect(github.querySelector('.dt-savebar')?.textContent).toContain('设置已在别处修改,你的修改已合并到最新版本')
await act(async () => { github.dispatchEvent(new Event('submit', { bubbles: true, cancelable: true })) }); await settle()
expect(server.settings.sources.github).toMatchObject({ enabled: false })
expect(server.revisions['source:github']).toBe(6)
})
it('shows the account each CLI signs in as, and keeps a saved CLI on screen while dsh-superagent restarts', async () => {
await mount()
await click(tab(zh['tab.settings']))
const github = all('.dt-card')[1]!
expect(github.textContent).toContain('当前账号 @agent-bot')
const labels = () => [...github.querySelectorAll('label')].map(label => label.textContent)
expect(labels()).toEqual(['监听的仓库', '智能体的 GitHub CLI'])
expect(field<HTMLInputElement>('监听的仓库').value).toBe('acme/widgets')
// A fact describes the saved value, so an edited CLI shows none until it is saved.
await type(field<HTMLInputElement>('智能体的 GitHub CLI'), 'gh-other')
expect(github.textContent).not.toContain('当前账号 @agent-bot')
await click(zh.discard)
expect(github.textContent).toContain('当前账号 @agent-bot')
await click([...github.querySelectorAll<HTMLElement>('[data-disclosure-row]')].find(row => row.textContent?.includes('高级'))!)
expect(labels()).toEqual(['监听的仓库', '智能体的 GitHub CLI', '轮询间隔 / 秒', '连续失败几次后报告异常'])
expect(all('.dt-card')[0]!.textContent).toContain('当前机器人 Widgets Bot')
await type(field<HTMLInputElement>('智能体的 GitHub CLI'), '/opt/bin/gh-agent')
expect(github.textContent).toContain('保存会重启 dsh-superagent:2 个进行中的派工会中断,并带恢复说明重新开始。')
await act(async () => { github.dispatchEvent(new Event('submit', { bubbles: true, cancelable: true })) }); await settle()
expect(server.settings.sources.github!.config).toMatchObject({ cli: '/opt/bin/gh-agent' })
expect(text()).toContain(zh.savedRestarting)
// The saved value stays, and the card's fields are disabled, until the saved revision returns with the restarted dsh-superagent.
expect(field<HTMLInputElement>('智能体的 GitHub CLI').value).toBe('/opt/bin/gh-agent')
expect(field('智能体的 GitHub CLI').matches(':disabled')).toBe(true)
expect(sessionStorage.getItem('dsh-superagent/draft/master/source:github')).toBeNull()
})
it('points a setup problem at the settings that fix it', async () => {
server.setup = { ...server.setup, problem: problemOf('agentCliUnset'), accounts: null }
// jsdom has no scrolling; mock scrollIntoView to record which card the link brings into view.
const scroll = vi.fn()
Element.prototype.scrollIntoView = scroll
await mount()
expect(text()).toContain(zh.problemSetup)
expect(text()).toContain(zh['setup.agentCliUnset'])
// Nothing runs while setup waits, so the Executor says so instead of counting.
expect(text()).toContain(zh.executorWaitingSetup)
expect(all('.dt-stat').map(stat => stat.textContent)).not.toContain('执行中2 / 3')
expect(text()).toContain('在设置 › 来源的 GitHub 卡片修正后保存,会重新检查')
await click(find(zh.problemSetup, 'button, a, [role="button"]'))
expect(tab(zh['tab.settings']).getAttribute('aria-selected')).toBe('true')
expect(document.querySelector('.dt-settings-link[aria-current="page"]')?.textContent).toBe(zh['term.source'])
expect(all('.dt-card')[1]!.textContent).toContain('部署检查未通过:没有设置智能体的 GitHub CLI。')
expect(scroll.mock.contexts).toContain(all('.dt-card')[1])
delete (Element.prototype as Partial<Element>).scrollIntoView
await click(find(zh['settingsGroup.deployment'], '.dt-settings-link'))
expect(text()).toContain('部署检查未通过:没有设置智能体的 GitHub CLI。')
// After a fix outside dsh-superagent, a check runs setup again.
await click(zh.setupCheck)
expect(server.requests.filter(request => request.path === 'setup/check')).toEqual([{ path: 'setup/check', method: 'POST' }])
expect(text()).toContain(zh.setupChecking)
server.setup = { ...server.setup, problem: problemOf('gitTooOld') }
await remount()
await click(zh['tab.overview'])
await click(find(zh.problemSetup, 'button, a, [role="button"]'))
expect(document.querySelector('.dt-settings-link[aria-current="page"]')?.textContent).toBe(zh['settingsGroup.deployment'])
expect(text()).toContain('部署检查未通过:git 没有读取 dsh-superagent 为 github.com 写的凭据配置')
})
it('shows a failed Feishu bot check as a problem of the Feishu Source only, with a check after a fix outside dsh-superagent', async () => {
server.setup = { ...server.setup, feishuProblem: problemOf('feishuBotNotReady', { app: 'cli_bot', status: 'missing' }),
accounts: { ...server.setup.accounts!, feishu: null } }
await mount()
expect(text()).toContain(zh.problemSetupFeishu)
expect(text()).not.toContain('飞书 来源异常')
await click(find(zh.problemSetupFeishu, 'button, a, [role="button"]'))
const feishu = all('.dt-card')[0]!
expect(feishu.textContent).toContain('部署检查未通过:应用 cli_bot 的机器人身份状态是 missing;用 lark-cli 配置应用密钥。')
// dsh-superagent runs meanwhile, so checking again asks first: the restart interrupts running Executor Sessions.
await click([...feishu.querySelectorAll('button')].find(button => button.textContent === '重新检查')!)
expect(document.querySelector('[role="dialog"]')?.textContent).toContain('重新检查会重启 dsh-superagent:2 个进行中的派工会中断')
expect(server.requests.some(request => request.path === 'setup/check')).toBe(false)
await click(zh.confirm)
expect(server.requests.some(request => request.path === 'setup/check')).toBe(true)
await click(tab(zh['tab.source']))
// The Source's state carries the problem once, not again as its health.
expect(all('.dt-form > .dt-list > .dt-row')[0]!.textContent?.match(/cli_bot 的机器人身份状态是 missing/g)).toHaveLength(1)
})
it('leaves Feishu out of a GitHub-only deployment', async () => {
server.setup = { ...server.setup, accounts: { ...server.setup.accounts!, feishu: null } }
server.settings = { ...server.settings, sources: { ...server.settings.sources, feishu: { enabled: false, config: {} } } }
at({ page: 'settings', query: { group: 'prompts' } })
await mount()
await click(tab(zh.promptViewFull))
expect([...field<HTMLSelectElement>(zh.promptPreviewTarget).options].map(option => option.textContent)).toEqual(['主 Session',
'执行器 Session', 'dsh-superagent-github'])
})
it('shows the Sources waiting for the deployment check, and the problem on the Source that fixes it, with its link', async () => {
server.setup = { ...server.setup, problem: problemOf('repositoryUnset'), accounts: null }
await mount()
await click(zh['tab.source'])
const rows = all('.dt-form > .dt-list > .dt-row')
// The GitHub Source is where the problem is fixed, so it shows the error; Feishu waits.
expect(rows.map(row => row.querySelector('.dt-status-text')?.textContent)).toEqual(['等待部署检查', '出错'])
expect(text()).not.toContain('Waiting for setup')
expect(rows[1]!.textContent).toContain('部署检查未通过:GitHub 来源没有设置监听的仓库。')
await click(find(zh.sourceOpenSettings, '.dt-row button'))
expect(document.querySelector('.dt-settings-link[aria-current="page"]')?.textContent).toBe(zh['term.source'])
})
it('shows a Source\'s health in the interface language, and a diagnostic as reported', async () => {
server.health = { github: { state: 'error', detail: '3 consecutive failed polls; last: EOF', code: 'pollFailures', values: { count: '3',
error: 'EOF' } } }
await mount()
await click(zh['tab.source'])
const github = all('.dt-form > .dt-list > .dt-row').find(row => row.querySelector('.dt-row-name')?.textContent === 'GitHub')!
expect(github.textContent).toContain('GitHub 来源:连续 3 次轮询失败,最近一次:EOF')
expect(github.textContent).not.toContain('consecutive')
const feishu = all('.dt-form > .dt-list > .dt-row').find(row => row.querySelector('.dt-row-name')?.textContent === '飞书')!
expect(feishu.querySelector('.dt-mono')?.textContent).toBe('socket closed')
})
it('shows a refused Source save with the output of the tool that failed on its own line', async () => {
await mount()
await click(zh['tab.source'])
await click(find(zh['tab.settings'], '.dt-status-head button'))
const feishu = all('.dt-card')[0]!
await type(field<HTMLInputElement>('机器人的 lark-cli'), 'lark-broken')
await act(async () => { feishu.dispatchEvent(new Event('submit', { bubbles: true, cancelable: true })) }); await settle()
expect(feishu.querySelector('.dt-savebar [role="alert"]')?.textContent).toBe('机器人的 lark-cli 不可用。\nlark-cli: app secret missing')
})
it('shows a setup problem on its Source card within one read of the settings, which the settings groups share', async () => {
vi.useFakeTimers({ shouldAdvanceTime: true })
onTestFinished(() => { vi.useRealTimers() })
at({ page: 'settings', query: { group: 'source' } })
await mount()
const card = () => document.getElementById('dt-source-github')!
expect(card().textContent).not.toContain(zh['setup.agentCliUnset'])
server.setup = { ...server.setup, problem: problemOf('agentCliUnset') }
// The settings are read every five seconds, as the state is; the Source settings do not read the Source status.
await act(async () => { await vi.advanceTimersByTimeAsync(5000) }); await settle()
expect(server.requests.filter(request => request.path === 'settings')).toHaveLength(2)
expect(server.requests.some(request => request.path === 'sources')).toBe(false)
expect(card().textContent).toContain(zh['setup.agentCliUnset'])
})
it('lists the messages a Source did not send in the last day under it, newest first, with why and where they belong', async () => {
// Include a drop older than 24h (m0) and one with a missing thread (m1).
server.dropped = [droppedReply('m3', 1, 'feishu', 'channelGone', { ticket: { id: 't1', title: '修复回归' } }),
droppedReply('m2', 3, 'feishu', 'channelIgnored'), droppedReply('m1', 4, 'feishu', 'channelGone', { thread: false }),
droppedReply('m0', 30, 'feishu', 'channelGone'), droppedReply('g0', 2, 'github', 'channelGone')]
await mount()
await click(zh['tab.source'])
const [feishu, github] = all('.dt-form > .dt-list > .dt-row')
const disclosure = (scope: Element) =>
[...scope.querySelectorAll<HTMLElement>('[data-disclosure-row]')].find(row => row.textContent?.includes(zh.sourceDroppedTitle))!
await click(disclosure(feishu!))
const rows = [...feishu!.querySelectorAll('.dt-disclosure-body .dt-row')].map(row => [row.querySelector('.dt-row-title')?.textContent,
row.querySelector('.dt-tag')?.textContent, row.querySelector('button')?.textContent])
expect(rows).toEqual([['Reply m3', zh['reason.channelGone'], '修复回归'],
['Reply m2', zh['reason.channelIgnored'], zh['term.thread']], ['Reply m1', zh['reason.channelGone'], undefined]])
expect(github!.textContent).toContain(`${zh.sourceDroppedTitle} 1`)
await click(find('修复回归', '.dt-disclosure-body button'))
expect(tab(zh['tab.execution']).getAttribute('aria-selected')).toBe('true')
await remount()
await click(zh['tab.source'])
await click(disclosure(document.body))
// A Feishu Channel names its chat; the link opens the Thread of the message it answers, which the host found.
await click(find(zh['term.thread'], '.dt-disclosure-body button'))
expect(server.requests.some(request => request.path.startsWith('threads/feishu:oc_1:om_root'))).toBe(true)
expect(text()).not.toContain(zh.threadNotFound)
})
it('takes no space for messages that were all sent', async () => {
await mount()
await click(zh['tab.source'])
expect(text()).not.toContain(zh.sourceDroppedTitle)
})
it('reads the settings again as soon as dsh-superagent answers after a save restarted it', async () => {
vi.useFakeTimers({ shouldAdvanceTime: true })
onTestFinished(() => { vi.useRealTimers() })
at({ page: 'settings', query: { group: 'source' } })
await mount()
await type(field<HTMLInputElement>('智能体的 GitHub CLI'), '/opt/bin/gh-agent')
const github = all('.dt-card')[1]!
await act(async () => { github.dispatchEvent(new Event('submit', { bubbles: true, cancelable: true })) }); await settle()
expect(field('智能体的 GitHub CLI').matches(':disabled')).toBe(true)
// dsh-superagent restarts: DSH answers with empty 404s, then it answers again.
server.down = true
await act(async () => { await vi.advanceTimersByTimeAsync(1000) }); await settle()
server.down = false
const reads = server.requests.filter(request => request.path === 'settings').length
await act(async () => { await vi.advanceTimersByTimeAsync(1000) }); await settle()
expect(server.requests.filter(request => request.path === 'settings').length).toBeGreaterThan(reads)
expect(field('智能体的 GitHub CLI').matches(':disabled')).toBe(false)
expect(field<HTMLInputElement>('智能体的 GitHub CLI').value).toBe('/opt/bin/gh-agent')
})