chore(integration): snapshot local main worktree
This commit is contained in:
@@ -1,4 +1,4 @@
|
||||
import { act, fireEvent, render, screen, waitFor } from '@testing-library/react';
|
||||
import { act, fireEvent, render, screen, waitFor, within } from '@testing-library/react';
|
||||
import { Profiler } from 'react';
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
import { createProductSnapshot } from '../fixtures/coding-conversation-product-fixtures';
|
||||
@@ -67,6 +67,570 @@ describe('CodingConversationTimeline', () => {
|
||||
expect(revokeObjectURL).toHaveBeenCalledWith('blob:authenticated-preview');
|
||||
});
|
||||
|
||||
it('renders assistant Markdown hierarchy and opens web links and local paths safely', async () => {
|
||||
const invoke = vi.mocked(window.electron.ipcRenderer.invoke);
|
||||
invoke.mockReset();
|
||||
invoke.mockImplementation(async (channel) => (
|
||||
channel === 'app:getPath' ? '/Users/tester' : undefined
|
||||
));
|
||||
const { codingConversationStore } = await import('@/stores/coding-conversations');
|
||||
const { CodingConversationTimeline } = await import(
|
||||
'@/pages/Chat/CodingConversationTimeline'
|
||||
);
|
||||
const base = createProductSnapshot('conversation-markdown-layout', 1);
|
||||
const snapshot = {
|
||||
...base,
|
||||
nodes: [{
|
||||
kind: 'message' as const,
|
||||
id: 'message-markdown-layout',
|
||||
role: 'assistant' as const,
|
||||
status: 'complete' as const,
|
||||
stopReason: 'stop' as const,
|
||||
blocks: [{
|
||||
kind: 'text' as const,
|
||||
id: 'text-markdown-layout',
|
||||
status: 'complete' as const,
|
||||
text: [
|
||||
'# 网站完成',
|
||||
'',
|
||||
'## 文件位置',
|
||||
'',
|
||||
'`~/Desktop/123/guiyang/index.html`',
|
||||
'',
|
||||
'## 查看方式',
|
||||
'',
|
||||
'- 直接打开 `guiyang/index.html`',
|
||||
'- 本地服务 `http://localhost:8642/index.html`',
|
||||
'',
|
||||
'## 页面内容',
|
||||
'',
|
||||
'| 区块 | 内容 |',
|
||||
'| --- | --- |',
|
||||
'| Hero 首屏 | 渐变主题与城市标语 |',
|
||||
'| 城市概况 | 面积与人口数据 |',
|
||||
'',
|
||||
'- 平滑滚动',
|
||||
'- 响应式布局',
|
||||
'',
|
||||
'[项目文档](https://example.com/docs)',
|
||||
'',
|
||||
'https://example.org/guide',
|
||||
'',
|
||||
'[不安全链接](file:///tmp/private.txt)',
|
||||
].join('\n'),
|
||||
}],
|
||||
}],
|
||||
};
|
||||
codingConversationStore.getState().applySnapshotEvent({
|
||||
type: 'snapshot',
|
||||
conversationId: 'conversation-markdown-layout',
|
||||
workerGeneration: 1,
|
||||
seq: snapshot.cursor.seq,
|
||||
snapshot,
|
||||
});
|
||||
|
||||
render(<CodingConversationTimeline conversationId="conversation-markdown-layout" />);
|
||||
|
||||
expect(screen.getByTestId('assistant-markdown')).toHaveClass('text-foreground');
|
||||
expect(screen.getByTestId('assistant-markdown')).not.toHaveClass('text-muted-foreground');
|
||||
expect(screen.getByRole('heading', { level: 1, name: '网站完成' }))
|
||||
.toHaveClass('text-xl');
|
||||
expect(screen.getByRole('heading', { level: 2, name: '文件位置' }))
|
||||
.toHaveClass('text-lg');
|
||||
expect(screen.getByText('平滑滚动').closest('ul')).toHaveClass('list-disc');
|
||||
expect(screen.getByRole('table').parentElement).toHaveClass('overflow-x-auto');
|
||||
expect(screen.getByRole('columnheader', { name: '区块' })).toBeVisible();
|
||||
expect(screen.getByRole('cell', { name: '渐变主题与城市标语' })).toBeVisible();
|
||||
|
||||
const markdownLink = screen.getByRole('link', { name: '项目文档' });
|
||||
expect(markdownLink).toHaveAttribute('href', 'https://example.com/docs');
|
||||
expect(markdownLink).toHaveClass('text-brand');
|
||||
expect(markdownLink.className).not.toMatch(/\bbg-/);
|
||||
fireEvent.click(markdownLink);
|
||||
await waitFor(() => expect(invoke).toHaveBeenCalledWith(
|
||||
'shell:openExternal',
|
||||
'https://example.com/docs',
|
||||
));
|
||||
fireEvent.contextMenu(markdownLink);
|
||||
await waitFor(() => expect(invoke).toHaveBeenCalledWith(
|
||||
'shell:showLinkContextMenu',
|
||||
{ kind: 'external', target: 'https://example.com/docs' },
|
||||
));
|
||||
expect(screen.getByRole('link', { name: 'https://example.org/guide' }))
|
||||
.toHaveAttribute('href', 'https://example.org/guide');
|
||||
const inlineCodeLink = screen.getByRole('link', {
|
||||
name: 'http://localhost:8642/index.html',
|
||||
});
|
||||
expect(inlineCodeLink).toHaveAttribute('href', 'http://localhost:8642/index.html');
|
||||
expect(inlineCodeLink).toHaveClass('text-brand');
|
||||
expect(inlineCodeLink.className).not.toMatch(/\bbg-/);
|
||||
fireEvent.click(inlineCodeLink);
|
||||
await waitFor(() => expect(invoke).toHaveBeenCalledWith(
|
||||
'shell:openExternal',
|
||||
'http://localhost:8642/index.html',
|
||||
));
|
||||
fireEvent.contextMenu(inlineCodeLink);
|
||||
await waitFor(() => expect(invoke).toHaveBeenCalledWith(
|
||||
'shell:showLinkContextMenu',
|
||||
{ kind: 'external', target: 'http://localhost:8642/index.html' },
|
||||
));
|
||||
expect(screen.queryByRole('link', { name: '不安全链接' })).not.toBeInTheDocument();
|
||||
expect(screen.getByText('不安全链接')).toBeVisible();
|
||||
|
||||
const absoluteLocalLink = screen.getByRole('button', {
|
||||
name: '用默认浏览器打开 ~/Desktop/123/guiyang/index.html',
|
||||
});
|
||||
expect(absoluteLocalLink).toHaveClass('text-brand');
|
||||
expect(absoluteLocalLink.className).not.toMatch(/\bbg-/);
|
||||
fireEvent.click(absoluteLocalLink);
|
||||
await waitFor(() => expect(invoke).toHaveBeenCalledWith('app:getPath', 'home'));
|
||||
await waitFor(() => expect(invoke).toHaveBeenCalledWith(
|
||||
'shell:openPath',
|
||||
'/Users/tester/Desktop/123/guiyang/index.html',
|
||||
));
|
||||
fireEvent.contextMenu(absoluteLocalLink);
|
||||
await waitFor(() => expect(invoke).toHaveBeenCalledWith(
|
||||
'shell:showLinkContextMenu',
|
||||
{ kind: 'local-web', target: '~/Desktop/123/guiyang/index.html' },
|
||||
));
|
||||
|
||||
const relativeLocalLink = screen.getByRole('button', {
|
||||
name: '用默认浏览器打开 guiyang/index.html',
|
||||
});
|
||||
expect(relativeLocalLink).toHaveClass('text-brand');
|
||||
expect(relativeLocalLink.className).not.toMatch(/\bbg-/);
|
||||
fireEvent.click(relativeLocalLink);
|
||||
await waitFor(() => expect(invoke).toHaveBeenCalledWith(
|
||||
'shell:openPath',
|
||||
'/Users/tester/Desktop/123/guiyang/index.html',
|
||||
));
|
||||
fireEvent.contextMenu(relativeLocalLink);
|
||||
await waitFor(() => expect(invoke).toHaveBeenCalledWith(
|
||||
'shell:showLinkContextMenu',
|
||||
{ kind: 'local-web', target: '~/Desktop/123/guiyang/index.html' },
|
||||
));
|
||||
expect(invoke).not.toHaveBeenCalledWith(
|
||||
'shell:showItemInFolder',
|
||||
expect.anything(),
|
||||
);
|
||||
});
|
||||
|
||||
it('streams thinking and tool progress through one-line previews with expandable details', async () => {
|
||||
const { codingConversationStore } = await import('@/stores/coding-conversations');
|
||||
const { CodingConversationTimeline } = await import(
|
||||
'@/pages/Chat/CodingConversationTimeline'
|
||||
);
|
||||
const base = createProductSnapshot('conversation-streaming-process', 1);
|
||||
const initialThinkingText = [
|
||||
'先确认 `project.json` 项目边界,再读取配置。',
|
||||
'然后核对入口文件与运行命令。',
|
||||
'最后确认本轮只读取、不修改项目内容。',
|
||||
].join('\n\n');
|
||||
const initialNoteText = [
|
||||
'好的,两件事都办:先再试一次产品素材服务,然后整体优化手机移动端体验。',
|
||||
'让我看看 `index.html` 当前的实际内容:',
|
||||
].join('\n\n');
|
||||
const initialToolOutput = '已定位项目根目录\n正在读取入口文件…';
|
||||
const createStreamingSnapshot = (
|
||||
seq: number,
|
||||
thinkingText: string,
|
||||
noteText: string,
|
||||
toolOutput: string,
|
||||
) => ({
|
||||
...base,
|
||||
cursor: { ...base.cursor, seq },
|
||||
nodes: [
|
||||
{
|
||||
kind: 'message' as const,
|
||||
id: 'message-streaming-process',
|
||||
role: 'assistant' as const,
|
||||
status: 'streaming' as const,
|
||||
blocks: [
|
||||
{
|
||||
kind: 'thinking' as const,
|
||||
id: 'thinking-streaming-process',
|
||||
text: thinkingText,
|
||||
status: 'streaming' as const,
|
||||
},
|
||||
{
|
||||
kind: 'text' as const,
|
||||
id: 'preamble-streaming-process',
|
||||
text: noteText,
|
||||
status: 'complete' as const,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
kind: 'tool' as const,
|
||||
id: 'tool-streaming-process',
|
||||
toolCallId: 'call-streaming-process',
|
||||
toolName: 'read_file',
|
||||
title: '读取项目配置',
|
||||
inputText: 'project.json',
|
||||
status: 'running' as const,
|
||||
output: [{
|
||||
kind: 'text' as const,
|
||||
id: 'tool-output-streaming-process',
|
||||
text: toolOutput,
|
||||
status: 'streaming' as const,
|
||||
}],
|
||||
},
|
||||
],
|
||||
});
|
||||
const snapshot = createStreamingSnapshot(
|
||||
base.cursor.seq,
|
||||
initialThinkingText,
|
||||
initialNoteText,
|
||||
initialToolOutput,
|
||||
);
|
||||
codingConversationStore.getState().applySnapshotEvent({
|
||||
type: 'snapshot',
|
||||
conversationId: 'conversation-streaming-process',
|
||||
workerGeneration: 1,
|
||||
seq: snapshot.cursor.seq,
|
||||
snapshot,
|
||||
});
|
||||
|
||||
render(
|
||||
<CodingConversationTimeline
|
||||
conversationId="conversation-streaming-process"
|
||||
assistantName="Builder"
|
||||
/>,
|
||||
);
|
||||
|
||||
const process = screen.getByTestId('coding-process-group');
|
||||
expect(process).toHaveAttribute('open');
|
||||
expect(within(process).getByText('处理中')).toBeVisible();
|
||||
const thinking = screen.getByLabelText('思考过程');
|
||||
const thinkingPreview = within(thinking).getByTestId('process-progress-preview');
|
||||
expect(thinkingPreview).toHaveTextContent('最后确认本轮只读取、不修改项目内容。');
|
||||
expect(thinkingPreview).toHaveAttribute('data-progress-tail', 'true');
|
||||
expect(thinkingPreview).toHaveAttribute('data-progress-alignment', 'left');
|
||||
expect(thinkingPreview).toHaveAttribute('data-progress-update-motion', 'none');
|
||||
expect(thinkingPreview).not.toHaveAttribute('data-roll-revision');
|
||||
expect(thinkingPreview).toHaveAttribute('data-progress-shimmer', 'true');
|
||||
expect(thinkingPreview).toHaveClass('text-muted-foreground/75');
|
||||
expect(thinkingPreview.querySelector('.streaming-progress-shimmer')).toBeInTheDocument();
|
||||
expect(thinkingPreview.querySelector('.streaming-progress-roll')).not.toBeInTheDocument();
|
||||
expect(thinkingPreview).toHaveClass('text-left');
|
||||
expect(thinkingPreview.firstElementChild).toHaveClass('inline-flex');
|
||||
expect(thinkingPreview.firstElementChild).not.toHaveClass('absolute', 'right-0');
|
||||
expect(thinking).not.toHaveTextContent('先确认 project.json 项目边界,再读取配置。');
|
||||
expect(thinking).toHaveAttribute('data-collapsed-lines', '1');
|
||||
expect(thinking).toHaveAttribute('data-expanded', 'false');
|
||||
expect(thinking).toHaveAttribute('data-streaming', 'true');
|
||||
expect(thinking).toHaveClass('max-h-[1.65em]', 'overflow-hidden');
|
||||
expect(thinking).toBeVisible();
|
||||
const thinkingToggle = screen.getByRole('button', { name: '展开思考详情' });
|
||||
expect(thinkingToggle).toHaveAttribute('aria-expanded', 'false');
|
||||
fireEvent.click(thinkingToggle);
|
||||
expect(thinking).toHaveAttribute('data-expanded', 'true');
|
||||
expect(thinking).toHaveClass('max-h-60', 'overflow-y-auto');
|
||||
expect(thinking).toHaveTextContent('先确认 project.json 项目边界,再读取配置。');
|
||||
expect(within(thinking).getByText('project.json').tagName).toBe('CODE');
|
||||
expect(within(thinking).getByTestId('assistant-markdown')).toHaveClass('text-muted-foreground');
|
||||
expect(screen.getByRole('button', { name: '收起思考详情' }))
|
||||
.toHaveAttribute('aria-expanded', 'true');
|
||||
fireEvent.click(screen.getByRole('button', { name: '收起思考详情' }));
|
||||
expect(thinking).toHaveAttribute('data-expanded', 'false');
|
||||
|
||||
const processNote = screen.getByLabelText('过程说明');
|
||||
const assistantCommentary = processNote.closest('[data-node-kind="assistant-commentary"]')!;
|
||||
expect(assistantCommentary).toHaveClass('text-foreground');
|
||||
expect(processNote).toHaveAttribute('data-collapsed-lines', '1');
|
||||
expect(processNote).toHaveClass('max-h-[1.65em]', 'overflow-hidden');
|
||||
const commentaryPreview = within(processNote).getByTestId('process-progress-preview');
|
||||
expect(commentaryPreview).toHaveTextContent('让我看看 index.html 当前的实际内容:');
|
||||
expect(commentaryPreview).toHaveClass('text-foreground');
|
||||
expect(commentaryPreview).not.toHaveClass('text-muted-foreground/75');
|
||||
expect(within(commentaryPreview).getByText('index.html')).toHaveClass('text-foreground');
|
||||
expect(within(commentaryPreview).getByText('index.html')).not.toHaveClass('text-muted-foreground');
|
||||
expect(commentaryPreview).toHaveAttribute('data-progress-shimmer', 'false');
|
||||
expect(commentaryPreview).toHaveAttribute('data-progress-update-motion', 'none');
|
||||
expect(commentaryPreview).not.toHaveAttribute('data-roll-revision');
|
||||
expect(commentaryPreview.querySelector('.streaming-progress-shimmer')).not.toBeInTheDocument();
|
||||
expect(commentaryPreview.querySelector('.streaming-progress-roll')).not.toBeInTheDocument();
|
||||
expect(processNote).not.toHaveTextContent('好的,两件事都办');
|
||||
fireEvent.click(screen.getByRole('button', { name: '展开过程说明' }));
|
||||
expect(screen.getByText(/好的,两件事都办/).closest('[data-testid="coding-process-group"]')).toBe(process);
|
||||
expect(within(processNote).getByTestId('assistant-markdown')).toHaveClass('text-foreground');
|
||||
expect(within(processNote).getByTestId('assistant-markdown')).not.toHaveClass('text-muted-foreground');
|
||||
fireEvent.click(screen.getByRole('button', { name: '收起过程说明' }));
|
||||
const tool = screen.getByText('读取项目配置').closest('details')!;
|
||||
expect(tool).not.toHaveAttribute('open');
|
||||
expect(tool.querySelector('summary')).toHaveAttribute('data-collapsed-lines', '1');
|
||||
expect(tool.querySelector('summary')).toHaveClass('h-8', 'overflow-hidden');
|
||||
const toolPreview = within(tool).getByTestId('tool-progress-preview');
|
||||
const initialToolRevision = toolPreview.getAttribute('data-roll-revision');
|
||||
expect(toolPreview).toHaveTextContent('正在读取入口文件…');
|
||||
expect(toolPreview).toHaveAttribute('data-progress-tail', 'true');
|
||||
expect(toolPreview).toHaveAttribute('data-progress-alignment', 'left');
|
||||
expect(toolPreview).toHaveAttribute('data-progress-update-motion', 'roll');
|
||||
expect(toolPreview).toHaveAttribute('data-progress-shimmer', 'true');
|
||||
expect(toolPreview).toHaveClass('text-muted-foreground/75');
|
||||
expect(screen.getByText('读取项目配置')).toHaveClass('text-muted-foreground');
|
||||
expect(toolPreview).toHaveClass('text-left');
|
||||
expect(within(tool).getByText('执行中')).toBeVisible();
|
||||
|
||||
const updatedSnapshot = createStreamingSnapshot(
|
||||
snapshot.cursor.seq + 1,
|
||||
`${initialThinkingText}\n\n正在确认最新入口 \`main.ts\`。`,
|
||||
`${initialNoteText}\n\n正在整理最新结论。`,
|
||||
`${initialToolOutput}\n正在追踪最新工具输出:main.ts 已读取。`,
|
||||
);
|
||||
act(() => codingConversationStore.getState().applySnapshotEvent({
|
||||
type: 'snapshot',
|
||||
conversationId: 'conversation-streaming-process',
|
||||
workerGeneration: 1,
|
||||
seq: updatedSnapshot.cursor.seq,
|
||||
snapshot: updatedSnapshot,
|
||||
}));
|
||||
|
||||
expect(within(thinking).getByTestId('process-progress-preview'))
|
||||
.toHaveTextContent('正在确认最新入口 main.ts。');
|
||||
expect(within(thinking).getByTestId('process-progress-preview'))
|
||||
.not.toHaveAttribute('data-roll-revision');
|
||||
expect(within(thinking).getByTestId('process-progress-preview')
|
||||
.querySelector('.streaming-progress-roll')).not.toBeInTheDocument();
|
||||
expect(within(processNote).getByTestId('process-progress-preview'))
|
||||
.toHaveTextContent('正在整理最新结论。');
|
||||
expect(within(processNote).getByTestId('process-progress-preview'))
|
||||
.toHaveClass('text-foreground');
|
||||
expect(within(processNote).getByTestId('process-progress-preview'))
|
||||
.toHaveAttribute('data-progress-shimmer', 'false');
|
||||
expect(within(tool).getByTestId('tool-progress-preview'))
|
||||
.toHaveTextContent('正在追踪最新工具输出:main.ts 已读取。');
|
||||
expect(within(tool).getByTestId('tool-progress-preview'))
|
||||
.not.toHaveAttribute('data-roll-revision', initialToolRevision);
|
||||
|
||||
fireEvent.click(tool.querySelector('summary')!);
|
||||
await waitFor(() => expect(tool).toHaveAttribute('open'));
|
||||
const toolDetails = within(tool).getByTestId('tool-details');
|
||||
expect(within(toolDetails).getByText('当前状态:执行中')).toBeVisible();
|
||||
expect(within(toolDetails).getByText(/已定位项目根目录/)).toBeVisible();
|
||||
});
|
||||
|
||||
it('folds completed process data into elapsed time, hides internal boundaries, and keeps the conclusion visible', async () => {
|
||||
const { codingConversationStore } = await import('@/stores/coding-conversations');
|
||||
const { CodingConversationTimeline } = await import(
|
||||
'@/pages/Chat/CodingConversationTimeline'
|
||||
);
|
||||
const base = createProductSnapshot('conversation-completed-process', 1);
|
||||
const snapshot = {
|
||||
...base,
|
||||
run: {
|
||||
status: 'idle' as const,
|
||||
runId: 'run-completed-process',
|
||||
startedAt: 1_000,
|
||||
settledAt: 5_500,
|
||||
terminalReason: 'completed' as const,
|
||||
},
|
||||
nodes: [
|
||||
{
|
||||
kind: 'message' as const,
|
||||
id: 'message-user-completed-process',
|
||||
role: 'user' as const,
|
||||
status: 'complete' as const,
|
||||
blocks: [{ kind: 'text' as const, id: 'user-text-completed-process', text: '检查项目', status: 'complete' as const }],
|
||||
},
|
||||
{
|
||||
kind: 'boundary' as const,
|
||||
id: 'boundary-start-completed-process',
|
||||
runId: 'run-completed-process',
|
||||
boundary: 'turn-start' as const,
|
||||
},
|
||||
{
|
||||
kind: 'message' as const,
|
||||
id: 'message-work-completed-process',
|
||||
role: 'assistant' as const,
|
||||
status: 'complete' as const,
|
||||
stopReason: 'tool-use' as const,
|
||||
blocks: [
|
||||
{ kind: 'thinking' as const, id: 'thinking-completed-process', text: '先读取配置,再核对入口。', status: 'streaming' as const },
|
||||
{ kind: 'text' as const, id: 'preamble-completed-process', text: '我先检查项目配置。', status: 'complete' as const },
|
||||
],
|
||||
},
|
||||
{
|
||||
kind: 'tool' as const,
|
||||
id: 'tool-completed-process',
|
||||
toolCallId: 'call-completed-process',
|
||||
toolName: 'read_file',
|
||||
title: '读取项目配置',
|
||||
inputText: 'package.json',
|
||||
status: 'complete' as const,
|
||||
output: [{ kind: 'text' as const, id: 'tool-output-completed-process', text: '工具输出内容', status: 'complete' as const }],
|
||||
},
|
||||
{
|
||||
kind: 'boundary' as const,
|
||||
id: 'boundary-end-completed-process',
|
||||
runId: 'run-completed-process',
|
||||
boundary: 'turn-end' as const,
|
||||
},
|
||||
{
|
||||
kind: 'message' as const,
|
||||
id: 'message-final-completed-process',
|
||||
role: 'assistant' as const,
|
||||
status: 'complete' as const,
|
||||
stopReason: 'stop' as const,
|
||||
blocks: [{ kind: 'text' as const, id: 'final-text-completed-process', text: '结论:项目入口与配置一致。', status: 'complete' as const }],
|
||||
},
|
||||
],
|
||||
};
|
||||
codingConversationStore.getState().applySnapshotEvent({
|
||||
type: 'snapshot',
|
||||
conversationId: 'conversation-completed-process',
|
||||
workerGeneration: 1,
|
||||
seq: snapshot.cursor.seq,
|
||||
snapshot,
|
||||
});
|
||||
|
||||
render(<CodingConversationTimeline conversationId="conversation-completed-process" />);
|
||||
|
||||
const process = screen.getByTestId('coding-process-group');
|
||||
expect(process).not.toHaveAttribute('open');
|
||||
expect(within(process).getByText('已处理 5 秒')).toBeVisible();
|
||||
expect(screen.getByText('结论:项目入口与配置一致。')).toBeVisible();
|
||||
expect(screen.queryByText('开始新一轮')).not.toBeInTheDocument();
|
||||
expect(screen.queryByText('本轮已结算')).not.toBeInTheDocument();
|
||||
expect(screen.getByLabelText('思考过程')).not.toBeVisible();
|
||||
|
||||
fireEvent.click(process.querySelector('summary')!);
|
||||
await waitFor(() => expect(process).toHaveAttribute('open'));
|
||||
const settledThinking = screen.getByLabelText('思考过程');
|
||||
expect(settledThinking).toBeVisible();
|
||||
expect(within(settledThinking).getByTestId('process-progress-preview'))
|
||||
.toHaveAttribute('data-progress-shimmer', 'false');
|
||||
expect(within(settledThinking).getByTestId('process-progress-preview')
|
||||
.querySelector('.streaming-progress-shimmer')).not.toBeInTheDocument();
|
||||
const tool = screen.getByText('读取项目配置').closest('details')!;
|
||||
expect(tool).not.toHaveAttribute('open');
|
||||
fireEvent.click(tool.querySelector('summary')!);
|
||||
const toolDetails = within(tool).getByTestId('tool-details');
|
||||
await waitFor(() => expect(within(toolDetails).getByText('工具输出内容')).toBeVisible());
|
||||
});
|
||||
|
||||
it('keeps a recovered tool error inside the process details without marking the completed turn as failed', async () => {
|
||||
const { codingConversationStore } = await import('@/stores/coding-conversations');
|
||||
const { CodingConversationTimeline } = await import(
|
||||
'@/pages/Chat/CodingConversationTimeline'
|
||||
);
|
||||
const base = createProductSnapshot('conversation-recovered-tool-error', 1);
|
||||
const snapshot = {
|
||||
...base,
|
||||
run: {
|
||||
status: 'idle' as const,
|
||||
runId: 'run-recovered-tool-error',
|
||||
startedAt: 1_000,
|
||||
settledAt: 32_000,
|
||||
terminalReason: 'completed' as const,
|
||||
},
|
||||
nodes: [
|
||||
{
|
||||
kind: 'message' as const,
|
||||
id: 'message-user-recovered-tool-error',
|
||||
role: 'user' as const,
|
||||
status: 'complete' as const,
|
||||
blocks: [{
|
||||
kind: 'text' as const,
|
||||
id: 'user-text-recovered-tool-error',
|
||||
text: '继续',
|
||||
status: 'complete' as const,
|
||||
}],
|
||||
},
|
||||
{
|
||||
kind: 'tool' as const,
|
||||
id: 'tool-recovered-tool-error',
|
||||
toolCallId: 'call-recovered-tool-error',
|
||||
toolName: 'agent_browser',
|
||||
title: '检查网站',
|
||||
inputText: '',
|
||||
status: 'error' as const,
|
||||
output: [{
|
||||
kind: 'text' as const,
|
||||
id: 'tool-output-recovered-tool-error',
|
||||
text: 'Bridge request failed',
|
||||
status: 'complete' as const,
|
||||
}],
|
||||
},
|
||||
{
|
||||
kind: 'message' as const,
|
||||
id: 'message-final-recovered-tool-error',
|
||||
role: 'assistant' as const,
|
||||
status: 'complete' as const,
|
||||
stopReason: 'stop' as const,
|
||||
blocks: [{
|
||||
kind: 'text' as const,
|
||||
id: 'final-text-recovered-tool-error',
|
||||
text: '网站完成啦!',
|
||||
status: 'complete' as const,
|
||||
}],
|
||||
},
|
||||
],
|
||||
};
|
||||
codingConversationStore.getState().applySnapshotEvent({
|
||||
type: 'snapshot',
|
||||
conversationId: 'conversation-recovered-tool-error',
|
||||
workerGeneration: 1,
|
||||
seq: snapshot.cursor.seq,
|
||||
snapshot,
|
||||
});
|
||||
|
||||
render(<CodingConversationTimeline conversationId="conversation-recovered-tool-error" />);
|
||||
|
||||
const process = screen.getByTestId('coding-process-group');
|
||||
expect(within(process).getByText('已处理 31 秒')).toBeVisible();
|
||||
expect(within(process).queryByText(/处理失败/)).not.toBeInTheDocument();
|
||||
expect(screen.getByText('网站完成啦!')).toBeVisible();
|
||||
|
||||
fireEvent.click(process.querySelector('summary')!);
|
||||
await waitFor(() => expect(process).toHaveAttribute('open'));
|
||||
const tool = screen.getByText('检查网站').closest('details')!;
|
||||
expect(tool).toHaveTextContent('失败');
|
||||
fireEvent.click(tool.querySelector('summary')!);
|
||||
const toolDetails = within(tool).getByTestId('tool-details');
|
||||
await waitFor(() => expect(within(toolDetails).getByText('Bridge request failed')).toBeVisible());
|
||||
});
|
||||
|
||||
it('marks the process as failed when the authoritative run outcome failed', async () => {
|
||||
const { codingConversationStore } = await import('@/stores/coding-conversations');
|
||||
const { CodingConversationTimeline } = await import(
|
||||
'@/pages/Chat/CodingConversationTimeline'
|
||||
);
|
||||
const base = createProductSnapshot('conversation-failed-process', 1);
|
||||
const snapshot = {
|
||||
...base,
|
||||
run: {
|
||||
status: 'error' as const,
|
||||
runId: 'run-failed-process',
|
||||
startedAt: 1_000,
|
||||
settledAt: 6_000,
|
||||
terminalReason: 'failed' as const,
|
||||
},
|
||||
nodes: [{
|
||||
kind: 'tool' as const,
|
||||
id: 'tool-failed-process',
|
||||
toolCallId: 'call-failed-process',
|
||||
toolName: 'bash',
|
||||
title: '执行命令',
|
||||
inputText: '',
|
||||
status: 'error' as const,
|
||||
output: [],
|
||||
}],
|
||||
};
|
||||
codingConversationStore.getState().applySnapshotEvent({
|
||||
type: 'snapshot',
|
||||
conversationId: 'conversation-failed-process',
|
||||
workerGeneration: 1,
|
||||
seq: snapshot.cursor.seq,
|
||||
snapshot,
|
||||
});
|
||||
|
||||
render(<CodingConversationTimeline conversationId="conversation-failed-process" />);
|
||||
|
||||
expect(within(screen.getByTestId('coding-process-group')).getByText('处理失败 · 5 秒'))
|
||||
.toBeVisible();
|
||||
});
|
||||
|
||||
it('windows long timelines and loads earlier nodes only on demand', async () => {
|
||||
const { codingConversationStore } = await import('@/stores/coding-conversations');
|
||||
const { CodingConversationTimeline } = await import(
|
||||
@@ -212,6 +776,17 @@ describe('CodingConversationTimeline', () => {
|
||||
output: [{ kind: 'text' as const, id: 'tool-output', text: 'inline result', status: 'complete' as const }],
|
||||
details: { schema: 'changed-file.v1' as const, paths: ['src/app.ts'] },
|
||||
},
|
||||
{
|
||||
kind: 'tool' as const,
|
||||
id: 'tool-changed-file-feature',
|
||||
toolCallId: 'call-changed-file-feature',
|
||||
toolName: 'changed_file',
|
||||
title: '记录文件更改',
|
||||
inputText: '',
|
||||
status: 'complete' as const,
|
||||
output: [],
|
||||
details: { schema: 'changed-file.v1' as const, paths: ['src/hidden-from-timeline.ts'] },
|
||||
},
|
||||
{
|
||||
kind: 'subagent' as const,
|
||||
id: 'subagent-feature',
|
||||
@@ -277,13 +852,32 @@ describe('CodingConversationTimeline', () => {
|
||||
expect(forkActions).toHaveLength(1);
|
||||
fireEvent.click(forkActions[0]!);
|
||||
expect(onFork).toHaveBeenCalledWith('entry-user-feature');
|
||||
expect(screen.getByText('并行子任务')).toBeInTheDocument();
|
||||
expect(screen.getByText('单个子任务')).toBeInTheDocument();
|
||||
expect(screen.getByText('串行子任务')).toBeInTheDocument();
|
||||
expect(screen.getByText('reader')).toBeInTheDocument();
|
||||
expect(screen.getByText('BUILD_FAILED')).toBeInTheDocument();
|
||||
expect(screen.getByText('系统会自动重试。')).toBeInTheDocument();
|
||||
expect(screen.getByText('inline result').closest('[data-node-kind="tool"]')).not.toBeNull();
|
||||
const process = screen.getByTestId('coding-process-group');
|
||||
expect(process).not.toHaveAttribute('open');
|
||||
fireEvent.click(process.querySelector('summary')!);
|
||||
const parallel = document.querySelector('[data-node-id="subagent-feature"]')!;
|
||||
const single = document.querySelector('[data-node-id="subagent-single"]')!;
|
||||
const chain = document.querySelector('[data-node-id="subagent-chain"]')!;
|
||||
expect(parallel.querySelector('summary')).toHaveTextContent('并行子任务');
|
||||
expect(single.querySelector('summary')).toHaveTextContent('单个子任务');
|
||||
expect(chain.querySelector('summary')).toHaveTextContent('串行子任务');
|
||||
fireEvent.click(parallel.querySelector('summary')!);
|
||||
fireEvent.click(single.querySelector('summary')!);
|
||||
fireEvent.click(chain.querySelector('summary')!);
|
||||
expect(screen.getByText('reader')).toBeVisible();
|
||||
expect(screen.getByText('BUILD_FAILED')).toBeVisible();
|
||||
|
||||
const compaction = document.querySelector('[data-node-id="compact-feature"]')!;
|
||||
fireEvent.click(compaction.querySelector('summary')!);
|
||||
expect(screen.getByText('系统会自动重试。')).toBeVisible();
|
||||
|
||||
const tool = document.querySelector('[data-node-id="tool-feature"]')!;
|
||||
expect(tool).not.toHaveAttribute('open');
|
||||
fireEvent.click(tool.querySelector('summary')!);
|
||||
const toolDetails = within(tool as HTMLElement).getByTestId('tool-details');
|
||||
expect(within(toolDetails).getByText('inline result')).toBeVisible();
|
||||
expect(within(toolDetails).getByText('inline result').closest('[data-node-kind="tool"]')).not.toBeNull();
|
||||
expect(document.querySelector('[data-node-id="tool-changed-file-feature"]')).toBeNull();
|
||||
expect(screen.queryByText(/回滚|revert/i)).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user