H-43: fix WEB Captain takeover E2E flow (#8)

* test(shangwutong): cover CID rename reliability

* H-43: fix WEB Captain takeover flow

* H-48: preserve compatible provider model

* H-49: make Captain takeover atomic

* H-50: prevent duplicate widget initialization

---------

Co-authored-by: Rogee <rogee@ipao.vip>
This commit is contained in:
Rogee
2026-08-13 14:49:04 +08:00
committed by GitHub
co-authored by rogee
parent fff3dce1bd
commit 74eb006985
21 changed files with 431 additions and 63 deletions
@@ -68,7 +68,7 @@ const isHMACVerified = computed(() => {
});
const currentContact = computed(() =>
store.getters['contacts/getContact'](props.chat.meta.sender.id)
store.getters['contacts/getContact'](props.chat?.meta?.sender?.id)
);
const isSnoozed = computed(
@@ -245,7 +245,8 @@ export const mutations = {
if (!conversation?.id) return;
const { allConversations } = _state;
const index = allConversations.findIndex(c => c.id === conversation.id);
const normalizedId = Number(conversation.id);
const index = allConversations.findIndex(c => Number(c.id) === normalizedId);
if (index > -1) {
const selectedConversation = allConversations[index];
@@ -256,8 +257,12 @@ export const mutations = {
}
const { messages, ...updates } = conversation;
allConversations[index] = { ...selectedConversation, ...updates };
if (_state.selectedChatId === conversation.id) {
allConversations[index] = {
...selectedConversation,
...updates,
id: selectedConversation.id,
};
if (Number(_state.selectedChatId) === normalizedId) {
emitter.emit(BUS_EVENTS.SCROLL_TO_MESSAGE);
}
} else {
@@ -802,6 +802,28 @@ describe('#mutations', () => {
});
});
it('should update when route and API ids use different primitive types', () => {
const state = {
allConversations: [{ id: '3', status: 'pending', updated_at: 100 }],
selectedChatId: '3',
};
mutations[types.UPDATE_CONVERSATION](state, {
id: 3,
status: 'open',
ai_takeover_active: false,
updated_at: 200,
});
expect(state.allConversations).toHaveLength(1);
expect(state.allConversations[0]).toMatchObject({
id: '3',
status: 'open',
ai_takeover_active: false,
});
expect(emitter.emit).toHaveBeenCalled();
});
it('should add conversation if not found on normal view', () => {
const state = {
allConversations: [],
@@ -0,0 +1,58 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';
const { app, ActionCableConnector } = vi.hoisted(() => ({
app: {
use: vi.fn(),
directive: vi.fn(),
mount: vi.fn(() => ({ $store: {} })),
},
ActionCableConnector: vi.fn(),
}));
vi.mock('vue', () => ({ createApp: () => app }));
vi.mock('vue-i18n', () => ({ createI18n: vi.fn() }));
vi.mock('vue-dompurify-html', () => ({ default: {} }));
vi.mock('../../widget/store', () => ({ default: {} }));
vi.mock('../../widget/App.vue', () => ({ default: {} }));
vi.mock('../../widget/helpers/actionCable', () => ({
default: ActionCableConnector,
}));
vi.mock('../../widget/i18n', () => ({ default: {} }));
vi.mock('../../widget/router', () => ({ default: {} }));
vi.mock('vue3-click-away', () => ({ directive: {} }));
vi.mock('../../shared/helpers/HTMLSanitizer', () => ({
domPurifyConfig: {},
}));
vi.mock('@formkit/vue', () => ({
plugin: {},
defaultConfig: vi.fn(),
}));
vi.mock('shared/helpers/Validators', () => ({
startsWithPlus: vi.fn(),
isPhoneNumberValidWithDialCode: vi.fn(),
}));
describe('widget entrypoint', () => {
beforeEach(() => {
vi.resetModules();
vi.clearAllMocks();
delete window.WOOT_WIDGET;
delete window.actionCable;
window.chatwootPubsubToken = 'pubsub-token';
});
it('mounts and connects only once when load fires repeatedly', async () => {
vi.spyOn(document, 'readyState', 'get').mockReturnValue('loading');
await import('../widget');
window.dispatchEvent(new Event('load'));
window.dispatchEvent(new Event('load'));
expect(app.mount).toHaveBeenCalledOnce();
expect(ActionCableConnector).toHaveBeenCalledOnce();
expect(ActionCableConnector).toHaveBeenCalledWith(
window.WOOT_WIDGET,
'pubsub-token'
);
});
});
@@ -45,10 +45,18 @@ app.use(
// Vue.config.productionTip = false;
window.onload = () => {
export const initWidget = () => {
if (window.WOOT_WIDGET) return;
window.WOOT_WIDGET = app.mount('#app');
window.actionCable = new ActionCableConnector(
window.WOOT_WIDGET,
window.chatwootPubsubToken
);
};
if (document.readyState === 'loading') {
window.addEventListener('load', initWidget, { once: true });
} else {
initWidget();
}
@@ -2,7 +2,7 @@ export default {
computed: {
messageContentAttributes() {
const { content_attributes: attribute = {} } = this.message;
return attribute;
return attribute || {};
},
hasAttachments() {
return !!(
@@ -34,4 +34,11 @@ describe('messageMixin', () => {
expect(wrapper.vm.messageContentAttributes).toEqual({});
expect(wrapper.vm.hasAttachments).toBe(true);
});
it('normalizes null content attributes', () => {
const wrapper = shallowMount(Component, {
data: () => ({ message: { content_attributes: null } }),
});
expect(wrapper.vm.messageContentAttributes).toEqual({});
});
});