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(''); }); });