Files
dsh-superagent/tests/panel-execution.spec.tsx
2026-10-31 20:05:39 +08:00

225 lines
14 KiB
TypeScript

// @vitest-environment jsdom
// The Execution status, a Ticket, and the Execution settings.
import { act } from 'react'
import { expect, it } from 'vitest'
import { en, zh } from '../src/client/locales.ts'
import { usePanel, mount, remount, all, find, tab, click, type, text, field, at } from './fixtures/panel.tsx'
import { MEMBER, server, ticketSummary, threadSummary } from './fixtures/panel-server.ts'
usePanel()
it('lists Tickets under Execution by who they wait on, with the Executor state and its pause as an action', async () => {
server.tickets.open = [ticketSummary(), ticketSummary({ id: 't2', title: '执行中的工单', threads: [{ key: 'github:7',
source: 'github', title: '#7 x', facts: { number: { value: '#7' }, autoMerge: { label: { zh: '自动合并', en: 'Auto-merge' },
value: { zh: '已开启', en: 'on' } } } }] })]
await mount()
await click(zh['tab.execution'])
expect(document.querySelector('.dt-status-title')?.textContent).toBe(zh.statusTitle)
expect(server.requests.some(request => request.path === 'tickets?state=open&limit=20')).toBe(true)
expect(text()).toContain(zh.executorRunning)
expect(all('.dt-chip').map(chip => chip.textContent)).toContain('GitHub#7自动合并已开启')
await click(zh.idleLabel)
expect(server.requests.some(request => request.path === 'tickets?state=idle&limit=20')).toBe(true)
expect(text()).toContain('执行器结束了当前回合')
await click(zh['ticket.ended'])
expect(text()).toContain('旧工单')
expect(text()).toContain(zh['outcome.stopped'])
expect(all('.dt-status-line .dt-lv').map(item => item.textContent)).toEqual(['执行中 2 / 3', '排队 0'])
// The pause is its own action: an Execution settings draft started before it still saves.
await click(find(zh['tab.settings'], '.dt-status-head button'))
expect(tab(zh['tab.settings']).getAttribute('aria-selected')).toBe('true')
expect(document.querySelector('.dt-settings-link[aria-current="page"]')?.textContent).toBe(zh['promptPart.runtime/work'])
await type(field<HTMLInputElement>(zh.maxWorkers), '4')
await click(tab(zh['tab.execution']))
await click(zh.pauseProcessing)
await click(zh.confirm)
expect(server.requests.find(request => request.path === 'executor/pause')).toMatchObject({ method: 'POST', body: { paused: true } })
expect(text()).toContain(zh.processingPaused)
expect(find(zh.resumeProcessing).textContent).toBe(zh.resumeProcessing)
await click(tab(zh['tab.settings']))
await click(zh.save)
expect(server.settings).toMatchObject({ paused: true, executor: { maxWorkers: 4 } })
})
it('stops an open Ticket from its page after confirmation', async () => {
await mount()
await click('修复回归')
await click(zh.stop)
expect(server.requests.some(request => request.path.endsWith('/stop'))).toBe(false)
expect(document.querySelector('[role="dialog"]')?.textContent).toContain('停止“修复回归”')
await click(zh.confirm)
expect(server.requests.some(request => request.path === 'tickets/t1/stop' && request.method === 'POST')).toBe(true)
})
it('shows a Ticket with its stage, its Mandate details with the Trigger and Decision behind it, its Threads, and the rest behind disclosures', async () => {
await mount()
await click(all('.dt-open').find(button => button.textContent === '修复回归')!)
expect(document.querySelector('.dt-page-title h2')?.textContent).toBe('修复回归')
// Progress is in the Channels and the Session, so the page has no report section.
expect(all('.dt-section-title').map(title => title.querySelector('.dt-section-name')?.textContent)).toEqual(['授权详情', '未送达的输入', '订阅的线程'])
expect(document.querySelector('.dt-status-line .dt-stage')?.textContent).toBe(zh.executingLabel)
expect(document.querySelector('.dt-status-line .dt-stage .dt-spinner')).not.toBeNull()
// A Thread this Ticket follows reads as subscribed on its page, though no other Ticket follows it.
expect(all('.dt-list .dt-ticket .dt-stage').map(stage => stage.textContent)).toContain(zh.threadSubscribed)
expect(all('.dt-list .dt-ticket .dt-stage').map(stage => stage.textContent)).not.toContain(zh.threadUnsubscribed)
expect(all('.dt-status-line .dt-lv').map(item => item.textContent)).toEqual(['授权 明确', '发起 飞书 · Alice', expect.stringMatching(/^更新 /)])
for (const expected of ['修复登录页回归', '点名方式@ 智能体', '名单里的人明确请求,由主 Session 接单', '未送达:等待派工运行',
'Alice:顺便改标题']) expect(text()).toContain(expected)
expect(all('.dt-chip').map(chip => chip.textContent)).toContain('飞书团队群')
await click(zh['term.channel'])
expect(all('.dt-disclosure-body a').map(link => [link.textContent, link.getAttribute('href')])).toEqual([['飞书消息',
'https://applink.feishu.cn/client/chat/open?openChatId=oc_1'], ['GitHub #42', 'https://github.com/acme/widgets/issues/42']])
await click(zh['term.environment'])
expect(all('.dt-disclosure-body .dt-table tr').map(row => row.textContent)).toEqual(['DSH_SUPERAGENT_DIR/w/t1',
'DSH_SUPERAGENT_GITHUB_NUMBER—'])
await click(document.querySelector<HTMLElement>('.dt-trigger-link')!)
expect(tab(zh['tab.trigger']).getAttribute('aria-selected')).toBe('true')
})
it('reports a missing Ticket instead of rendering an empty page', async () => {
at({ page: 'execution', id: 'missing' })
await mount()
expect(document.querySelector('.dt-hint')?.textContent).toBe(zh.ticketNotFound)
})
it('links a question to its Channel, folds consecutive automated messages among Inputs, and shows field explanations in a hover tooltip',
async () => {
const ci = (id: string, at: string, failed: number) => ({ id, kind: 'trigger' as const, triggerId: `c${id}`, at, delivered: true,
trigger: { id: `c${id}`, actor: { source: 'github', id: '' }, human: false,
preview: `CI of PR #42 at abc1234: 10 checks, ${failed} failed, 0 running.`, thread: 'github:42' } })
const t1 = server.ticketDetails.t1!
server.ticketDetails.t1 = { ...t1, stage: 'person', action: 'answer', attention: { id: 'q1', question: '需要确认按钮颜色' }, inputs: [ci('i3',
'2026-09-22T00:06:00Z', 1), ci('i2', '2026-09-22T00:05:00Z', 0)] }
await mount()
await click(all('.dt-open').find(button => button.textContent === '修复回归')!)
expect(all('.dt-banner a').map(link => [link.textContent, link.getAttribute('href')])).toEqual([['去飞书回答',
'https://applink.feishu.cn/client/chat/open?openChatId=oc_1']])
await click('已送达的输入 · 2')
expect(all('.dt-disclosure-body .dt-row-title').map(title => title.textContent)).toEqual(['自动通知:CI of PR #42 at abc1234: 10 checks, 1 failed, 0 running.'])
await click('同一发送者之前还有 1 条自动消息')
expect(text()).toContain('CI of PR #42 at abc1234: 10 checks, 0 failed, 0 running.')
await click(zh['tab.eval'])
await click(find(zh['tab.settings'], '.dt-status-head button'))
expect(text()).not.toContain('到期后记为未完成')
const tip = field(zh.evalTimeout).closest('.dt-field')!.querySelector<HTMLElement>('.dt-info')!
await act(async () => { tip.dispatchEvent(new MouseEvent('mouseover', { bubbles: true })) })
expect(document.querySelector('[role="tooltip"]')?.textContent).toContain('到期后记为未完成')
expect(tip.closest('.dt-field')!.textContent).not.toContain('到期后记为未完成')
await act(async () => { tip.dispatchEvent(new MouseEvent('mouseout', { bubbles: true })) })
expect(document.querySelector('[role="tooltip"]')).toBeNull()
})
it('offers a Ticket awaiting review its review place and Session in the page head', async () => {
const t1 = server.ticketDetails.t1!
server.ticketDetails.t1 = { ...t1, stage: 'person', action: 'review', threads: [threadSummary(),
{ ...threadSummary({ key: 'github:42', source: 'github', title: '#42 Fix login' }), url: 'https://github.com/acme/widgets/pull/42' }] }
at({ page: 'execution', id: 't1' })
await mount()
expect(document.querySelector('.dt-status-line .dt-stage')?.textContent).toBe(zh['stage.review'])
expect(all('.dt-page-head a').map(link => [link.textContent, link.getAttribute('href')])).toEqual([['打开 GitHub 原文',
'https://github.com/acme/widgets/pull/42']])
})
it('keeps what a person types in the Execution settings, converting it once on save', async () => {
await mount()
await click(zh['tab.execution'])
await click(find(zh['tab.settings'], '.dt-status-head button'))
const blocked = () => field<HTMLInputElement>(zh.blockedCommands), root = () => field<HTMLInputElement>(zh.executionDirectory),
workers = () => field<HTMLInputElement>(zh.maxWorkers)
// A separator typed before the next command, a space inside a path and a cleared number stay as typed.
await type(blocked(), 'gh-personal ')
expect(blocked().value).toBe('gh-personal ')
await type(blocked(), 'gh-personal, gh-other')
await type(root(), '/srv/my work ')
expect(root().value).toBe('/srv/my work ')
await type(workers(), '')
expect(workers().value).toBe('')
await type(workers(), '2')
await click(zh.save)
expect(server.requests.find(request => request.path === 'settings/executor')?.body).toMatchObject({ value: { blockedCommands: ['gh-personal', 'gh-other'], directory: '/srv/my work ', maxWorkers: 2 } })
expect(server.settings.executor).toMatchObject({ blockedCommands: ['gh-personal', 'gh-other'], directory: '/srv/my work', maxWorkers: 2 })
})
it('says why an Input was not delivered on its Ticket\'s page, in either language', async () => {
const t1 = server.ticketDetails.t1!
server.ticketDetails.t1 = { ...t1, stage: 'person', action: 'delivery', deliveryError: 'feishu: message recalled' }
at({ page: 'execution', id: 't1' })
await mount()
expect(document.querySelector('.dt-status-line .dt-stage')?.textContent).toBe(zh['stage.delivery'])
expect(document.querySelector('.dt-banner.dt-danger')?.textContent).toBe(`${zh.deliveryFailedPrefix}feishu: message recalled`)
expect(text()).toContain(zh.inputRetryHint)
expect(text()).not.toContain(zh.inputWaitingHint)
expect(document.querySelector('.dt-status-line')?.textContent).toMatch(/9月\d+日/)
await remount(en)
expect(document.querySelector('.dt-status-line')?.textContent).toMatch(/Sep \d+/)
expect(document.querySelector('.dt-status-line .dt-stage')?.textContent).toBe(en['stage.delivery'])
expect(document.querySelector('.dt-banner.dt-danger')?.textContent).toBe(`${en.deliveryFailedPrefix}feishu: message recalled`)
expect(text()).toContain(en.inputRetryHint)
})
it('tells a Ticket or Thread that does not exist from a dsh-superagent that is not answering', async () => {
at({ page: 'execution', id: 't9' })
await mount()
expect(text()).toContain(zh.ticketNotFound)
// While dsh-superagent restarts, DSH's web server answers every route with an empty 404.
at({ page: 'execution', id: 't1' })
server.down = true
await remount()
expect(text()).not.toContain(zh.ticketNotFound)
expect(text()).toContain(zh.hostNotRunning)
at({ page: 'trigger', id: 'feishu:oc_1:om_root' })
await remount()
expect(text()).not.toContain(zh.threadNotFound)
expect(text()).toContain(zh.hostNotRunning)
})
it('gives a Ticket a bot\'s message started its basis, marks a Thread it follows for context, and an Input it handed back', async () => {
const t1 = server.ticketDetails.t1!
server.ticketDetails.t1 = { ...t1, mandate: { ...t1.mandate, strength: 'inferred', basis: { via: 'bot', sender: '评审机器人',
observation: { mode: 'auto' } } },
basis: { facts: [{ name: 'sender', value: '评审机器人' }, { name: 'bot' }, { name: 'mode', value: 'auto' }], legacy: false },
threads: [threadSummary(), { ...threadSummary({ key: 'github:7', title: '#7 相关 Issue' }), context: true }],
inputs: [{ id: 'i1', kind: 'trigger', triggerId: 'e2', at: '2026-09-22T00:03:00Z', delivered: true, trigger: { id: 'e2',
actor: { source: 'feishu', id: MEMBER, name: { zh: 'Alice', en: 'Alice' } }, human: true, preview: '顺便加一个页面',
thread: 'feishu:oc_1:om_root', handback: { reason: 'Asks for a new page', at: '2026-09-22T00:05:00Z' } } }] }
at({ page: 'execution', id: 't1' })
await mount()
const facts = () => all('.dt-kv > div').map(row => [row.querySelector('dt')?.textContent, row.querySelector('dd')?.textContent])
expect(facts()).toEqual(expect.arrayContaining([[zh['basis.sender'], '评审机器人'], [zh['basis.bot'], zh.yes], [zh['basis.mode'], zh['mode.auto']]]))
expect(all('.dt-list .dt-ticket').map(row => row.querySelector('.dt-tag')?.textContent)).toEqual([undefined, zh.contextOnly])
await click(zh.deliveredInputs.replace('{count}', '1'))
expect(text()).toContain('已交回评估:Asks for a new page')
await remount(en)
expect(facts()).toEqual(expect.arrayContaining([[en['basis.bot'], en.yes]]))
expect(all('.dt-list .dt-ticket').map(row => row.querySelector('.dt-tag')?.textContent)).toEqual([undefined, en.contextOnly])
await click(en.deliveredInputs.replace('{count}', '1'))
expect(text()).toContain('Handed back for an Eval: Asks for a new page')
})
it('words an ended Ticket by its outcome, on its row and its page, with a person\'s reason after it', async () => {
const fields = { id: 't3', title: '已合并的工单', state: 'ended' as const, actions: { retry: false, stop: false },
ended: { at: '2026-09-21T00:00:00Z', by: 'executor' as const, outcome: 'merged' as const } }
server.tickets.ended = [ticketSummary(fields, { stage: 'ended' }), ...server.tickets.ended]
server.ticketDetails.t3 = { ...server.ticketDetails.t1!, ...fields, stage: 'ended' }
await mount()
await click(zh['tab.execution'])
await click(zh['ticket.ended'])
expect(all('.dt-ticket-stage').map(stage => stage.textContent)).toEqual([zh['outcome.merged'], zh['outcome.stopped']])
await click('已合并的工单')
expect(document.querySelector('.dt-status-line .dt-stage')?.textContent).toBe(zh['outcome.merged'])
expect(text()).toContain(zh['outcome.merged'])
expect(text()).not.toContain('执行器结束')
server.ticketDetails.t3 = { ...server.ticketDetails.t3, ended: { at: '2026-09-21T00:00:00Z', by: 'human', outcome: 'stopped',
reason: '不需要了' } }
await remount(en)
expect(all('.dt-hint').map(hint => hint.textContent).find(line => line?.includes(en['outcome.stopped']))).toMatch(/Stopped by a person: 不需要了$/)
})
it('sends one stop for a double click on Confirm', async () => {
at({ page: 'execution', id: 't1' })
await mount()
await click(zh.stop)
const confirm = find(zh.confirm)
await act(async () => { confirm.click(); confirm.click() })
expect(server.requests.filter(request => request.path === 'tickets/t1/stop')).toHaveLength(1)
})