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:
Abhinav Raut
2026-08-21 23:14:34 +05:30
parent 5813ac3e73
commit a6e7812c68
8 changed files with 694 additions and 6 deletions
+3
View File
@@ -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
View File
@@ -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)
}
)
})
})
})
+182
View File
@@ -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 }
)
})