mirror of
https://github.com/nexu-io/open-design.git
synced 2026-10-27 09:51:12 +01:00
* 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>
308 lines
12 KiB
TypeScript
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();
|
|
});
|
|
});
|