Files
open-design/apps/web/tests/components/ChatPane.error-card-ladder.test.tsx
eca7c7ab98 fix(web): align ChatPanel failures with approved product copy (#8140)
* fix(web): align runtime error messages with approved product copy

* test(chat): give retry deduplication scenario a Cloud owner

* test(chat): use the supported Cloud model preference shape

* test(chat): align CLI recovery scenarios with fixed Cloud actions

* test(e2e): align Cloud error layout with fixed recovery actions

* test(e2e): use ordinary Cloud failure for error-card layout

* test(web): preserve ChatPane helpers and align recovery fixtures

* test(e2e): resize error-card layout through the native separator

* test(e2e): reject missing error-card action geometry

---------

Co-authored-by: lefarcen <935902669@qq.com>
2026-09-16 03:28:29 +00:00

308 lines
12 KiB
TypeScript

// @vitest-environment jsdom
//
// 报错卡不泄漏原始错误;专属文案和真实重连行的交接保持不变。
// OPEND-2807 / G16 覆盖旧恢复阶梯:联系我们、导出日志常驻次级,
// Cloud 的主动作固定重试,CLI/BYOK 的主动作固定切换到 OpenDesign Cloud。
import { cleanup, render, screen } from '@testing-library/react';
import { forwardRef } from 'react';
import type { ComponentProps } from 'react';
import { afterEach, describe, expect, it, vi } from 'vitest';
import { ChatPane } from '../../src/components/ChatPane';
import {
GENERIC_DAEMON_DISCONNECT_CODE,
GENERIC_DAEMON_DISCONNECT_MESSAGE,
} from '../../src/providers/daemon';
import type { AppConfig, ChatMessage } from '../../src/types';
const translate = (key: string, vars?: Record<string, string | number>) => {
if (vars && Object.keys(vars).length > 0) {
return `${key} ${Object.values(vars).join(' ')}`;
}
return key;
};
vi.mock('../../src/i18n', () => ({
useI18n: () => ({ locale: 'en', setLocale: () => undefined, t: translate }),
useT: () => translate,
}));
vi.mock('../../src/components/AssistantMessage', () => ({
AssistantMessage: ({ message }: { message: ChatMessage }) => (
<div data-testid={`assistant-${message.id}`}>{message.content}</div>
),
}));
vi.mock('../../src/components/ChatComposer', () => ({
ChatComposer: forwardRef((_props, _ref) => <div data-testid="composer" />),
}));
vi.mock('../../src/analytics/events', async (importOriginal) => {
const actual = await importOriginal<typeof import('../../src/analytics/events')>();
return {
...actual,
trackChatPanelClick: vi.fn(),
trackRunFailedToastSurfaceView: vi.fn(),
trackRunRecoveryActionClick: vi.fn(),
trackRunRecoveryActionSurfaceView: vi.fn(),
};
});
afterEach(() => {
cleanup();
vi.clearAllMocks();
});
// 一段真实形状的上游原文:英文、带栈尾。今天它会被原样摊在卡面上。
const RAW_STDERR =
'Error: spawn ENOENT\n at ChildProcess._handle.onexit (node:internal/child_process:286:19)';
function failedMessage(
overrides: Partial<ChatMessage> = {},
errorEvent: Record<string, unknown> = {},
): ChatMessage {
return {
id: 'msg-failed',
role: 'assistant',
content: 'Partial work.',
createdAt: 1,
runId: 'run-1',
runStatus: 'failed',
agentId: 'claude',
events: [
{
kind: 'status',
label: 'error',
detail: RAW_STDERR,
code: 'AGENT_EXECUTION_FAILED',
...errorEvent,
},
],
...overrides,
} as ChatMessage;
}
function renderChat(
message: ChatMessage,
extraProps: Partial<ComponentProps<typeof ChatPane>> = {},
) {
return render(
<ChatPane
messages={[message]}
streaming={false}
error={null}
projectId="project-1"
projectFiles={[]}
onEnsureProject={async () => 'project-1'}
onSend={vi.fn()}
onStop={vi.fn()}
onRetry={vi.fn()}
// 对应已接真实 Cloud 恢复回调的宿主;侧聊全链由独立宿主测试覆盖。
onSwitchToAmrAndRetry={vi.fn()}
conversations={[
{ projectId: 'project-1', id: 'conv-1', title: 'Current', createdAt: 1, updatedAt: 1 },
]}
activeConversationId="conv-1"
onSelectConversation={vi.fn()}
onDeleteConversation={vi.fn()}
config={{ agentId: 'claude', agentCliEnv: {} } as unknown as AppConfig}
{...extraProps}
/>,
);
}
describe('报错卡兜底文案(E2)', () => {
it('没命中映射表时卡面给人话,不摊上游原文', () => {
// `failureDetail` 缺失 → 走最后那条 catch-all,`messageKey` 为 null。
const { container } = renderChat(failedMessage());
const card = container.querySelector('[data-user-action-card="run-recovery"]');
expect(card).toBeTruthy();
const description = card!.querySelector('[data-testid="chat-run-error-description"]');
expect(description).toBeTruthy();
expect(description!.textContent).toBe('chat.runError.fallbackMessage');
// 卡面上不许出现那段 stderr。
expect(description!.textContent).not.toContain('spawn ENOENT');
});
// 「原文收在折叠里」那条用例已随折叠一起删掉(用户 2026-08-27)。
// 「卡上没有折叠、也没有原文」现在钉在
// `ChatPane.error-card-no-details.test.tsx`。
it('命中了映射表的失败,照旧用它自己的文案(兜底不许盖掉专属文案)', () => {
const { container } = renderChat(
failedMessage({}, { failureDetail: 'process_crashed' }),
);
const description = container.querySelector(
'[data-user-action-card="run-recovery"] [data-testid="chat-run-error-description"]',
);
expect(description).toBeTruthy();
// S19 的专属文案,不是兜底那条。
expect(description!.textContent).toContain('chat.runError.agentCrashedMessage');
expect(description!.textContent).not.toBe('chat.runError.fallbackMessage');
});
});
describe('G16:主动作按失败运行来源固定,联系支持保持次级', () => {
it('BYOK 封号:主位归 Cloud CTA,〔联系支持〕退回常驻次级', () => {
renderChat(failedMessage({}, { failureDetail: 'account_suspended' }));
const support = screen.getByTestId('chat-error-contact-support');
expect(support.dataset.primary).toBeUndefined();
expect(
screen.getByTestId('chat-error-switch-to-cloud').dataset.runErrorAction,
).toBe('primary');
});
it('已经在 Cloud 上的封号:主动作重试,联系支持保持次级', () => {
renderChat(
failedMessage({ agentId: 'amr' }, { failureDetail: 'account_suspended' }),
);
expect(screen.queryByTestId('chat-error-switch-to-cloud')).toBeNull();
const support = screen.getByTestId('chat-error-contact-support');
expect(support.dataset.primary).toBeUndefined();
expect(support.dataset.runErrorAction).toBe('secondary');
expect(screen.getByTestId('chat-error-retry').dataset.runErrorAction).toBe('primary');
});
it('普通可重试的卡上,〔联系支持〕仍是常驻次级', () => {
renderChat(failedMessage({}, { failureDetail: 'process_crashed' }));
const support = screen.getByTestId('chat-error-contact-support');
expect(support.dataset.primary).toBeUndefined();
});
});
describe('R9 断线:报错卡让位给流水最后一行的重连行', () => {
// 传输层重连预算用尽时的顺序是 `emitReconnect('exhausted')` → `onError(...)`
// (`providers/daemon.ts`),于是这一刻**两块 UI 的数据同时在手**:
// 流水最后一行的〔重新连接〕,和一张写着「任务执行失败」的通用报错卡。
// 交付稿第 84 格只画了前者。
const reconnectExhausted = {
// 「交回给人」那一档只属于传输层:自动重试烧完预算之后接手的是报错卡,
// 不是一颗〔重新连接〕。
reason: 'transport' as const,
runId: 'run-1',
conversationId: 'conv-1',
attempt: 5,
max: 5,
exhausted: true,
// 传输层交回给人的那一行,不是「按下之后的乐观读数」。
manualRetry: false,
};
/*
* ⚠️ 这两条原来是**不带 `reconnect`** 渲染的,断言仍然是「不出卡」——
* 那钉住的是 bug,不是不变量:接手方不在场时让位,等于两边都不说话。
* 现在两条都把接手方摆在场,钉的才是它们本来要钉的那件事:**认出**这条失败的
* 两条线索(结构化 code / 老行只有 detail)各自都能触发交接。
* 「接手方不在场」那一侧由下面两条负责。
*/
it('持久化的断线行,重连行在场时不出报错卡', () => {
const { container } = renderChat(
failedMessage({}, {
code: GENERIC_DAEMON_DISCONNECT_CODE,
detail: GENERIC_DAEMON_DISCONNECT_MESSAGE,
}),
{ reconnect: reconnectExhausted, onManualReconnect: vi.fn() },
);
expect(screen.getByTestId('chat-reconnect')).toBeTruthy();
expect(container.querySelector('[data-user-action-card="run-recovery"]')).toBeNull();
});
it('这条码引入之前落库的行(只有 detail、没有 code)同样交给在场的重连行', () => {
const { container } = renderChat(
failedMessage({}, { code: undefined, detail: GENERIC_DAEMON_DISCONNECT_MESSAGE }),
{ reconnect: reconnectExhausted, onManualReconnect: vi.fn() },
);
expect(screen.getByTestId('chat-reconnect')).toBeTruthy();
expect(container.querySelector('[data-user-action-card="run-recovery"]')).toBeNull();
});
/*
* 交接只在接手方真的在场时成立 —— 这是余额那一档
* (`balanceCardCannotTakeTheHandoff`)早就写死的同一条不变量,断线这一档漏了半边。
*
* 现场:一轮因断流失败,用户退出项目再进来。`ProjectView` 有一条专门的卸载
* effect 把 `reconnectView` 清空(「换项目 / 离开这一屏,本地就不再跟着那条流了」),
* 于是重连行不在场;而报错卡这边仍然无条件让位。两边都不说话,那一轮在屏幕上
* 一个字都没有 —— 既没有失败的说明,也没有任何恢复入口。
*/
it('重连行不在场时(退出项目再进来),断线那一轮必须由报错卡接住', () => {
const { container } = renderChat(
failedMessage({}, {
code: GENERIC_DAEMON_DISCONNECT_CODE,
detail: GENERIC_DAEMON_DISCONNECT_MESSAGE,
}),
{ reconnect: null },
);
// 接手方不在场。
expect(screen.queryByTestId('chat-reconnect')).toBeNull();
// 所以这张卡不能再让位。
const card = container.querySelector('[data-user-action-card="run-recovery"]');
expect(card).toBeTruthy();
// 说的是断线这件事本身,不是兜底那句。
const description = card!.querySelector(
'[data-testid="chat-run-error-description"]',
)!.textContent;
expect(description).toContain('chat.connectionDropped');
expect(description).not.toContain('chat.runError.fallbackMessage');
// 而且带得出恢复动作 —— 屏幕上什么都不剩才是这条 bug 的形状。
expect(screen.getByTestId('chat-error-switch-to-cloud').dataset.runErrorAction).toBe('primary');
expect(screen.queryByTestId('chat-error-retry')).toBeNull();
});
it('重连行不在场时,老行(只有 detail、没有 code)同样由报错卡接住', () => {
const { container } = renderChat(
failedMessage({}, { code: undefined, detail: GENERIC_DAEMON_DISCONNECT_MESSAGE }),
{ reconnect: null },
);
expect(screen.queryByTestId('chat-reconnect')).toBeNull();
expect(container.querySelector('[data-user-action-card="run-recovery"]')).toBeTruthy();
expect(screen.getByTestId('chat-error-switch-to-cloud').dataset.runErrorAction).toBe('primary');
expect(screen.queryByTestId('chat-error-retry')).toBeNull();
});
it('重连行在场时,屏幕上只有它一块 UI', () => {
const { container } = render(
<ChatPane
messages={[
failedMessage({}, {
code: GENERIC_DAEMON_DISCONNECT_CODE,
detail: GENERIC_DAEMON_DISCONNECT_MESSAGE,
}),
]}
streaming={false}
error={GENERIC_DAEMON_DISCONNECT_MESSAGE}
reconnect={reconnectExhausted}
onManualReconnect={vi.fn()}
projectId="project-1"
projectFiles={[]}
onEnsureProject={async () => 'project-1'}
onSend={vi.fn()}
onStop={vi.fn()}
onRetry={vi.fn()}
conversations={[
{ projectId: 'project-1', id: 'conv-1', title: 'Current', createdAt: 1, updatedAt: 1 },
]}
activeConversationId="conv-1"
onSelectConversation={vi.fn()}
onDeleteConversation={vi.fn()}
config={{ agentId: 'claude', agentCliEnv: {} } as unknown as AppConfig}
/>,
);
// 重连行在。
expect(screen.getByTestId('chat-reconnect')).toBeTruthy();
expect(screen.getByRole('button', { name: 'chat.edge.reconnectCta' })).toBeTruthy();
// 报错卡不在 —— 这才是「不同时出现」。
expect(container.querySelector('[data-user-action-card="run-recovery"]')).toBeNull();
});
// 反向:上游模型那条连接断了(S11)跟 SSE 重连不是一回事,那张卡要留着。
it('不误伤 S11:上游连接中断照旧出卡', () => {
const { container } = renderChat(
failedMessage({}, { code: 'AGENT_CONNECTION_DROPPED', detail: 'stream closed' }),
);
expect(container.querySelector('[data-user-action-card="run-recovery"]')).toBeTruthy();
});
});