From a6e7812c682492cddfa69415e54bed46cc9513cf Mon Sep 17 00:00:00 2001 From: Abhinav Raut Date: Fri, 21 Aug 2026 23:14:34 +0530 Subject: [PATCH] name the field in pre-chat length errors and hide the start button when blocked An over-long pre-chat value came back as "Must be at most 128 characters" with no clue which field it was about, so use the existing fieldTooLong message. The home screen also offered the start button when the inbox says visitors cannot start conversations, and the visitor only found out when the send failed, so the button now follows the same rule as the server. Adds a browser-level livechat suite that embeds the widget on a host page the way a customer site does, drives the real widget UI, and covers every livechat config option that has a visible effect. CI needs the widget rate limit lifted because the suite makes more than 100 widget requests a minute. --- .github/workflows/frontend-ci.yml | 3 + cmd/chat.go | 12 +- frontend/apps/widget/src/views/HomeView.vue | 2 + .../e2e/integration/livechat/config.cy.js | 280 ++++++++++++++++++ .../e2e/integration/livechat/embed.cy.js | 154 ++++++++++ .../e2e/integration/livechat/session.cy.js | 49 +++ .../e2e/integration/livechat/settings.cy.js | 18 ++ frontend/cypress/support/livechat.js | 182 ++++++++++++ 8 files changed, 694 insertions(+), 6 deletions(-) create mode 100644 frontend/cypress/e2e/integration/livechat/config.cy.js create mode 100644 frontend/cypress/e2e/integration/livechat/embed.cy.js diff --git a/.github/workflows/frontend-ci.yml b/.github/workflows/frontend-ci.yml index 5ce211b7..b62b5acf 100644 --- a/.github/workflows/frontend-ci.yml +++ b/.github/workflows/frontend-ci.yml @@ -72,6 +72,9 @@ jobs: # E2E runs over plain HTTP, so the Secure session/csrf cookies must be off # or real login + writes would be rejected. LIBREDESK_APP__SERVER__DISABLE_SECURE_COOKIES: "true" + # The livechat widget suite makes far more than 100 widget requests a minute. + LIBREDESK_RATE_LIMIT__WIDGET__REQUESTS_PER_MINUTE: "100000" + LIBREDESK_RATE_LIMIT__PUBLIC__REQUESTS_PER_MINUTE: "100000" CYPRESS_SYSTEM_PASSWORD: "StrongPass!123" CYPRESS_MAILHOG_URL: "http://localhost:8025" run: | diff --git a/cmd/chat.go b/cmd/chat.go index 622d0539..215803fa 100644 --- a/cmd/chat.go +++ b/cmd/chat.go @@ -383,10 +383,10 @@ func handleAuthExchange(r *fastglue.Request) error { return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.Ts("globals.messages.required", "name", "first_name"), nil, envelope.InputError) } if len(claims.LastName) > maxNameLength { - return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.Ts("globals.messages.maxLength", "max", strconv.Itoa(maxNameLength)), nil, envelope.InputError) + return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.Ts("globals.messages.fieldTooLong", "field", "{globals.terms.name}", "max", strconv.Itoa(maxNameLength)), nil, envelope.InputError) } if len(claims.PhoneNumber) > maxPhoneNumberLength { - return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.Ts("globals.messages.maxLength", "max", strconv.Itoa(maxPhoneNumberLength)), nil, envelope.InputError) + return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.Ts("globals.messages.fieldTooLong", "field", "{globals.terms.phoneNumber}", "max", strconv.Itoa(maxPhoneNumberLength)), nil, envelope.InputError) } // Country code is cosmetic - drop an invalid one instead of failing the whole exchange. if len(claims.PhoneNumberCountryCode) > maxPhoneCountryCodeLength { @@ -1175,7 +1175,7 @@ func validateFormData(app *App, formData map[string]any, config livechat.Config, return "", "", "", "", envelope.NewError(envelope.InputError, app.i18n.Ts("globals.messages.required", "name", "{globals.terms.name}"), nil) } if len(finalName) > maxNameLength { - return "", "", "", "", envelope.NewError(envelope.InputError, app.i18n.Ts("globals.messages.maxLength", "max", strconv.Itoa(maxNameLength)), nil) + return "", "", "", "", envelope.NewError(envelope.InputError, app.i18n.Ts("globals.messages.fieldTooLong", "field", "{globals.terms.name}", "max", strconv.Itoa(maxNameLength)), nil) } case "email": @@ -1184,7 +1184,7 @@ func validateFormData(app *App, formData map[string]any, config livechat.Config, return "", "", "", "", envelope.NewError(envelope.InputError, app.i18n.Ts("globals.messages.required", "name", "{globals.terms.email}"), nil) } if len(finalEmail) > maxEmailLength { - return "", "", "", "", envelope.NewError(envelope.InputError, app.i18n.Ts("globals.messages.maxLength", "max", strconv.Itoa(maxEmailLength)), nil) + return "", "", "", "", envelope.NewError(envelope.InputError, app.i18n.Ts("globals.messages.fieldTooLong", "field", "{globals.terms.email}", "max", strconv.Itoa(maxEmailLength)), nil) } if finalEmail != "" && !stringutil.ValidEmail(finalEmail) { return "", "", "", "", envelope.NewError(envelope.InputError, app.i18n.T("validation.invalidEmail"), nil) @@ -1197,10 +1197,10 @@ func validateFormData(app *App, formData map[string]any, config livechat.Config, return "", "", "", "", envelope.NewError(envelope.InputError, app.i18n.Ts("globals.messages.required", "name", "{globals.terms.phoneNumber}"), nil) } if len(finalPhone) > maxPhoneNumberLength { - return "", "", "", "", envelope.NewError(envelope.InputError, app.i18n.Ts("globals.messages.maxLength", "max", strconv.Itoa(maxPhoneNumberLength)), nil) + return "", "", "", "", envelope.NewError(envelope.InputError, app.i18n.Ts("globals.messages.fieldTooLong", "field", "{globals.terms.phoneNumber}", "max", strconv.Itoa(maxPhoneNumberLength)), nil) } if len(finalPhoneCountryCode) > maxPhoneCountryCodeLength { - return "", "", "", "", envelope.NewError(envelope.InputError, app.i18n.Ts("globals.messages.maxLength", "max", strconv.Itoa(maxPhoneCountryCodeLength)), nil) + return "", "", "", "", envelope.NewError(envelope.InputError, app.i18n.Ts("globals.messages.fieldTooLong", "field", "{globals.terms.phoneNumber}", "max", strconv.Itoa(maxPhoneCountryCodeLength)), nil) } if finalPhone == "" { finalPhoneCountryCode = "" diff --git a/frontend/apps/widget/src/views/HomeView.vue b/frontend/apps/widget/src/views/HomeView.vue index f6fc268e..324d95f6 100644 --- a/frontend/apps/widget/src/views/HomeView.vue +++ b/frontend/apps/widget/src/views/HomeView.vue @@ -58,6 +58,8 @@ const mostRecentConversation = computed(() => { const canStartConversation = computed(() => { const userConfig = userStore.isVisitor ? config.value.visitors : config.value.users + // Mirrors the server check, else the button is offered and the send fails. + if (!userConfig?.allow_start_conversation) return false return userConfig?.prevent_multiple_conversations !== true || !chatStore.hasConversations }) diff --git a/frontend/cypress/e2e/integration/livechat/config.cy.js b/frontend/cypress/e2e/integration/livechat/config.cy.js new file mode 100644 index 00000000..1f4a5ea8 --- /dev/null +++ b/frontend/cypress/e2e/integration/livechat/config.cy.js @@ -0,0 +1,280 @@ +const startText = 'Cypress start chat' + +const withStart = (overrides = {}) => ({ + visitors: { allow_start_conversation: true, start_conversation_button_text: startText }, + users: { allow_start_conversation: true, start_conversation_button_text: startText }, + ...overrides +}) + +describe('Live chat widget config applied end to end', () => { + it('shows brand name, logo, greeting and introduction on the home screen', () => { + cy.createLivechatInbox( + withStart({ + brand_name: 'Cypress Brand', + logo_url: `${Cypress.config('baseUrl')}/static/public/launcher-logo.png`, + greeting_message: 'Hello from Cypress', + introduction_message: 'We reply fast' + }) + ).then((inbox) => { + cy.visitWidgetHost(inbox.uuid) + cy.widgetLauncher().click() + cy.widgetBody().contains('Hello from Cypress').should('be.visible') + cy.widgetBody().contains('We reply fast').should('be.visible') + cy.widgetBody().find('img[src*="launcher-logo.png"]').should('exist') + cy.widgetBody().contains(startText).click() + cy.widgetBody().contains('Cypress Brand').should('be.visible') + }) + }) + + it('applies the launcher color from config', () => { + cy.createLivechatInbox( + withStart({ colors: { primary: '#ff0000' }, launcher: { color: '#00ff00', position: 'right', spacing: { side: 20, bottom: 20 } } }) + ).then((inbox) => { + cy.visitWidgetHost(inbox.uuid) + cy.widgetLauncher().then((el) => { + const style = el[0].ownerDocument.defaultView.getComputedStyle(el[0]) + expect(style.backgroundColor, 'launcher color not applied').to.eq('rgb(0, 255, 0)') + }) + }) + }) + + it('shows the notice banner only when enabled', () => { + cy.createLivechatInbox( + withStart({ notice_banner: { enabled: true, text: 'Cypress notice text' } }) + ).then((inbox) => { + cy.openWidget(inbox) + cy.widgetBody().contains(startText).click() + cy.widgetBody().contains('Cypress notice text').should('be.visible') + }) + + cy.createLivechatInbox( + withStart({ notice_banner: { enabled: false, text: 'Hidden notice' } }) + ).then((inbox) => { + cy.openWidget(inbox) + cy.widgetBody().contains(startText).click() + cy.widgetBody().find('textarea').should('be.visible') + cy.widgetBody().contains('Hidden notice').should('not.exist') + }) + }) + + it('hides the powered-by link when show_powered_by is false', () => { + cy.createLivechatInbox(withStart({ show_powered_by: true })).then((inbox) => { + cy.openWidget(inbox) + cy.widgetBody().contains(startText).click() + cy.widgetBody().find('a[href="https://libredesk.io"]').should('exist') + }) + + cy.createLivechatInbox(withStart({ show_powered_by: false })).then((inbox) => { + cy.openWidget(inbox) + cy.widgetBody().contains(startText).click() + cy.widgetBody().find('textarea').should('be.visible') + cy.widgetBody().find('a[href="https://libredesk.io"]').should('not.exist') + }) + }) + + it('toggles the emoji and file upload actions from features', () => { + cy.createLivechatInbox(withStart({ features: { emoji: true, file_upload: true } })).then((inbox) => { + cy.openWidget(inbox) + cy.widgetBody().contains(startText).click() + cy.widgetBody().find('button[aria-label="Add emoji"]').should('exist') + // The attach button only renders once a conversation exists to upload against. + cy.widgetSend(`Message for upload ${Date.now()}`) + cy.widgetBody().find('button[aria-label="Attach file"]').should('exist') + }) + + cy.createLivechatInbox(withStart({ features: { emoji: false, file_upload: false } })).then((inbox) => { + cy.openWidget(inbox) + cy.widgetBody().contains(startText).click() + cy.widgetBody().find('textarea').should('be.visible') + cy.widgetBody().find('button[aria-label="Add emoji"]').should('not.exist') + cy.widgetBody().find('button[aria-label="Attach file"]').should('not.exist') + }) + }) + + it('renders configured home apps', () => { + cy.createLivechatInbox( + withStart({ + home_apps: [ + { type: 'announcement', title: 'Cypress announcement', description: 'Read this', url: '' }, + { type: 'external_link', text: 'Cypress docs link', url: 'https://example.test/docs' } + ] + }) + ).then((inbox) => { + cy.openWidget(inbox) + cy.widgetBody().contains('Cypress announcement').should('be.visible') + cy.widgetBody().contains('Cypress docs link').should('be.visible') + }) + }) + + it('opens straight into the chat when direct_to_conversation is set', () => { + cy.createLivechatInbox(withStart({ direct_to_conversation: true })).then((inbox) => { + cy.openWidget(inbox) + cy.widgetBody().find('textarea', { timeout: 20000 }).should('be.visible') + }) + }) + + it('hides the start button when visitors may not start conversations', () => { + let inbox + cy.createLivechatInbox( + withStart({ visitors: { allow_start_conversation: false, start_conversation_button_text: startText } }) + ).then((created) => { + inbox = created + }) + cy.then(() => cy.openWidget(inbox)) + cy.widgetBody().contains('Home').should('be.visible') + cy.widgetBody().should('not.contain', startText) + cy.then(() => + cy.conversationForInbox(inbox).then((conversation) => { + expect(conversation, 'a conversation was created anyway').to.be.null + }) + ) + }) + + it('replaces the start button with the running conversation when multiples are prevented', () => { + cy.createLivechatInbox( + withStart({ + visitors: { + allow_start_conversation: true, + prevent_multiple_conversations: true, + start_conversation_button_text: startText + } + }) + ).then((inbox) => { + const firstMessage = `First conversation ${Date.now()}` + cy.openWidget(inbox) + cy.widgetBody().contains(startText).click() + cy.widgetSend(firstMessage) + cy.widgetBody().find('button[aria-label="Go back"]').click() + cy.widgetBody().contains('Home').click() + cy.widgetBody().contains(firstMessage, { timeout: 20000 }).should('be.visible') + cy.widgetBody().should('not.contain', startText) + }) + }) + + it('blocks replies to a closed conversation when configured', () => { + let inbox + cy.createLivechatInbox( + withStart({ + visitors: { + allow_start_conversation: true, + prevent_reply_to_closed_conversation: true, + start_conversation_button_text: startText + } + }) + ).then((created) => { + inbox = created + }) + cy.then(() => cy.openWidget(inbox)) + cy.widgetBody().contains(startText).click() + cy.widgetSend(`Message before close ${Date.now()}`) + cy.then(() => cy.closeConversation(inbox)) + cy.widgetBody().find('textarea', { timeout: 20000 }).should('not.exist') + }) + + it('applies dark mode', () => { + cy.createLivechatInbox(withStart({ dark_mode: true })).then((inbox) => { + cy.openWidget(inbox) + cy.widgetBody().find('.dark').should('exist') + }) + }) + + it('paints the home screen background from config', () => { + cy.createLivechatInbox( + withStart({ + home_screen: { + header_text_color: 'light', + background: { type: 'gradient', gradient_start: '#112233', gradient_end: '#445566' }, + fade_background: true + } + }) + ).then((inbox) => { + cy.openWidget(inbox) + cy.widgetBody().contains(startText) + cy.widgetBody().then((body) => { + const hasGradient = Array.from(body[0].querySelectorAll('div')).some((el) => + (el.getAttribute('style') || '').includes('gradient') + ) + expect(hasGradient, 'gradient background not applied').to.be.true + }) + }) + }) + + it('shows the reply expectation message once business hours are configured', () => { + let inbox + cy.login() + cy.api('POST', '/api/v1/business-hours', { + name: `Cypress always open ${Date.now()}`, + is_always_open: true, + hours: {}, + holidays: [] + }) + .its('body.data.id') + .then((id) => cy.setDefaultBusinessHours(String(id))) + + cy.createLivechatInbox( + withStart({ + chat_reply_expectation_message: 'We usually reply in 5 minutes', + show_office_hours_in_chat: true + }) + ).then((created) => { + inbox = created + }) + cy.then(() => cy.openWidget(inbox)) + cy.widgetBody().contains(startText).click() + // The message only renders against a live conversation. + cy.then(() => cy.widgetSend(`Expectation probe ${Date.now()}`)) + cy.widgetBody().contains('We usually reply in 5 minutes', { timeout: 20000 }).should('be.visible') + cy.then(() => cy.setDefaultBusinessHours('')) + }) + + it('renders the widget in the configured language', () => { + cy.createLivechatInbox({ + language: 'de-DE', + fallback_language: 'en-US', + visitors: { allow_start_conversation: true }, + users: { allow_start_conversation: true } + }).then((inbox) => { + cy.openWidget(inbox) + cy.widgetBody().contains('Senden Sie uns eine Nachricht', { timeout: 20000 }).should('be.visible') + }) + }) + + it('collects pre-chat form fields and attaches them to the contact', () => { + const stamp = Date.now() + const email = `prechat.${stamp}@cypress.test` + let inbox + + cy.createLivechatInbox( + withStart({ + prechat_form: { + enabled: true, + title: 'Tell us about you', + fields: [ + { key: 'name', type: 'text', label: 'Name', required: true, enabled: true, order: 1, is_default: true }, + { key: 'email', type: 'email', label: 'Email', required: true, enabled: true, order: 2, is_default: true } + ] + } + }) + ).then((created) => { + inbox = created + }) + + cy.then(() => cy.openWidget(inbox)) + cy.widgetBody().contains(startText).click() + cy.widgetBody().contains('Tell us about you').should('be.visible') + + cy.widgetBody().contains('button', 'Start chat').should('be.disabled') + cy.widgetBody().find('input').eq(0).type('Cypress Prechat') + cy.widgetBody().find('input').eq(1).type(email) + cy.widgetBody().find('textarea').should('be.visible').type(`Prechat message ${stamp}`) + cy.widgetBody().contains('button', 'Start chat').click() + cy.widgetBody().contains(`Prechat message ${stamp}`, { timeout: 20000 }).should('be.visible') + + cy.then(() => + cy.latestConversation(inbox).then((conversation) => { + expect(conversation.contact.email, 'pre-chat email not saved on the contact').to.eq(email) + expect(JSON.stringify(conversation.contact)).to.include('Cypress Prechat') + }) + ) + }) +}) diff --git a/frontend/cypress/e2e/integration/livechat/embed.cy.js b/frontend/cypress/e2e/integration/livechat/embed.cy.js new file mode 100644 index 00000000..267c2b28 --- /dev/null +++ b/frontend/cypress/e2e/integration/livechat/embed.cy.js @@ -0,0 +1,154 @@ +const startButtonText = 'Cypress start chat' + +const embedConfig = (overrides = {}) => ({ + visitors: { allow_start_conversation: true, start_conversation_button_text: startButtonText }, + users: { allow_start_conversation: true, start_conversation_button_text: startButtonText }, + ...overrides +}) + +describe('Live chat widget embedded on a host page', () => { + it('renders the launcher and keeps the panel closed until clicked', () => { + cy.createLivechatInbox(embedConfig()).then((inbox) => { + cy.visitWidgetHost(inbox.uuid) + cy.widgetLauncher().should('be.visible') + cy.get('iframe[src*="/widget?inbox_id="]').should('not.be.visible') + + cy.widgetLauncher().click() + cy.get('iframe[src*="/widget?inbox_id="]').should('be.visible') + cy.widgetBody().contains(startButtonText).should('be.visible') + }) + }) + + it('moves the launcher when the inbox launcher position changes', () => { + cy.createLivechatInbox( + embedConfig({ launcher: { position: 'right', spacing: { side: 20, bottom: 20 } } }) + ).then((inbox) => { + cy.visitWidgetHost(inbox.uuid) + cy.widgetWrapperSide().then((side) => { + expect(side.right, 'launcher not on the right').to.eq('20px') + expect(side.left).to.eq('auto') + }) + + cy.then(() => + cy.saveLivechatInbox(inbox, { launcher: { position: 'left', spacing: { side: 40, bottom: 60 } } }) + ) + cy.then(() => cy.visitWidgetHost(inbox.uuid)) + cy.widgetWrapperSide().then((side) => { + expect(side.left, 'launcher did not move to the left').to.eq('40px') + expect(side.right).to.eq('auto') + expect(side.bottom).to.eq('60px') + }) + }) + }) + + it('starts a conversation as a visitor and shows the agent reply', () => { + const visitorMessage = `Visitor from the widget ${Date.now()}` + const agentMessage = `Agent answer ${Date.now()}` + let inbox + + cy.createLivechatInbox(embedConfig()).then((created) => { + inbox = created + }) + cy.then(() => cy.visitWidgetHost(inbox.uuid)) + cy.widgetLauncher().click() + + cy.widgetBody().contains(startButtonText).click() + cy.widgetBody().find('textarea').should('be.visible').type(visitorMessage) + cy.widgetBody().find('button[aria-label="Send"]').click() + cy.widgetBody().contains(visitorMessage, { timeout: 20000 }).should('be.visible') + + cy.then(() => cy.agentReplyToLatestConversation(inbox, agentMessage)) + cy.widgetBody().contains(agentMessage, { timeout: 20000 }).should('be.visible') + }) + + it('keeps delivering agent replies in the widget after the inbox is saved', () => { + const visitorMessage = `Visitor before save ${Date.now()}` + const beforeSave = `Agent before save ${Date.now()}` + const afterSave = `Agent after save ${Date.now()}` + let inbox + + cy.createLivechatInbox(embedConfig()).then((created) => { + inbox = created + }) + cy.then(() => cy.visitWidgetHost(inbox.uuid)) + cy.widgetLauncher().click() + cy.widgetBody().contains(startButtonText).click() + cy.widgetBody().find('textarea').should('be.visible').type(visitorMessage) + cy.widgetBody().find('button[aria-label="Send"]').click() + cy.widgetBody().contains(visitorMessage, { timeout: 20000 }).should('be.visible') + + cy.then(() => cy.agentReplyToLatestConversation(inbox, beforeSave)) + cy.widgetBody().contains(beforeSave, { timeout: 20000 }).should('be.visible') + + cy.then(() => cy.saveLivechatInbox(inbox, { brand_name: 'Cypress saved' })) + + cy.then(() => cy.agentReplyToLatestConversation(inbox, afterSave)) + cy.widgetBody().contains(afterSave, { timeout: 30000 }).should('be.visible') + }) + + it('identifies the contact from a signed JWT', () => { + const stamp = Date.now() + const email = `jwt.visitor.${stamp}@cypress.test` + const secret = `cypress-secret-${stamp}` + const visitorMessage = `JWT visitor message ${stamp}` + let inbox + + cy.createLivechatInbox(embedConfig(), { secret }).then((created) => { + inbox = created + }) + + cy.then(() => { + cy.intercept('POST', '/api/v1/widget/chat/auth/exchange').as('exchange') + return cy.visitWidgetHost(inbox.uuid, { + secret, + jwtPayload: { + external_user_id: `cypress_${stamp}`, + email, + first_name: 'Cypress', + last_name: 'Visitor' + } + }) + }) + cy.wait('@exchange').its('response.statusCode').should('eq', 200) + + cy.widgetLauncher().click() + cy.widgetBody().contains(startButtonText).click() + cy.widgetBody().find('textarea').should('be.visible').type(visitorMessage) + cy.widgetBody().find('button[aria-label="Send"]').click() + cy.widgetBody().contains(visitorMessage, { timeout: 20000 }).should('be.visible') + + cy.then(() => + cy.latestConversation(inbox).then((conversation) => { + expect(JSON.stringify(conversation), 'conversation not attached to the JWT contact').to.include(email) + }) + ) + }) + + it('enforces a required pre-chat field before the chat opens', () => { + cy.createLivechatInbox( + embedConfig({ + prechat_form: { + enabled: true, + title: 'Before we start', + fields: [ + { + key: 'name', + type: 'text', + label: 'Name', + required: true, + enabled: true, + order: 1, + is_default: true + } + ] + } + }) + ).then((inbox) => { + cy.visitWidgetHost(inbox.uuid) + cy.widgetLauncher().click() + cy.widgetBody().contains(startButtonText).click() + cy.widgetBody().contains('Before we start').should('be.visible') + cy.widgetBody().find('input').should('exist') + }) + }) +}) diff --git a/frontend/cypress/e2e/integration/livechat/session.cy.js b/frontend/cypress/e2e/integration/livechat/session.cy.js index 602f43c5..600c203a 100644 --- a/frontend/cypress/e2e/integration/livechat/session.cy.js +++ b/frontend/cypress/e2e/integration/livechat/session.cy.js @@ -129,4 +129,53 @@ describe('Live chat widget session and auth', () => { }) cy.then(() => cy.waitForFrame(joined(socket), 'session did not survive a reload')) }) + + it('merges an anonymous visitor into the JWT contact', () => { + const stamp = Date.now() + const email = `merge.${stamp}@cypress.test` + const secret = `cypress-secret-${stamp}` + let jwtInbox + let visitorToken + let contactToken + + cy.createLivechatInbox({}, { secret }).then((created) => { + jwtInbox = created + }) + cy.then(() => cy.widgetInit(jwtInbox.uuid, { message: `Anonymous message ${stamp}` })).then((res) => { + visitorToken = res.sessionToken + }) + + cy.then(() => + cy + .signWidgetJWT( + { external_user_id: `merge_${stamp}`, email, first_name: 'Merged', last_name: 'Contact' }, + secret + ) + .then((jwt) => + cy + .widgetApi('POST', '/api/v1/widget/chat/auth/exchange', null, jwtInbox.uuid, { jwt }) + .then(({ status, body }) => { + expect(status).to.eq(200) + contactToken = body.data.session_token + }) + ) + ) + + cy.then(() => + cy + .widgetApi('GET', '/api/v1/widget/chat/conversations', contactToken, jwtInbox.uuid, null, { + headers: { 'X-Libredesk-Visitor-Token': visitorToken } + }) + .then((res) => { + expect(res.status).to.eq(200) + expect(res.headers['x-libredesk-clear-visitor'], 'visitor token not cleared after merge').to.eq('true') + }) + ) + + cy.then(() => + cy.latestConversation(jwtInbox).then((conversation) => { + expect(conversation.contact.email, 'conversation not moved to the JWT contact').to.eq(email) + }) + ) + }) }) diff --git a/frontend/cypress/e2e/integration/livechat/settings.cy.js b/frontend/cypress/e2e/integration/livechat/settings.cy.js index dff2baae..8d65e1d1 100644 --- a/frontend/cypress/e2e/integration/livechat/settings.cy.js +++ b/frontend/cypress/e2e/integration/livechat/settings.cy.js @@ -126,4 +126,22 @@ describe('Live chat widget settings and init rules', () => { .should('eq', 400) }) }) + + it('names the field when a pre-chat value is too long', () => { + cy.createLivechatInbox({ + prechat_form: { + enabled: true, + fields: [ + { key: 'name', type: 'text', label: 'Name', required: true, enabled: true, order: 1, is_default: true } + ] + } + }).then((inbox) => { + cy.widgetInit(inbox.uuid, { form_data: { name: 'x'.repeat(300) } }, { failOnStatusCode: false }).then( + (res) => { + expect(res.status).to.eq(400) + expect(res.body.message, 'error does not name the field').to.match(/name/i) + } + ) + }) + }) }) diff --git a/frontend/cypress/support/livechat.js b/frontend/cypress/support/livechat.js index 8d26a03c..f210d0c4 100644 --- a/frontend/cypress/support/livechat.js +++ b/frontend/cypress/support/livechat.js @@ -152,3 +152,185 @@ after(() => { cy.api('DELETE', `/api/v1/inboxes/${id}`, null, { failOnStatusCode: false }) }) }) + +const embedHostPath = '/__widget-embed-test' + +// Served from the app's own origin, else the iframe is cross-origin and its DOM is unreachable. +Cypress.Commands.add('visitWidgetHost', (inboxUuid, { secret = null, jwtPayload = null } = {}) => { + const html = ` +widget embed host + +

Customer site

+ + +` + cy.intercept('GET', `${embedHostPath}*`, { + statusCode: 200, + headers: { 'content-type': 'text/html; charset=utf-8' }, + body: html + }).as('embedHost') + cy.visit(embedHostPath) + return cy.window({ timeout: 20000 }).its('Libredesk.toggleButton', { timeout: 20000 }) +}) + +Cypress.Commands.add('widgetLauncher', () => + cy.window().its('Libredesk.toggleButton').then((el) => cy.wrap(el, { log: false })) +) + +Cypress.Commands.add('widgetWrapperSide', () => + cy.window().its('Libredesk.widgetButtonWrapper').then((el) => { + const s = el.ownerDocument.defaultView.getComputedStyle(el) + return { left: s.left, right: s.right, bottom: s.bottom } + }) +) + +Cypress.Commands.add('widgetBody', () => + cy + .get('iframe[src*="/widget?inbox_id="]', { timeout: 20000 }) + .its('0.contentDocument.body', { timeout: 20000 }) + .should('not.be.empty') + .then((body) => cy.wrap(body, { log: false })) +) + +// No cy.login() here: cy.session() blanks the page, which would tear down an embedded widget mid-test. +Cypress.Commands.add('latestConversation', (inbox) => { + return cy + .api( + 'GET', + '/api/v1/conversations/all?order=desc&order_by=conversations.created_at&page=1&page_size=50' + ) + .then(({ body }) => { + const match = body.data.results.find((c) => c.inbox_name === inbox.payload.name) + expect(match, `no conversation found for inbox ${inbox.payload.name}`).to.exist + return match + }) +}) + +Cypress.Commands.add('agentReplyToLatestConversation', (inbox, body) => + cy.latestConversation(inbox).then((conversation) => + cy + .api('POST', `/api/v1/conversations/${conversation.uuid}/messages`, { + message: `

${body}

`, + private: false, + sender_type: 'agent' + }) + .its('status') + .should('eq', 200) + ) +) + +Cypress.Commands.add('openWidget', (inbox, opts = {}) => { + cy.visitWidgetHost(inbox.uuid, opts) + cy.widgetLauncher().click() + return cy.widgetBody() +}) + +Cypress.Commands.add('widgetSend', (text) => { + cy.widgetBody().find('textarea').should('be.visible').type(text) + cy.widgetBody().find('button[aria-label="Send"]').click() + return cy.widgetBody().contains(text, { timeout: 20000 }).should('be.visible') +}) + +Cypress.Commands.add('closeConversation', (inbox) => + cy.latestConversation(inbox).then((conversation) => + cy + .api('PUT', `/api/v1/conversations/${conversation.uuid}/status`, { status: 'Closed' }) + .its('status') + .should('eq', 200) + ) +) + +Cypress.Commands.add('conversationForInbox', (inbox) => + cy + .api('GET', '/api/v1/conversations/all?order=desc&order_by=conversations.created_at&page=1&page_size=50') + .then(({ body }) => body.data.results.find((c) => c.inbox_name === inbox.payload.name) || null) +) + +// Retries because the visitor-to-contact merge lands on a later widget request, not on the exchange itself. +Cypress.Commands.add('waitForConversationContact', (inbox, email, timeout = 20000) => + cy.wrap(null, { timeout, log: false }).should(() => { + const found = Cypress.$.ajax({ + url: '/api/v1/conversations/all?order=desc&order_by=conversations.created_at&page=1&page_size=50', + async: false + }) + const results = JSON.parse(found.responseText).data.results + const match = results.find((c) => c.inbox_name === inbox.payload.name) + expect(match && match.contact && match.contact.email, 'contact email on the conversation').to.eq(email) + }) +) + +Cypress.Commands.add('setDefaultBusinessHours', (businessHoursId) => + cy.api('GET', '/api/v1/settings/general').then(({ body }) => { + const settings = { ...body.data, 'app.business_hours_id': businessHoursId } + delete settings['app.version'] + delete settings['app.update'] + delete settings['app.restart_required'] + return cy.api('PUT', '/api/v1/settings/general', settings).its('status').should('eq', 200) + }) +) + +// The widget renders a message optimistically, so the session cookie can lag behind it. +Cypress.Commands.add('waitForWidgetCookie', (inbox, type = 'session', timeout = 20000) => + cy.wrap(null, { timeout, log: false }).should(() => { + const doc = cy.state('window').document + expect(doc.cookie, `libredesk-${type} cookie`).to.include(`libredesk-${type}-${inbox.uuid}=`) + }) +) + +const b64url = (bytes) => { + let s = '' + bytes.forEach((b) => { + s += String.fromCharCode(b) + }) + return btoa(s).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '') +} + +Cypress.Commands.add('signWidgetJWT', (payload, secret) => { + const enc = new TextEncoder() + const body = { exp: Math.floor(Date.now() / 1000) + 3600, ...payload } + const header = b64url(enc.encode(JSON.stringify({ alg: 'HS256', typ: 'JWT' }))) + const claims = b64url(enc.encode(JSON.stringify(body))) + return cy.wrap( + window.crypto.subtle + .importKey('raw', enc.encode(secret), { name: 'HMAC', hash: 'SHA-256' }, false, ['sign']) + .then((key) => window.crypto.subtle.sign('HMAC', key, enc.encode(`${header}.${claims}`))) + .then((sig) => `${header}.${claims}.${b64url(new Uint8Array(sig))}`), + { log: false } + ) +})