mirror of
https://github.com/abhinavxd/libredesk.git
synced 2026-09-10 06:05:41 +00:00
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.
This commit is contained in:
@@ -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: |
|
||||
|
||||
+6
-6
@@ -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 = ""
|
||||
|
||||
@@ -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
|
||||
})
|
||||
|
||||
|
||||
@@ -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')
|
||||
})
|
||||
)
|
||||
})
|
||||
})
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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)
|
||||
})
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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 = `<!doctype html>
|
||||
<html lang="en"><head><meta charset="utf-8"><title>widget embed host</title></head>
|
||||
<body>
|
||||
<h1 id="host-title">Customer site</h1>
|
||||
<script>
|
||||
window.__widgetMessages = []
|
||||
window.addEventListener('message', function (e) {
|
||||
if (!e.source || e.source === window) return
|
||||
var lc = window.Libredesk
|
||||
var fromIframe = !!(lc && lc.iframe && e.source === lc.iframe.contentWindow)
|
||||
window.__widgetMessages.push(((e.data || {}).type || '?') + ':' + fromIframe)
|
||||
})
|
||||
</script>
|
||||
<script>
|
||||
function b64url (bytes) {
|
||||
let s = ''
|
||||
bytes.forEach(b => { s += String.fromCharCode(b) })
|
||||
return btoa(s).replace(/\\+/g, '-').replace(/\\//g, '_').replace(/=+$/, '')
|
||||
}
|
||||
async function signJWT (payload, secret) {
|
||||
const enc = new TextEncoder()
|
||||
const h = b64url(enc.encode(JSON.stringify({ alg: 'HS256', typ: 'JWT' })))
|
||||
const p = b64url(enc.encode(JSON.stringify(payload)))
|
||||
const key = await crypto.subtle.importKey('raw', enc.encode(secret), { name: 'HMAC', hash: 'SHA-256' }, false, ['sign'])
|
||||
const sig = await crypto.subtle.sign('HMAC', key, enc.encode(h + '.' + p))
|
||||
return h + '.' + p + '.' + b64url(new Uint8Array(sig))
|
||||
}
|
||||
;(async function () {
|
||||
const cfg = { baseURL: window.location.origin, inboxID: ${JSON.stringify(inboxUuid)} }
|
||||
const secret = ${JSON.stringify(secret)}
|
||||
const payload = ${JSON.stringify(jwtPayload)}
|
||||
if (secret && payload) {
|
||||
payload.exp = Math.floor(Date.now() / 1000) + 3600
|
||||
cfg.userJWT = await signJWT(payload, secret)
|
||||
window.__widgetJWT = cfg.userJWT
|
||||
}
|
||||
window.LibredeskSettings = cfg
|
||||
const s = document.createElement('script')
|
||||
s.src = '/widget.js'
|
||||
document.body.appendChild(s)
|
||||
})()
|
||||
</script>
|
||||
</body></html>`
|
||||
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: `<p>${body}</p>`,
|
||||
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 }
|
||||
)
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user