From d3fbcf9dda4e613c29a627ed2dd4052a246e3455 Mon Sep 17 00:00:00 2001 From: Nazareno Bucciarelli Date: Mon, 9 Mar 2026 23:17:51 -0300 Subject: [PATCH 01/44] move offline agents filtering to query root --- apps/meteor/tests/data/livechat/users.ts | 3 +-- .../tests/end-to-end/api/livechat/00-rooms.ts | 2 +- packages/models/src/models/Users.ts | 23 +++++++------------ 3 files changed, 10 insertions(+), 18 deletions(-) diff --git a/apps/meteor/tests/data/livechat/users.ts b/apps/meteor/tests/data/livechat/users.ts index 8e7b4213aadab..878661702d04b 100644 --- a/apps/meteor/tests/data/livechat/users.ts +++ b/apps/meteor/tests/data/livechat/users.ts @@ -5,7 +5,7 @@ import { Random } from '@rocket.chat/random'; import { api, credentials, request } from '../api-data'; import { password } from '../user'; -import { createUser, login, setUserAway, setUserStatus } from '../users.helper'; +import { createUser, login, setUserStatus } from '../users.helper'; import { createAgent, makeAgentAvailable, makeAgentUnavailable } from './rooms'; export const createBotAgent = async (): Promise<{ @@ -102,7 +102,6 @@ export const createAnAwayAgent = async (): Promise<{ await createAgent(agent.username); await makeAgentAvailable(createdUserCredentials); await setUserStatus(createdUserCredentials, UserStatus.AWAY); - await setUserAway(createdUserCredentials); return { credentials: createdUserCredentials, diff --git a/apps/meteor/tests/end-to-end/api/livechat/00-rooms.ts b/apps/meteor/tests/end-to-end/api/livechat/00-rooms.ts index d606feac77a87..c41be39241281 100644 --- a/apps/meteor/tests/end-to-end/api/livechat/00-rooms.ts +++ b/apps/meteor/tests/end-to-end/api/livechat/00-rooms.ts @@ -1714,7 +1714,7 @@ describe('LIVECHAT - rooms', () => { async () => { await updateSetting('Livechat_Routing_Method', 'Auto_Selection'); const { department: initialDepartment } = await createDepartmentWithAnOnlineAgent(); - const { department: forwardToOfflineDepartment } = await createDepartmentWithAnAwayAgent({ allowReceiveForwardOffline: false }); + const { department: forwardToOfflineDepartment } = await createDepartmentWithAnOfflineAgent({ allowReceiveForwardOffline: false }); const newVisitor = await createVisitor(initialDepartment._id); const newRoom = await createLivechatRoom(newVisitor.token); diff --git a/packages/models/src/models/Users.ts b/packages/models/src/models/Users.ts index d8ff89c93fa7e..1b5b9b152b8de 100644 --- a/packages/models/src/models/Users.ts +++ b/packages/models/src/models/Users.ts @@ -37,22 +37,15 @@ const queryStatusAgentOnline = (extraFilters = {}, isLivechatEnabledWhenAgentIdl roles: 'livechat-agent', // ignore deactivated users active: true, - ...(!isLivechatEnabledWhenAgentIdle && { - $or: [ - { - status: { - $exists: true, - $ne: UserStatus.OFFLINE, - }, - roles: { - $ne: 'bot', - }, - }, - { - roles: 'bot', + $or: [ + { roles: 'bot' }, + { + status: { + $exists: true, + $ne: UserStatus.OFFLINE, }, - ], - }), + }, + ], ...extraFilters, ...(isLivechatEnabledWhenAgentIdle === false && { statusConnection: { $ne: 'away' }, From 04dc75a9ec2ad0003288a12f5224eb8e057861a1 Mon Sep 17 00:00:00 2001 From: Nazareno Bucciarelli Date: Tue, 10 Mar 2026 22:35:11 -0300 Subject: [PATCH 02/44] fix user presence update from test users --- apps/meteor/tests/data/livechat/users.ts | 3 ++- apps/meteor/tests/end-to-end/api/livechat/00-rooms.ts | 2 +- ee/packages/presence/src/lib/processConnectionStatus.ts | 2 +- 3 files changed, 4 insertions(+), 3 deletions(-) diff --git a/apps/meteor/tests/data/livechat/users.ts b/apps/meteor/tests/data/livechat/users.ts index 878661702d04b..8e7b4213aadab 100644 --- a/apps/meteor/tests/data/livechat/users.ts +++ b/apps/meteor/tests/data/livechat/users.ts @@ -5,7 +5,7 @@ import { Random } from '@rocket.chat/random'; import { api, credentials, request } from '../api-data'; import { password } from '../user'; -import { createUser, login, setUserStatus } from '../users.helper'; +import { createUser, login, setUserAway, setUserStatus } from '../users.helper'; import { createAgent, makeAgentAvailable, makeAgentUnavailable } from './rooms'; export const createBotAgent = async (): Promise<{ @@ -102,6 +102,7 @@ export const createAnAwayAgent = async (): Promise<{ await createAgent(agent.username); await makeAgentAvailable(createdUserCredentials); await setUserStatus(createdUserCredentials, UserStatus.AWAY); + await setUserAway(createdUserCredentials); return { credentials: createdUserCredentials, diff --git a/apps/meteor/tests/end-to-end/api/livechat/00-rooms.ts b/apps/meteor/tests/end-to-end/api/livechat/00-rooms.ts index c41be39241281..d606feac77a87 100644 --- a/apps/meteor/tests/end-to-end/api/livechat/00-rooms.ts +++ b/apps/meteor/tests/end-to-end/api/livechat/00-rooms.ts @@ -1714,7 +1714,7 @@ describe('LIVECHAT - rooms', () => { async () => { await updateSetting('Livechat_Routing_Method', 'Auto_Selection'); const { department: initialDepartment } = await createDepartmentWithAnOnlineAgent(); - const { department: forwardToOfflineDepartment } = await createDepartmentWithAnOfflineAgent({ allowReceiveForwardOffline: false }); + const { department: forwardToOfflineDepartment } = await createDepartmentWithAnAwayAgent({ allowReceiveForwardOffline: false }); const newVisitor = await createVisitor(initialDepartment._id); const newRoom = await createLivechatRoom(newVisitor.token); diff --git a/ee/packages/presence/src/lib/processConnectionStatus.ts b/ee/packages/presence/src/lib/processConnectionStatus.ts index a14aa45219e8a..6c17df87334f0 100644 --- a/ee/packages/presence/src/lib/processConnectionStatus.ts +++ b/ee/packages/presence/src/lib/processConnectionStatus.ts @@ -36,7 +36,7 @@ export const processPresenceAndStatus = ( userSessions: IUserSessionConnection[] = [], statusDefault = UserStatus.ONLINE, ): { status: UserStatus; statusConnection: UserStatus } => { - const statusConnection = userSessions.map((s) => s.status).reduce(processConnectionStatus, UserStatus.OFFLINE); + const statusConnection = process.env.TEST_MODE ? statusDefault : userSessions.map((s) => s.status).reduce(processConnectionStatus, UserStatus.OFFLINE); const status = processStatus(statusConnection, statusDefault); From 81cf18e2768bb3542f4d1cc6b6cdb86144e5551b Mon Sep 17 00:00:00 2001 From: Nazareno Bucciarelli Date: Wed, 11 Mar 2026 10:48:20 -0300 Subject: [PATCH 03/44] fix linter error --- ee/packages/presence/src/lib/processConnectionStatus.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/ee/packages/presence/src/lib/processConnectionStatus.ts b/ee/packages/presence/src/lib/processConnectionStatus.ts index 6c17df87334f0..685ddc8dc00d3 100644 --- a/ee/packages/presence/src/lib/processConnectionStatus.ts +++ b/ee/packages/presence/src/lib/processConnectionStatus.ts @@ -36,7 +36,9 @@ export const processPresenceAndStatus = ( userSessions: IUserSessionConnection[] = [], statusDefault = UserStatus.ONLINE, ): { status: UserStatus; statusConnection: UserStatus } => { - const statusConnection = process.env.TEST_MODE ? statusDefault : userSessions.map((s) => s.status).reduce(processConnectionStatus, UserStatus.OFFLINE); + const statusConnection = process.env.TEST_MODE + ? statusDefault + : userSessions.map((s) => s.status).reduce(processConnectionStatus, UserStatus.OFFLINE); const status = processStatus(statusConnection, statusDefault); From cae2152f1cc2f12b2383b39d21f1fbe2bf6d2b51 Mon Sep 17 00:00:00 2001 From: Nazareno Bucciarelli Date: Wed, 11 Mar 2026 19:32:05 -0300 Subject: [PATCH 04/44] rollback TEST_MODE conditional --- ee/packages/presence/src/lib/processConnectionStatus.ts | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/ee/packages/presence/src/lib/processConnectionStatus.ts b/ee/packages/presence/src/lib/processConnectionStatus.ts index 685ddc8dc00d3..a14aa45219e8a 100644 --- a/ee/packages/presence/src/lib/processConnectionStatus.ts +++ b/ee/packages/presence/src/lib/processConnectionStatus.ts @@ -36,9 +36,7 @@ export const processPresenceAndStatus = ( userSessions: IUserSessionConnection[] = [], statusDefault = UserStatus.ONLINE, ): { status: UserStatus; statusConnection: UserStatus } => { - const statusConnection = process.env.TEST_MODE - ? statusDefault - : userSessions.map((s) => s.status).reduce(processConnectionStatus, UserStatus.OFFLINE); + const statusConnection = userSessions.map((s) => s.status).reduce(processConnectionStatus, UserStatus.OFFLINE); const status = processStatus(statusConnection, statusDefault); From 0942d4a3d76ed6d165f35127cb3c78cdf030b5d3 Mon Sep 17 00:00:00 2001 From: Nazareno Bucciarelli Date: Thu, 12 Mar 2026 13:18:03 -0300 Subject: [PATCH 05/44] add ws user helpers to fix connectionStatus on tests --- apps/meteor/tests/data/livechat/users.ts | 5 +- apps/meteor/tests/data/users.helper.ts | 83 ++++++++++++++++++++++++ 2 files changed, 86 insertions(+), 2 deletions(-) diff --git a/apps/meteor/tests/data/livechat/users.ts b/apps/meteor/tests/data/livechat/users.ts index 8e7b4213aadab..55f4b005cc064 100644 --- a/apps/meteor/tests/data/livechat/users.ts +++ b/apps/meteor/tests/data/livechat/users.ts @@ -5,7 +5,7 @@ import { Random } from '@rocket.chat/random'; import { api, credentials, request } from '../api-data'; import { password } from '../user'; -import { createUser, login, setUserAway, setUserStatus } from '../users.helper'; +import { createUser, login, setUserAwayWS, ddpLogin, setUserStatus } from '../users.helper'; import { createAgent, makeAgentAvailable, makeAgentUnavailable } from './rooms'; export const createBotAgent = async (): Promise<{ @@ -99,10 +99,11 @@ export const createAnAwayAgent = async (): Promise<{ const { body } = await request.post(api('users.create')).set(credentials).send({ email, name: username, username, password }); const agent = body.user; const createdUserCredentials = await login(agent.username, password); + const ws = await ddpLogin(createdUserCredentials['X-Auth-Token']); await createAgent(agent.username); await makeAgentAvailable(createdUserCredentials); await setUserStatus(createdUserCredentials, UserStatus.AWAY); - await setUserAway(createdUserCredentials); + await setUserAwayWS(ws); return { credentials: createdUserCredentials, diff --git a/apps/meteor/tests/data/users.helper.ts b/apps/meteor/tests/data/users.helper.ts index b8835cc5ef79b..49c66913a22ea 100644 --- a/apps/meteor/tests/data/users.helper.ts +++ b/apps/meteor/tests/data/users.helper.ts @@ -172,6 +172,89 @@ export const setUserAway = (overrideCredentials = credentials, config?: IRequest }); }; +export const ddpLogin = (resume: string): Promise => + new Promise((resolve, reject) => { + const ws = new WebSocket('ws://localhost:4000/websocket'); + + ws.onopen = () => { + ws.send( + JSON.stringify({ + msg: 'connect', + version: '1', + support: ['1'], + }), + ); + }; + + ws.onmessage = (event) => { + const data = JSON.parse(event.data); + + switch (data.msg) { + case 'connected': + ws.send( + JSON.stringify({ + msg: 'method', + id: 'login-1', + method: 'login', + params: [{ resume }], + }), + ); + break; + + case 'result': + if (data.id === 'login-1') { + // NO cerrar el websocket + resolve(ws); + } + break; + + case 'ping': + ws.close(); + break; + + case 'error': + reject(data); + break; + } + }; + + ws.onerror = reject; + }); + +export const setUserAwayWS = (ws: WebSocket): Promise => + new Promise((resolve, reject) => { + const id = 'away-1'; + + const handler = (event: MessageEvent) => { + const data = JSON.parse(event.data); + + if (data.msg === 'result' && data.id === id) { + ws.removeEventListener('message', handler); + resolve(); + } + + if (data.msg === 'ping') { + ws.send(JSON.stringify({ msg: 'pong' })); + } + + if (data.msg === 'error') { + ws.removeEventListener('message', handler); + reject(data); + } + }; + + ws.addEventListener('message', handler); + + ws.send( + JSON.stringify({ + msg: 'method', + method: 'UserPresence:away', + params: [], + id, + }), + ); + }); + export const setUserOnline = (overrideCredentials = credentials, config?: IRequestConfig) => { const requestInstance = config?.request || request; return requestInstance From 9102e164def3de3903a11522c81b2fa98aaf2bb1 Mon Sep 17 00:00:00 2001 From: Nazareno Bucciarelli Date: Thu, 12 Mar 2026 14:05:26 -0300 Subject: [PATCH 06/44] test on CI without closing ws --- apps/meteor/tests/data/users.helper.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/meteor/tests/data/users.helper.ts b/apps/meteor/tests/data/users.helper.ts index 49c66913a22ea..46c2dc79279f1 100644 --- a/apps/meteor/tests/data/users.helper.ts +++ b/apps/meteor/tests/data/users.helper.ts @@ -209,7 +209,7 @@ export const ddpLogin = (resume: string): Promise => break; case 'ping': - ws.close(); + ws.send(JSON.stringify({ msg: 'pong' })); break; case 'error': From 395c57ba75314e62019238c5cc0c9ff9b456cc0b Mon Sep 17 00:00:00 2001 From: Nazareno Bucciarelli Date: Thu, 12 Mar 2026 16:15:38 -0300 Subject: [PATCH 07/44] add use of setUserAwayWS to routing test --- .../end-to-end/api/livechat/24-routing.ts | 18 +++++++++++++++--- 1 file changed, 15 insertions(+), 3 deletions(-) diff --git a/apps/meteor/tests/end-to-end/api/livechat/24-routing.ts b/apps/meteor/tests/end-to-end/api/livechat/24-routing.ts index 3bc9c8d9f218a..b10a1b818cb12 100644 --- a/apps/meteor/tests/end-to-end/api/livechat/24-routing.ts +++ b/apps/meteor/tests/end-to-end/api/livechat/24-routing.ts @@ -18,7 +18,16 @@ import { } from '../../../data/livechat/rooms'; import { updateSetting } from '../../../data/permissions.helper'; import { password } from '../../../data/user'; -import { createUser, deleteUser, login, setUserActiveStatus, setUserAway, setUserStatus } from '../../../data/users.helper'; +import { + createUser, + deleteUser, + login, + ddpLogin, + setUserAwayWS, + setUserActiveStatus, + setUserAway, + setUserStatus, +} from '../../../data/users.helper'; import { IS_EE } from '../../../e2e/config/constants'; (IS_EE ? describe : describe.skip)('Omnichannel - Routing', () => { @@ -358,10 +367,13 @@ import { IS_EE } from '../../../e2e/config/constants'; expect(roomInfo.servedBy).to.be.undefined; }); it('should not route to an idle user', async () => { + await updateSetting('Livechat_enabled_when_agent_idle', false); await setUserStatus(testUser.credentials, UserStatus.AWAY); - await setUserAway(testUser.credentials); + const ws1 = await ddpLogin(testUser.credentials['X-Auth-Token']); + await setUserAwayWS(ws1); await setUserStatus(testUser3.credentials, UserStatus.AWAY); - await setUserAway(testUser3.credentials); + const ws2 = await ddpLogin(testUser3.credentials['X-Auth-Token']); + await setUserAwayWS(ws2); // Agent is available but should be ignored await switchLivechatStatus('available', testUser.credentials); From 6ebec4241b47711199a79ab1a10e41b063b69d19 Mon Sep 17 00:00:00 2001 From: Nazareno Bucciarelli Date: Fri, 13 Mar 2026 15:24:03 -0300 Subject: [PATCH 08/44] enhance ws connection, add ws closures --- apps/meteor/tests/data/livechat/department.ts | 4 ++- apps/meteor/tests/data/livechat/users.ts | 2 ++ apps/meteor/tests/data/users.helper.ts | 34 +++++++++++-------- .../tests/end-to-end/api/livechat/00-rooms.ts | 20 ++++++++--- .../end-to-end/api/livechat/24-routing.ts | 17 +++++++--- 5 files changed, 53 insertions(+), 24 deletions(-) diff --git a/apps/meteor/tests/data/livechat/department.ts b/apps/meteor/tests/data/livechat/department.ts index 2064bb8b03b4b..f8b2ed5298a0e 100644 --- a/apps/meteor/tests/data/livechat/department.ts +++ b/apps/meteor/tests/data/livechat/department.ts @@ -175,8 +175,9 @@ export const createDepartmentWithAnAwayAgent = async ({ credentials: Credentials; user: WithRequiredProperty; }; + ws: WebSocket; }> => { - const { user, credentials } = await createAnAwayAgent(); + const { user, credentials, ws } = await createAnAwayAgent(); const department = (await createDepartment({ allowReceiveForwardOffline, @@ -192,6 +193,7 @@ export const createDepartmentWithAnAwayAgent = async ({ credentials, user, }, + ws, }; }; diff --git a/apps/meteor/tests/data/livechat/users.ts b/apps/meteor/tests/data/livechat/users.ts index 55f4b005cc064..2afc321e8f3a7 100644 --- a/apps/meteor/tests/data/livechat/users.ts +++ b/apps/meteor/tests/data/livechat/users.ts @@ -93,6 +93,7 @@ export const createAnOfflineAgent = async (): Promise<{ export const createAnAwayAgent = async (): Promise<{ credentials: Credentials; user: IUser & { username: string }; + ws: WebSocket; }> => { const username = `user.test.${Date.now()}.away`; const email = `${username}.offline@rocket.chat`; @@ -108,6 +109,7 @@ export const createAnAwayAgent = async (): Promise<{ return { credentials: createdUserCredentials, user: agent, + ws, }; }; diff --git a/apps/meteor/tests/data/users.helper.ts b/apps/meteor/tests/data/users.helper.ts index 46c2dc79279f1..d7c63071de4c3 100644 --- a/apps/meteor/tests/data/users.helper.ts +++ b/apps/meteor/tests/data/users.helper.ts @@ -175,18 +175,9 @@ export const setUserAway = (overrideCredentials = credentials, config?: IRequest export const ddpLogin = (resume: string): Promise => new Promise((resolve, reject) => { const ws = new WebSocket('ws://localhost:4000/websocket'); + const loginId = `login-${Date.now()}-${Math.random()}`; - ws.onopen = () => { - ws.send( - JSON.stringify({ - msg: 'connect', - version: '1', - support: ['1'], - }), - ); - }; - - ws.onmessage = (event) => { + const handler = (event: MessageEvent) => { const data = JSON.parse(event.data); switch (data.msg) { @@ -194,7 +185,7 @@ export const ddpLogin = (resume: string): Promise => ws.send( JSON.stringify({ msg: 'method', - id: 'login-1', + id: loginId, method: 'login', params: [{ resume }], }), @@ -202,8 +193,8 @@ export const ddpLogin = (resume: string): Promise => break; case 'result': - if (data.id === 'login-1') { - // NO cerrar el websocket + if (data.id === loginId) { + ws.removeEventListener('message', handler); resolve(ws); } break; @@ -213,17 +204,30 @@ export const ddpLogin = (resume: string): Promise => break; case 'error': + ws.removeEventListener('message', handler); reject(data); break; } }; + ws.addEventListener('message', handler); + + ws.onopen = () => { + ws.send( + JSON.stringify({ + msg: 'connect', + version: '1', + support: ['1'], + }), + ); + }; + ws.onerror = reject; }); export const setUserAwayWS = (ws: WebSocket): Promise => new Promise((resolve, reject) => { - const id = 'away-1'; + const id = `away-${Date.now()}-${Math.random()}`; const handler = (event: MessageEvent) => { const data = JSON.parse(event.data); diff --git a/apps/meteor/tests/end-to-end/api/livechat/00-rooms.ts b/apps/meteor/tests/end-to-end/api/livechat/00-rooms.ts index d606feac77a87..68b3868514a29 100644 --- a/apps/meteor/tests/end-to-end/api/livechat/00-rooms.ts +++ b/apps/meteor/tests/end-to-end/api/livechat/00-rooms.ts @@ -1547,7 +1547,7 @@ describe('LIVECHAT - rooms', () => { await updateSetting('Livechat_Routing_Method', 'Manual_Selection'); await updateSetting('Livechat_enabled_when_agent_idle', false); const { department: initialDepartment } = await createDepartmentWithAnOnlineAgent(); - const { department: forwardToOfflineDepartment } = await createDepartmentWithAnAwayAgent({ + const { department: forwardToOfflineDepartment, ws } = await createDepartmentWithAnAwayAgent({ allowReceiveForwardOffline: true, }); @@ -1582,6 +1582,8 @@ describe('LIVECHAT - rooms', () => { expect(res.body).to.have.property('count'); }); + ws.close(); + await Promise.all([ deleteDepartment(initialDepartment._id), deleteDepartment(forwardToOfflineDepartment._id), @@ -1675,7 +1677,7 @@ describe('LIVECHAT - rooms', () => { async () => { await updateSetting('Livechat_Routing_Method', 'Auto_Selection'); const { department: initialDepartment } = await createDepartmentWithAnOnlineAgent(); - const { department: forwardToOfflineDepartment } = await createDepartmentWithAnAwayAgent({ allowReceiveForwardOffline: true }); + const { department: forwardToOfflineDepartment, ws } = await createDepartmentWithAnAwayAgent({ allowReceiveForwardOffline: true }); const newVisitor = await createVisitor(initialDepartment._id); const newRoom = await createLivechatRoom(newVisitor.token); @@ -1698,6 +1700,8 @@ describe('LIVECHAT - rooms', () => { expect(inquiry.status).to.equal('queued'); expect(inquiry.department).to.equal(forwardToOfflineDepartment._id); + ws.close(); + await Promise.all([ deleteDepartment(initialDepartment._id), deleteDepartment(forwardToOfflineDepartment._id), @@ -1714,7 +1718,7 @@ describe('LIVECHAT - rooms', () => { async () => { await updateSetting('Livechat_Routing_Method', 'Auto_Selection'); const { department: initialDepartment } = await createDepartmentWithAnOnlineAgent(); - const { department: forwardToOfflineDepartment } = await createDepartmentWithAnAwayAgent({ allowReceiveForwardOffline: false }); + const { department: forwardToOfflineDepartment, ws } = await createDepartmentWithAnAwayAgent({ allowReceiveForwardOffline: false }); const newVisitor = await createVisitor(initialDepartment._id); const newRoom = await createLivechatRoom(newVisitor.token); @@ -1735,6 +1739,8 @@ describe('LIVECHAT - rooms', () => { expect(res.status).to.equal(400); expect(res.body).to.have.property('error', 'error-no-agents-available-for-service-on-department'); + ws.close(); + await Promise.all([ deleteDepartment(initialDepartment._id), deleteDepartment(forwardToOfflineDepartment._id), @@ -1791,7 +1797,11 @@ describe('LIVECHAT - rooms', () => { await updateSetting('Livechat_Routing_Method', 'Auto_Selection'); await updateSetting('Livechat_enabled_when_agent_idle', true); const { department: initialDepartment } = await createDepartmentWithAnOnlineAgent(); - const { department: forwardToOfflineDepartment, agent } = await createDepartmentWithAnAwayAgent({ + const { + department: forwardToOfflineDepartment, + agent, + ws, + } = await createDepartmentWithAnAwayAgent({ allowReceiveForwardOffline: true, }); @@ -1814,6 +1824,8 @@ describe('LIVECHAT - rooms', () => { expect(roomInfo.servedBy).to.have.property('_id', agent.user._id); expect(roomInfo.departmentId).to.be.equal(forwardToOfflineDepartment._id); + ws.close(); + await Promise.all([ deleteDepartment(initialDepartment._id), deleteDepartment(forwardToOfflineDepartment._id), diff --git a/apps/meteor/tests/end-to-end/api/livechat/24-routing.ts b/apps/meteor/tests/end-to-end/api/livechat/24-routing.ts index b10a1b818cb12..c1500e49e8479 100644 --- a/apps/meteor/tests/end-to-end/api/livechat/24-routing.ts +++ b/apps/meteor/tests/end-to-end/api/livechat/24-routing.ts @@ -227,6 +227,7 @@ import { IS_EE } from '../../../e2e/config/constants'; let testUser3: { user: IUser; credentials: Credentials }; let testDepartment: ILivechatDepartment; let visitorEmail: string; + const sockets: WebSocket[] = []; before(async () => { const user = await createUser(); @@ -280,14 +281,20 @@ import { IS_EE } from '../../../e2e/config/constants'; }); }); - after(async () => - Promise.all([ + after(async () => { + await Promise.all([ deleteUser(testUser.user), deleteUser(testUser2.user), deleteUser(testUser3.user), updateSetting('Livechat_enabled_when_agent_idle', true), - ]), - ); + ]); + + for (const ws of sockets) { + if (ws.readyState === WebSocket.OPEN || ws.readyState === WebSocket.CONNECTING) { + ws.close(); + } + } + }); it('should route a room to an available agent', async () => { const visitor = await createVisitor(testDepartment._id); @@ -370,9 +377,11 @@ import { IS_EE } from '../../../e2e/config/constants'; await updateSetting('Livechat_enabled_when_agent_idle', false); await setUserStatus(testUser.credentials, UserStatus.AWAY); const ws1 = await ddpLogin(testUser.credentials['X-Auth-Token']); + sockets.push(ws1); await setUserAwayWS(ws1); await setUserStatus(testUser3.credentials, UserStatus.AWAY); const ws2 = await ddpLogin(testUser3.credentials['X-Auth-Token']); + sockets.push(ws2); await setUserAwayWS(ws2); // Agent is available but should be ignored await switchLivechatStatus('available', testUser.credentials); From 8b41cd9ab86cfd4c9a7da8dcb9a4a3fe7a5e8ca3 Mon Sep 17 00:00:00 2001 From: Nazareno Bucciarelli Date: Fri, 13 Mar 2026 15:26:12 -0300 Subject: [PATCH 09/44] change ws port to 3000 --- apps/meteor/tests/data/users.helper.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/meteor/tests/data/users.helper.ts b/apps/meteor/tests/data/users.helper.ts index d7c63071de4c3..63635c099bddf 100644 --- a/apps/meteor/tests/data/users.helper.ts +++ b/apps/meteor/tests/data/users.helper.ts @@ -174,7 +174,7 @@ export const setUserAway = (overrideCredentials = credentials, config?: IRequest export const ddpLogin = (resume: string): Promise => new Promise((resolve, reject) => { - const ws = new WebSocket('ws://localhost:4000/websocket'); + const ws = new WebSocket('ws://localhost:3000/websocket'); const loginId = `login-${Date.now()}-${Math.random()}`; const handler = (event: MessageEvent) => { From 6f3c33f448adb156a7da3bbfd5e2b0c83ea63544 Mon Sep 17 00:00:00 2001 From: Nazareno Bucciarelli Date: Fri, 13 Mar 2026 17:33:13 -0300 Subject: [PATCH 10/44] update idle status tests from routing --- .../end-to-end/api/livechat/24-routing.ts | 74 ++++++++++--------- 1 file changed, 39 insertions(+), 35 deletions(-) diff --git a/apps/meteor/tests/end-to-end/api/livechat/24-routing.ts b/apps/meteor/tests/end-to-end/api/livechat/24-routing.ts index c1500e49e8479..8c65eaa08fe77 100644 --- a/apps/meteor/tests/end-to-end/api/livechat/24-routing.ts +++ b/apps/meteor/tests/end-to-end/api/livechat/24-routing.ts @@ -18,23 +18,20 @@ import { } from '../../../data/livechat/rooms'; import { updateSetting } from '../../../data/permissions.helper'; import { password } from '../../../data/user'; -import { - createUser, - deleteUser, - login, - ddpLogin, - setUserAwayWS, - setUserActiveStatus, - setUserAway, - setUserStatus, -} from '../../../data/users.helper'; +import { createUser, deleteUser, login, ddpLogin, setUserAwayWS, setUserActiveStatus, setUserStatus } from '../../../data/users.helper'; import { IS_EE } from '../../../e2e/config/constants'; (IS_EE ? describe : describe.skip)('Omnichannel - Routing', () => { + const sockets: WebSocket[] = []; before((done) => getCredentials(done)); after(async () => { await updateSetting('Livechat_Routing_Method', 'Manual_Selection'); + for (const ws of sockets) { + if (ws.readyState === WebSocket.OPEN || ws.readyState === WebSocket.CONNECTING) { + ws.close(); + } + } }); // Basically: if there's a bot in the department, it should be assigned to the conversation @@ -227,7 +224,6 @@ import { IS_EE } from '../../../e2e/config/constants'; let testUser3: { user: IUser; credentials: Credentials }; let testDepartment: ILivechatDepartment; let visitorEmail: string; - const sockets: WebSocket[] = []; before(async () => { const user = await createUser(); @@ -281,20 +277,14 @@ import { IS_EE } from '../../../e2e/config/constants'; }); }); - after(async () => { - await Promise.all([ + after(async () => + Promise.all([ deleteUser(testUser.user), deleteUser(testUser2.user), deleteUser(testUser3.user), updateSetting('Livechat_enabled_when_agent_idle', true), - ]); - - for (const ws of sockets) { - if (ws.readyState === WebSocket.OPEN || ws.readyState === WebSocket.CONNECTING) { - ws.close(); - } - } - }); + ]), + ); it('should route a room to an available agent', async () => { const visitor = await createVisitor(testDepartment._id); @@ -363,16 +353,6 @@ import { IS_EE } from '../../../e2e/config/constants'; const roomInfo = await getLivechatRoomInfo(room._id); expect(roomInfo.servedBy).to.be.undefined; }); - it('should ignore offline users when Livechat_enabled_when_agent_idle is false', async () => { - await updateSetting('Livechat_enabled_when_agent_idle', false); - await setUserStatus(testUser.credentials, UserStatus.OFFLINE); - - const visitor = await createVisitor(testDepartment._id); - const room = await createLivechatRoom(visitor.token); - - const roomInfo = await getLivechatRoomInfo(room._id); - expect(roomInfo.servedBy).to.be.undefined; - }); it('should not route to an idle user', async () => { await updateSetting('Livechat_enabled_when_agent_idle', false); await setUserStatus(testUser.credentials, UserStatus.AWAY); @@ -383,6 +363,7 @@ import { IS_EE } from '../../../e2e/config/constants'; const ws2 = await ddpLogin(testUser3.credentials['X-Auth-Token']); sockets.push(ws2); await setUserAwayWS(ws2); + // Agent is available but should be ignored await switchLivechatStatus('available', testUser.credentials); @@ -533,9 +514,15 @@ import { IS_EE } from '../../../e2e/config/constants'; it('should not route to an idle user', async () => { await updateSetting('Livechat_enabled_when_agent_idle', false); await setUserStatus(testUser.credentials, UserStatus.AWAY); - await setUserAway(testUser.credentials); + const ws1 = await ddpLogin(testUser.credentials['X-Auth-Token']); + sockets.push(ws1); + await setUserAwayWS(ws1); await setUserStatus(testUser2.credentials, UserStatus.AWAY); - await setUserAway(testUser2.credentials); + const ws2 = await ddpLogin(testUser2.credentials['X-Auth-Token']); + sockets.push(ws2); + await setUserAwayWS(ws2); + + await new Promise((r) => setTimeout(r, 9000)); // Agent is available but should be ignored await switchLivechatStatus('available', testUser.credentials); @@ -548,7 +535,13 @@ import { IS_EE } from '../../../e2e/config/constants'; it('should route to agents even if theyre idle when setting is enabled', async () => { await updateSetting('Livechat_enabled_when_agent_idle', true); await setUserStatus(testUser.credentials, UserStatus.AWAY); + const ws1 = await ddpLogin(testUser.credentials['X-Auth-Token']); + sockets.push(ws1); + await setUserAwayWS(ws1); await setUserStatus(testUser2.credentials, UserStatus.AWAY); + const ws2 = await ddpLogin(testUser2.credentials['X-Auth-Token']); + sockets.push(ws2); + await setUserAwayWS(ws2); const visitor = await createVisitor(testDepartment._id); const room = await createLivechatRoom(visitor.token); @@ -641,9 +634,14 @@ import { IS_EE } from '../../../e2e/config/constants'; it('should not route to an idle user', async () => { await updateSetting('Livechat_enabled_when_agent_idle', false); await setUserStatus(testUser.credentials, UserStatus.AWAY); - await setUserAway(testUser.credentials); + const ws1 = await ddpLogin(testUser.credentials['X-Auth-Token']); + sockets.push(ws1); + await setUserAwayWS(ws1); await setUserStatus(testUser2.credentials, UserStatus.AWAY); - await setUserAway(testUser2.credentials); + const ws2 = await ddpLogin(testUser2.credentials['X-Auth-Token']); + sockets.push(ws2); + await setUserAwayWS(ws2); + // Agent is available but should be ignored await switchLivechatStatus('available', testUser.credentials); @@ -656,7 +654,13 @@ import { IS_EE } from '../../../e2e/config/constants'; it('should route to agents even if theyre idle when setting is enabled', async () => { await updateSetting('Livechat_enabled_when_agent_idle', true); await setUserStatus(testUser.credentials, UserStatus.AWAY); + const ws1 = await ddpLogin(testUser.credentials['X-Auth-Token']); + sockets.push(ws1); + await setUserAwayWS(ws1); await setUserStatus(testUser2.credentials, UserStatus.AWAY); + const ws2 = await ddpLogin(testUser2.credentials['X-Auth-Token']); + sockets.push(ws2); + await setUserAwayWS(ws2); const visitor = await createVisitor(testDepartment._id); const room = await createLivechatRoom(visitor.token); From 79d8eb9c219eda0ffbcf6f10bd07b71997bd247c Mon Sep 17 00:00:00 2001 From: Nazareno Bucciarelli Date: Mon, 16 Mar 2026 10:43:37 -0300 Subject: [PATCH 11/44] fix playwright tests --- .../tests/e2e/omnichannel/omnichannel-assign-room-tags.spec.ts | 2 ++ .../e2e/omnichannel/omnichannel-custom-field-usage.spec.ts | 3 ++- apps/meteor/tests/e2e/omnichannel/omnichannel-livechat.spec.ts | 2 ++ 3 files changed, 6 insertions(+), 1 deletion(-) diff --git a/apps/meteor/tests/e2e/omnichannel/omnichannel-assign-room-tags.spec.ts b/apps/meteor/tests/e2e/omnichannel/omnichannel-assign-room-tags.spec.ts index c3ceeae55d853..10a1c221d628f 100644 --- a/apps/meteor/tests/e2e/omnichannel/omnichannel-assign-room-tags.spec.ts +++ b/apps/meteor/tests/e2e/omnichannel/omnichannel-assign-room-tags.spec.ts @@ -1,3 +1,4 @@ +import { ddpLogin } from '../../data/users.helper'; import { createFakeVisitor } from '../../mocks/data'; import { IS_EE } from '../config/constants'; import { Users } from '../fixtures/userStates'; @@ -33,6 +34,7 @@ test.describe('OC - Tags Visibility', () => { test.beforeAll('Create agent', async ({ api }) => { agent = await createAgent(api, 'user1'); + await ddpLogin(Users.user1.data.loginToken); }); test.beforeAll('Add agents to departments', async ({ api }) => { diff --git a/apps/meteor/tests/e2e/omnichannel/omnichannel-custom-field-usage.spec.ts b/apps/meteor/tests/e2e/omnichannel/omnichannel-custom-field-usage.spec.ts index ff9e1ff384e74..e62e86df89622 100644 --- a/apps/meteor/tests/e2e/omnichannel/omnichannel-custom-field-usage.spec.ts +++ b/apps/meteor/tests/e2e/omnichannel/omnichannel-custom-field-usage.spec.ts @@ -1,5 +1,6 @@ import { faker } from '@faker-js/faker'; +import { ddpLogin } from '../../data/users.helper'; import { createFakeVisitor } from '../../mocks/data'; import { Users } from '../fixtures/userStates'; import { HomeOmnichannel } from '../page-objects'; @@ -32,7 +33,7 @@ test.describe.serial('OC - Custom fields usage, scope : room and visitor', () => let visitorCustomField: Awaited>; test.beforeAll('Set up agent, manager and custom fields', async ({ api }) => { - [agent, manager] = await Promise.all([createAgent(api, 'user1'), createManager(api, 'user1')]); + [agent, manager] = await Promise.all([createAgent(api, 'user1'), createManager(api, 'user1'), ddpLogin(Users.user1.data.loginToken)]); [roomCustomField, visitorCustomField, conversation] = await Promise.all([ createCustomField(api, { diff --git a/apps/meteor/tests/e2e/omnichannel/omnichannel-livechat.spec.ts b/apps/meteor/tests/e2e/omnichannel/omnichannel-livechat.spec.ts index 2822a9c691321..12d7caa30aa01 100644 --- a/apps/meteor/tests/e2e/omnichannel/omnichannel-livechat.spec.ts +++ b/apps/meteor/tests/e2e/omnichannel/omnichannel-livechat.spec.ts @@ -1,5 +1,6 @@ import type { Page } from 'playwright-core'; +import { ddpLogin } from '../../data/users.helper'; import { createFakeVisitor } from '../../mocks/data'; import { createAuxContext } from '../fixtures/createAuxContext'; import { Users } from '../fixtures/userStates'; @@ -267,6 +268,7 @@ test.describe('OC - Livechat - Close chat using widget', () => { test.beforeAll(async ({ api }) => { agent = await createAgent(api, 'user1'); + await ddpLogin(Users.user1.data.loginToken); }); test.beforeEach(async ({ page, api }) => { From 46d52d058e27999b6d25624e240ed58f2e36dff1 Mon Sep 17 00:00:00 2001 From: Nazareno Bucciarelli Date: Mon, 16 Mar 2026 10:53:30 -0300 Subject: [PATCH 12/44] add ws closure to playwright tests --- .../e2e/omnichannel/omnichannel-assign-room-tags.spec.ts | 4 +++- .../omnichannel/omnichannel-custom-field-usage.spec.ts | 8 +++++++- .../tests/e2e/omnichannel/omnichannel-livechat.spec.ts | 4 +++- 3 files changed, 13 insertions(+), 3 deletions(-) diff --git a/apps/meteor/tests/e2e/omnichannel/omnichannel-assign-room-tags.spec.ts b/apps/meteor/tests/e2e/omnichannel/omnichannel-assign-room-tags.spec.ts index 10a1c221d628f..6407447e53dab 100644 --- a/apps/meteor/tests/e2e/omnichannel/omnichannel-assign-room-tags.spec.ts +++ b/apps/meteor/tests/e2e/omnichannel/omnichannel-assign-room-tags.spec.ts @@ -26,6 +26,7 @@ test.describe('OC - Tags Visibility', () => { let tagB: Awaited>; let globalTag: Awaited>; let sharedTag: Awaited>; + let ws: WebSocket; test.beforeAll('Create departments', async ({ api }) => { departmentA = await createDepartment(api, { name: 'Department A' }); @@ -34,7 +35,7 @@ test.describe('OC - Tags Visibility', () => { test.beforeAll('Create agent', async ({ api }) => { agent = await createAgent(api, 'user1'); - await ddpLogin(Users.user1.data.loginToken); + ws = await ddpLogin(Users.user1.data.loginToken); }); test.beforeAll('Add agents to departments', async ({ api }) => { @@ -71,6 +72,7 @@ test.describe('OC - Tags Visibility', () => { await agent.delete(); await departmentA.delete(); await departmentB.delete(); + ws.close(); }); test('Verify agent should see correct tags based on department association', async () => { diff --git a/apps/meteor/tests/e2e/omnichannel/omnichannel-custom-field-usage.spec.ts b/apps/meteor/tests/e2e/omnichannel/omnichannel-custom-field-usage.spec.ts index e62e86df89622..1bb9b8616ec23 100644 --- a/apps/meteor/tests/e2e/omnichannel/omnichannel-custom-field-usage.spec.ts +++ b/apps/meteor/tests/e2e/omnichannel/omnichannel-custom-field-usage.spec.ts @@ -31,9 +31,14 @@ test.describe.serial('OC - Custom fields usage, scope : room and visitor', () => let conversation: Awaited>; let roomCustomField: Awaited>; let visitorCustomField: Awaited>; + let ws: WebSocket; test.beforeAll('Set up agent, manager and custom fields', async ({ api }) => { - [agent, manager] = await Promise.all([createAgent(api, 'user1'), createManager(api, 'user1'), ddpLogin(Users.user1.data.loginToken)]); + [agent, manager, ws] = await Promise.all([ + createAgent(api, 'user1'), + createManager(api, 'user1'), + ddpLogin(Users.user1.data.loginToken), + ]); [roomCustomField, visitorCustomField, conversation] = await Promise.all([ createCustomField(api, { @@ -68,6 +73,7 @@ test.describe.serial('OC - Custom fields usage, scope : room and visitor', () => test.afterAll('Remove agent, manager, custom fields and conversation', async () => { await Promise.all([agent.delete(), manager.delete(), roomCustomField.delete(), visitorCustomField.delete(), conversation.delete()]); + ws.close(); }); test('Should be allowed to set room custom field for a conversation', async () => { diff --git a/apps/meteor/tests/e2e/omnichannel/omnichannel-livechat.spec.ts b/apps/meteor/tests/e2e/omnichannel/omnichannel-livechat.spec.ts index 12d7caa30aa01..035b66f2bb9a7 100644 --- a/apps/meteor/tests/e2e/omnichannel/omnichannel-livechat.spec.ts +++ b/apps/meteor/tests/e2e/omnichannel/omnichannel-livechat.spec.ts @@ -265,10 +265,11 @@ test.describe('OC - Livechat - Resume chat after closing', () => { test.describe('OC - Livechat - Close chat using widget', () => { let poLiveChat: OmnichannelLiveChat; let agent: Awaited>; + let ws: WebSocket; test.beforeAll(async ({ api }) => { agent = await createAgent(api, 'user1'); - await ddpLogin(Users.user1.data.loginToken); + ws = await ddpLogin(Users.user1.data.loginToken); }); test.beforeEach(async ({ page, api }) => { @@ -279,6 +280,7 @@ test.describe('OC - Livechat - Close chat using widget', () => { test.afterAll(async () => { await agent.delete(); + ws.close(); }); test('OC - Livechat - Close Chat', async () => { From 6c3b0102d3c588c6b98711bbd665f940a182985f Mon Sep 17 00:00:00 2001 From: Nazareno Bucciarelli Date: Mon, 16 Mar 2026 14:59:21 -0300 Subject: [PATCH 13/44] improve ws cleanup on rooms test, make ws port depend on CI env variable --- apps/meteor/tests/data/users.helper.ts | 40 ++++++++++++------- .../tests/end-to-end/api/livechat/00-rooms.ts | 15 +++++-- 2 files changed, 37 insertions(+), 18 deletions(-) diff --git a/apps/meteor/tests/data/users.helper.ts b/apps/meteor/tests/data/users.helper.ts index 63635c099bddf..330892920a687 100644 --- a/apps/meteor/tests/data/users.helper.ts +++ b/apps/meteor/tests/data/users.helper.ts @@ -172,11 +172,26 @@ export const setUserAway = (overrideCredentials = credentials, config?: IRequest }); }; -export const ddpLogin = (resume: string): Promise => +const connectWS = (port: number): Promise => new Promise((resolve, reject) => { - const ws = new WebSocket('ws://localhost:3000/websocket'); - const loginId = `login-${Date.now()}-${Math.random()}`; + const ws = new WebSocket(`ws://localhost:${port}/websocket`); + ws.onopen = () => resolve(ws); + ws.onerror = () => reject(new Error(`WS connection failed on ${port}`)); + }); + +export const ddpLogin = async (resume: string): Promise => { + let ws: WebSocket; + + if (process.env.CI) { + ws = await connectWS(3000); + } else if (process.env.IS_EE) { + ws = await connectWS(4000); + } + + const loginId = `login-${Date.now()}-${Math.random()}`; + + return new Promise((resolve, reject) => { const handler = (event: MessageEvent) => { const data = JSON.parse(event.data); @@ -212,18 +227,15 @@ export const ddpLogin = (resume: string): Promise => ws.addEventListener('message', handler); - ws.onopen = () => { - ws.send( - JSON.stringify({ - msg: 'connect', - version: '1', - support: ['1'], - }), - ); - }; - - ws.onerror = reject; + ws.send( + JSON.stringify({ + msg: 'connect', + version: '1', + support: ['1'], + }), + ); }); +}; export const setUserAwayWS = (ws: WebSocket): Promise => new Promise((resolve, reject) => { diff --git a/apps/meteor/tests/end-to-end/api/livechat/00-rooms.ts b/apps/meteor/tests/end-to-end/api/livechat/00-rooms.ts index 68b3868514a29..6a5dd1314bd2f 100644 --- a/apps/meteor/tests/end-to-end/api/livechat/00-rooms.ts +++ b/apps/meteor/tests/end-to-end/api/livechat/00-rooms.ts @@ -84,6 +84,7 @@ describe('LIVECHAT - rooms', () => { let visitor: ILivechatVisitor; let room: IOmnichannelRoom; let appId: string; + const sockets: WebSocket[] = []; before((done) => getCredentials(done)); @@ -129,6 +130,11 @@ describe('LIVECHAT - rooms', () => { .set(credentials) .expect(200); } + for (const ws of sockets) { + if (ws.readyState === WebSocket.OPEN || ws.readyState === WebSocket.CONNECTING) { + ws.close(); + } + } }); describe('livechat/room', () => { @@ -1550,6 +1556,7 @@ describe('LIVECHAT - rooms', () => { const { department: forwardToOfflineDepartment, ws } = await createDepartmentWithAnAwayAgent({ allowReceiveForwardOffline: true, }); + sockets.push(ws); const newVisitor = await createVisitor(initialDepartment._id); const newRoom = await createLivechatRoom(newVisitor.token); @@ -1582,8 +1589,6 @@ describe('LIVECHAT - rooms', () => { expect(res.body).to.have.property('count'); }); - ws.close(); - await Promise.all([ deleteDepartment(initialDepartment._id), deleteDepartment(forwardToOfflineDepartment._id), @@ -1678,6 +1683,7 @@ describe('LIVECHAT - rooms', () => { await updateSetting('Livechat_Routing_Method', 'Auto_Selection'); const { department: initialDepartment } = await createDepartmentWithAnOnlineAgent(); const { department: forwardToOfflineDepartment, ws } = await createDepartmentWithAnAwayAgent({ allowReceiveForwardOffline: true }); + sockets.push(ws); const newVisitor = await createVisitor(initialDepartment._id); const newRoom = await createLivechatRoom(newVisitor.token); @@ -1719,6 +1725,7 @@ describe('LIVECHAT - rooms', () => { await updateSetting('Livechat_Routing_Method', 'Auto_Selection'); const { department: initialDepartment } = await createDepartmentWithAnOnlineAgent(); const { department: forwardToOfflineDepartment, ws } = await createDepartmentWithAnAwayAgent({ allowReceiveForwardOffline: false }); + sockets.push(ws); const newVisitor = await createVisitor(initialDepartment._id); const newRoom = await createLivechatRoom(newVisitor.token); @@ -1804,7 +1811,7 @@ describe('LIVECHAT - rooms', () => { } = await createDepartmentWithAnAwayAgent({ allowReceiveForwardOffline: true, }); - + // await new Promise((r) => setTimeout(r, 7000)); const newVisitor = await createVisitor(initialDepartment._id); const newRoom = await createLivechatRoom(newVisitor.token); @@ -1820,7 +1827,7 @@ describe('LIVECHAT - rooms', () => { }); const roomInfo = await getLivechatRoomInfo(newRoom._id); - + console.log(roomInfo); expect(roomInfo.servedBy).to.have.property('_id', agent.user._id); expect(roomInfo.departmentId).to.be.equal(forwardToOfflineDepartment._id); From 7c2f1506f38d7b79bc54bdbfadffd1a1fc668aad Mon Sep 17 00:00:00 2001 From: Nazareno Bucciarelli Date: Tue, 17 Mar 2026 16:42:37 -0300 Subject: [PATCH 14/44] remove unintended test lines --- apps/meteor/tests/end-to-end/api/livechat/00-rooms.ts | 2 -- 1 file changed, 2 deletions(-) diff --git a/apps/meteor/tests/end-to-end/api/livechat/00-rooms.ts b/apps/meteor/tests/end-to-end/api/livechat/00-rooms.ts index 6a5dd1314bd2f..ba7d1c3407552 100644 --- a/apps/meteor/tests/end-to-end/api/livechat/00-rooms.ts +++ b/apps/meteor/tests/end-to-end/api/livechat/00-rooms.ts @@ -1811,7 +1811,6 @@ describe('LIVECHAT - rooms', () => { } = await createDepartmentWithAnAwayAgent({ allowReceiveForwardOffline: true, }); - // await new Promise((r) => setTimeout(r, 7000)); const newVisitor = await createVisitor(initialDepartment._id); const newRoom = await createLivechatRoom(newVisitor.token); @@ -1827,7 +1826,6 @@ describe('LIVECHAT - rooms', () => { }); const roomInfo = await getLivechatRoomInfo(newRoom._id); - console.log(roomInfo); expect(roomInfo.servedBy).to.have.property('_id', agent.user._id); expect(roomInfo.departmentId).to.be.equal(forwardToOfflineDepartment._id); From 02239eff05c2780fb19538958263204d73d5fe79 Mon Sep 17 00:00:00 2001 From: Nazareno Bucciarelli Date: Tue, 17 Mar 2026 18:23:37 -0300 Subject: [PATCH 15/44] add changeset --- .changeset/purple-boxes-shout.md | 6 ++++++ 1 file changed, 6 insertions(+) create mode 100644 .changeset/purple-boxes-shout.md diff --git a/.changeset/purple-boxes-shout.md b/.changeset/purple-boxes-shout.md new file mode 100644 index 0000000000000..e338ac60ed7f6 --- /dev/null +++ b/.changeset/purple-boxes-shout.md @@ -0,0 +1,6 @@ +--- +'@rocket.chat/models': patch +'@rocket.chat/meteor': patch +--- + +Fixes an issue where offline agents were being assigned to visitors. From ba26d7fa0db0e62647e1021f2a7d663026c9fa41 Mon Sep 17 00:00:00 2001 From: Nazareno Bucciarelli Date: Tue, 17 Mar 2026 20:24:31 -0300 Subject: [PATCH 16/44] remove unneeded ws closures --- .../tests/end-to-end/api/livechat/00-rooms.ts | 7 +- .../tests/end-to-end/api/livechat/test.ts | 92 +++++++++++++++++++ 2 files changed, 93 insertions(+), 6 deletions(-) create mode 100644 apps/meteor/tests/end-to-end/api/livechat/test.ts diff --git a/apps/meteor/tests/end-to-end/api/livechat/00-rooms.ts b/apps/meteor/tests/end-to-end/api/livechat/00-rooms.ts index ba7d1c3407552..35b69cdf6be6f 100644 --- a/apps/meteor/tests/end-to-end/api/livechat/00-rooms.ts +++ b/apps/meteor/tests/end-to-end/api/livechat/00-rooms.ts @@ -1706,8 +1706,6 @@ describe('LIVECHAT - rooms', () => { expect(inquiry.status).to.equal('queued'); expect(inquiry.department).to.equal(forwardToOfflineDepartment._id); - ws.close(); - await Promise.all([ deleteDepartment(initialDepartment._id), deleteDepartment(forwardToOfflineDepartment._id), @@ -1746,8 +1744,6 @@ describe('LIVECHAT - rooms', () => { expect(res.status).to.equal(400); expect(res.body).to.have.property('error', 'error-no-agents-available-for-service-on-department'); - ws.close(); - await Promise.all([ deleteDepartment(initialDepartment._id), deleteDepartment(forwardToOfflineDepartment._id), @@ -1811,6 +1807,7 @@ describe('LIVECHAT - rooms', () => { } = await createDepartmentWithAnAwayAgent({ allowReceiveForwardOffline: true, }); + sockets.push(ws); const newVisitor = await createVisitor(initialDepartment._id); const newRoom = await createLivechatRoom(newVisitor.token); @@ -1829,8 +1826,6 @@ describe('LIVECHAT - rooms', () => { expect(roomInfo.servedBy).to.have.property('_id', agent.user._id); expect(roomInfo.departmentId).to.be.equal(forwardToOfflineDepartment._id); - ws.close(); - await Promise.all([ deleteDepartment(initialDepartment._id), deleteDepartment(forwardToOfflineDepartment._id), diff --git a/apps/meteor/tests/end-to-end/api/livechat/test.ts b/apps/meteor/tests/end-to-end/api/livechat/test.ts new file mode 100644 index 0000000000000..12f8e4fb4d820 --- /dev/null +++ b/apps/meteor/tests/end-to-end/api/livechat/test.ts @@ -0,0 +1,92 @@ +import fs from 'fs'; +import path from 'path'; + +import { faker } from '@faker-js/faker'; +import type { Credentials } from '@rocket.chat/api-client'; +import type { + IOmnichannelRoom, + ILivechatVisitor, + IOmnichannelSystemMessage, + ILivechatPriority, + ILivechatDepartment, + ISubscription, + IOmnichannelBusinessUnit, + IUser, +} from '@rocket.chat/core-typings'; +import { LivechatPriorityWeight } from '@rocket.chat/core-typings'; +import { expect } from 'chai'; +import { after, afterEach, before, describe, it } from 'mocha'; +import type { Response } from 'supertest'; + +import type { SuccessResult } from '../../../../app/api/server/definition'; +import { getCredentials, api, request, credentials } from '../../../data/api-data'; +import { apps, APP_URL } from '../../../data/apps/apps-data'; +import { createCustomField, deleteCustomField } from '../../../data/livechat/custom-fields'; +import type { OnlineAgent } from '../../../data/livechat/department'; +import { + createDepartmentWith2OnlineAgents, + createDepartmentWithAnAwayAgent, + createDepartmentWithAnOfflineAgent, + createDepartmentWithAnOnlineAgent, + deleteDepartment, +} from '../../../data/livechat/department'; +import { createSLA, getRandomPriority } from '../../../data/livechat/priorities'; +import { + createVisitor, + createLivechatRoom, + createAgent, + makeAgentAvailable, + getLivechatRoomInfo, + sendMessage, + startANewLivechatRoomAndTakeIt, + createManager, + closeOmnichannelRoom, + createDepartment, + fetchMessages, + deleteVisitor, + makeAgentUnavailable, + sendAgentMessage, + fetchInquiry, + takeInquiry, +} from '../../../data/livechat/rooms'; +import { saveTags } from '../../../data/livechat/tags'; +import { createMonitor, createUnit, deleteUnit } from '../../../data/livechat/units'; +import type { DummyResponse } from '../../../data/livechat/utils'; +import { sleep } from '../../../data/livechat/utils'; +import { + restorePermissionToRoles, + addPermissions, + removePermissionFromAllRoles, + updateEEPermission, + updatePermission, + updateSetting, + updateEESetting, +} from '../../../data/permissions.helper'; +import { adminUsername, password } from '../../../data/user'; +import type { TestUser } from '../../../data/users.helper'; +import { createUser, deleteUser, login } from '../../../data/users.helper'; +import { IS_EE } from '../../../e2e/config/constants'; + + +describe.only('LIVECHAT - rooms', () => { + const sockets: WebSocket[] = []; + + before((done) => getCredentials(done)); + + it( + 'when manager forward to offline (agent away, accept when agent idle off) department the inquiry should be set to the queue', + async () => { + await updateSetting('Livechat_Routing_Method', 'Manual_Selection'); + await updateSetting('Livechat_enabled_when_agent_idle', false); + const { department: initialDepartment } = await createDepartmentWithAnOnlineAgent(); + const { department: forwardToOfflineDepartment, ws } = await createDepartmentWithAnAwayAgent({ + allowReceiveForwardOffline: true, + }); + sockets.push(ws); + + }, + ); + + +}) + From e287b5b894310decbb9bb6565e77120f6c84b9bc Mon Sep 17 00:00:00 2001 From: Nazareno Bucciarelli Date: Tue, 17 Mar 2026 20:35:21 -0300 Subject: [PATCH 17/44] handle errors on websockets result --- apps/meteor/tests/data/users.helper.ts | 52 ++++++----- .../tests/end-to-end/api/livechat/test.ts | 92 ------------------- 2 files changed, 27 insertions(+), 117 deletions(-) delete mode 100644 apps/meteor/tests/end-to-end/api/livechat/test.ts diff --git a/apps/meteor/tests/data/users.helper.ts b/apps/meteor/tests/data/users.helper.ts index 330892920a687..bb67293231e11 100644 --- a/apps/meteor/tests/data/users.helper.ts +++ b/apps/meteor/tests/data/users.helper.ts @@ -195,33 +195,31 @@ export const ddpLogin = async (resume: string): Promise => { const handler = (event: MessageEvent) => { const data = JSON.parse(event.data); - switch (data.msg) { - case 'connected': - ws.send( - JSON.stringify({ - msg: 'method', - id: loginId, - method: 'login', - params: [{ resume }], - }), - ); - break; - - case 'result': - if (data.id === loginId) { - ws.removeEventListener('message', handler); - resolve(ws); - } - break; + if (data.msg === 'connected') { + ws.send( + JSON.stringify({ + msg: 'method', + id: loginId, + method: 'login', + params: [{ resume }], + }), + ); + } else if (data.msg === 'result') { + if (data.id === loginId) { + ws.removeEventListener('message', handler); - case 'ping': - ws.send(JSON.stringify({ msg: 'pong' })); - break; + if (data.error) { + reject(data.error); + return; + } - case 'error': - ws.removeEventListener('message', handler); - reject(data); - break; + resolve(ws); + } + } else if (data.msg === 'ping') { + ws.send(JSON.stringify({ msg: 'pong' })); + } else if (data.msg === 'error') { + ws.removeEventListener('message', handler); + reject(data); } }; @@ -246,6 +244,10 @@ export const setUserAwayWS = (ws: WebSocket): Promise => if (data.msg === 'result' && data.id === id) { ws.removeEventListener('message', handler); + if (data.error) { + reject(data.error); + return; + } resolve(); } diff --git a/apps/meteor/tests/end-to-end/api/livechat/test.ts b/apps/meteor/tests/end-to-end/api/livechat/test.ts deleted file mode 100644 index 12f8e4fb4d820..0000000000000 --- a/apps/meteor/tests/end-to-end/api/livechat/test.ts +++ /dev/null @@ -1,92 +0,0 @@ -import fs from 'fs'; -import path from 'path'; - -import { faker } from '@faker-js/faker'; -import type { Credentials } from '@rocket.chat/api-client'; -import type { - IOmnichannelRoom, - ILivechatVisitor, - IOmnichannelSystemMessage, - ILivechatPriority, - ILivechatDepartment, - ISubscription, - IOmnichannelBusinessUnit, - IUser, -} from '@rocket.chat/core-typings'; -import { LivechatPriorityWeight } from '@rocket.chat/core-typings'; -import { expect } from 'chai'; -import { after, afterEach, before, describe, it } from 'mocha'; -import type { Response } from 'supertest'; - -import type { SuccessResult } from '../../../../app/api/server/definition'; -import { getCredentials, api, request, credentials } from '../../../data/api-data'; -import { apps, APP_URL } from '../../../data/apps/apps-data'; -import { createCustomField, deleteCustomField } from '../../../data/livechat/custom-fields'; -import type { OnlineAgent } from '../../../data/livechat/department'; -import { - createDepartmentWith2OnlineAgents, - createDepartmentWithAnAwayAgent, - createDepartmentWithAnOfflineAgent, - createDepartmentWithAnOnlineAgent, - deleteDepartment, -} from '../../../data/livechat/department'; -import { createSLA, getRandomPriority } from '../../../data/livechat/priorities'; -import { - createVisitor, - createLivechatRoom, - createAgent, - makeAgentAvailable, - getLivechatRoomInfo, - sendMessage, - startANewLivechatRoomAndTakeIt, - createManager, - closeOmnichannelRoom, - createDepartment, - fetchMessages, - deleteVisitor, - makeAgentUnavailable, - sendAgentMessage, - fetchInquiry, - takeInquiry, -} from '../../../data/livechat/rooms'; -import { saveTags } from '../../../data/livechat/tags'; -import { createMonitor, createUnit, deleteUnit } from '../../../data/livechat/units'; -import type { DummyResponse } from '../../../data/livechat/utils'; -import { sleep } from '../../../data/livechat/utils'; -import { - restorePermissionToRoles, - addPermissions, - removePermissionFromAllRoles, - updateEEPermission, - updatePermission, - updateSetting, - updateEESetting, -} from '../../../data/permissions.helper'; -import { adminUsername, password } from '../../../data/user'; -import type { TestUser } from '../../../data/users.helper'; -import { createUser, deleteUser, login } from '../../../data/users.helper'; -import { IS_EE } from '../../../e2e/config/constants'; - - -describe.only('LIVECHAT - rooms', () => { - const sockets: WebSocket[] = []; - - before((done) => getCredentials(done)); - - it( - 'when manager forward to offline (agent away, accept when agent idle off) department the inquiry should be set to the queue', - async () => { - await updateSetting('Livechat_Routing_Method', 'Manual_Selection'); - await updateSetting('Livechat_enabled_when_agent_idle', false); - const { department: initialDepartment } = await createDepartmentWithAnOnlineAgent(); - const { department: forwardToOfflineDepartment, ws } = await createDepartmentWithAnAwayAgent({ - allowReceiveForwardOffline: true, - }); - sockets.push(ws); - - }, - ); - - -}) - From 1ae62309ee72a866c079ea79806ad7bda0807e72 Mon Sep 17 00:00:00 2001 From: Nazareno Bucciarelli Date: Wed, 18 Mar 2026 12:24:23 -0300 Subject: [PATCH 18/44] remove accidental timeout --- apps/meteor/tests/end-to-end/api/livechat/24-routing.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/apps/meteor/tests/end-to-end/api/livechat/24-routing.ts b/apps/meteor/tests/end-to-end/api/livechat/24-routing.ts index 8c65eaa08fe77..e6eaa6bb47915 100644 --- a/apps/meteor/tests/end-to-end/api/livechat/24-routing.ts +++ b/apps/meteor/tests/end-to-end/api/livechat/24-routing.ts @@ -522,7 +522,6 @@ import { IS_EE } from '../../../e2e/config/constants'; sockets.push(ws2); await setUserAwayWS(ws2); - await new Promise((r) => setTimeout(r, 9000)); // Agent is available but should be ignored await switchLivechatStatus('available', testUser.credentials); From 6134a5c17489039e5f3e7663b4f8bb2bec5073b2 Mon Sep 17 00:00:00 2001 From: Nazareno Bucciarelli Date: Thu, 19 Mar 2026 13:01:50 -0300 Subject: [PATCH 19/44] add DDP_LOGIN_PORT env variable for ws connection --- apps/meteor/tests/data/users.helper.ts | 10 ++-------- 1 file changed, 2 insertions(+), 8 deletions(-) diff --git a/apps/meteor/tests/data/users.helper.ts b/apps/meteor/tests/data/users.helper.ts index bb67293231e11..1aa56cf6dfa11 100644 --- a/apps/meteor/tests/data/users.helper.ts +++ b/apps/meteor/tests/data/users.helper.ts @@ -172,7 +172,7 @@ export const setUserAway = (overrideCredentials = credentials, config?: IRequest }); }; -const connectWS = (port: number): Promise => +const connectWS = (port: string): Promise => new Promise((resolve, reject) => { const ws = new WebSocket(`ws://localhost:${port}/websocket`); @@ -181,13 +181,7 @@ const connectWS = (port: number): Promise => }); export const ddpLogin = async (resume: string): Promise => { - let ws: WebSocket; - - if (process.env.CI) { - ws = await connectWS(3000); - } else if (process.env.IS_EE) { - ws = await connectWS(4000); - } + const ws: WebSocket = await connectWS(process.env.DDP_LOGIN_PORT || '3000'); const loginId = `login-${Date.now()}-${Math.random()}`; From fca1d9a40047a151216b2ca0c09befbe03e4357a Mon Sep 17 00:00:00 2001 From: Nazareno Bucciarelli <84046180+nazabucciarelli@users.noreply.github.com> Date: Thu, 19 Mar 2026 13:07:28 -0300 Subject: [PATCH 20/44] Correct phrasing in changeset description --- .changeset/purple-boxes-shout.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.changeset/purple-boxes-shout.md b/.changeset/purple-boxes-shout.md index e338ac60ed7f6..f5a908b5458db 100644 --- a/.changeset/purple-boxes-shout.md +++ b/.changeset/purple-boxes-shout.md @@ -3,4 +3,4 @@ '@rocket.chat/meteor': patch --- -Fixes an issue where offline agents were being assigned to visitors. +Fixes an issue where offline livechat agents were being assigned to visitors. From 0b3395ca823aa2cf79c83dd2dafda6d9f85738a7 Mon Sep 17 00:00:00 2001 From: Nazareno Bucciarelli Date: Thu, 19 Mar 2026 15:15:07 -0300 Subject: [PATCH 21/44] add forgotten CI new env variable --- .github/workflows/ci-test-e2e.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/workflows/ci-test-e2e.yml b/.github/workflows/ci-test-e2e.yml index d8fd21a6e707d..df0ec331aa778 100644 --- a/.github/workflows/ci-test-e2e.yml +++ b/.github/workflows/ci-test-e2e.yml @@ -65,6 +65,7 @@ env: TOOL_NODE_FLAGS: ${{ vars.TOOL_NODE_FLAGS }} LOWERCASE_REPOSITORY: ${{ inputs.lowercase-repo }} DOCKER_TAG: ${{ inputs.gh-docker-tag }}-amd64 + DDP_LOGIN_PORT: 3000 jobs: test: From 6d760f1799e9884e8dc36dc5c08da88743ad854c Mon Sep 17 00:00:00 2001 From: Nazareno Bucciarelli Date: Thu, 19 Mar 2026 15:18:04 -0300 Subject: [PATCH 22/44] safe ws closure --- apps/meteor/tests/data/users.helper.ts | 1 + .../e2e/omnichannel/omnichannel-custom-field-usage.spec.ts | 2 +- 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/apps/meteor/tests/data/users.helper.ts b/apps/meteor/tests/data/users.helper.ts index 1aa56cf6dfa11..e4d8979d86af4 100644 --- a/apps/meteor/tests/data/users.helper.ts +++ b/apps/meteor/tests/data/users.helper.ts @@ -203,6 +203,7 @@ export const ddpLogin = async (resume: string): Promise => { ws.removeEventListener('message', handler); if (data.error) { + ws.close(); reject(data.error); return; } diff --git a/apps/meteor/tests/e2e/omnichannel/omnichannel-custom-field-usage.spec.ts b/apps/meteor/tests/e2e/omnichannel/omnichannel-custom-field-usage.spec.ts index 1bb9b8616ec23..e059f54e4d1cd 100644 --- a/apps/meteor/tests/e2e/omnichannel/omnichannel-custom-field-usage.spec.ts +++ b/apps/meteor/tests/e2e/omnichannel/omnichannel-custom-field-usage.spec.ts @@ -73,7 +73,7 @@ test.describe.serial('OC - Custom fields usage, scope : room and visitor', () => test.afterAll('Remove agent, manager, custom fields and conversation', async () => { await Promise.all([agent.delete(), manager.delete(), roomCustomField.delete(), visitorCustomField.delete(), conversation.delete()]); - ws.close(); + ws?.close(); }); test('Should be allowed to set room custom field for a conversation', async () => { From 7199cc4faefecce171e52a4cd3c8aca067ee6368 Mon Sep 17 00:00:00 2001 From: Nazareno Bucciarelli Date: Thu, 19 Mar 2026 21:45:56 -0300 Subject: [PATCH 23/44] enhance overall WS connection logic --- apps/meteor/tests/data/users.helper.ts | 117 +++++++++++-------------- 1 file changed, 49 insertions(+), 68 deletions(-) diff --git a/apps/meteor/tests/data/users.helper.ts b/apps/meteor/tests/data/users.helper.ts index e4d8979d86af4..00b66c5c731a6 100644 --- a/apps/meteor/tests/data/users.helper.ts +++ b/apps/meteor/tests/data/users.helper.ts @@ -176,97 +176,78 @@ const connectWS = (port: string): Promise => new Promise((resolve, reject) => { const ws = new WebSocket(`ws://localhost:${port}/websocket`); - ws.onopen = () => resolve(ws); + ws.onopen = () => { + ws.addEventListener('message', (event: MessageEvent) => { + const data = JSON.parse(event.data); + if (data.msg === 'ping') { + ws.send(JSON.stringify({ msg: 'pong' })); + } + }); + resolve(ws); + }; ws.onerror = () => reject(new Error(`WS connection failed on ${port}`)); }); -export const ddpLogin = async (resume: string): Promise => { - const ws: WebSocket = await connectWS(process.env.DDP_LOGIN_PORT || '3000'); +const waitForDDP = (ws: WebSocket, id: string | 'handshake', sendAction: () => void): Promise => { + return new Promise((resolve, reject) => { + const timeout = setTimeout(() => { + cleanup(); + ws.close(); + reject(new Error(`Timeout waiting for DDP id: ${id}`)); + }, 5000); + + const cleanup = () => { + clearTimeout(timeout); + ws.removeEventListener('message', handler); + ws.removeEventListener('close', onClose); + }; - const loginId = `login-${Date.now()}-${Math.random()}`; + const onClose = () => { + cleanup(); + reject(new Error(`WS closed while waiting for id: ${id}`)); + }; - return new Promise((resolve, reject) => { const handler = (event: MessageEvent) => { - const data = JSON.parse(event.data); - - if (data.msg === 'connected') { - ws.send( - JSON.stringify({ - msg: 'method', - id: loginId, - method: 'login', - params: [{ resume }], - }), - ); - } else if (data.msg === 'result') { - if (data.id === loginId) { - ws.removeEventListener('message', handler); + try { + const data = JSON.parse(event.data); + const isHandshake = id === 'handshake' && data.msg === 'connected'; + const isResult = data.id === id && (data.msg === 'result' || data.msg === 'error'); + if (isHandshake || isResult) { + cleanup(); if (data.error) { ws.close(); - reject(data.error); - return; + return reject(data.error); } - - resolve(ws); + resolve(data); } - } else if (data.msg === 'ping') { - ws.send(JSON.stringify({ msg: 'pong' })); - } else if (data.msg === 'error') { - ws.removeEventListener('message', handler); - reject(data); + } catch (e) { + // Ignore no JSON message } }; ws.addEventListener('message', handler); - - ws.send( - JSON.stringify({ - msg: 'connect', - version: '1', - support: ['1'], - }), - ); + ws.addEventListener('close', onClose); + sendAction(); }); }; -export const setUserAwayWS = (ws: WebSocket): Promise => - new Promise((resolve, reject) => { - const id = `away-${Date.now()}-${Math.random()}`; - - const handler = (event: MessageEvent) => { - const data = JSON.parse(event.data); +export const ddpLogin = async (resume: string): Promise => { + const ws = await connectWS(process.env.DDP_LOGIN_PORT || '3000'); + const loginId = `login-${Date.now()}`; - if (data.msg === 'result' && data.id === id) { - ws.removeEventListener('message', handler); - if (data.error) { - reject(data.error); - return; - } - resolve(); - } + await waitForDDP(ws, 'handshake', () => ws.send(JSON.stringify({ msg: 'connect', version: '1', support: ['1'] }))); - if (data.msg === 'ping') { - ws.send(JSON.stringify({ msg: 'pong' })); - } + await waitForDDP(ws, loginId, () => ws.send(JSON.stringify({ msg: 'method', id: loginId, method: 'login', params: [{ resume }] }))); - if (data.msg === 'error') { - ws.removeEventListener('message', handler); - reject(data); - } - }; + return ws; +}; - ws.addEventListener('message', handler); +export const setUserAwayWS = async (ws: WebSocket): Promise => { + const id = `away-${Date.now()}`; - ws.send( - JSON.stringify({ - msg: 'method', - method: 'UserPresence:away', - params: [], - id, - }), - ); - }); + await waitForDDP(ws, id, () => ws.send(JSON.stringify({ msg: 'method', method: 'UserPresence:away', params: [], id }))); +}; export const setUserOnline = (overrideCredentials = credentials, config?: IRequestConfig) => { const requestInstance = config?.request || request; From d808a27b2015a856bd17097203d56a4ead2e666c Mon Sep 17 00:00:00 2001 From: Nazareno Bucciarelli Date: Thu, 19 Mar 2026 21:57:36 -0300 Subject: [PATCH 24/44] add onError handler for ws --- apps/meteor/tests/data/users.helper.ts | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/apps/meteor/tests/data/users.helper.ts b/apps/meteor/tests/data/users.helper.ts index 00b66c5c731a6..2626f385b0431 100644 --- a/apps/meteor/tests/data/users.helper.ts +++ b/apps/meteor/tests/data/users.helper.ts @@ -200,6 +200,7 @@ const waitForDDP = (ws: WebSocket, id: string | 'handshake', sendAction: () => v clearTimeout(timeout); ws.removeEventListener('message', handler); ws.removeEventListener('close', onClose); + ws.removeEventListener('error', onError); }; const onClose = () => { @@ -207,6 +208,12 @@ const waitForDDP = (ws: WebSocket, id: string | 'handshake', sendAction: () => v reject(new Error(`WS closed while waiting for id: ${id}`)); }; + const onError = (error: any) => { + cleanup(); + ws.close(); + reject(error || new Error(`WS error during operation id: ${id}`)); + }; + const handler = (event: MessageEvent) => { try { const data = JSON.parse(event.data); @@ -228,6 +235,8 @@ const waitForDDP = (ws: WebSocket, id: string | 'handshake', sendAction: () => v ws.addEventListener('message', handler); ws.addEventListener('close', onClose); + ws.addEventListener('error', onError); + sendAction(); }); }; From b19afad8313778537aef52ea42804c8c4d3033fe Mon Sep 17 00:00:00 2001 From: Nazareno Bucciarelli Date: Wed, 8 Apr 2026 18:44:02 -0300 Subject: [PATCH 25/44] modify changeset from patch to minor --- .changeset/purple-boxes-shout.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/.changeset/purple-boxes-shout.md b/.changeset/purple-boxes-shout.md index f5a908b5458db..2fe8eca446613 100644 --- a/.changeset/purple-boxes-shout.md +++ b/.changeset/purple-boxes-shout.md @@ -1,6 +1,6 @@ --- -'@rocket.chat/models': patch -'@rocket.chat/meteor': patch +'@rocket.chat/models': minor +'@rocket.chat/meteor': minor --- -Fixes an issue where offline livechat agents were being assigned to visitors. +Updates the behavior of the `Livechat_enabled_when_agent_idle` setting. When enabled, the routing query now excludes agents with an offline status, ensuring visitors are not assigned to them. From 06820bce1729c82968a0d60a4581457c64b7f36b Mon Sep 17 00:00:00 2001 From: Nazareno Bucciarelli Date: Wed, 8 Apr 2026 18:54:45 -0300 Subject: [PATCH 26/44] remove sendAction parameter in favor of stringifiedJsonPayload --- apps/meteor/tests/data/users.helper.ts | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/apps/meteor/tests/data/users.helper.ts b/apps/meteor/tests/data/users.helper.ts index 2626f385b0431..9a02663b5bb4c 100644 --- a/apps/meteor/tests/data/users.helper.ts +++ b/apps/meteor/tests/data/users.helper.ts @@ -188,7 +188,7 @@ const connectWS = (port: string): Promise => ws.onerror = () => reject(new Error(`WS connection failed on ${port}`)); }); -const waitForDDP = (ws: WebSocket, id: string | 'handshake', sendAction: () => void): Promise => { +const waitForDDP = (ws: WebSocket, id: string | 'handshake', stringifiedJsonPayload: string): Promise => { return new Promise((resolve, reject) => { const timeout = setTimeout(() => { cleanup(); @@ -237,7 +237,7 @@ const waitForDDP = (ws: WebSocket, id: string | 'handshake', sendAction: () => v ws.addEventListener('close', onClose); ws.addEventListener('error', onError); - sendAction(); + ws.send(stringifiedJsonPayload); }); }; @@ -245,9 +245,9 @@ export const ddpLogin = async (resume: string): Promise => { const ws = await connectWS(process.env.DDP_LOGIN_PORT || '3000'); const loginId = `login-${Date.now()}`; - await waitForDDP(ws, 'handshake', () => ws.send(JSON.stringify({ msg: 'connect', version: '1', support: ['1'] }))); + await waitForDDP(ws, 'handshake', JSON.stringify({ msg: 'connect', version: '1', support: ['1'] })); - await waitForDDP(ws, loginId, () => ws.send(JSON.stringify({ msg: 'method', id: loginId, method: 'login', params: [{ resume }] }))); + await waitForDDP(ws, loginId, JSON.stringify({ msg: 'method', id: loginId, method: 'login', params: [{ resume }] })); return ws; }; @@ -255,7 +255,7 @@ export const ddpLogin = async (resume: string): Promise => { export const setUserAwayWS = async (ws: WebSocket): Promise => { const id = `away-${Date.now()}`; - await waitForDDP(ws, id, () => ws.send(JSON.stringify({ msg: 'method', method: 'UserPresence:away', params: [], id }))); + await waitForDDP(ws, id, JSON.stringify({ msg: 'method', method: 'UserPresence:away', params: [], id })); }; export const setUserOnline = (overrideCredentials = credentials, config?: IRequestConfig) => { From c75d663d496e280a32981f7e22192f9c7bc2e555 Mon Sep 17 00:00:00 2001 From: Nazareno Bucciarelli Date: Wed, 8 Apr 2026 19:19:33 -0300 Subject: [PATCH 27/44] add comment to document auto-assignment bussines rule --- packages/models/src/helpers/omnichannel/agentStatus.ts | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/packages/models/src/helpers/omnichannel/agentStatus.ts b/packages/models/src/helpers/omnichannel/agentStatus.ts index 1fa28ee085a71..235956871379b 100644 --- a/packages/models/src/helpers/omnichannel/agentStatus.ts +++ b/packages/models/src/helpers/omnichannel/agentStatus.ts @@ -2,6 +2,15 @@ import { UserStatus } from '@rocket.chat/core-typings'; import type { IUser } from '@rocket.chat/core-typings'; import type { Filter } from 'mongodb'; +/** + * Builds the query to find online and available agents for livechat auto-assignment. + * * According to product rules, the primary conditions for auto-assignment are: + * - User must have the 'livechat-agent' role. + * - Livechat service status (`statusLivechat`) must be 'available'. + * - User's global status must NOT be 'offline' (Bots are exempt from this rule). + * - If the "Accept new omnichannel requests when the agent is idle" (aka `Livechat_enabled_when_agent_idle`) setting is OFF, + * then the statusConnection must NOT be 'away'. + */ export const queryStatusAgentOnline = (extraFilters = {}, isLivechatEnabledWhenAgentIdle?: boolean): Filter => ({ statusLivechat: 'available', roles: 'livechat-agent', From 402aad3290f1e8c371a14cc6fcd8b97edd162ff1 Mon Sep 17 00:00:00 2001 From: Nazareno Bucciarelli Date: Wed, 8 Apr 2026 19:29:26 -0300 Subject: [PATCH 28/44] change websocket variables naming --- .../tests/end-to-end/api/livechat/00-rooms.ts | 20 ++++--- .../end-to-end/api/livechat/24-routing.ts | 60 +++++++++---------- 2 files changed, 42 insertions(+), 38 deletions(-) diff --git a/apps/meteor/tests/end-to-end/api/livechat/00-rooms.ts b/apps/meteor/tests/end-to-end/api/livechat/00-rooms.ts index 35b69cdf6be6f..3f1dfc5f51c1e 100644 --- a/apps/meteor/tests/end-to-end/api/livechat/00-rooms.ts +++ b/apps/meteor/tests/end-to-end/api/livechat/00-rooms.ts @@ -1553,10 +1553,10 @@ describe('LIVECHAT - rooms', () => { await updateSetting('Livechat_Routing_Method', 'Manual_Selection'); await updateSetting('Livechat_enabled_when_agent_idle', false); const { department: initialDepartment } = await createDepartmentWithAnOnlineAgent(); - const { department: forwardToOfflineDepartment, ws } = await createDepartmentWithAnAwayAgent({ + const { department: forwardToOfflineDepartment, ws: awayAgentWebSocket } = await createDepartmentWithAnAwayAgent({ allowReceiveForwardOffline: true, }); - sockets.push(ws); + sockets.push(awayAgentWebSocket); const newVisitor = await createVisitor(initialDepartment._id); const newRoom = await createLivechatRoom(newVisitor.token); @@ -1682,8 +1682,10 @@ describe('LIVECHAT - rooms', () => { async () => { await updateSetting('Livechat_Routing_Method', 'Auto_Selection'); const { department: initialDepartment } = await createDepartmentWithAnOnlineAgent(); - const { department: forwardToOfflineDepartment, ws } = await createDepartmentWithAnAwayAgent({ allowReceiveForwardOffline: true }); - sockets.push(ws); + const { department: forwardToOfflineDepartment, ws: awayAgentWebSocket } = await createDepartmentWithAnAwayAgent({ + allowReceiveForwardOffline: true, + }); + sockets.push(awayAgentWebSocket); const newVisitor = await createVisitor(initialDepartment._id); const newRoom = await createLivechatRoom(newVisitor.token); @@ -1722,8 +1724,10 @@ describe('LIVECHAT - rooms', () => { async () => { await updateSetting('Livechat_Routing_Method', 'Auto_Selection'); const { department: initialDepartment } = await createDepartmentWithAnOnlineAgent(); - const { department: forwardToOfflineDepartment, ws } = await createDepartmentWithAnAwayAgent({ allowReceiveForwardOffline: false }); - sockets.push(ws); + const { department: forwardToOfflineDepartment, ws: awayAgentWebSocket } = await createDepartmentWithAnAwayAgent({ + allowReceiveForwardOffline: false, + }); + sockets.push(awayAgentWebSocket); const newVisitor = await createVisitor(initialDepartment._id); const newRoom = await createLivechatRoom(newVisitor.token); @@ -1803,11 +1807,11 @@ describe('LIVECHAT - rooms', () => { const { department: forwardToOfflineDepartment, agent, - ws, + ws: awayAgentWebSocket, } = await createDepartmentWithAnAwayAgent({ allowReceiveForwardOffline: true, }); - sockets.push(ws); + sockets.push(awayAgentWebSocket); const newVisitor = await createVisitor(initialDepartment._id); const newRoom = await createLivechatRoom(newVisitor.token); diff --git a/apps/meteor/tests/end-to-end/api/livechat/24-routing.ts b/apps/meteor/tests/end-to-end/api/livechat/24-routing.ts index e6eaa6bb47915..c4621dc38174a 100644 --- a/apps/meteor/tests/end-to-end/api/livechat/24-routing.ts +++ b/apps/meteor/tests/end-to-end/api/livechat/24-routing.ts @@ -356,13 +356,13 @@ import { IS_EE } from '../../../e2e/config/constants'; it('should not route to an idle user', async () => { await updateSetting('Livechat_enabled_when_agent_idle', false); await setUserStatus(testUser.credentials, UserStatus.AWAY); - const ws1 = await ddpLogin(testUser.credentials['X-Auth-Token']); - sockets.push(ws1); - await setUserAwayWS(ws1); + const firstAwayUserWebSocket = await ddpLogin(testUser.credentials['X-Auth-Token']); + sockets.push(firstAwayUserWebSocket); + await setUserAwayWS(firstAwayUserWebSocket); await setUserStatus(testUser3.credentials, UserStatus.AWAY); - const ws2 = await ddpLogin(testUser3.credentials['X-Auth-Token']); - sockets.push(ws2); - await setUserAwayWS(ws2); + const secondAwayUserWebSocket = await ddpLogin(testUser3.credentials['X-Auth-Token']); + sockets.push(secondAwayUserWebSocket); + await setUserAwayWS(secondAwayUserWebSocket); // Agent is available but should be ignored await switchLivechatStatus('available', testUser.credentials); @@ -514,13 +514,13 @@ import { IS_EE } from '../../../e2e/config/constants'; it('should not route to an idle user', async () => { await updateSetting('Livechat_enabled_when_agent_idle', false); await setUserStatus(testUser.credentials, UserStatus.AWAY); - const ws1 = await ddpLogin(testUser.credentials['X-Auth-Token']); - sockets.push(ws1); - await setUserAwayWS(ws1); + const firstAwayUserWebSocket = await ddpLogin(testUser.credentials['X-Auth-Token']); + sockets.push(firstAwayUserWebSocket); + await setUserAwayWS(firstAwayUserWebSocket); await setUserStatus(testUser2.credentials, UserStatus.AWAY); - const ws2 = await ddpLogin(testUser2.credentials['X-Auth-Token']); - sockets.push(ws2); - await setUserAwayWS(ws2); + const secondAwayUserWebSocket = await ddpLogin(testUser2.credentials['X-Auth-Token']); + sockets.push(secondAwayUserWebSocket); + await setUserAwayWS(secondAwayUserWebSocket); // Agent is available but should be ignored await switchLivechatStatus('available', testUser.credentials); @@ -534,13 +534,13 @@ import { IS_EE } from '../../../e2e/config/constants'; it('should route to agents even if theyre idle when setting is enabled', async () => { await updateSetting('Livechat_enabled_when_agent_idle', true); await setUserStatus(testUser.credentials, UserStatus.AWAY); - const ws1 = await ddpLogin(testUser.credentials['X-Auth-Token']); - sockets.push(ws1); - await setUserAwayWS(ws1); + const firstAwayUserWebSocket = await ddpLogin(testUser.credentials['X-Auth-Token']); + sockets.push(firstAwayUserWebSocket); + await setUserAwayWS(firstAwayUserWebSocket); await setUserStatus(testUser2.credentials, UserStatus.AWAY); - const ws2 = await ddpLogin(testUser2.credentials['X-Auth-Token']); - sockets.push(ws2); - await setUserAwayWS(ws2); + const secondAwayUserWebSocket = await ddpLogin(testUser2.credentials['X-Auth-Token']); + sockets.push(secondAwayUserWebSocket); + await setUserAwayWS(secondAwayUserWebSocket); const visitor = await createVisitor(testDepartment._id); const room = await createLivechatRoom(visitor.token); @@ -633,13 +633,13 @@ import { IS_EE } from '../../../e2e/config/constants'; it('should not route to an idle user', async () => { await updateSetting('Livechat_enabled_when_agent_idle', false); await setUserStatus(testUser.credentials, UserStatus.AWAY); - const ws1 = await ddpLogin(testUser.credentials['X-Auth-Token']); - sockets.push(ws1); - await setUserAwayWS(ws1); + const firstAwayUserWebSocket = await ddpLogin(testUser.credentials['X-Auth-Token']); + sockets.push(firstAwayUserWebSocket); + await setUserAwayWS(firstAwayUserWebSocket); await setUserStatus(testUser2.credentials, UserStatus.AWAY); - const ws2 = await ddpLogin(testUser2.credentials['X-Auth-Token']); - sockets.push(ws2); - await setUserAwayWS(ws2); + const secondAwayUserWebSocket = await ddpLogin(testUser2.credentials['X-Auth-Token']); + sockets.push(secondAwayUserWebSocket); + await setUserAwayWS(secondAwayUserWebSocket); // Agent is available but should be ignored await switchLivechatStatus('available', testUser.credentials); @@ -653,13 +653,13 @@ import { IS_EE } from '../../../e2e/config/constants'; it('should route to agents even if theyre idle when setting is enabled', async () => { await updateSetting('Livechat_enabled_when_agent_idle', true); await setUserStatus(testUser.credentials, UserStatus.AWAY); - const ws1 = await ddpLogin(testUser.credentials['X-Auth-Token']); - sockets.push(ws1); - await setUserAwayWS(ws1); + const firstAwayUserWebSocket = await ddpLogin(testUser.credentials['X-Auth-Token']); + sockets.push(firstAwayUserWebSocket); + await setUserAwayWS(firstAwayUserWebSocket); await setUserStatus(testUser2.credentials, UserStatus.AWAY); - const ws2 = await ddpLogin(testUser2.credentials['X-Auth-Token']); - sockets.push(ws2); - await setUserAwayWS(ws2); + const secondAwayUserWebSocket = await ddpLogin(testUser2.credentials['X-Auth-Token']); + sockets.push(secondAwayUserWebSocket); + await setUserAwayWS(secondAwayUserWebSocket); const visitor = await createVisitor(testDepartment._id); const room = await createLivechatRoom(visitor.token); From f4c0f10668e6172c13569cbcc955b9d450ded16f Mon Sep 17 00:00:00 2001 From: Kevin Aleman Date: Mon, 13 Apr 2026 09:17:36 -0600 Subject: [PATCH 29/44] Update .changeset/purple-boxes-shout.md --- .changeset/purple-boxes-shout.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.changeset/purple-boxes-shout.md b/.changeset/purple-boxes-shout.md index 2fe8eca446613..a6884404692c0 100644 --- a/.changeset/purple-boxes-shout.md +++ b/.changeset/purple-boxes-shout.md @@ -3,4 +3,4 @@ '@rocket.chat/meteor': minor --- -Updates the behavior of the `Livechat_enabled_when_agent_idle` setting. When enabled, the routing query now excludes agents with an offline status, ensuring visitors are not assigned to them. +Updates the behavior of the `Livechat_enabled_when_agent_idle` setting. When enabled, the routing query now excludes `offline` agents, ensuring no new conversations are assigned to them. From d03991ef2cddf5f3d3efad2c4caeb298a008f2a9 Mon Sep 17 00:00:00 2001 From: Kevin Aleman Date: Mon, 13 Apr 2026 09:21:32 -0600 Subject: [PATCH 30/44] Update .changeset/purple-boxes-shout.md Co-authored-by: cubic-dev-ai[bot] <191113872+cubic-dev-ai[bot]@users.noreply.github.com> --- .changeset/purple-boxes-shout.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.changeset/purple-boxes-shout.md b/.changeset/purple-boxes-shout.md index a6884404692c0..9e15fbd28faaf 100644 --- a/.changeset/purple-boxes-shout.md +++ b/.changeset/purple-boxes-shout.md @@ -3,4 +3,4 @@ '@rocket.chat/meteor': minor --- -Updates the behavior of the `Livechat_enabled_when_agent_idle` setting. When enabled, the routing query now excludes `offline` agents, ensuring no new conversations are assigned to them. +Updates omnichannel routing so agents with `offline` status are always excluded from assignment. The `Livechat_enabled_when_agent_idle` setting now only affects agents with `away` status. From 928583fde67c9c8749bd4e43f77541c5e389fbbf Mon Sep 17 00:00:00 2001 From: Nazareno Bucciarelli Date: Mon, 13 Apr 2026 21:07:09 -0300 Subject: [PATCH 31/44] remove ddpLogin from livechat test --- .../meteor/tests/e2e/omnichannel/omnichannel-livechat.spec.ts | 4 ---- 1 file changed, 4 deletions(-) diff --git a/apps/meteor/tests/e2e/omnichannel/omnichannel-livechat.spec.ts b/apps/meteor/tests/e2e/omnichannel/omnichannel-livechat.spec.ts index 035b66f2bb9a7..2822a9c691321 100644 --- a/apps/meteor/tests/e2e/omnichannel/omnichannel-livechat.spec.ts +++ b/apps/meteor/tests/e2e/omnichannel/omnichannel-livechat.spec.ts @@ -1,6 +1,5 @@ import type { Page } from 'playwright-core'; -import { ddpLogin } from '../../data/users.helper'; import { createFakeVisitor } from '../../mocks/data'; import { createAuxContext } from '../fixtures/createAuxContext'; import { Users } from '../fixtures/userStates'; @@ -265,11 +264,9 @@ test.describe('OC - Livechat - Resume chat after closing', () => { test.describe('OC - Livechat - Close chat using widget', () => { let poLiveChat: OmnichannelLiveChat; let agent: Awaited>; - let ws: WebSocket; test.beforeAll(async ({ api }) => { agent = await createAgent(api, 'user1'); - ws = await ddpLogin(Users.user1.data.loginToken); }); test.beforeEach(async ({ page, api }) => { @@ -280,7 +277,6 @@ test.describe('OC - Livechat - Close chat using widget', () => { test.afterAll(async () => { await agent.delete(); - ws.close(); }); test('OC - Livechat - Close Chat', async () => { From a4845d943a03d90c016134915063ec914607a64c Mon Sep 17 00:00:00 2001 From: Nazareno Bucciarelli Date: Tue, 14 Apr 2026 10:45:05 -0300 Subject: [PATCH 32/44] update e2e tests to not use ddpLogin --- .../omnichannel-assign-room-tags.spec.ts | 29 +++++++++------- .../omnichannel-custom-field-usage.spec.ts | 34 ++++++++----------- 2 files changed, 31 insertions(+), 32 deletions(-) diff --git a/apps/meteor/tests/e2e/omnichannel/omnichannel-assign-room-tags.spec.ts b/apps/meteor/tests/e2e/omnichannel/omnichannel-assign-room-tags.spec.ts index 6e45a358f869e..97b089f882c46 100644 --- a/apps/meteor/tests/e2e/omnichannel/omnichannel-assign-room-tags.spec.ts +++ b/apps/meteor/tests/e2e/omnichannel/omnichannel-assign-room-tags.spec.ts @@ -1,4 +1,3 @@ -import { ddpLogin } from '../../data/users.helper'; import { createFakeVisitor } from '../../mocks/data'; import { IS_EE } from '../config/constants'; import { Users } from '../fixtures/userStates'; @@ -27,7 +26,6 @@ test.describe('OC - Tags Visibility', () => { let tagB: Awaited>; let globalTag: Awaited>; let sharedTag: Awaited>; - let ws: WebSocket; test.beforeAll('Create departments', async ({ api }) => { expect((await setSettingValueById(api, 'Omnichannel_enable_department_removal', true)).status()).toBe(200); @@ -37,7 +35,6 @@ test.describe('OC - Tags Visibility', () => { test.beforeAll('Create agent', async ({ api }) => { agent = await createAgent(api, 'user1'); - ws = await ddpLogin(Users.user1.data.loginToken); }); test.beforeAll('Add agents to departments', async ({ api }) => { @@ -56,16 +53,25 @@ test.describe('OC - Tags Visibility', () => { }); }); - test.beforeAll('Create conversations', async ({ api }) => { - conversations = await Promise.all([ - createConversation(api, { visitorName: visitorA.name, agentId: 'user1', departmentId: departmentA.data._id }), - createConversation(api, { visitorName: visitorB.name, agentId: 'user1', departmentId: departmentB.data._id }), - ]); - }); - - test.beforeEach(async ({ page }) => { + test.beforeEach(async ({ page, api }) => { poOmnichannel = new HomeOmnichannel(page); await page.goto('/'); + await poOmnichannel.waitForHome(); + + if (conversations.length === 0) { + conversations = await Promise.all([ + createConversation(api, { + visitorName: visitorA.name, + agentId: 'user1', + departmentId: departmentA.data._id, + }), + createConversation(api, { + visitorName: visitorB.name, + agentId: 'user1', + departmentId: departmentB.data._id, + }), + ]); + } }); test.afterAll(async ({ api }) => { @@ -74,7 +80,6 @@ test.describe('OC - Tags Visibility', () => { await agent.delete(); await departmentA.delete(); await departmentB.delete(); - ws.close(); expect((await setSettingValueById(api, 'Omnichannel_enable_department_removal', false)).status()).toBe(200); }); diff --git a/apps/meteor/tests/e2e/omnichannel/omnichannel-custom-field-usage.spec.ts b/apps/meteor/tests/e2e/omnichannel/omnichannel-custom-field-usage.spec.ts index e059f54e4d1cd..2c537eae01aca 100644 --- a/apps/meteor/tests/e2e/omnichannel/omnichannel-custom-field-usage.spec.ts +++ b/apps/meteor/tests/e2e/omnichannel/omnichannel-custom-field-usage.spec.ts @@ -1,6 +1,5 @@ import { faker } from '@faker-js/faker'; -import { ddpLogin } from '../../data/users.helper'; import { createFakeVisitor } from '../../mocks/data'; import { Users } from '../fixtures/userStates'; import { HomeOmnichannel } from '../page-objects'; @@ -31,16 +30,11 @@ test.describe.serial('OC - Custom fields usage, scope : room and visitor', () => let conversation: Awaited>; let roomCustomField: Awaited>; let visitorCustomField: Awaited>; - let ws: WebSocket; test.beforeAll('Set up agent, manager and custom fields', async ({ api }) => { - [agent, manager, ws] = await Promise.all([ - createAgent(api, 'user1'), - createManager(api, 'user1'), - ddpLogin(Users.user1.data.loginToken), - ]); + [agent, manager] = await Promise.all([createAgent(api, 'user1'), createManager(api, 'user1')]); - [roomCustomField, visitorCustomField, conversation] = await Promise.all([ + [roomCustomField, visitorCustomField] = await Promise.all([ createCustomField(api, { field: roomCustomFieldLabel, label: roomCustomFieldName, @@ -51,12 +45,19 @@ test.describe.serial('OC - Custom fields usage, scope : room and visitor', () => label: visitorCustomFieldName, scope: 'visitor', }), - createConversation(api, { - visitorName: visitor.name, - agentId: 'user1', - visitorToken, - }), ]); + }); + + test.beforeEach(async ({ page, api }) => { + poHomeChannel = new HomeOmnichannel(page); + await page.goto('/'); + await poHomeChannel.waitForHome(); + + conversation = await createConversation(api, { + visitorName: visitor.name, + agentId: 'user1', + visitorToken, + }); await setVisitorCustomFieldValue(api, { token: visitorToken, @@ -65,15 +66,8 @@ test.describe.serial('OC - Custom fields usage, scope : room and visitor', () => }); }); - test.beforeEach(async ({ page }) => { - poHomeChannel = new HomeOmnichannel(page); - await page.goto('/'); - await poHomeChannel.waitForHome(); - }); - test.afterAll('Remove agent, manager, custom fields and conversation', async () => { await Promise.all([agent.delete(), manager.delete(), roomCustomField.delete(), visitorCustomField.delete(), conversation.delete()]); - ws?.close(); }); test('Should be allowed to set room custom field for a conversation', async () => { From b047bc04bcb35c91758e1c7025d7b2bfed7ac930 Mon Sep 17 00:00:00 2001 From: Nazareno Bucciarelli Date: Tue, 14 Apr 2026 17:20:44 -0300 Subject: [PATCH 33/44] implement omnichannel room forward e2e tests --- .../omnichannel-rooms-forward.spec.ts | 258 ++++++++++++++++++ .../e2e/utils/omnichannel/departments.ts | 3 + 2 files changed, 261 insertions(+) create mode 100644 apps/meteor/tests/e2e/omnichannel/omnichannel-rooms-forward.spec.ts diff --git a/apps/meteor/tests/e2e/omnichannel/omnichannel-rooms-forward.spec.ts b/apps/meteor/tests/e2e/omnichannel/omnichannel-rooms-forward.spec.ts new file mode 100644 index 0000000000000..0ea4c8bbf0490 --- /dev/null +++ b/apps/meteor/tests/e2e/omnichannel/omnichannel-rooms-forward.spec.ts @@ -0,0 +1,258 @@ +import type { Page } from '@playwright/test'; +import type { IRoom } from '@rocket.chat/core-typings'; + +import { sleep } from '../../../lib/utils/sleep'; +import { createFakeVisitor } from '../../mocks/data'; +import { IS_EE } from '../config/constants'; +import { createAuxContext } from '../fixtures/createAuxContext'; +import { Users } from '../fixtures/userStates'; +import { HomeOmnichannel } from '../page-objects'; +import { OmnichannelLiveChat } from '../page-objects/omnichannel'; +import { setSettingValueById } from '../utils'; +import { createAgent } from '../utils/omnichannel/agents'; +import { addAgentToDepartment, createDepartment } from '../utils/omnichannel/departments'; +import { createManager } from '../utils/omnichannel/managers'; +import { test, expect } from '../utils/test'; + +test.use({ storageState: Users.user1.state }); + +test.describe('OC - Forwarding to away agents (EE)', () => { + test.skip(!IS_EE, 'Enterprise Edition Only'); + + let poHomeOmnichannel: HomeOmnichannel; + let poLivechat: OmnichannelLiveChat; + + let livechatPage: Page; + let omnichannelPage: Page; + + let manager: Awaited>; + let onlineAgent: Awaited>; + let awayAgent: Awaited>; + let visitor: { name: string; email: string }; + + let initialDepartment: Awaited>; + let forwardToOfflineDepartment: Awaited>; + + test.beforeAll(async ({ api }) => { + [manager, onlineAgent, awayAgent, initialDepartment, forwardToOfflineDepartment] = await Promise.all([ + createManager(api, 'user1'), + createAgent(api, 'user1'), + createAgent(api, 'user2'), + createDepartment(api, { name: 'Initial Dept' }), + createDepartment(api, { name: 'Forward Dept', allowReceiveForwardOffline: true }), + setSettingValueById(api, 'Accounts_Default_User_Preferences_idleTimeLimit', 300), + expect((await setSettingValueById(api, 'Omnichannel_enable_department_removal', true)).status()).toBe(200), + ]); + + await Promise.all([ + addAgentToDepartment(api, { department: initialDepartment.data, agentId: 'user1' }), + addAgentToDepartment(api, { department: forwardToOfflineDepartment.data, agentId: 'user2' }), + ]); + }); + + test.beforeEach(async ({ page, browser, api }) => { + visitor = createFakeVisitor(); + poHomeOmnichannel = new HomeOmnichannel(page); + + ({ page: livechatPage } = await createAuxContext(browser, Users.user1, '/livechat', false)); + poLivechat = new OmnichannelLiveChat(livechatPage, api); + + await page.goto('/'); + await page.locator('#main-content').waitFor(); + }); + + test.afterEach(async ({ api }) => { + if (livechatPage) await livechatPage.context().close(); + if (omnichannelPage) await omnichannelPage.context().close(); + + await setSettingValueById(api, 'Livechat_waiting_queue', false); + await setSettingValueById(api, 'Accounts_Default_User_Preferences_idleTimeLimit', 300); + }); + + test.afterAll(async ({ api }) => { + await Promise.all([ + initialDepartment.delete(), + forwardToOfflineDepartment.delete(), + manager.delete(), + onlineAgent.delete(), + awayAgent.delete(), + setSettingValueById(api, 'Livechat_Routing_Method', 'Auto_Selection'), + setSettingValueById(api, 'Livechat_enabled_when_agent_idle', false), + expect((await setSettingValueById(api, 'Omnichannel_enable_department_removal', false)).status()).toBe(200), + ]); + }); + + test('when manager forward to offline (agent away, accept when agent idle off) department the inquiry should be set to the queue', async ({ + api, + browser, + }) => { + await test.step('Setup routing settings', async () => { + await setSettingValueById(api, 'Livechat_Routing_Method', 'Manual_Selection'); + await setSettingValueById(api, 'Livechat_enabled_when_agent_idle', false); + }); + + await test.step('Visitor initiates chat', async () => { + await poLivechat.page.reload(); + await poLivechat.openAnyLiveChat(); + await poLivechat.sendMessage(visitor, false); + await poLivechat.onlineAgentMessage.fill('test'); + await poLivechat.btnSendMessageToOnlineAgent.click(); + }); + + await test.step('Set user2 away by idle timeout', async () => { + await setSettingValueById(api, 'Accounts_Default_User_Preferences_idleTimeLimit', 1); + ({ page: omnichannelPage } = await createAuxContext(browser, Users.user2, '/', false)); + await sleep(2000); // we give the agent time to go statusConnection: away + }); + + await test.step('Manager forwards chat', async () => { + await poHomeOmnichannel.sidebar.getSidebarItemByName(visitor.name).click(); + await poHomeOmnichannel.quickActionsRoomToolbar.forwardChat(); + await poHomeOmnichannel.content.forwardChatModal.selectDepartment('Forward Dept'); + await poHomeOmnichannel.content.forwardChatModal.btnForward.click(); + await expect(poHomeOmnichannel.content.forwardChatModal.btnForward).not.toBeVisible(); + }); + + await test.step('Check inquiry status via API is queued', async () => { + const roomInfoResp = await api.get(`/livechat/rooms`); + const roomBody = await roomInfoResp.json(); + const roomId = roomBody.rooms.find((room: IRoom) => room.fname === visitor.name)._id; + + const inquiryResp = await api.get(`/livechat/inquiries.getOne?roomId=${roomId}`); + const inquiryBody = await inquiryResp.json(); + + expect(inquiryBody.inquiry.status).toBe('queued'); + expect(inquiryBody.inquiry.department).toBe(forwardToOfflineDepartment.data._id); + }); + }); + + test('when manager forward to a department while waiting_queue is active and allowReceiveForwardOffline is true, chat should end in departments queue', async ({ + api, + browser, + }) => { + await test.step('Setup routing settings', async () => { + await setSettingValueById(api, 'Livechat_Routing_Method', 'Auto_Selection'); + }); + + await test.step('Visitor initiates chat', async () => { + await poLivechat.page.reload(); + await poLivechat.openAnyLiveChat(); + await poLivechat.sendMessage(visitor, false); + await poLivechat.onlineAgentMessage.fill('test'); + await poLivechat.btnSendMessageToOnlineAgent.click(); + }); + + await test.step('Set user2 away by idle timeout', async () => { + await setSettingValueById(api, 'Accounts_Default_User_Preferences_idleTimeLimit', 1); + ({ page: omnichannelPage } = await createAuxContext(browser, Users.user2, '/', false)); + await sleep(2000); // we give the agent time to go statusConnection: away + }); + + await test.step('Manager enables queue and forwards chat', async () => { + await poHomeOmnichannel.sidebar.getSidebarItemByName(visitor.name).click(); + await setSettingValueById(api, 'Livechat_waiting_queue', true); + + await poHomeOmnichannel.quickActionsRoomToolbar.forwardChat(); + await poHomeOmnichannel.content.forwardChatModal.selectDepartment('Forward Dept'); + await poHomeOmnichannel.content.forwardChatModal.btnForward.click(); + await expect(poHomeOmnichannel.content.forwardChatModal.btnForward).not.toBeVisible(); + }); + + await test.step('Check inquiry status via API is queued', async () => { + const roomInfoResp = await api.get(`/livechat/rooms`); + const roomBody = await roomInfoResp.json(); + const roomId = roomBody.rooms.find((room: IRoom) => room.fname === visitor.name)._id; + + const inquiryResp = await api.get(`/livechat/inquiries.getOne?roomId=${roomId}`); + const inquiryBody = await inquiryResp.json(); + + expect(inquiryBody.inquiry.status).toBe('queued'); + expect(inquiryBody.inquiry.department).toBe(forwardToOfflineDepartment.data._id); + }); + }); + + test('when manager forward to a department while waiting_queue is active and allowReceiveForwardOffline is false, transfer should fail', async ({ + page, + api, + browser, + }) => { + await test.step('Setup routing and department settings', async () => { + await setSettingValueById(api, 'Livechat_Routing_Method', 'Auto_Selection'); + await api.put(`/livechat/department/${forwardToOfflineDepartment.data._id}`, { + department: { ...forwardToOfflineDepartment.data, allowReceiveForwardOffline: false }, + }); + }); + + await test.step('Visitor initiates chat', async () => { + await poLivechat.page.reload(); + await poLivechat.openAnyLiveChat(); + await poLivechat.sendMessage(visitor, false); + await poLivechat.onlineAgentMessage.fill('test'); + await poLivechat.btnSendMessageToOnlineAgent.click(); + }); + + await test.step('Set user2 away by idle timeout', async () => { + await setSettingValueById(api, 'Accounts_Default_User_Preferences_idleTimeLimit', 1); + ({ page: omnichannelPage } = await createAuxContext(browser, Users.user2, '/', false)); + await sleep(2000); // we give the agent time to go statusConnection: away + }); + + await test.step('Manager attempts to forward and sees error', async () => { + await poHomeOmnichannel.sidebar.getSidebarItemByName(visitor.name).click(); + await setSettingValueById(api, 'Livechat_waiting_queue', true); + + await poHomeOmnichannel.quickActionsRoomToolbar.forwardChat(); + await poHomeOmnichannel.content.forwardChatModal.selectDepartment('Forward Dept'); + await poHomeOmnichannel.content.forwardChatModal.btnForward.click(); + + await expect(page.locator('.rcx-toastbar')).toContainText('No agents are available for service on this department.'); + }); + + await test.step('Restore department setting', async () => { + await api.put(`/livechat/department/${forwardToOfflineDepartment.data._id}`, { + department: { ...forwardToOfflineDepartment.data, allowReceiveForwardOffline: true }, + }); + }); + }); + + test('when manager forward to online (agent away, accept when agent idle on) department the inquiry should not be set to the queue', async ({ + api, + browser, + }) => { + await test.step('Setup routing settings', async () => { + await setSettingValueById(api, 'Livechat_Routing_Method', 'Auto_Selection'); + await setSettingValueById(api, 'Livechat_enabled_when_agent_idle', true); + }); + + await test.step('Visitor initiates chat', async () => { + await poLivechat.page.reload(); + await poLivechat.openAnyLiveChat(); + await poLivechat.sendMessage(visitor, false); + await poLivechat.onlineAgentMessage.fill('test'); + await poLivechat.btnSendMessageToOnlineAgent.click(); + }); + + await test.step('Set user2 away by idle timeout', async () => { + await setSettingValueById(api, 'Accounts_Default_User_Preferences_idleTimeLimit', 1); + ({ page: omnichannelPage } = await createAuxContext(browser, Users.user2, '/', false)); + await sleep(2000); // we give the agent time to go statusConnection: away + }); + + await test.step('Manager forwards chat successfully', async () => { + await poHomeOmnichannel.sidebar.getSidebarItemByName(visitor.name).click(); + await poHomeOmnichannel.quickActionsRoomToolbar.forwardChat(); + await poHomeOmnichannel.content.forwardChatModal.selectDepartment('Forward Dept'); + await poHomeOmnichannel.content.forwardChatModal.btnForward.click(); + await expect(poHomeOmnichannel.content.forwardChatModal.btnForward).not.toBeVisible(); + }); + + await test.step('Check room routing via API serves to agent 2', async () => { + const roomInfoResp = await api.get(`/livechat/rooms`); + const roomBody = await roomInfoResp.json(); + const room = roomBody.rooms.find((room: IRoom) => room.fname === visitor.name); + + expect(room.servedBy._id).toBe(awayAgent.data._id); + expect(room.departmentId).toBe(forwardToOfflineDepartment.data._id); + }); + }); +}); diff --git a/apps/meteor/tests/e2e/utils/omnichannel/departments.ts b/apps/meteor/tests/e2e/utils/omnichannel/departments.ts index fb7d8cd14ec32..e268ff8a52ae9 100644 --- a/apps/meteor/tests/e2e/utils/omnichannel/departments.ts +++ b/apps/meteor/tests/e2e/utils/omnichannel/departments.ts @@ -18,6 +18,7 @@ type CreateDepartmentParams = { departmentsAllowedToForward?: string[]; fallbackForwardDepartment?: string; maxNumberSimultaneousChat?: number; + allowReceiveForwardOffline?: boolean; }; export const createDepartment = async ( @@ -37,6 +38,7 @@ export const createDepartment = async ( departmentsAllowedToForward = [], fallbackForwardDepartment = '', maxNumberSimultaneousChat, + allowReceiveForwardOffline, }: CreateDepartmentParams = {}, ) => { const response = await api.post('/livechat/department', { @@ -55,6 +57,7 @@ export const createDepartment = async ( departmentsAllowedToForward, fallbackForwardDepartment, maxNumberSimultaneousChat, + allowReceiveForwardOffline, }, }); From b35f583b35b69d6c9d9452ad453b27b4fba4aa24 Mon Sep 17 00:00:00 2001 From: Nazareno Bucciarelli Date: Tue, 14 Apr 2026 18:19:05 -0300 Subject: [PATCH 34/44] remove converted api tests and ws logic from 00-rooms.ts --- .../tests/end-to-end/api/livechat/00-rooms.ts | 184 ------------------ 1 file changed, 184 deletions(-) diff --git a/apps/meteor/tests/end-to-end/api/livechat/00-rooms.ts b/apps/meteor/tests/end-to-end/api/livechat/00-rooms.ts index 3f1dfc5f51c1e..848e47c5546f7 100644 --- a/apps/meteor/tests/end-to-end/api/livechat/00-rooms.ts +++ b/apps/meteor/tests/end-to-end/api/livechat/00-rooms.ts @@ -25,7 +25,6 @@ import { createCustomField, deleteCustomField } from '../../../data/livechat/cus import type { OnlineAgent } from '../../../data/livechat/department'; import { createDepartmentWith2OnlineAgents, - createDepartmentWithAnAwayAgent, createDepartmentWithAnOfflineAgent, createDepartmentWithAnOnlineAgent, deleteDepartment, @@ -84,7 +83,6 @@ describe('LIVECHAT - rooms', () => { let visitor: ILivechatVisitor; let room: IOmnichannelRoom; let appId: string; - const sockets: WebSocket[] = []; before((done) => getCredentials(done)); @@ -130,11 +128,6 @@ describe('LIVECHAT - rooms', () => { .set(credentials) .expect(200); } - for (const ws of sockets) { - if (ws.readyState === WebSocket.OPEN || ws.readyState === WebSocket.CONNECTING) { - ws.close(); - } - } }); describe('livechat/room', () => { @@ -1547,58 +1540,6 @@ describe('LIVECHAT - rooms', () => { }, ); - (IS_EE ? it : it.skip)( - 'when manager forward to offline (agent away, accept when agent idle off) department the inquiry should be set to the queue', - async () => { - await updateSetting('Livechat_Routing_Method', 'Manual_Selection'); - await updateSetting('Livechat_enabled_when_agent_idle', false); - const { department: initialDepartment } = await createDepartmentWithAnOnlineAgent(); - const { department: forwardToOfflineDepartment, ws: awayAgentWebSocket } = await createDepartmentWithAnAwayAgent({ - allowReceiveForwardOffline: true, - }); - sockets.push(awayAgentWebSocket); - - const newVisitor = await createVisitor(initialDepartment._id); - const newRoom = await createLivechatRoom(newVisitor.token); - - const manager = await createUser(); - const managerCredentials = await login(manager.username, password); - await createManager(manager.username); - - await request.post(api('livechat/room.forward')).set(managerCredentials).send({ - roomId: newRoom._id, - departmentId: forwardToOfflineDepartment._id, - clientAction: true, - comment: 'test comment', - }); - - await request - .get(api(`livechat/queue`)) - .set(credentials) - .query({ - count: 1, - }) - .expect('Content-Type', 'application/json') - .expect(200) - .expect((res: Response) => { - expect(res.body).to.have.property('success', true); - expect(res.body.queue).to.be.an('array'); - expect(res.body.queue[0].chats).not.to.undefined; - expect(res.body).to.have.property('offset'); - expect(res.body).to.have.property('total'); - expect(res.body).to.have.property('count'); - }); - - await Promise.all([ - deleteDepartment(initialDepartment._id), - deleteDepartment(forwardToOfflineDepartment._id), - closeOmnichannelRoom(newRoom._id), - deleteVisitor(newVisitor.token), - deleteUser(manager), - ]); - }, - ); - (IS_EE ? it : it.skip)( 'when manager forwards a chat that hasnt been assigned to a user to another department with no online agents, chat should end ready in department (not queued)', async () => { @@ -1677,88 +1618,6 @@ describe('LIVECHAT - rooms', () => { }, ); - (IS_EE ? it : it.skip)( - 'when manager forward to a department while waiting_queue is active and allowReceiveForwardOffline is true, chat should end in departments queue', - async () => { - await updateSetting('Livechat_Routing_Method', 'Auto_Selection'); - const { department: initialDepartment } = await createDepartmentWithAnOnlineAgent(); - const { department: forwardToOfflineDepartment, ws: awayAgentWebSocket } = await createDepartmentWithAnAwayAgent({ - allowReceiveForwardOffline: true, - }); - sockets.push(awayAgentWebSocket); - - const newVisitor = await createVisitor(initialDepartment._id); - const newRoom = await createLivechatRoom(newVisitor.token); - - const manager = await createUser(); - const managerCredentials = await login(manager.username, password); - await createManager(manager.username); - - // Waiting queue enabled after assignement but before transfer, otherwise, chat will fall on previous test case - await updateSetting('Livechat_waiting_queue', true); - await request.post(api('livechat/room.forward')).set(managerCredentials).send({ - roomId: newRoom._id, - departmentId: forwardToOfflineDepartment._id, - clientAction: true, - comment: 'test comment', - }); - - const inquiry = await fetchInquiry(newRoom._id); - - expect(inquiry.status).to.equal('queued'); - expect(inquiry.department).to.equal(forwardToOfflineDepartment._id); - - await Promise.all([ - deleteDepartment(initialDepartment._id), - deleteDepartment(forwardToOfflineDepartment._id), - closeOmnichannelRoom(newRoom._id), - deleteVisitor(newVisitor.token), - deleteUser(manager), - updateSetting('Livechat_waiting_queue', false), - ]); - }, - ); - - (IS_EE ? it : it.skip)( - 'when manager forward to a department while waiting_queue is active and allowReceiveForwardOffline is false, transfer should fail', - async () => { - await updateSetting('Livechat_Routing_Method', 'Auto_Selection'); - const { department: initialDepartment } = await createDepartmentWithAnOnlineAgent(); - const { department: forwardToOfflineDepartment, ws: awayAgentWebSocket } = await createDepartmentWithAnAwayAgent({ - allowReceiveForwardOffline: false, - }); - sockets.push(awayAgentWebSocket); - - const newVisitor = await createVisitor(initialDepartment._id); - const newRoom = await createLivechatRoom(newVisitor.token); - - const manager = await createUser(); - const managerCredentials = await login(manager.username, password); - await createManager(manager.username); - - // Waiting queue enabled after assignement but before transfer, otherwise, chat will fall on previous test case - await updateSetting('Livechat_waiting_queue', true); - const res = await request.post(api('livechat/room.forward')).set(managerCredentials).send({ - roomId: newRoom._id, - departmentId: forwardToOfflineDepartment._id, - clientAction: true, - comment: 'test comment', - }); - - expect(res.status).to.equal(400); - expect(res.body).to.have.property('error', 'error-no-agents-available-for-service-on-department'); - - await Promise.all([ - deleteDepartment(initialDepartment._id), - deleteDepartment(forwardToOfflineDepartment._id), - updateSetting('Livechat_waiting_queue', false), - closeOmnichannelRoom(newRoom._id), - deleteVisitor(newVisitor.token), - deleteUser(manager), - ]); - }, - ); - (IS_EE ? it : it.skip)( 'when manager forward to a department while waiting_queue is disabled and allowReceiveForwardOffline is false, but department is online, transfer should succeed', async () => { @@ -1798,49 +1657,6 @@ describe('LIVECHAT - rooms', () => { }, ); - (IS_EE ? it : it.skip)( - 'when manager forward to online (agent away, accept when agent idle on) department the inquiry should not be set to the queue', - async () => { - await updateSetting('Livechat_Routing_Method', 'Auto_Selection'); - await updateSetting('Livechat_enabled_when_agent_idle', true); - const { department: initialDepartment } = await createDepartmentWithAnOnlineAgent(); - const { - department: forwardToOfflineDepartment, - agent, - ws: awayAgentWebSocket, - } = await createDepartmentWithAnAwayAgent({ - allowReceiveForwardOffline: true, - }); - sockets.push(awayAgentWebSocket); - const newVisitor = await createVisitor(initialDepartment._id); - const newRoom = await createLivechatRoom(newVisitor.token); - - const manager = await createUser(); - const managerCredentials = await login(manager.username, password); - await createManager(manager.username); - - await request.post(api('livechat/room.forward')).set(managerCredentials).send({ - roomId: newRoom._id, - departmentId: forwardToOfflineDepartment._id, - clientAction: true, - comment: 'test comment', - }); - - const roomInfo = await getLivechatRoomInfo(newRoom._id); - expect(roomInfo.servedBy).to.have.property('_id', agent.user._id); - expect(roomInfo.departmentId).to.be.equal(forwardToOfflineDepartment._id); - - await Promise.all([ - deleteDepartment(initialDepartment._id), - deleteDepartment(forwardToOfflineDepartment._id), - closeOmnichannelRoom(newRoom._id), - deleteVisitor(newVisitor.token), - deleteUser(manager), - updateSetting('Livechat_enabled_when_agent_idle', false), - ]); - }, - ); - (IS_EE ? it : it.skip)( 'when manager forward to a department while waiting_queue is enabled, but department is online, transfer should succeed but it should end queued on target', async () => { From 99144ed7434d7d3e5b1be827bcc34fd065b045c7 Mon Sep 17 00:00:00 2001 From: Nazareno Bucciarelli Date: Tue, 14 Apr 2026 20:51:44 -0300 Subject: [PATCH 35/44] add e2e test for livechat_enabled_when_agent_idle setting --- ...hannel-livechat-agent-idle-setting.spec.ts | 125 ++++++++++++++++++ 1 file changed, 125 insertions(+) create mode 100644 apps/meteor/tests/e2e/omnichannel/omnichannel-livechat-agent-idle-setting.spec.ts diff --git a/apps/meteor/tests/e2e/omnichannel/omnichannel-livechat-agent-idle-setting.spec.ts b/apps/meteor/tests/e2e/omnichannel/omnichannel-livechat-agent-idle-setting.spec.ts new file mode 100644 index 0000000000000..3bdb6e171a5f6 --- /dev/null +++ b/apps/meteor/tests/e2e/omnichannel/omnichannel-livechat-agent-idle-setting.spec.ts @@ -0,0 +1,125 @@ +import type { Page } from '@playwright/test'; + +import { sleep } from '../../../lib/utils/sleep'; +import { createFakeVisitor } from '../../mocks/data'; +import { IS_EE } from '../config/constants'; +import { createAuxContext } from '../fixtures/createAuxContext'; +import { Users } from '../fixtures/userStates'; +import { OmnichannelLiveChat } from '../page-objects/omnichannel'; +import { setSettingValueById } from '../utils'; +import { createAgent } from '../utils/omnichannel/agents'; +import { addAgentToDepartment, createDepartment } from '../utils/omnichannel/departments'; +import { test, expect } from '../utils/test'; + +test.use({ storageState: Users.user1.state }); + +test.describe.only('OC - Routing to Idle Agents', () => { + test.skip(!IS_EE, 'Enterprise Edition Only'); + + let poLivechat: OmnichannelLiveChat; + let livechatPage: Page; + + let agent: Awaited>; + + let visitor: { name: string; email: string }; + + let testDepartment: Awaited>; + + const routingMethods = ['Auto_Selection', 'Load_Balancing', 'Load_Rotation']; + + test.beforeAll(async ({ api }) => { + [agent, testDepartment] = await Promise.all([ + createAgent(api, 'user1'), + createDepartment(api, { name: 'Idle Routing Dept' }), + setSettingValueById(api, 'Accounts_Default_User_Preferences_idleTimeLimit', 300), + expect((await setSettingValueById(api, 'Omnichannel_enable_department_removal', true)).status()).toBe(200), + ]); + + await Promise.all([addAgentToDepartment(api, { department: testDepartment.data, agentId: 'user1' })]); + }); + + test.beforeEach(async ({ page, browser, api }) => { + visitor = createFakeVisitor(); + + ({ page: livechatPage } = await createAuxContext(browser, Users.user1, '/livechat', false)); + poLivechat = new OmnichannelLiveChat(livechatPage, api); + + await page.goto('/'); + await page.locator('#main-content').waitFor(); + }); + + test.afterEach(async ({ api }) => { + if (livechatPage) await livechatPage.context().close(); + + await setSettingValueById(api, 'Accounts_Default_User_Preferences_idleTimeLimit', 300); + }); + + test.afterAll(async ({ api }) => { + await Promise.all([ + testDepartment.delete(), + agent.delete(), + setSettingValueById(api, 'Livechat_Routing_Method', 'Auto_Selection'), + setSettingValueById(api, 'Livechat_enabled_when_agent_idle', false), + expect((await setSettingValueById(api, 'Omnichannel_enable_department_removal', false)).status()).toBe(200), + ]); + }); + + routingMethods.forEach((routingMethod) => { + test.describe(`Routing method: ${routingMethod}`, () => { + test(`should not route to idle agents`, async ({ api, page }) => { + await test.step(`Setup routing method to ${routingMethod} and ignore idle agents`, async () => { + await setSettingValueById(api, 'Livechat_Routing_Method', routingMethod); + await setSettingValueById(api, 'Livechat_enabled_when_agent_idle', false); + }); + + await test.step('Visitor tries to initiate a conversation with the away agent', async () => { + await poLivechat.page.reload(); + await poLivechat.openAnyLiveChat(); + await poLivechat.sendMessage(visitor, false); + await poLivechat.onlineAgentMessage.fill('Hello from visitor'); + + // Force Agent to become away by idle timeout + await setSettingValueById(api, 'Accounts_Default_User_Preferences_idleTimeLimit', 1); + await page.reload(); + await page.locator('#main-content').waitFor(); + + await sleep(2000); // we give the agent time to go statusConnection: away + await poLivechat.btnSendMessageToOnlineAgent.click(); + }); + + await test.step('Verify visitor is not taken by agent', async () => { + await expect( + poLivechat.alertMessage('Error starting a new conversation: Sorry, no online agents [no-agent-online]'), + ).toBeVisible(); + }); + }); + + test(`should route to agents even if they are idle when setting is enabled`, async ({ api, page }) => { + await test.step(`Setup routing method to ${routingMethod} and allow idle agents`, async () => { + await setSettingValueById(api, 'Livechat_Routing_Method', routingMethod); + await setSettingValueById(api, 'Livechat_enabled_when_agent_idle', true); + }); + + await test.step('Force agent to become away by idle timeout', async () => { + await setSettingValueById(api, 'Accounts_Default_User_Preferences_idleTimeLimit', 1); + await page.reload(); + await page.locator('#main-content').waitFor(); + + await sleep(2000); // we give the agent time to go statusConnection: away + }); + + await test.step('Visitor initiates chat', async () => { + await poLivechat.page.reload(); + await poLivechat.openAnyLiveChat(); + await poLivechat.sendMessage(visitor, false); + await poLivechat.onlineAgentMessage.fill('test message'); + await poLivechat.btnSendMessageToOnlineAgent.click(); + }); + + await test.step('Verify chat is served to an agent', async () => { + await expect(poLivechat.headerTitle).toHaveText(agent.data.username); + }); + }); + }); + }); +}); From 17ffb0802ec09df5c887843584b67f0c2a3b078d Mon Sep 17 00:00:00 2001 From: Nazareno Bucciarelli Date: Tue, 14 Apr 2026 20:55:26 -0300 Subject: [PATCH 36/44] remove converted tests from 24-routing.ts --- .../end-to-end/api/livechat/24-routing.ts | 115 +----------------- 1 file changed, 1 insertion(+), 114 deletions(-) diff --git a/apps/meteor/tests/end-to-end/api/livechat/24-routing.ts b/apps/meteor/tests/end-to-end/api/livechat/24-routing.ts index c4621dc38174a..3be747694cd8d 100644 --- a/apps/meteor/tests/end-to-end/api/livechat/24-routing.ts +++ b/apps/meteor/tests/end-to-end/api/livechat/24-routing.ts @@ -1,6 +1,5 @@ import { faker } from '@faker-js/faker'; import type { Credentials } from '@rocket.chat/api-client'; -import { UserStatus } from '@rocket.chat/core-typings'; import type { ILivechatDepartment, IUser } from '@rocket.chat/core-typings'; import { expect } from 'chai'; import { after, before, describe, it } from 'mocha'; @@ -14,24 +13,17 @@ import { createLivechatRoom, getLivechatRoomInfo, makeAgentUnavailable, - switchLivechatStatus, } from '../../../data/livechat/rooms'; import { updateSetting } from '../../../data/permissions.helper'; import { password } from '../../../data/user'; -import { createUser, deleteUser, login, ddpLogin, setUserAwayWS, setUserActiveStatus, setUserStatus } from '../../../data/users.helper'; +import { createUser, deleteUser, login, setUserActiveStatus } from '../../../data/users.helper'; import { IS_EE } from '../../../e2e/config/constants'; (IS_EE ? describe : describe.skip)('Omnichannel - Routing', () => { - const sockets: WebSocket[] = []; before((done) => getCredentials(done)); after(async () => { await updateSetting('Livechat_Routing_Method', 'Manual_Selection'); - for (const ws of sockets) { - if (ws.readyState === WebSocket.OPEN || ws.readyState === WebSocket.CONNECTING) { - ws.close(); - } - } }); // Basically: if there's a bot in the department, it should be assigned to the conversation @@ -353,35 +345,6 @@ import { IS_EE } from '../../../e2e/config/constants'; const roomInfo = await getLivechatRoomInfo(room._id); expect(roomInfo.servedBy).to.be.undefined; }); - it('should not route to an idle user', async () => { - await updateSetting('Livechat_enabled_when_agent_idle', false); - await setUserStatus(testUser.credentials, UserStatus.AWAY); - const firstAwayUserWebSocket = await ddpLogin(testUser.credentials['X-Auth-Token']); - sockets.push(firstAwayUserWebSocket); - await setUserAwayWS(firstAwayUserWebSocket); - await setUserStatus(testUser3.credentials, UserStatus.AWAY); - const secondAwayUserWebSocket = await ddpLogin(testUser3.credentials['X-Auth-Token']); - sockets.push(secondAwayUserWebSocket); - await setUserAwayWS(secondAwayUserWebSocket); - - // Agent is available but should be ignored - await switchLivechatStatus('available', testUser.credentials); - - const visitor = await createVisitor(testDepartment._id); - const room = await createLivechatRoom(visitor.token); - - const roomInfo = await getLivechatRoomInfo(room._id); - expect(roomInfo.servedBy).to.be.undefined; - }); - it('should route to an idle user', async () => { - await updateSetting('Livechat_enabled_when_agent_idle', true); - - const visitor = await createVisitor(testDepartment._id); - const room = await createLivechatRoom(visitor.token); - - const roomInfo = await getLivechatRoomInfo(room._id); - expect(roomInfo.servedBy).to.be.an('object'); - }); it('should route to another available agent if contact manager is unavailable and Omnichannel_contact_manager_routing is enabled', async () => { await makeAgentAvailable(testUser.credentials); const visitor = await createVisitor(testDepartment._id, faker.person.fullName(), visitorEmail); @@ -511,44 +474,6 @@ import { IS_EE } from '../../../e2e/config/constants'; expect(roomInfo.servedBy).to.be.an('object'); expect(roomInfo.servedBy?._id).to.be.equal(testUser2.user._id); }); - it('should not route to an idle user', async () => { - await updateSetting('Livechat_enabled_when_agent_idle', false); - await setUserStatus(testUser.credentials, UserStatus.AWAY); - const firstAwayUserWebSocket = await ddpLogin(testUser.credentials['X-Auth-Token']); - sockets.push(firstAwayUserWebSocket); - await setUserAwayWS(firstAwayUserWebSocket); - await setUserStatus(testUser2.credentials, UserStatus.AWAY); - const secondAwayUserWebSocket = await ddpLogin(testUser2.credentials['X-Auth-Token']); - sockets.push(secondAwayUserWebSocket); - await setUserAwayWS(secondAwayUserWebSocket); - - // Agent is available but should be ignored - await switchLivechatStatus('available', testUser.credentials); - - const visitor = await createVisitor(testDepartment._id); - const room = await createLivechatRoom(visitor.token); - - const roomInfo = await getLivechatRoomInfo(room._id); - expect(roomInfo.servedBy).to.be.undefined; - }); - it('should route to agents even if theyre idle when setting is enabled', async () => { - await updateSetting('Livechat_enabled_when_agent_idle', true); - await setUserStatus(testUser.credentials, UserStatus.AWAY); - const firstAwayUserWebSocket = await ddpLogin(testUser.credentials['X-Auth-Token']); - sockets.push(firstAwayUserWebSocket); - await setUserAwayWS(firstAwayUserWebSocket); - await setUserStatus(testUser2.credentials, UserStatus.AWAY); - const secondAwayUserWebSocket = await ddpLogin(testUser2.credentials['X-Auth-Token']); - sockets.push(secondAwayUserWebSocket); - await setUserAwayWS(secondAwayUserWebSocket); - - const visitor = await createVisitor(testDepartment._id); - const room = await createLivechatRoom(visitor.token); - - const roomInfo = await getLivechatRoomInfo(room._id); - // Not checking who, just checking it's served - expect(roomInfo.servedBy).to.be.an('object'); - }); }); describe('Load Rotation', () => { before(async () => { @@ -630,43 +555,5 @@ import { IS_EE } from '../../../e2e/config/constants'; expect(roomInfo.servedBy).to.be.an('object'); expect(roomInfo.servedBy?._id).to.be.equal(testUser.user._id); }); - it('should not route to an idle user', async () => { - await updateSetting('Livechat_enabled_when_agent_idle', false); - await setUserStatus(testUser.credentials, UserStatus.AWAY); - const firstAwayUserWebSocket = await ddpLogin(testUser.credentials['X-Auth-Token']); - sockets.push(firstAwayUserWebSocket); - await setUserAwayWS(firstAwayUserWebSocket); - await setUserStatus(testUser2.credentials, UserStatus.AWAY); - const secondAwayUserWebSocket = await ddpLogin(testUser2.credentials['X-Auth-Token']); - sockets.push(secondAwayUserWebSocket); - await setUserAwayWS(secondAwayUserWebSocket); - - // Agent is available but should be ignored - await switchLivechatStatus('available', testUser.credentials); - - const visitor = await createVisitor(testDepartment._id); - const room = await createLivechatRoom(visitor.token); - - const roomInfo = await getLivechatRoomInfo(room._id); - expect(roomInfo.servedBy).to.be.undefined; - }); - it('should route to agents even if theyre idle when setting is enabled', async () => { - await updateSetting('Livechat_enabled_when_agent_idle', true); - await setUserStatus(testUser.credentials, UserStatus.AWAY); - const firstAwayUserWebSocket = await ddpLogin(testUser.credentials['X-Auth-Token']); - sockets.push(firstAwayUserWebSocket); - await setUserAwayWS(firstAwayUserWebSocket); - await setUserStatus(testUser2.credentials, UserStatus.AWAY); - const secondAwayUserWebSocket = await ddpLogin(testUser2.credentials['X-Auth-Token']); - sockets.push(secondAwayUserWebSocket); - await setUserAwayWS(secondAwayUserWebSocket); - - const visitor = await createVisitor(testDepartment._id); - const room = await createLivechatRoom(visitor.token); - - const roomInfo = await getLivechatRoomInfo(room._id); - // Not checking who, just checking it's served - expect(roomInfo.servedBy).to.be.an('object'); - }); }); }); From 8dae157e627cd1241c9b886202db98a5f958ef35 Mon Sep 17 00:00:00 2001 From: Nazareno Bucciarelli Date: Tue, 14 Apr 2026 22:14:12 -0300 Subject: [PATCH 37/44] rollback ws logic --- apps/meteor/tests/data/livechat/department.ts | 4 +- apps/meteor/tests/data/livechat/users.ts | 7 +- apps/meteor/tests/data/users.helper.ts | 86 ------------------- 3 files changed, 3 insertions(+), 94 deletions(-) diff --git a/apps/meteor/tests/data/livechat/department.ts b/apps/meteor/tests/data/livechat/department.ts index f8b2ed5298a0e..2064bb8b03b4b 100644 --- a/apps/meteor/tests/data/livechat/department.ts +++ b/apps/meteor/tests/data/livechat/department.ts @@ -175,9 +175,8 @@ export const createDepartmentWithAnAwayAgent = async ({ credentials: Credentials; user: WithRequiredProperty; }; - ws: WebSocket; }> => { - const { user, credentials, ws } = await createAnAwayAgent(); + const { user, credentials } = await createAnAwayAgent(); const department = (await createDepartment({ allowReceiveForwardOffline, @@ -193,7 +192,6 @@ export const createDepartmentWithAnAwayAgent = async ({ credentials, user, }, - ws, }; }; diff --git a/apps/meteor/tests/data/livechat/users.ts b/apps/meteor/tests/data/livechat/users.ts index 2afc321e8f3a7..8e7b4213aadab 100644 --- a/apps/meteor/tests/data/livechat/users.ts +++ b/apps/meteor/tests/data/livechat/users.ts @@ -5,7 +5,7 @@ import { Random } from '@rocket.chat/random'; import { api, credentials, request } from '../api-data'; import { password } from '../user'; -import { createUser, login, setUserAwayWS, ddpLogin, setUserStatus } from '../users.helper'; +import { createUser, login, setUserAway, setUserStatus } from '../users.helper'; import { createAgent, makeAgentAvailable, makeAgentUnavailable } from './rooms'; export const createBotAgent = async (): Promise<{ @@ -93,23 +93,20 @@ export const createAnOfflineAgent = async (): Promise<{ export const createAnAwayAgent = async (): Promise<{ credentials: Credentials; user: IUser & { username: string }; - ws: WebSocket; }> => { const username = `user.test.${Date.now()}.away`; const email = `${username}.offline@rocket.chat`; const { body } = await request.post(api('users.create')).set(credentials).send({ email, name: username, username, password }); const agent = body.user; const createdUserCredentials = await login(agent.username, password); - const ws = await ddpLogin(createdUserCredentials['X-Auth-Token']); await createAgent(agent.username); await makeAgentAvailable(createdUserCredentials); await setUserStatus(createdUserCredentials, UserStatus.AWAY); - await setUserAwayWS(ws); + await setUserAway(createdUserCredentials); return { credentials: createdUserCredentials, user: agent, - ws, }; }; diff --git a/apps/meteor/tests/data/users.helper.ts b/apps/meteor/tests/data/users.helper.ts index 9a02663b5bb4c..b8835cc5ef79b 100644 --- a/apps/meteor/tests/data/users.helper.ts +++ b/apps/meteor/tests/data/users.helper.ts @@ -172,92 +172,6 @@ export const setUserAway = (overrideCredentials = credentials, config?: IRequest }); }; -const connectWS = (port: string): Promise => - new Promise((resolve, reject) => { - const ws = new WebSocket(`ws://localhost:${port}/websocket`); - - ws.onopen = () => { - ws.addEventListener('message', (event: MessageEvent) => { - const data = JSON.parse(event.data); - if (data.msg === 'ping') { - ws.send(JSON.stringify({ msg: 'pong' })); - } - }); - resolve(ws); - }; - ws.onerror = () => reject(new Error(`WS connection failed on ${port}`)); - }); - -const waitForDDP = (ws: WebSocket, id: string | 'handshake', stringifiedJsonPayload: string): Promise => { - return new Promise((resolve, reject) => { - const timeout = setTimeout(() => { - cleanup(); - ws.close(); - reject(new Error(`Timeout waiting for DDP id: ${id}`)); - }, 5000); - - const cleanup = () => { - clearTimeout(timeout); - ws.removeEventListener('message', handler); - ws.removeEventListener('close', onClose); - ws.removeEventListener('error', onError); - }; - - const onClose = () => { - cleanup(); - reject(new Error(`WS closed while waiting for id: ${id}`)); - }; - - const onError = (error: any) => { - cleanup(); - ws.close(); - reject(error || new Error(`WS error during operation id: ${id}`)); - }; - - const handler = (event: MessageEvent) => { - try { - const data = JSON.parse(event.data); - const isHandshake = id === 'handshake' && data.msg === 'connected'; - const isResult = data.id === id && (data.msg === 'result' || data.msg === 'error'); - - if (isHandshake || isResult) { - cleanup(); - if (data.error) { - ws.close(); - return reject(data.error); - } - resolve(data); - } - } catch (e) { - // Ignore no JSON message - } - }; - - ws.addEventListener('message', handler); - ws.addEventListener('close', onClose); - ws.addEventListener('error', onError); - - ws.send(stringifiedJsonPayload); - }); -}; - -export const ddpLogin = async (resume: string): Promise => { - const ws = await connectWS(process.env.DDP_LOGIN_PORT || '3000'); - const loginId = `login-${Date.now()}`; - - await waitForDDP(ws, 'handshake', JSON.stringify({ msg: 'connect', version: '1', support: ['1'] })); - - await waitForDDP(ws, loginId, JSON.stringify({ msg: 'method', id: loginId, method: 'login', params: [{ resume }] })); - - return ws; -}; - -export const setUserAwayWS = async (ws: WebSocket): Promise => { - const id = `away-${Date.now()}`; - - await waitForDDP(ws, id, JSON.stringify({ msg: 'method', method: 'UserPresence:away', params: [], id })); -}; - export const setUserOnline = (overrideCredentials = credentials, config?: IRequestConfig) => { const requestInstance = config?.request || request; return requestInstance From 9303454e0e836150d6a84e992e4c8fa1ac1c77cc Mon Sep 17 00:00:00 2001 From: Nazareno Bucciarelli Date: Tue, 14 Apr 2026 22:15:48 -0300 Subject: [PATCH 38/44] remove ci-test-e2e.yml added env variable --- .github/workflows/ci-test-e2e.yml | 1 - 1 file changed, 1 deletion(-) diff --git a/.github/workflows/ci-test-e2e.yml b/.github/workflows/ci-test-e2e.yml index f63f33909c0d0..99395082a9fbc 100644 --- a/.github/workflows/ci-test-e2e.yml +++ b/.github/workflows/ci-test-e2e.yml @@ -65,7 +65,6 @@ env: TOOL_NODE_FLAGS: ${{ vars.TOOL_NODE_FLAGS }} LOWERCASE_REPOSITORY: ${{ inputs.lowercase-repo }} DOCKER_TAG: ${{ inputs.gh-docker-tag }}-amd64 - DDP_LOGIN_PORT: 3000 jobs: test: From 489e27f5cd084197055d7d1162404632a5448864 Mon Sep 17 00:00:00 2001 From: Nazareno Bucciarelli Date: Tue, 14 Apr 2026 22:27:37 -0300 Subject: [PATCH 39/44] remove .only --- .../omnichannel/omnichannel-livechat-agent-idle-setting.spec.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/meteor/tests/e2e/omnichannel/omnichannel-livechat-agent-idle-setting.spec.ts b/apps/meteor/tests/e2e/omnichannel/omnichannel-livechat-agent-idle-setting.spec.ts index 3bdb6e171a5f6..daa049587602d 100644 --- a/apps/meteor/tests/e2e/omnichannel/omnichannel-livechat-agent-idle-setting.spec.ts +++ b/apps/meteor/tests/e2e/omnichannel/omnichannel-livechat-agent-idle-setting.spec.ts @@ -13,7 +13,7 @@ import { test, expect } from '../utils/test'; test.use({ storageState: Users.user1.state }); -test.describe.only('OC - Routing to Idle Agents', () => { +test.describe('OC - Routing to Idle Agents', () => { test.skip(!IS_EE, 'Enterprise Edition Only'); let poLivechat: OmnichannelLiveChat; From 484e3c2756270cc945ef0bcb88dae7bdf2b22cf8 Mon Sep 17 00:00:00 2001 From: Nazareno Bucciarelli Date: Wed, 15 Apr 2026 10:40:00 -0300 Subject: [PATCH 40/44] fix livechat test by adding agent browser context --- .../meteor/tests/e2e/omnichannel/omnichannel-livechat.spec.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/apps/meteor/tests/e2e/omnichannel/omnichannel-livechat.spec.ts b/apps/meteor/tests/e2e/omnichannel/omnichannel-livechat.spec.ts index 2822a9c691321..336a0cbc355aa 100644 --- a/apps/meteor/tests/e2e/omnichannel/omnichannel-livechat.spec.ts +++ b/apps/meteor/tests/e2e/omnichannel/omnichannel-livechat.spec.ts @@ -265,8 +265,10 @@ test.describe('OC - Livechat - Close chat using widget', () => { let poLiveChat: OmnichannelLiveChat; let agent: Awaited>; - test.beforeAll(async ({ api }) => { + test.beforeAll(async ({ api, browser }) => { agent = await createAgent(api, 'user1'); + + await createAuxContext(browser, Users.user1, '/', true); }); test.beforeEach(async ({ page, api }) => { From 5aec5db5c52bd33dd9ec703fdab4f7ba749ea864 Mon Sep 17 00:00:00 2001 From: Nazareno Bucciarelli Date: Wed, 15 Apr 2026 11:53:37 -0300 Subject: [PATCH 41/44] improve tests by using element waiter instead of hardcoded sleep and tweak minor things --- ...hannel-livechat-agent-idle-setting.spec.ts | 29 +++---- .../omnichannel-rooms-forward.spec.ts | 80 ++++++++++--------- .../e2e/page-objects/fragments/navbar.ts | 4 + 3 files changed, 58 insertions(+), 55 deletions(-) diff --git a/apps/meteor/tests/e2e/omnichannel/omnichannel-livechat-agent-idle-setting.spec.ts b/apps/meteor/tests/e2e/omnichannel/omnichannel-livechat-agent-idle-setting.spec.ts index daa049587602d..4a1569380b5f6 100644 --- a/apps/meteor/tests/e2e/omnichannel/omnichannel-livechat-agent-idle-setting.spec.ts +++ b/apps/meteor/tests/e2e/omnichannel/omnichannel-livechat-agent-idle-setting.spec.ts @@ -1,10 +1,10 @@ import type { Page } from '@playwright/test'; -import { sleep } from '../../../lib/utils/sleep'; import { createFakeVisitor } from '../../mocks/data'; import { IS_EE } from '../config/constants'; import { createAuxContext } from '../fixtures/createAuxContext'; import { Users } from '../fixtures/userStates'; +import { HomeOmnichannel } from '../page-objects'; import { OmnichannelLiveChat } from '../page-objects/omnichannel'; import { setSettingValueById } from '../utils'; import { createAgent } from '../utils/omnichannel/agents'; @@ -16,14 +16,14 @@ test.use({ storageState: Users.user1.state }); test.describe('OC - Routing to Idle Agents', () => { test.skip(!IS_EE, 'Enterprise Edition Only'); + let poHomeOmnichannel: HomeOmnichannel; let poLivechat: OmnichannelLiveChat; + let livechatPage: Page; let agent: Awaited>; - - let visitor: { name: string; email: string }; - let testDepartment: Awaited>; + let visitor: { name: string; email: string }; const routingMethods = ['Auto_Selection', 'Load_Balancing', 'Load_Rotation']; @@ -40,12 +40,12 @@ test.describe('OC - Routing to Idle Agents', () => { test.beforeEach(async ({ page, browser, api }) => { visitor = createFakeVisitor(); + poHomeOmnichannel = new HomeOmnichannel(page); + await page.goto('/'); + await page.locator('#main-content').waitFor(); ({ page: livechatPage } = await createAuxContext(browser, Users.user1, '/livechat', false)); poLivechat = new OmnichannelLiveChat(livechatPage, api); - - await page.goto('/'); - await page.locator('#main-content').waitFor(); }); test.afterEach(async ({ api }) => { @@ -66,7 +66,7 @@ test.describe('OC - Routing to Idle Agents', () => { routingMethods.forEach((routingMethod) => { test.describe(`Routing method: ${routingMethod}`, () => { - test(`should not route to idle agents`, async ({ api, page }) => { + test(`should not route to idle agents`, async ({ api }) => { await test.step(`Setup routing method to ${routingMethod} and ignore idle agents`, async () => { await setSettingValueById(api, 'Livechat_Routing_Method', routingMethod); await setSettingValueById(api, 'Livechat_enabled_when_agent_idle', false); @@ -80,10 +80,9 @@ test.describe('OC - Routing to Idle Agents', () => { // Force Agent to become away by idle timeout await setSettingValueById(api, 'Accounts_Default_User_Preferences_idleTimeLimit', 1); - await page.reload(); - await page.locator('#main-content').waitFor(); + await poHomeOmnichannel.page.reload(); + await expect(poHomeOmnichannel.navbar.getUserStatusBadge('away')).toBeVisible(); - await sleep(2000); // we give the agent time to go statusConnection: away await poLivechat.btnSendMessageToOnlineAgent.click(); }); @@ -94,7 +93,7 @@ test.describe('OC - Routing to Idle Agents', () => { }); }); - test(`should route to agents even if they are idle when setting is enabled`, async ({ api, page }) => { + test(`should route to agents even if they are idle when setting is enabled`, async ({ api }) => { await test.step(`Setup routing method to ${routingMethod} and allow idle agents`, async () => { await setSettingValueById(api, 'Livechat_Routing_Method', routingMethod); await setSettingValueById(api, 'Livechat_enabled_when_agent_idle', true); @@ -102,10 +101,8 @@ test.describe('OC - Routing to Idle Agents', () => { await test.step('Force agent to become away by idle timeout', async () => { await setSettingValueById(api, 'Accounts_Default_User_Preferences_idleTimeLimit', 1); - await page.reload(); - await page.locator('#main-content').waitFor(); - - await sleep(2000); // we give the agent time to go statusConnection: away + await poHomeOmnichannel.page.reload(); + await expect(poHomeOmnichannel.navbar.getUserStatusBadge('away')).toBeVisible(); }); await test.step('Visitor initiates chat', async () => { diff --git a/apps/meteor/tests/e2e/omnichannel/omnichannel-rooms-forward.spec.ts b/apps/meteor/tests/e2e/omnichannel/omnichannel-rooms-forward.spec.ts index 0ea4c8bbf0490..60424cafc96a8 100644 --- a/apps/meteor/tests/e2e/omnichannel/omnichannel-rooms-forward.spec.ts +++ b/apps/meteor/tests/e2e/omnichannel/omnichannel-rooms-forward.spec.ts @@ -1,7 +1,6 @@ import type { Page } from '@playwright/test'; import type { IRoom } from '@rocket.chat/core-typings'; -import { sleep } from '../../../lib/utils/sleep'; import { createFakeVisitor } from '../../mocks/data'; import { IS_EE } from '../config/constants'; import { createAuxContext } from '../fixtures/createAuxContext'; @@ -19,7 +18,8 @@ test.use({ storageState: Users.user1.state }); test.describe('OC - Forwarding to away agents (EE)', () => { test.skip(!IS_EE, 'Enterprise Edition Only'); - let poHomeOmnichannel: HomeOmnichannel; + let poHomeOmnichannelOnlineAgent: HomeOmnichannel; + let poHomeOmnichannelAwayAgent: HomeOmnichannel; let poLivechat: OmnichannelLiveChat; let livechatPage: Page; @@ -28,10 +28,9 @@ test.describe('OC - Forwarding to away agents (EE)', () => { let manager: Awaited>; let onlineAgent: Awaited>; let awayAgent: Awaited>; - let visitor: { name: string; email: string }; - let initialDepartment: Awaited>; let forwardToOfflineDepartment: Awaited>; + let visitor: { name: string; email: string }; test.beforeAll(async ({ api }) => { [manager, onlineAgent, awayAgent, initialDepartment, forwardToOfflineDepartment] = await Promise.all([ @@ -52,13 +51,12 @@ test.describe('OC - Forwarding to away agents (EE)', () => { test.beforeEach(async ({ page, browser, api }) => { visitor = createFakeVisitor(); - poHomeOmnichannel = new HomeOmnichannel(page); + poHomeOmnichannelOnlineAgent = new HomeOmnichannel(page); + await page.goto('/'); + await page.locator('#main-content').waitFor(); ({ page: livechatPage } = await createAuxContext(browser, Users.user1, '/livechat', false)); poLivechat = new OmnichannelLiveChat(livechatPage, api); - - await page.goto('/'); - await page.locator('#main-content').waitFor(); }); test.afterEach(async ({ api }) => { @@ -99,18 +97,19 @@ test.describe('OC - Forwarding to away agents (EE)', () => { await poLivechat.btnSendMessageToOnlineAgent.click(); }); - await test.step('Set user2 away by idle timeout', async () => { + await test.step('Set user2 agent away by idle timeout', async () => { await setSettingValueById(api, 'Accounts_Default_User_Preferences_idleTimeLimit', 1); ({ page: omnichannelPage } = await createAuxContext(browser, Users.user2, '/', false)); - await sleep(2000); // we give the agent time to go statusConnection: away + poHomeOmnichannelAwayAgent = new HomeOmnichannel(omnichannelPage); + await expect(poHomeOmnichannelAwayAgent.navbar.getUserStatusBadge('away')).toBeVisible(); }); await test.step('Manager forwards chat', async () => { - await poHomeOmnichannel.sidebar.getSidebarItemByName(visitor.name).click(); - await poHomeOmnichannel.quickActionsRoomToolbar.forwardChat(); - await poHomeOmnichannel.content.forwardChatModal.selectDepartment('Forward Dept'); - await poHomeOmnichannel.content.forwardChatModal.btnForward.click(); - await expect(poHomeOmnichannel.content.forwardChatModal.btnForward).not.toBeVisible(); + await poHomeOmnichannelOnlineAgent.sidebar.getSidebarItemByName(visitor.name).click(); + await poHomeOmnichannelOnlineAgent.quickActionsRoomToolbar.forwardChat(); + await poHomeOmnichannelOnlineAgent.content.forwardChatModal.selectDepartment('Forward Dept'); + await poHomeOmnichannelOnlineAgent.content.forwardChatModal.btnForward.click(); + await expect(poHomeOmnichannelOnlineAgent.content.forwardChatModal.btnForward).not.toBeVisible(); }); await test.step('Check inquiry status via API is queued', async () => { @@ -142,20 +141,21 @@ test.describe('OC - Forwarding to away agents (EE)', () => { await poLivechat.btnSendMessageToOnlineAgent.click(); }); - await test.step('Set user2 away by idle timeout', async () => { + await test.step('Set user2 agent away by idle timeout', async () => { await setSettingValueById(api, 'Accounts_Default_User_Preferences_idleTimeLimit', 1); ({ page: omnichannelPage } = await createAuxContext(browser, Users.user2, '/', false)); - await sleep(2000); // we give the agent time to go statusConnection: away + poHomeOmnichannelAwayAgent = new HomeOmnichannel(omnichannelPage); + await expect(poHomeOmnichannelAwayAgent.navbar.getUserStatusBadge('away')).toBeVisible(); }); await test.step('Manager enables queue and forwards chat', async () => { - await poHomeOmnichannel.sidebar.getSidebarItemByName(visitor.name).click(); + await poHomeOmnichannelOnlineAgent.sidebar.getSidebarItemByName(visitor.name).click(); await setSettingValueById(api, 'Livechat_waiting_queue', true); - await poHomeOmnichannel.quickActionsRoomToolbar.forwardChat(); - await poHomeOmnichannel.content.forwardChatModal.selectDepartment('Forward Dept'); - await poHomeOmnichannel.content.forwardChatModal.btnForward.click(); - await expect(poHomeOmnichannel.content.forwardChatModal.btnForward).not.toBeVisible(); + await poHomeOmnichannelOnlineAgent.quickActionsRoomToolbar.forwardChat(); + await poHomeOmnichannelOnlineAgent.content.forwardChatModal.selectDepartment('Forward Dept'); + await poHomeOmnichannelOnlineAgent.content.forwardChatModal.btnForward.click(); + await expect(poHomeOmnichannelOnlineAgent.content.forwardChatModal.btnForward).not.toBeVisible(); }); await test.step('Check inquiry status via API is queued', async () => { @@ -172,7 +172,6 @@ test.describe('OC - Forwarding to away agents (EE)', () => { }); test('when manager forward to a department while waiting_queue is active and allowReceiveForwardOffline is false, transfer should fail', async ({ - page, api, browser, }) => { @@ -191,21 +190,23 @@ test.describe('OC - Forwarding to away agents (EE)', () => { await poLivechat.btnSendMessageToOnlineAgent.click(); }); - await test.step('Set user2 away by idle timeout', async () => { + await test.step('Set user2 agent away by idle timeout', async () => { await setSettingValueById(api, 'Accounts_Default_User_Preferences_idleTimeLimit', 1); ({ page: omnichannelPage } = await createAuxContext(browser, Users.user2, '/', false)); - await sleep(2000); // we give the agent time to go statusConnection: away + poHomeOmnichannelAwayAgent = new HomeOmnichannel(omnichannelPage); + await expect(poHomeOmnichannelAwayAgent.navbar.getUserStatusBadge('away')).toBeVisible(); }); await test.step('Manager attempts to forward and sees error', async () => { - await poHomeOmnichannel.sidebar.getSidebarItemByName(visitor.name).click(); + await poHomeOmnichannelOnlineAgent.sidebar.getSidebarItemByName(visitor.name).click(); await setSettingValueById(api, 'Livechat_waiting_queue', true); - await poHomeOmnichannel.quickActionsRoomToolbar.forwardChat(); - await poHomeOmnichannel.content.forwardChatModal.selectDepartment('Forward Dept'); - await poHomeOmnichannel.content.forwardChatModal.btnForward.click(); - - await expect(page.locator('.rcx-toastbar')).toContainText('No agents are available for service on this department.'); + await poHomeOmnichannelOnlineAgent.quickActionsRoomToolbar.forwardChat(); + await poHomeOmnichannelOnlineAgent.content.forwardChatModal.selectDepartment('Forward Dept'); + await poHomeOmnichannelOnlineAgent.content.forwardChatModal.btnForward.click(); + await expect(poHomeOmnichannelOnlineAgent.page.locator('role=alert')).toContainText( + 'No agents are available for service on this department.', + ); }); await test.step('Restore department setting', async () => { @@ -232,21 +233,22 @@ test.describe('OC - Forwarding to away agents (EE)', () => { await poLivechat.btnSendMessageToOnlineAgent.click(); }); - await test.step('Set user2 away by idle timeout', async () => { + await test.step('Set user2 agent away by idle timeout', async () => { await setSettingValueById(api, 'Accounts_Default_User_Preferences_idleTimeLimit', 1); ({ page: omnichannelPage } = await createAuxContext(browser, Users.user2, '/', false)); - await sleep(2000); // we give the agent time to go statusConnection: away + poHomeOmnichannelAwayAgent = new HomeOmnichannel(omnichannelPage); + await expect(poHomeOmnichannelAwayAgent.navbar.getUserStatusBadge('away')).toBeVisible(); }); await test.step('Manager forwards chat successfully', async () => { - await poHomeOmnichannel.sidebar.getSidebarItemByName(visitor.name).click(); - await poHomeOmnichannel.quickActionsRoomToolbar.forwardChat(); - await poHomeOmnichannel.content.forwardChatModal.selectDepartment('Forward Dept'); - await poHomeOmnichannel.content.forwardChatModal.btnForward.click(); - await expect(poHomeOmnichannel.content.forwardChatModal.btnForward).not.toBeVisible(); + await poHomeOmnichannelOnlineAgent.sidebar.getSidebarItemByName(visitor.name).click(); + await poHomeOmnichannelOnlineAgent.quickActionsRoomToolbar.forwardChat(); + await poHomeOmnichannelOnlineAgent.content.forwardChatModal.selectDepartment('Forward Dept'); + await poHomeOmnichannelOnlineAgent.content.forwardChatModal.btnForward.click(); + await expect(poHomeOmnichannelOnlineAgent.content.forwardChatModal.btnForward).not.toBeVisible(); }); - await test.step('Check room routing via API serves to agent 2', async () => { + await test.step('Check room routing via API serves to away agent', async () => { const roomInfoResp = await api.get(`/livechat/rooms`); const roomBody = await roomInfoResp.json(); const room = roomBody.rooms.find((room: IRoom) => room.fname === visitor.name); diff --git a/apps/meteor/tests/e2e/page-objects/fragments/navbar.ts b/apps/meteor/tests/e2e/page-objects/fragments/navbar.ts index 7a3ad3a8c0e7c..5a6a653645298 100644 --- a/apps/meteor/tests/e2e/page-objects/fragments/navbar.ts +++ b/apps/meteor/tests/e2e/page-objects/fragments/navbar.ts @@ -268,4 +268,8 @@ export class Navbar { const newStatus = await this.btnSwitchOmnichannelStatus.getAttribute('title'); expect(newStatus).toBe(status === 'offline' ? StatusTitleMap.offline : StatusTitleMap.online); } + + getUserStatusBadge(status: 'online' | 'away' | 'busy' | 'offline'): Locator { + return this.btnUserMenu.locator(`svg[class*="${status}"]`); + } } From 851a449b8fb73404adf209140690af29b381bc6c Mon Sep 17 00:00:00 2001 From: Nazareno Bucciarelli Date: Wed, 15 Apr 2026 14:24:50 -0300 Subject: [PATCH 42/44] move setting update to the end of needed test cases --- .../e2e/omnichannel/omnichannel-rooms-forward.spec.ts | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/apps/meteor/tests/e2e/omnichannel/omnichannel-rooms-forward.spec.ts b/apps/meteor/tests/e2e/omnichannel/omnichannel-rooms-forward.spec.ts index 60424cafc96a8..523aa346d96f1 100644 --- a/apps/meteor/tests/e2e/omnichannel/omnichannel-rooms-forward.spec.ts +++ b/apps/meteor/tests/e2e/omnichannel/omnichannel-rooms-forward.spec.ts @@ -63,7 +63,6 @@ test.describe('OC - Forwarding to away agents (EE)', () => { if (livechatPage) await livechatPage.context().close(); if (omnichannelPage) await omnichannelPage.context().close(); - await setSettingValueById(api, 'Livechat_waiting_queue', false); await setSettingValueById(api, 'Accounts_Default_User_Preferences_idleTimeLimit', 300); }); @@ -169,6 +168,10 @@ test.describe('OC - Forwarding to away agents (EE)', () => { expect(inquiryBody.inquiry.status).toBe('queued'); expect(inquiryBody.inquiry.department).toBe(forwardToOfflineDepartment.data._id); }); + + await test.step('Disable waiting queue', async () => { + await setSettingValueById(api, 'Livechat_waiting_queue', false); + }); }); test('when manager forward to a department while waiting_queue is active and allowReceiveForwardOffline is false, transfer should fail', async ({ @@ -214,6 +217,10 @@ test.describe('OC - Forwarding to away agents (EE)', () => { department: { ...forwardToOfflineDepartment.data, allowReceiveForwardOffline: true }, }); }); + + await test.step('Disable waiting queue', async () => { + await setSettingValueById(api, 'Livechat_waiting_queue', false); + }); }); test('when manager forward to online (agent away, accept when agent idle on) department the inquiry should not be set to the queue', async ({ From e7898f23ebcf3c0d2d0e9ec88fd4d860200c6fc4 Mon Sep 17 00:00:00 2001 From: Nazareno Bucciarelli Date: Wed, 15 Apr 2026 14:28:15 -0300 Subject: [PATCH 43/44] use long ifs --- .../omnichannel-livechat-agent-idle-setting.spec.ts | 4 +++- .../e2e/omnichannel/omnichannel-rooms-forward.spec.ts | 8 ++++++-- 2 files changed, 9 insertions(+), 3 deletions(-) diff --git a/apps/meteor/tests/e2e/omnichannel/omnichannel-livechat-agent-idle-setting.spec.ts b/apps/meteor/tests/e2e/omnichannel/omnichannel-livechat-agent-idle-setting.spec.ts index 4a1569380b5f6..7d9fdb4d39421 100644 --- a/apps/meteor/tests/e2e/omnichannel/omnichannel-livechat-agent-idle-setting.spec.ts +++ b/apps/meteor/tests/e2e/omnichannel/omnichannel-livechat-agent-idle-setting.spec.ts @@ -49,7 +49,9 @@ test.describe('OC - Routing to Idle Agents', () => { }); test.afterEach(async ({ api }) => { - if (livechatPage) await livechatPage.context().close(); + if (livechatPage) { + await livechatPage.context().close(); + } await setSettingValueById(api, 'Accounts_Default_User_Preferences_idleTimeLimit', 300); }); diff --git a/apps/meteor/tests/e2e/omnichannel/omnichannel-rooms-forward.spec.ts b/apps/meteor/tests/e2e/omnichannel/omnichannel-rooms-forward.spec.ts index 523aa346d96f1..bdbce796241a1 100644 --- a/apps/meteor/tests/e2e/omnichannel/omnichannel-rooms-forward.spec.ts +++ b/apps/meteor/tests/e2e/omnichannel/omnichannel-rooms-forward.spec.ts @@ -60,8 +60,12 @@ test.describe('OC - Forwarding to away agents (EE)', () => { }); test.afterEach(async ({ api }) => { - if (livechatPage) await livechatPage.context().close(); - if (omnichannelPage) await omnichannelPage.context().close(); + if (livechatPage) { + await livechatPage.context().close(); + } + if (omnichannelPage) { + await omnichannelPage.context().close(); + } await setSettingValueById(api, 'Accounts_Default_User_Preferences_idleTimeLimit', 300); }); From 1de82a7bc772faa338084205ec97c964b66af108 Mon Sep 17 00:00:00 2001 From: Nazareno Bucciarelli Date: Wed, 15 Apr 2026 14:39:23 -0300 Subject: [PATCH 44/44] use toBeOK instead of toBe for 200 status assertion --- .../omnichannel-livechat-agent-idle-setting.spec.ts | 4 ++-- .../tests/e2e/omnichannel/omnichannel-rooms-forward.spec.ts | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/apps/meteor/tests/e2e/omnichannel/omnichannel-livechat-agent-idle-setting.spec.ts b/apps/meteor/tests/e2e/omnichannel/omnichannel-livechat-agent-idle-setting.spec.ts index 7d9fdb4d39421..3f83ac989480e 100644 --- a/apps/meteor/tests/e2e/omnichannel/omnichannel-livechat-agent-idle-setting.spec.ts +++ b/apps/meteor/tests/e2e/omnichannel/omnichannel-livechat-agent-idle-setting.spec.ts @@ -32,7 +32,7 @@ test.describe('OC - Routing to Idle Agents', () => { createAgent(api, 'user1'), createDepartment(api, { name: 'Idle Routing Dept' }), setSettingValueById(api, 'Accounts_Default_User_Preferences_idleTimeLimit', 300), - expect((await setSettingValueById(api, 'Omnichannel_enable_department_removal', true)).status()).toBe(200), + expect(await setSettingValueById(api, 'Omnichannel_enable_department_removal', true)).toBeOK(), ]); await Promise.all([addAgentToDepartment(api, { department: testDepartment.data, agentId: 'user1' })]); @@ -62,7 +62,7 @@ test.describe('OC - Routing to Idle Agents', () => { agent.delete(), setSettingValueById(api, 'Livechat_Routing_Method', 'Auto_Selection'), setSettingValueById(api, 'Livechat_enabled_when_agent_idle', false), - expect((await setSettingValueById(api, 'Omnichannel_enable_department_removal', false)).status()).toBe(200), + expect(await setSettingValueById(api, 'Omnichannel_enable_department_removal', false)).toBeOK(), ]); }); diff --git a/apps/meteor/tests/e2e/omnichannel/omnichannel-rooms-forward.spec.ts b/apps/meteor/tests/e2e/omnichannel/omnichannel-rooms-forward.spec.ts index bdbce796241a1..f7540a9cc09b9 100644 --- a/apps/meteor/tests/e2e/omnichannel/omnichannel-rooms-forward.spec.ts +++ b/apps/meteor/tests/e2e/omnichannel/omnichannel-rooms-forward.spec.ts @@ -40,7 +40,7 @@ test.describe('OC - Forwarding to away agents (EE)', () => { createDepartment(api, { name: 'Initial Dept' }), createDepartment(api, { name: 'Forward Dept', allowReceiveForwardOffline: true }), setSettingValueById(api, 'Accounts_Default_User_Preferences_idleTimeLimit', 300), - expect((await setSettingValueById(api, 'Omnichannel_enable_department_removal', true)).status()).toBe(200), + expect(await setSettingValueById(api, 'Omnichannel_enable_department_removal', true)).toBeOK(), ]); await Promise.all([ @@ -79,7 +79,7 @@ test.describe('OC - Forwarding to away agents (EE)', () => { awayAgent.delete(), setSettingValueById(api, 'Livechat_Routing_Method', 'Auto_Selection'), setSettingValueById(api, 'Livechat_enabled_when_agent_idle', false), - expect((await setSettingValueById(api, 'Omnichannel_enable_department_removal', false)).status()).toBe(200), + expect(await setSettingValueById(api, 'Omnichannel_enable_department_removal', false)).toBeOK(), ]); });