@steerable/agent-shell-web 0.6.19 → 0.6.20

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 (52) hide show
  1. package/package.json +15 -8
  2. package/vite.base.d.ts +7 -0
  3. package/vite.base.js +83 -0
  4. package/src/brand.test.ts +0 -22
  5. package/src/components/AgentSidebar.test.tsx +0 -791
  6. package/src/components/ChatHeader.test.tsx +0 -230
  7. package/src/components/LocalLlmSettingsModal.test.tsx +0 -372
  8. package/src/components/chat/ApprovalModal.test.tsx +0 -83
  9. package/src/components/chat/AskUserModalHost.test.tsx +0 -176
  10. package/src/components/chat/AskUserQuestionMenu.test.tsx +0 -190
  11. package/src/components/chat/AssistantMessage.test.tsx +0 -68
  12. package/src/components/chat/ChatInput.test.tsx +0 -609
  13. package/src/components/chat/ExecPolicyPicker.test.tsx +0 -26
  14. package/src/components/chat/Markdown.test.tsx +0 -170
  15. package/src/components/chat/ModelPicker.test.tsx +0 -149
  16. package/src/components/chat/SessionTreeModal.test.tsx +0 -161
  17. package/src/components/chat/TaskOutcomeCards.test.tsx +0 -97
  18. package/src/components/chat/TaskPanelModal.test.tsx +0 -198
  19. package/src/components/chat/TaskProcessPanel.test.tsx +0 -111
  20. package/src/components/chat/TurnProcessGroup.test.tsx +0 -161
  21. package/src/components/chat/elapsed.test.ts +0 -42
  22. package/src/components/chat/executed-actions-model.test.ts +0 -134
  23. package/src/components/chat/orchestration-children-model.test.ts +0 -115
  24. package/src/components/chat/turn-timeline.test.ts +0 -97
  25. package/src/components/chat/useChatTasks.test.ts +0 -136
  26. package/src/components/settings/AgentsSettingsPanel.test.tsx +0 -229
  27. package/src/components/settings/InsightsSettingsPanel.test.tsx +0 -132
  28. package/src/components/settings/LlmSettingsPanel.test.tsx +0 -461
  29. package/src/components/settings/ModelIdCombobox.test.tsx +0 -88
  30. package/src/components/settings/SecuritySettingsPanel.test.tsx +0 -122
  31. package/src/components/settings/SkillsSettingsPanel.test.tsx +0 -62
  32. package/src/components/settings/WebSearchSettingsPanel.test.tsx +0 -90
  33. package/src/components/settings/compat-flags-model.test.ts +0 -71
  34. package/src/components/settings/llm-vendors.test.ts +0 -171
  35. package/src/components/settings/model-capabilities.test.ts +0 -88
  36. package/src/components/settings/preset-choice-model.test.ts +0 -96
  37. package/src/hooks/useChatsAndAgents.test.ts +0 -165
  38. package/src/lib/attachments.test.ts +0 -127
  39. package/src/lib/chat-title.test.ts +0 -53
  40. package/src/lib/chat-transport.test.ts +0 -282
  41. package/src/lib/date-groups.test.ts +0 -62
  42. package/src/lib/demo-flag.test.ts +0 -17
  43. package/src/lib/electron-bridge.test.ts +0 -54
  44. package/src/lib/exec-policy.test.ts +0 -71
  45. package/src/lib/http-bridge.test.ts +0 -283
  46. package/src/lib/insights.test.ts +0 -56
  47. package/src/lib/local-api.test.ts +0 -353
  48. package/src/lib/pending-first-message.test.ts +0 -17
  49. package/src/lib/slash-sources.test.ts +0 -161
  50. package/src/pages/AgentPage.test.tsx +0 -627
  51. package/src/pages/SettingsPage.test.tsx +0 -102
  52. package/vite.base.ts +0 -94
@@ -1,170 +0,0 @@
1
- /**
2
- * 消息气泡里的 token 渲染契约:发出去的消息要和输入框看到的一致——
3
- * `@专家` / `@对话` 渲染成提及卡片,`/技能` / `/mcp__srv__tool` 渲染成工具
4
- * 卡片,而路径、URL 这类带斜杠的普通文本必须保持原样。
5
- */
6
- import { cleanup, render, screen } from '@testing-library/react';
7
- import { afterEach, describe, expect, it } from 'vitest';
8
- import { Markdown } from './Markdown';
9
- import type { McpToolItem, SkillItem } from '@/lib/slash-sources';
10
-
11
- afterEach(() => cleanup());
12
-
13
- const SKILLS: SkillItem[] = [
14
- { name: 'loop', id: '11-loop', displayName: '循环执行', description: '重复执行' },
15
- { name: 'goal', id: '10-goal', displayName: '目标跟踪', description: '目标与验收' },
16
- { name: 'plan-mode', id: '70-plan-mode', description: '计划模式约束' },
17
- ];
18
-
19
- const MCP_TOOLS: McpToolItem[] = [
20
- {
21
- token: 'mcp__sqlite__query',
22
- toolName: 'query',
23
- serverKey: 'sqlite',
24
- serverName: 'SQLite',
25
- description: '执行 SQL',
26
- },
27
- ];
28
-
29
- function renderMessage(content: string) {
30
- render(
31
- <Markdown inlineParagraph skills={SKILLS} mcpTools={MCP_TOOLS}>
32
- {content}
33
- </Markdown>,
34
- );
35
- }
36
-
37
- describe('Markdown 消息内的工具 token', () => {
38
- it('把行首的技能 token 渲染成琥珀色工具卡片,后续文字保持原样', () => {
39
- const { container } = render(
40
- <Markdown inlineParagraph skills={SKILLS} mcpTools={MCP_TOOLS}>
41
- {'/loop 5分钟说一次你好'}
42
- </Markdown>,
43
- );
44
-
45
- const chip = container.querySelector('[data-message-tool-chip]');
46
- expect(chip).not.toBeNull();
47
- expect(chip!.getAttribute('data-tool-type')).toBe('skill');
48
- expect(chip!.textContent).toBe('/loop');
49
- expect(chip!.className).toContain('amber');
50
- expect(container.textContent).toBe('/loop 5分钟说一次你好');
51
- });
52
-
53
- it('把 MCP token 渲染成天蓝色工具卡片', () => {
54
- const { container } = render(
55
- <Markdown inlineParagraph skills={SKILLS} mcpTools={MCP_TOOLS}>
56
- {'/mcp__sqlite__query 查一下用户表'}
57
- </Markdown>,
58
- );
59
-
60
- const chip = container.querySelector('[data-message-tool-chip]');
61
- expect(chip!.getAttribute('data-tool-type')).toBe('mcp');
62
- expect(chip!.textContent).toBe('/mcp__sqlite__query');
63
- expect(chip!.className).toContain('sky');
64
- });
65
-
66
- it('句中的技能 token 也成卡片', () => {
67
- const { container } = render(
68
- <Markdown inlineParagraph skills={SKILLS} mcpTools={MCP_TOOLS}>
69
- {'帮我用 /goal 把这件事跟到底'}
70
- </Markdown>,
71
- );
72
-
73
- const chip = container.querySelector('[data-message-tool-chip]');
74
- expect(chip!.textContent).toBe('/goal');
75
- expect(container.textContent).toBe('帮我用 /goal 把这件事跟到底');
76
- });
77
-
78
- it('路径、URL 与未知 token 保持纯文本', () => {
79
- const { container } = render(
80
- <Markdown inlineParagraph skills={SKILLS} mcpTools={MCP_TOOLS}>
81
- {'看下 /mnt/c 和 /not-a-skill'}
82
- </Markdown>,
83
- );
84
-
85
- expect(container.querySelector('[data-message-tool-chip]')).toBeNull();
86
- expect(container.textContent).toBe('看下 /mnt/c 和 /not-a-skill');
87
- });
88
-
89
- it('引擎内部技能不渲染成卡片', () => {
90
- const { container } = render(
91
- <Markdown inlineParagraph skills={SKILLS} mcpTools={MCP_TOOLS}>
92
- {'/plan-mode 试试'}
93
- </Markdown>,
94
- );
95
-
96
- expect(container.querySelector('[data-message-tool-chip]')).toBeNull();
97
- });
98
-
99
- it('@提及与 /工具可以共存', () => {
100
- const { container } = render(
101
- <Markdown
102
- inlineParagraph
103
- skills={SKILLS}
104
- mcpTools={MCP_TOOLS}
105
- agents={[
106
- {
107
- id: 'a1',
108
- slug: 'ops',
109
- name: '电脑操作员',
110
- icon: null,
111
- color: '#2563eb',
112
- description: '',
113
- rolePrompt: '',
114
- isBuiltin: true,
115
- sortOrder: 1,
116
- },
117
- ]}
118
- >
119
- {'@电脑操作员 用 /loop 每小时跑一次'}
120
- </Markdown>,
121
- );
122
-
123
- const toolChip = container.querySelector('[data-message-tool-chip]');
124
- expect(toolChip!.textContent).toBe('/loop');
125
- const mentionChip = container.querySelector('[data-mention-chip]');
126
- expect(mentionChip!.getAttribute('data-mention-type')).toBe('agent');
127
- expect(screen.getByText('@电脑操作员')).toBeTruthy();
128
- });
129
-
130
- it('邮箱与认不出的 @token 保持纯文本', () => {
131
- const { container } = render(
132
- <Markdown inlineParagraph skills={SKILLS} mcpTools={MCP_TOOLS}>
133
- {'发到 a@b.com,参数写 @param'}
134
- </Markdown>,
135
- );
136
-
137
- expect(container.querySelector('[data-mention-chip]')).toBeNull();
138
- expect(container.textContent).toBe('发到 a@b.com,参数写 @param');
139
- });
140
-
141
- it('没有传目录时斜杠 token 保持纯文本', () => {
142
- const { container } = render(<Markdown inlineParagraph>{'/loop 你好'}</Markdown>);
143
- expect(container.querySelector('[data-message-tool-chip]')).toBeNull();
144
- expect(container.textContent).toBe('/loop 你好');
145
- });
146
- });
147
-
148
- describe('Markdown 消息内的代码块', () => {
149
- it('代码块里的斜杠 token 不做替换', () => {
150
- const { container } = renderFenced();
151
- expect(container.querySelector('[data-message-tool-chip]')).toBeNull();
152
- expect(container.textContent).toContain('/loop');
153
- });
154
-
155
- function renderFenced() {
156
- return render(
157
- <Markdown skills={SKILLS} mcpTools={MCP_TOOLS}>
158
- {'```\n/loop 5m\n```'}
159
- </Markdown>,
160
- );
161
- }
162
- });
163
-
164
- // 保证渲染入口本身没有在无 token 时改写文本节点
165
- describe('Markdown 普通文本', () => {
166
- it('不含 token 的段落原样渲染', () => {
167
- renderMessage('普通一句话,没有任何 token。');
168
- expect(screen.getByText('普通一句话,没有任何 token。')).toBeTruthy();
169
- });
170
- });
@@ -1,149 +0,0 @@
1
- /**
2
- * ModelPicker 交互测试:
3
- * - 目录行渲染 + 选模型(换模型清掉旧档位);
4
- * - 有 reasoningLevels 的模型才出档位选择器;
5
- * - 「跟随设置」回到 null(全局设置);
6
- * - offline/stale 状态如实披露(目录不可用 / 缓存),offline 时仍可选择。
7
- */
8
- import { cleanup, fireEvent, render, screen } from '@testing-library/react';
9
- import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
10
- import type { GatewayModelCatalog } from '@/lib/local-api';
11
-
12
- const getLlmModels = vi.fn();
13
- const getLlmSettings = vi.fn();
14
-
15
- vi.mock('@/lib/local-api', () => ({
16
- getLlmModels: () => getLlmModels(),
17
- getLlmSettings: () => getLlmSettings(),
18
- }));
19
-
20
- const { ModelPicker } = await import('./ModelPicker');
21
-
22
- const CATALOG: GatewayModelCatalog = {
23
- models: [
24
- {
25
- id: 'z-ai/glm-5.3-flash',
26
- name: 'GLM 5.3 Flash',
27
- window: 131072,
28
- modalities: ['text'],
29
- reasoningLevels: ['low', 'high', 'max'],
30
- pricing: { promptPerMtok: 0.2, completionPerMtok: 0.8 },
31
- joinedFrom: 'z-ai/glm-5.3-flash',
32
- capabilities: 'known',
33
- },
34
- {
35
- id: 'openai/qwen/qwen3.8-27b',
36
- name: null,
37
- window: null,
38
- modalities: ['text'],
39
- reasoningLevels: [],
40
- pricing: null,
41
- joinedFrom: null,
42
- capabilities: 'unknown',
43
- },
44
- ],
45
- catalogStatus: 'live',
46
- fetchedAt: 1_757_000_000,
47
- current: { model: null, reasoningEffort: null },
48
- };
49
-
50
- function renderPicker(overrides: Partial<Parameters<typeof ModelPicker>[0]> = {}) {
51
- const props = {
52
- model: null as string | null,
53
- reasoningEffort: null as string | null,
54
- onSelectModel: vi.fn(),
55
- onSelectEffort: vi.fn(),
56
- ...overrides,
57
- };
58
- const utils = render(<ModelPicker {...props} />);
59
- return { ...utils, props };
60
- }
61
-
62
- describe('ModelPicker', () => {
63
- beforeEach(() => {
64
- getLlmModels.mockResolvedValue(CATALOG);
65
- getLlmSettings.mockResolvedValue({ provider: 'openai-compat', model: 'deepseek-chat' });
66
- });
67
- afterEach(() => cleanup());
68
-
69
- it('shows the settings model by default and lists catalog rows', async () => {
70
- const { props } = renderPicker();
71
- // 默认展示设置里的模型
72
- const trigger = await screen.findByText('deepseek-chat');
73
- fireEvent.click(trigger);
74
-
75
- // 目录行 + 跟随设置行
76
- expect(await screen.findByText('z-ai/glm-5.3-flash')).toBeTruthy();
77
- expect(screen.getByText('openai/qwen/qwen3.8-27b')).toBeTruthy();
78
- expect(screen.getByText(/跟随设置/)).toBeTruthy();
79
- expect(screen.getByText('思考')).toBeTruthy();
80
- expect(screen.getByText('131K')).toBeTruthy();
81
- expect(screen.getByText('未识别')).toBeTruthy();
82
-
83
- fireEvent.click(screen.getByText('z-ai/glm-5.3-flash'));
84
- expect(props.onSelectModel).toHaveBeenCalledWith('z-ai/glm-5.3-flash');
85
- // 换模型清档位,让 sidecar 按新模型校验
86
- expect(props.onSelectEffort).toHaveBeenCalledWith(null);
87
- });
88
-
89
- it('offers the effort picker only for models with reasoning levels', async () => {
90
- const { props, rerender } = renderPicker({ model: 'z-ai/glm-5.3-flash' });
91
- // 等目录到达后档位按钮出现
92
- const effortTrigger = await screen.findByText('自动');
93
- fireEvent.click(effortTrigger);
94
- for (const level of ['low', 'high', 'max']) {
95
- expect(screen.getByText(level)).toBeTruthy();
96
- }
97
- fireEvent.click(screen.getByText('max'));
98
- expect(props.onSelectEffort).toHaveBeenCalledWith('max');
99
-
100
- // 切到未识别模型:档位选择器消失
101
- rerender(
102
- <ModelPicker
103
- model="openai/qwen/qwen3.8-27b"
104
- reasoningEffort={null}
105
- onSelectModel={props.onSelectModel}
106
- onSelectEffort={props.onSelectEffort}
107
- />,
108
- );
109
- expect(screen.queryByText('自动')).toBeNull();
110
- });
111
-
112
- it('discloses the offline state and still allows manual selection', async () => {
113
- getLlmModels.mockResolvedValue({
114
- models: [],
115
- catalogStatus: 'offline',
116
- error: 'connect refused',
117
- });
118
- renderPicker();
119
- const trigger = await screen.findByText('deepseek-chat');
120
- fireEvent.click(trigger);
121
- expect(await screen.findByText('目录不可用')).toBeTruthy();
122
- expect(screen.getByText(/网关不可达/)).toBeTruthy();
123
- });
124
-
125
- it('badges a stale catalog as cache', async () => {
126
- getLlmModels.mockResolvedValue({ ...CATALOG, catalogStatus: 'stale' });
127
- renderPicker();
128
- fireEvent.click(await screen.findByText('deepseek-chat'));
129
- expect(await screen.findByText('缓存')).toBeTruthy();
130
- // 缓存目录的模型行照常可选
131
- expect(screen.getByText('z-ai/glm-5.3-flash')).toBeTruthy();
132
- });
133
-
134
- it('omits a leading icon on the trigger', async () => {
135
- renderPicker({ model: 'z-ai/glm-5.3-flash' });
136
- const trigger = (await screen.findByText('z-ai/glm-5.3-flash')).closest('button');
137
- expect(trigger).toBeTruthy();
138
- expect(screen.getByText('本会话')).toBeTruthy();
139
- // Chevron only — no CPU / vendor logo in the chip.
140
- expect(trigger!.querySelectorAll('svg')).toHaveLength(1);
141
- });
142
-
143
- it('resets to the global settings model via 跟随设置', async () => {
144
- const { props } = renderPicker({ model: 'z-ai/glm-5.3-flash' });
145
- fireEvent.click(await screen.findByText('z-ai/glm-5.3-flash'));
146
- fireEvent.click(await screen.findByText(/跟随设置/));
147
- expect(props.onSelectModel).toHaveBeenCalledWith(null);
148
- });
149
- });
@@ -1,161 +0,0 @@
1
- /**
2
- * SessionTreeModal 的接线契约:
3
- * - 打开时经 localBackend 拉 GET /branches/tree,DFS 渲染缩进树,
4
- * activeRecordId 行高亮带 ✓;
5
- * - 点击(或 Enter)非 active 节点 → POST /branches/activate →
6
- * onBranchSwitched + onClose;
7
- * - 树只有当前记录一个节点时显示空态文案;
8
- * - Escape 关闭。
9
- */
10
- import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react';
11
- import { afterEach, describe, expect, it, vi } from 'vitest';
12
- import type { ChatBranchTreeResponse } from '@/lib/local-api';
13
- import { SessionTreeModal } from './SessionTreeModal';
14
-
15
- afterEach(() => {
16
- cleanup();
17
- delete (window as { electron?: unknown }).electron;
18
- });
19
-
20
- const TREE_RESPONSE: ChatBranchTreeResponse = {
21
- activeRecordId: 'chat_1:r2',
22
- nodeCount: 4,
23
- truncated: false,
24
- tree: {
25
- recordId: 'chat_1',
26
- sourceRecordId: null,
27
- sourceUntilSeq: null,
28
- label: 'root',
29
- depth: 0,
30
- children: [
31
- {
32
- recordId: 'chat_1:r2',
33
- sourceRecordId: 'chat_1',
34
- sourceUntilSeq: 5,
35
- label: 'question 1',
36
- depth: 1,
37
- children: [
38
- {
39
- recordId: 'chat_1:r2:x',
40
- sourceRecordId: 'chat_1:r2',
41
- sourceUntilSeq: 2,
42
- label: 'variant question',
43
- depth: 2,
44
- children: [],
45
- },
46
- ],
47
- },
48
- {
49
- recordId: 'chat_1:r3',
50
- sourceRecordId: 'chat_1',
51
- sourceUntilSeq: 3,
52
- label: 'question 0',
53
- depth: 1,
54
- children: [],
55
- },
56
- ],
57
- },
58
- };
59
-
60
- function installBridge(treeResponse: ChatBranchTreeResponse | null = TREE_RESPONSE) {
61
- const request = vi.fn((input: { method: string; path: string; body?: unknown }) => {
62
- if (input.method === 'GET' && input.path.endsWith('/branches/tree')) {
63
- return Promise.resolve(treeResponse);
64
- }
65
- if (input.method === 'POST' && input.path.endsWith('/branches/activate')) {
66
- return Promise.resolve({ activeRecordId: 'x', messageCount: 3 });
67
- }
68
- return Promise.reject(new Error(`unexpected request: ${input.method} ${input.path}`));
69
- });
70
- (window as { electron?: unknown }).electron = { localBackend: { request } };
71
- return { request };
72
- }
73
-
74
- describe('SessionTreeModal', () => {
75
- it('渲染全树:缩进行 + active 节点高亮', async () => {
76
- installBridge();
77
- render(<SessionTreeModal chatId="chat_1" onClose={() => {}} />);
78
-
79
- await waitFor(() => expect(screen.queryByText('正在加载分支…')).toBeNull());
80
-
81
- const rows = screen.getAllByRole('button').filter((b) => b.hasAttribute('data-tree-row'));
82
- expect(rows).toHaveLength(4);
83
- // DFS 顺序:root → r2 → r2:x → r3;label 按行展示(label 在 truncate span 里)。
84
- const labels = rows.map(
85
- (r) => (r.querySelector('span.truncate') as HTMLElement | null)?.textContent,
86
- );
87
- expect(labels).toEqual(['root', 'question 1', 'variant question', 'question 0']);
88
- // 连接线前缀:根无前缀,孙节点带 │ 缩进。
89
- const prefixes = rows.map(
90
- (r) => (r.querySelector('span.font-mono') as HTMLElement | null)?.textContent ?? '',
91
- );
92
- expect(prefixes).toEqual(['', '├─ ', '│ └─ ', '└─ ']);
93
- // active 标记在 r2 行。
94
- expect(rows[1].getAttribute('data-active')).toBe('true');
95
- expect(rows[0].getAttribute('data-active')).toBeNull();
96
- });
97
-
98
- it('点击堂兄弟节点:activate + onBranchSwitched + 关闭', async () => {
99
- const { request } = installBridge();
100
- const onClose = vi.fn();
101
- const onBranchSwitched = vi.fn();
102
- render(
103
- <SessionTreeModal chatId="chat_1" onClose={onClose} onBranchSwitched={onBranchSwitched} />,
104
- );
105
- await waitFor(() => screen.getByText('question 0'));
106
-
107
- fireEvent.click(screen.getByText('question 0'));
108
-
109
- await waitFor(() => expect(onClose).toHaveBeenCalledTimes(1));
110
- expect(request).toHaveBeenCalledWith({
111
- method: 'POST',
112
- path: '/api/v2/chats/chat_1/branches/activate',
113
- body: { recordId: 'chat_1:r3' },
114
- });
115
- expect(onBranchSwitched).toHaveBeenCalledTimes(1);
116
- });
117
-
118
- it('点击 active 节点不触发切换', async () => {
119
- const { request } = installBridge();
120
- render(<SessionTreeModal chatId="chat_1" onClose={() => {}} />);
121
- await waitFor(() => screen.getByText('question 1'));
122
-
123
- fireEvent.click(screen.getByText('question 1'));
124
-
125
- expect(
126
- request.mock.calls.filter(([input]) => (input as { method: string }).method === 'POST'),
127
- ).toHaveLength(0);
128
- });
129
-
130
- it('单节点树显示空态', async () => {
131
- installBridge({
132
- activeRecordId: 'chat_1',
133
- nodeCount: 1,
134
- truncated: false,
135
- tree: {
136
- recordId: 'chat_1',
137
- sourceRecordId: null,
138
- sourceUntilSeq: null,
139
- label: 'root',
140
- depth: 0,
141
- children: [],
142
- },
143
- });
144
- render(<SessionTreeModal chatId="chat_1" onClose={() => {}} />);
145
-
146
- await waitFor(() => screen.getByText(/重新生成回复后,旧版本会保留在这里/));
147
- expect(screen.queryAllByRole('button').filter((b) => b.hasAttribute('data-tree-row')))
148
- .toHaveLength(0);
149
- });
150
-
151
- it('Escape 关闭模态', async () => {
152
- const onClose = vi.fn();
153
- installBridge();
154
- render(<SessionTreeModal chatId="chat_1" onClose={onClose} />);
155
- await waitFor(() => screen.getByText('question 0'));
156
-
157
- fireEvent.keyDown(document, { key: 'Escape' });
158
-
159
- expect(onClose).toHaveBeenCalledTimes(1);
160
- });
161
- });
@@ -1,97 +0,0 @@
1
- /**
2
- * TaskOutcomeCards 契约:后台任务的终态要出现在用户正在看的对话里。
3
- * - 完成显示 answer、失败显示 error,任务名都在;
4
- * - 「查看过程」把任务交给右侧推理面板,「忽略」只发出 taskId;
5
- * - worktree 待合并的任务提示去任务按钮处理(卡片不重复那套操作);
6
- * - 一次结束太多任务时只留前几张,其余折成一行汇总。
7
- */
8
- import { cleanup, fireEvent, render, screen } from '@testing-library/react';
9
- import { afterEach, describe, expect, it, vi } from 'vitest';
10
- import type { LocalTask } from '@/lib/local-api';
11
- import { TaskOutcomeCards } from './TaskOutcomeCards';
12
-
13
- afterEach(cleanup);
14
-
15
- function makeTask(overrides: Partial<LocalTask> = {}): LocalTask {
16
- return {
17
- id: 'task-1',
18
- chatId: 'chat_1',
19
- task: '统计仓库行数',
20
- status: 'completed',
21
- answer: '共 42 行。',
22
- error: null,
23
- worktreePath: null,
24
- worktreeBranch: null,
25
- worktreeState: null,
26
- createdAt: '2026-09-13T01:00:00.000Z',
27
- updatedAt: '2026-09-13T01:01:00.000Z',
28
- ...overrides,
29
- };
30
- }
31
-
32
- function renderCards(tasks: LocalTask[]) {
33
- const onInspect = vi.fn();
34
- const onDismiss = vi.fn();
35
- render(<TaskOutcomeCards tasks={tasks} onInspect={onInspect} onDismiss={onDismiss} />);
36
- return { onInspect, onDismiss };
37
- }
38
-
39
- describe('TaskOutcomeCards', () => {
40
- it('没有终态任务时什么都不渲染', () => {
41
- renderCards([]);
42
- expect(screen.queryByTestId('task-outcome-cards')).toBeNull();
43
- });
44
-
45
- it('完成的任务显示任务名与结果', () => {
46
- renderCards([makeTask()]);
47
-
48
- expect(screen.getByText('统计仓库行数')).toBeTruthy();
49
- expect(screen.getByText('共 42 行。')).toBeTruthy();
50
- expect(
51
- document.querySelector('[data-task-outcome="task-1"]')?.getAttribute('data-status'),
52
- ).toBe('completed');
53
- });
54
-
55
- it('失败的任务显示错误原因', () => {
56
- renderCards([makeTask({ status: 'failed', answer: null, error: '依赖任务失败:abc' })]);
57
-
58
- expect(screen.getByText('依赖任务失败:abc')).toBeTruthy();
59
- expect(
60
- document.querySelector('[data-task-outcome="task-1"]')?.getAttribute('data-status'),
61
- ).toBe('failed');
62
- });
63
-
64
- it('「查看过程」交出整个任务,「忽略」交出 taskId', () => {
65
- const task = makeTask();
66
- const { onInspect, onDismiss } = renderCards([task]);
67
-
68
- fireEvent.click(screen.getByRole('button', { name: '查看过程' }));
69
- expect(onInspect).toHaveBeenCalledWith(task);
70
-
71
- fireEvent.click(screen.getByRole('button', { name: '忽略' }));
72
- expect(onDismiss).toHaveBeenCalledWith('task-1');
73
- });
74
-
75
- it('worktree 待合并时指回任务按钮,卡片自己不放合并操作', () => {
76
- renderCards([
77
- makeTask({
78
- worktreeState: 'pending',
79
- worktreePath: '/tmp/wt',
80
- worktreeBranch: 'steerable/fix',
81
- }),
82
- ]);
83
-
84
- expect(screen.getByText('steerable/fix')).toBeTruthy();
85
- expect(screen.getByText(/待合并 — 在标题栏的任务按钮里处理/)).toBeTruthy();
86
- expect(screen.queryByRole('button', { name: /合并/ })).toBeNull();
87
- });
88
-
89
- it('超过三个时只留前三张,其余折成一行汇总', () => {
90
- renderCards(
91
- Array.from({ length: 5 }, (_, i) => makeTask({ id: `t${i}`, task: `任务 ${i}` })),
92
- );
93
-
94
- expect(document.querySelectorAll('[data-task-outcome]')).toHaveLength(3);
95
- expect(screen.getByText(/另有 2 个后台任务已结束/)).toBeTruthy();
96
- });
97
- });