import { config, mount } from '@vue/test-utils';
import {
afterAll,
beforeAll,
beforeEach,
describe,
expect,
it,
vi,
} from 'vitest';
import VueDOMPurifyHTML from 'vue-dompurify-html';
import Activity from '../Activity.vue';
const mocks = vi.hoisted(() => ({
content: '',
contentAttributes: {},
}));
vi.mock('shared/helpers/timeHelper', () => ({
messageTimestamp: () => '8月6日 下午 3:12',
}));
const longURL =
'https://ada.baidu.com/site/wjzt6ah8/agent?imid=e159aa97cd2c403f2fba76899e47615f&trinfo=XzFWIaclcWbzcMY&word=%E4%B8%89%E9%98%B3%E7%97%85%E6%AF%92%E6%90%BA%E5%B8%A6';
const i18n = config.global.plugins.find(plugin => plugin.global?.locale);
const initialLocale = i18n.global.locale.value;
beforeAll(() => {
i18n.global.locale.value = 'zh_CN';
});
afterAll(() => {
i18n.global.locale.value = initialLocale;
});
vi.mock('../../provider.js', async () => {
const { ref } = await vi.importActual('vue');
return {
useMessageContext: () => ({
content: ref(mocks.content),
contentAttributes: ref(mocks.contentAttributes),
createdAt: ref(1786018000),
}),
};
});
const mountActivity = () =>
mount(Activity, {
global: {
plugins: [VueDOMPurifyHTML],
stubs: {
BaseBubble: {
template: '
',
},
},
},
});
describe('Activity bubble', () => {
beforeEach(() => {
i18n.global.locale.value = 'zh_CN';
mocks.content = '';
mocks.contentAttributes = {};
});
it('allows long links in system messages to wrap inside the bubble', () => {
mocks.content = `来源页面:${longURL}`;
const wrapper = mountActivity();
expect(wrapper.get('[data-bubble-name="activity"]').classes()).toContain(
'max-w-full'
);
const content = wrapper.get('span');
expect(content.classes()).toContain('min-w-0');
expect(content.classes()).toContain('max-w-full');
expect(content.classes()).toContain('[overflow-wrap:anywhere]');
expect(content.get('a').text()).toBe(longURL);
});
it.each([
[
'Conversation was resolved by Anonymous Visitor',
'会话已由 匿名访客 解决',
],
[
'Conversation was marked resolved by Alice',
'会话已由 Alice 标记为已解决',
],
['Conversation was reopened by Alice', '会话已由 Alice 重新打开'],
[
'Conversation was marked as pending by Alice',
'会话已由 Alice 标记为待处理',
],
['Conversation was snoozed by Alice', '会话已由 Alice 推迟'],
[
'Conversation was marked resolved by system due to 2 hours of inactivity',
'会话因 2 小时无活动已由系统自动解决',
],
[
'Alice changed the priority from low to high',
'Alice 将优先级从 低 更改为 高',
],
['Assigned to Bob by Alice', 'Alice 将会话分配给 Bob'],
[
'Assigned to Bob via Support by Alice',
'Alice 通过团队 Support 将会话分配给 Bob',
],
['Alice added vip, billing', 'Alice 添加了标签:vip, billing'],
['Alice removed SLA policy Gold', 'Alice 移除了 SLA 策略 Gold'],
['Alice has muted the conversation', 'Alice 已将会话静音'],
[
'Conversation was reopened due to agent bot webhook failure',
'由于客服机器人 Webhook 失败,会话已重新打开',
],
[
'CSAT survey not sent due to outgoing message restrictions',
'由于发送消息受限,未发送 CSAT 调查',
],
[
'Auto-resolve message not sent due to outgoing message restrictions',
'由于发送消息受限,未发送自动解决消息',
],
[
'Linear issue ENG-1 was created by Alice',
'Linear 问题 ENG-1 已由 Alice 创建',
],
[
'Conversation was marked resolved by Captain due to inactivity',
'Captain 因无活动将会话标记为已解决',
],
['Sent a call permission request to Bob.', '已向 Bob 发送通话权限请求。'],
])('localizes known legacy activity content: %s', (content, expected) => {
mocks.content = content;
const wrapper = mountActivity();
expect(wrapper.get('[data-bubble-name="activity"] span').text()).toBe(
expected
);
});
it.each([
[
{ type: 'status.resolved', userName: 'Alice' },
'会话已由 Alice 标记为已解决',
],
[
{ type: 'status.contact_resolved', contactName: 'visitor' },
'会话已由 匿名访客 解决',
],
[{ type: 'status.open', userName: 'Alice' }, '会话已由 Alice 重新打开'],
[
{ type: 'status.pending', userName: 'Alice' },
'会话已由 Alice 标记为待处理',
],
[{ type: 'status.snoozed', userName: 'Alice' }, '会话已由 Alice 推迟'],
[
{ type: 'status.auto_resolved_days', count: 2 },
'会话因 2 天无活动已由系统自动解决',
],
[
{ type: 'status.auto_resolved_hours', count: 3 },
'会话因 3 小时无活动已由系统自动解决',
],
[
{ type: 'status.auto_resolved_minutes', count: 15 },
'会话因 15 分钟无活动已由系统自动解决',
],
[
{ type: 'status.system_auto_open' },
'收到新的传入消息后,系统重新打开了会话。',
],
[
{ type: 'priority.added', userName: 'Alice', newPriority: 'urgent' },
'Alice 将优先级设为 紧急的',
],
[
{
type: 'priority.updated',
userName: 'Alice',
oldPriority: 'low',
newPriority: 'high',
},
'Alice 将优先级从 低 更改为 高',
],
[{ type: 'priority.removed', userName: 'Alice' }, 'Alice 移除了优先级'],
[
{ type: 'assignee.self_assigned', userName: 'Alice' },
'Alice 将此会话分配给自己',
],
[
{ type: 'assignee.assigned', userName: 'Alice', assigneeName: 'Bob' },
'Alice 将会话分配给 Bob',
],
[{ type: 'assignee.removed', userName: 'Alice' }, 'Alice 取消了会话分配'],
[
{ type: 'team.assigned', userName: 'Alice', teamName: 'Support' },
'Alice 将会话分配给团队 Support',
],
[
{
type: 'team.assigned_with_assignee',
userName: 'Alice',
teamName: 'Support',
assigneeName: 'Bob',
},
'Alice 通过团队 Support 将会话分配给 Bob',
],
[
{ type: 'team.removed', userName: 'Alice', teamName: 'Support' },
'Alice 将会话从团队 Support 中移除',
],
[
{ type: 'labels.added', userName: 'Alice', labels: ['vip', 'billing'] },
'Alice 添加了标签:vip, billing',
],
[
{ type: 'labels.removed', userName: 'Alice', labels: 'vip' },
'Alice 移除了标签:vip',
],
[
{ type: 'sla.added', userName: 'Alice', slaName: 'Gold' },
'Alice 添加了 SLA 策略 Gold',
],
[
{ type: 'sla.removed', userName: 'Alice', slaName: 'Gold' },
'Alice 移除了 SLA 策略 Gold',
],
[{ type: 'muted', userName: 'Alice' }, 'Alice 已将会话静音'],
[{ type: 'unmuted', userName: 'Alice' }, 'Alice 已取消会话静音'],
[
{ type: 'captain.resolved', userName: 'Captain' },
'Captain 因无活动将会话标记为已解决',
],
[
{
type: 'captain.resolved_with_reason',
userName: 'Captain',
reason: 'Done',
},
'Captain 将会话标记为已解决(Done)',
],
[
{ type: 'captain.resolved_by_tool', userName: 'Captain', reason: 'Done' },
'Captain 将会话标记为已解决:Done',
],
[{ type: 'captain.open', userName: 'Captain' }, 'Captain 将会话标记为打开'],
[
{
type: 'captain.open_with_reason',
userName: 'Captain',
reason: 'Reply',
},
'Captain 将会话标记为打开(Reply)',
],
[
{ type: 'captain.auto_opened_after_agent_reply' },
'客服回复后,会话已自动标记为打开',
],
[
{ type: 'agent_bot.error_moved_to_open' },
'由于客服机器人 Webhook 失败,会话已重新打开',
],
[
{ type: 'csat.not_sent_due_to_messaging_window' },
'由于发送消息受限,未发送 CSAT 调查',
],
[
{ type: 'auto_resolve.not_sent_due_to_messaging_window' },
'由于发送消息受限,未发送自动解决消息',
],
[
{ type: 'auto_resolution_message' },
'会话已有一段时间没有活动,现将自动解决。如需进一步帮助,请发起新会话。',
],
[
{ type: 'linear.issue_created', issueId: 'ENG-1', userName: 'Alice' },
'Linear 问题 ENG-1 已由 Alice 创建',
],
[
{ type: 'linear.issue_linked', issueId: 'ENG-1', userName: 'Alice' },
'Linear 问题 ENG-1 已由 Alice 关联',
],
[
{ type: 'linear.issue_unlinked', issueId: 'ENG-1', userName: 'Alice' },
'Linear 问题 ENG-1 已由 Alice 取消关联',
],
[
{ type: 'whatsapp_call.permission_requested', contactName: 'Bob' },
'已向 Bob 发送通话权限请求。',
],
[
{ type: 'whatsapp_call.permission_granted', contactName: 'Bob' },
'Bob 已接受通话权限请求。',
],
])('localizes a structured activity type', (activity, expected) => {
mocks.content = 'Untranslated fallback';
mocks.contentAttributes = { activity };
const wrapper = mountActivity();
expect(wrapper.get('[data-bubble-name="activity"] span').text()).toBe(
expected
);
});
it.each([
['custom.unknown', 'Conversation was resolved by Alice'],
['sla.updated', 'Alice added SLA policy Gold'],
['toString', 'Conversation was reopened by Alice'],
['constructor', 'Conversation was marked as pending by Alice'],
])(
'falls back to stored content for unsupported structured type %s',
(type, content) => {
mocks.content = content;
mocks.contentAttributes = { activity: { type } };
expect(mountActivity().get('span').text()).toBe(content);
}
);
it('uses the English activity catalog when the dashboard locale is English', () => {
i18n.global.locale.value = 'en';
mocks.contentAttributes = {
activity: {
type: 'priority.updated',
userName: 'Automation System',
oldPriority: 'low',
newPriority: 'high',
},
};
expect(mountActivity().get('span').text()).toBe(
'Automation System changed the priority from Low to High'
);
});
it('sanitizes malicious dynamic activity names with the real directive', () => {
mocks.contentAttributes = {
activity: {
type: 'status.resolved',
userName: 'Alice
',
},
};
const content = mountActivity().get('span');
expect(content.text()).toBe('会话已由 Alice 标记为已解决');
expect(content.get('img').attributes('onerror')).toBeUndefined();
expect(window.activityXss).toBeUndefined();
});
it('renders an empty activity when content is null', () => {
mocks.content = null;
const wrapper = mountActivity();
expect(wrapper.get('[data-bubble-name="activity"] span').text()).toBe('');
});
});