Bug fixes in live chat widget form and more e2e tests

This commit is contained in:
Abhinav Raut
2026-09-22 12:19:57 +05:30
parent 127d98c46e
commit 5702bbbd77
12 changed files with 935 additions and 138 deletions
+90
View File
@@ -1,11 +1,13 @@
package main
import (
"encoding/json"
"slices"
"strconv"
cmodels "github.com/abhinavxd/libredesk/internal/custom_attribute/models"
"github.com/abhinavxd/libredesk/internal/envelope"
"github.com/abhinavxd/libredesk/internal/inbox/channel/livechat"
"github.com/valyala/fasthttp"
"github.com/zerodha/fastglue"
)
@@ -112,6 +114,9 @@ func handleDeleteCustomAttribute(r *fastglue.Request) error {
if err = app.customAttribute.Delete(id); err != nil {
return sendErrorEnvelope(r, err)
}
if err := removeAttributeFromPreChatForms(app, id); err != nil {
return sendErrorEnvelope(r, err)
}
return r.SendEnvelope(true)
}
@@ -137,3 +142,88 @@ func validateCustomAttribute(app *App, attribute cmodels.CustomAttribute) error
}
return nil
}
func removeAttributeFromPreChatForms(app *App, attributeID int) error {
inboxes, err := app.inbox.GetAll()
if err != nil {
return err
}
for _, inb := range inboxes {
if inb.Channel != livechat.ChannelLiveChat {
continue
}
config, changed, err := stripPreChatFormAttribute(inb.Config, attributeID)
if err != nil {
app.lo.Error("error parsing live chat config", "id", inb.ID, "error", err)
return envelope.NewError(envelope.GeneralError, app.i18n.T("globals.messages.somethingWentWrong"), nil)
}
if !changed {
continue
}
if err := app.inbox.UpdateConfig(inb.ID, config); err != nil {
return envelope.NewError(envelope.GeneralError, app.i18n.T("globals.messages.somethingWentWrong"), nil)
}
if err := reloadInbox(app, inb.ID); err != nil {
app.lo.Error("error reloading inbox", "id", inb.ID, "error", err)
}
}
return nil
}
func stripPreChatFormAttribute(raw json.RawMessage, attributeID int) (json.RawMessage, bool, error) {
var config map[string]any
if len(raw) == 0 {
return raw, false, nil
}
if err := json.Unmarshal(raw, &config); err != nil {
return raw, false, err
}
form, ok := config["prechat_form"].(map[string]any)
if !ok {
return raw, false, nil
}
var changed bool
for _, holder := range []map[string]any{form, asObject(form["visitors"]), asObject(form["users"])} {
if holder == nil {
continue
}
fields, ok := holder["fields"].([]any)
if !ok {
continue
}
kept := make([]any, 0, len(fields))
for _, field := range fields {
if attributeIDOf(field) == attributeID {
changed = true
continue
}
kept = append(kept, field)
}
holder["fields"] = kept
}
if !changed {
return raw, false, nil
}
updated, err := json.Marshal(config)
if err != nil {
return raw, false, err
}
return updated, true, nil
}
func asObject(value any) map[string]any {
object, _ := value.(map[string]any)
return object
}
func attributeIDOf(field any) int {
object, ok := field.(map[string]any)
if !ok {
return 0
}
id, ok := object["custom_attribute_id"].(float64)
if !ok {
return 0
}
return int(id)
}
+60
View File
@@ -0,0 +1,60 @@
package main
import (
"encoding/json"
"testing"
)
func TestStripPreChatFormAttribute(t *testing.T) {
raw := json.RawMessage(`{
"brand_name":"Acme",
"prechat_form":{
"fields":[{"key":"name"},{"key":"plan","custom_attribute_id":7}],
"visitors":{"fields":[{"key":"plan","custom_attribute_id":7},{"key":"email"}]},
"users":{"fields":[{"key":"email"}]}
}
}`)
updated, changed, err := stripPreChatFormAttribute(raw, 7)
if err != nil {
t.Fatal(err)
}
if !changed {
t.Fatal("expected config to change")
}
var config struct {
BrandName string `json:"brand_name"`
PreChatForm struct {
Fields []map[string]any `json:"fields"`
Visitors struct {
Fields []map[string]any `json:"fields"`
} `json:"visitors"`
Users struct {
Fields []map[string]any `json:"fields"`
} `json:"users"`
} `json:"prechat_form"`
}
if err := json.Unmarshal(updated, &config); err != nil {
t.Fatal(err)
}
if config.BrandName != "Acme" {
t.Fatalf("brand name = %q", config.BrandName)
}
if len(config.PreChatForm.Fields) != 1 || config.PreChatForm.Fields[0]["key"] != "name" {
t.Fatalf("fields = %v", config.PreChatForm.Fields)
}
if len(config.PreChatForm.Visitors.Fields) != 1 || config.PreChatForm.Visitors.Fields[0]["key"] != "email" {
t.Fatalf("visitor fields = %v", config.PreChatForm.Visitors.Fields)
}
if len(config.PreChatForm.Users.Fields) != 1 {
t.Fatalf("user fields = %v", config.PreChatForm.Users.Fields)
}
if _, changed, err := stripPreChatFormAttribute(raw, 8); err != nil || changed {
t.Fatalf("unused attribute changed = %v, err = %v", changed, err)
}
if _, changed, err := stripPreChatFormAttribute(json.RawMessage(`{}`), 7); err != nil || changed {
t.Fatalf("config without a pre-chat form changed = %v, err = %v", changed, err)
}
}
@@ -1735,7 +1735,7 @@ const submitLabel = computed(() => {
})
const theme = computed(() => form.values.config?.theme || 'light')
const editingTheme = ref('light')
const editingTheme = inject('livechatPreviewTheme', ref('light'))
const brandingPath = computed(() => `config.branding.${editingTheme.value}`)
const activeBranding = computed(() => form.values.config?.branding?.[editingTheme.value])
const activeBackgroundType = computed(() => activeBranding.value?.home_screen?.background?.type)
@@ -1807,13 +1807,12 @@ const updateCampaignCooldown = (duration) =>
form.setFieldValue('config.campaign_cooldown', duration, hasCampaignErrors.value)
// home_apps in form.values only syncs on change events, so pull the live ref for the preview.
const previewTheme = inject('livechatPreviewTheme', ref('light'))
const previewConfig = computed(() => {
const branding = form.values.config?.branding?.[previewTheme.value] || {}
const branding = form.values.config?.branding?.[editingTheme.value] || {}
return {
...form.values.config,
...branding,
dark: previewTheme.value === 'dark',
dark: editingTheme.value === 'dark',
launcher: { ...form.values.config?.launcher, ...branding.launcher },
home_apps: homeApps.value,
help_tree: helpTree.value,
+7 -7
View File
@@ -28,13 +28,13 @@ export const useHelpStore = defineStore('help', () => {
return collect(data.value?.tree)
})
const available = computed(() => !!data.value && articles.value.length > 0)
const featured = computed(() =>
data.value?.featured_ids?.length
? data.value.featured_ids
.map((id) => articles.value.find((article) => article.id === id))
.filter(Boolean)
: data.value?.popular || []
)
const featured = computed(() => {
const picked = (data.value?.featured_ids || [])
.map((id) => articles.value.find((article) => article.id === id))
.filter(Boolean)
// Featured articles outside the visitor's locale are missing from the tree.
return picked.length ? picked : data.value?.popular || []
})
const reset = () => {
generation++
identity.value++
@@ -0,0 +1,611 @@
// The steps run in order and share the inbox created by the first one.
const stamp = Date.now()
const inboxName = `Cypress Branches ${stamp}`
const brandName = `Cypress Branches Brand ${stamp}`
const emailInboxName = `Cypress Branch Email ${stamp}`
const attributeName = `Cypress Branch Attr ${stamp}`
const attributeKey = `cypress_branch_attr_${stamp}`
const campaignName = `Cypress Branch Campaign ${stamp}`
const listPath = '/admin/inboxes'
const selectOption = (trigger, option) => {
cy.get(trigger).click()
cy.get('[role="option"]').contains(option).click()
// The listbox keeps a focus guard over the form until it has finished closing.
cy.get('[role="option"]').should('not.exist')
}
const editInbox = (inboxId) => cy.visit(`${listPath}/${inboxId}/edit`)
const pickFromLabelledSelect = (label, optionIndex) => {
cy.contains('label', label).parent().find('button').click()
cy.get('[role="option"]').eq(optionIndex).click()
cy.get('[role="option"]').should('not.exist')
}
const linkEmailInbox = (name) => {
cy.inboxSection('Conversation continuity email inbox').find('button[role="combobox"]').click()
cy.get('[role="option"]').contains(name).click()
cy.get('[role="option"]').should('not.exist')
}
// The accordion's open animation makes ':visible' flaky, so these scope by section instead.
const audienceEnabledSwitch = () =>
cy
.inboxSection('Pre-chat form')
.contains('p', 'Show form to this audience')
.parent()
.find('button[role="switch"]')
const enablePreChatForm = () =>
cy.switchField('Enable pre-chat form').then(($switch) => {
if ($switch.attr('data-state') !== 'checked') cy.wrap($switch).click()
})
const savedConfig = (alias) => cy.wait(alias).then(({ response }) => response.body.data.config)
describe('Live chat inbox form: branches the other specs skip', () => {
let inboxId
let emailInboxId
let attributeId
before(() => {
cy.login()
cy.api('POST', '/api/v1/custom-attributes', {
name: attributeName,
key: attributeKey,
description: 'Cypress branch coverage',
applies_to: 'contact',
data_type: 'text',
values: [],
regex: '',
regex_hint: ''
}).then(({ body }) => {
attributeId = body.data.id
})
cy.api('POST', '/api/v1/inboxes', {
name: emailInboxName,
channel: 'email',
enabled: true,
from: `Branch ${stamp} <branch.${stamp}@example.com>`,
config: {
auth_type: 'password',
from: `Branch ${stamp} <branch.${stamp}@example.com>`,
smtp: [
{
host: '127.0.0.1',
port: Number(Cypress.env('SMTP_PORT') || 1025),
username: '',
password: '',
auth_protocol: 'none',
tls_type: 'none',
max_conns: 2,
max_msg_retries: 1,
idle_timeout: '5s',
pool_wait_timeout: '5s'
}
],
imap: []
}
}).then(({ body }) => {
emailInboxId = body.data.id
})
})
beforeEach(() => {
// radix-vue's select focuses a node that is already gone in headless runs.
Cypress.on('uncaught:exception', (err) => !err.message.includes("reading 'focus'"))
cy.viewport(1280, 900)
cy.login()
})
after(() => {
if (inboxId) cy.api('DELETE', `/api/v1/inboxes/${inboxId}`, null, { failOnStatusCode: false })
if (emailInboxId) {
cy.api('DELETE', `/api/v1/inboxes/${emailInboxId}`, null, { failOnStatusCode: false })
}
if (attributeId) {
cy.api('DELETE', `/api/v1/custom-attributes/${attributeId}`, null, {
failOnStatusCode: false
})
}
})
it('creates the inbox the other steps edit', () => {
cy.intercept('POST', '**/api/v1/inboxes').as('createInbox')
cy.visit(`${listPath}/new`)
cy.contains('Create a live chat inbox').click()
cy.openInboxSection('General')
cy.get('input[name="name"]').type(inboxName)
cy.get('input[name="config.brand_name"]').type(brandName)
cy.get('button[type="submit"]').click()
cy.wait('@createInbox').then(({ response }) => {
expect(response.statusCode).to.eq(200)
inboxId = response.body.data.id
})
})
it('rejects an image background with no url, then saves one', () => {
cy.intercept('PUT', `**/api/v1/inboxes/${inboxId}`).as('updateInbox')
const imageUrl = `https://cypress.test/${stamp}/background.jpg`
editInbox(inboxId)
cy.openInboxSection('Branding')
cy.get('#bg-image').click()
cy.get('button[type="submit"]').click()
cy.get('@updateInbox.all').should('have.length', 0)
cy.inboxSection('Branding').should('have.attr', 'data-state', 'open')
cy.get('input[name="config.branding.light.home_screen.background.image_url"]').type(imageUrl)
cy.get('button[type="submit"]').click()
savedConfig('@updateInbox').then((config) => {
expect(config.branding.light.home_screen.background.type).to.eq('image')
expect(config.branding.light.home_screen.background.image_url).to.eq(imageUrl)
})
})
it('rejects an enabled notice banner with no text', () => {
cy.intercept('PUT', `**/api/v1/inboxes/${inboxId}`).as('updateInbox')
editInbox(inboxId)
cy.openInboxSection('Notice banner')
cy.switchField('Enable notice banner').click()
cy.get('textarea[name="config.notice_banner.text"]').clear()
cy.get('button[type="submit"]').click()
cy.get('@updateInbox.all').should('have.length', 0)
cy.inboxSection('Notice banner').should('have.attr', 'data-state', 'open')
})
it('requires continuity values once an email inbox is linked', () => {
cy.intercept('PUT', `**/api/v1/inboxes/${inboxId}`).as('updateInbox')
editInbox(inboxId)
cy.openInboxSection('Conversation continuity email inbox')
linkEmailInbox(emailInboxName)
cy.get('input[name="config.continuity.offline_threshold"]').clear()
cy.get('button[type="submit"]').click()
cy.get('@updateInbox.all').should('have.length', 0)
cy.get('input[name="config.continuity.offline_threshold"]').type('20m')
cy.get('input[name="config.continuity.min_email_interval"]').clear()
cy.get('input[name="config.continuity.min_email_interval"]').type('30m')
cy.get('input[name="config.continuity.max_messages_per_email"]').clear()
cy.get('input[name="config.continuity.max_messages_per_email"]').type('5')
cy.get('button[type="submit"]').click()
savedConfig('@updateInbox').then((config) => {
expect(config.continuity.offline_threshold).to.eq('20m')
expect(config.continuity.min_email_interval).to.eq('30m')
expect(config.continuity.max_messages_per_email).to.eq(5)
})
})
it('rejects a continuity message count outside its range', () => {
cy.intercept('PUT', `**/api/v1/inboxes/${inboxId}`).as('updateInbox')
editInbox(inboxId)
cy.openInboxSection('Conversation continuity email inbox')
cy.get('input[name="config.continuity.max_messages_per_email"]').clear()
cy.get('input[name="config.continuity.max_messages_per_email"]').type('200')
cy.get('button[type="submit"]').click()
cy.get('@updateInbox.all').should('have.length', 0)
cy.inboxSection('Conversation continuity email inbox').should(
'have.attr',
'data-state',
'open'
)
})
it('saves the launch-directly toggle separately for each audience', () => {
cy.intercept('PUT', `**/api/v1/inboxes/${inboxId}`).as('updateInbox')
editInbox(inboxId)
cy.openInboxSection('Pre-chat form')
enablePreChatForm()
cy.openInboxSection('Users')
cy.switchFieldIn('Users', 'Launch directly into conversation').click()
cy.get('button[type="submit"]').click()
savedConfig('@updateInbox').then((config) => {
expect(config.prechat_form.enabled, 'pre-chat on').to.eq(true)
expect(config.visitors.direct_to_conversation, 'visitors launch direct').to.eq(true)
expect(config.users.direct_to_conversation, 'users launch direct').to.eq(false)
})
})
it('adds a pre-chat custom attribute field and removes it again', () => {
cy.intercept('PUT', `**/api/v1/inboxes/${inboxId}`).as('updateInbox')
editInbox(inboxId)
cy.openInboxSection('Pre-chat form')
enablePreChatForm()
cy.inboxSection('Pre-chat form').contains('div.font-medium', attributeName).click()
cy.get('button[type="submit"]').click()
savedConfig('@updateInbox').then((config) => {
const keys = config.prechat_form.visitors.fields.map((field) => field.key)
expect(keys).to.include(attributeKey)
})
editInbox(inboxId)
cy.openInboxSection('Pre-chat form')
cy.inboxSection('Pre-chat form')
.contains('div.font-medium', attributeName)
.closest('div.border')
.find('button[aria-label="Remove"]')
.click()
cy.get('button[type="submit"]').click()
savedConfig('@updateInbox').then((config) => {
const keys = config.prechat_form.visitors.fields.map((field) => field.key)
expect(keys).to.not.include(attributeKey)
})
})
it('rejects a business-hours-limited proactive message with no schedule picked', () => {
cy.intercept('PUT', `**/api/v1/inboxes/${inboxId}`).as('updateInbox')
editInbox(inboxId)
cy.openInboxSection('Proactive messages')
cy.contains('button', 'Add proactive message').click()
cy.get('#campaign-name').type(campaignName)
cy.get('#campaign-message').type('Need a hand?')
selectOption('#campaign-business_hours', 'During business hours')
cy.get('button[type="submit"]').click()
cy.get('@updateInbox.all').should('have.length', 0)
cy.get('#campaign-hours').should('be.visible')
selectOption('#campaign-business_hours', 'Any time')
cy.get('button[type="submit"]').click()
savedConfig('@updateInbox').then((config) => {
expect(config.campaigns).to.have.length(1)
expect(config.campaigns[0].business_hours).to.eq('any')
})
})
it('saves a proactive message condition and removes it again', () => {
cy.intercept('PUT', `**/api/v1/inboxes/${inboxId}`).as('updateInbox')
editInbox(inboxId)
cy.openInboxSection('Proactive messages')
cy.contains('button', campaignName).click()
cy.contains('button', 'Add condition').click()
cy.contains('button[role="combobox"]', 'Select field').click()
cy.get('[role="option"]').contains(attributeName).click()
cy.contains('button[role="combobox"]', 'Select operator').click()
cy.get('[role="option"]').contains('equals').click()
cy.get('input[placeholder="Set value"]').type('gold')
cy.inboxSection('Proactive messages').find('[role="radio"][value="OR"]').click()
cy.get('button[type="submit"]').click()
savedConfig('@updateInbox').then((config) => {
const conditions = config.campaigns[0].conditions
expect(conditions.logical_op).to.eq('OR')
expect(conditions.rules).to.have.length(1)
expect(conditions.rules[0].field).to.eq(attributeKey)
expect(conditions.rules[0].value).to.eq('gold')
})
editInbox(inboxId)
cy.openInboxSection('Proactive messages')
cy.contains('button', campaignName).click()
cy.inboxSection('Proactive messages').contains('button[role="combobox"]', attributeName)
cy.inboxSection('Proactive messages').find('button[aria-label="Close"]').first().click()
cy.get('button[type="submit"]').click()
savedConfig('@updateInbox').then((config) => {
expect(config.campaigns[0].conditions.rules).to.have.length(0)
})
})
it('saves a different pre-chat form for visitors and for users', () => {
cy.intercept('PUT', `**/api/v1/inboxes/${inboxId}`).as('updateInbox')
editInbox(inboxId)
cy.openInboxSection('Pre-chat form')
enablePreChatForm()
cy.audienceTab('Pre-chat form', 'Visitors').click()
audienceEnabledSwitch().should('have.attr', 'data-state', 'checked')
cy.inboxSection('Pre-chat form')
.find('input[placeholder="Tell us about yourself"]')
.clear()
cy.inboxSection('Pre-chat form')
.find('input[placeholder="Tell us about yourself"]')
.type('Visitor questions')
cy.audienceTab('Pre-chat form', 'Users').click()
audienceEnabledSwitch().then(($switch) => {
if ($switch.attr('data-state') !== 'checked') cy.wrap($switch).click()
})
cy.inboxSection('Pre-chat form')
.find('input[placeholder="Tell us about yourself"]')
.clear()
cy.inboxSection('Pre-chat form')
.find('input[placeholder="Tell us about yourself"]')
.type('Signed-in questions')
cy.get('button[type="submit"]').click()
savedConfig('@updateInbox').then((config) => {
expect(config.prechat_form.visitors.enabled, 'visitors on').to.eq(true)
expect(config.prechat_form.users.enabled, 'users on').to.eq(true)
expect(config.prechat_form.visitors.title).to.eq('Visitor questions')
expect(config.prechat_form.users.title).to.eq('Signed-in questions')
})
editInbox(inboxId)
cy.openInboxSection('Pre-chat form')
cy.inboxSection('Pre-chat form')
.find('input[placeholder="Tell us about yourself"]')
.should('have.value', 'Visitor questions')
cy.audienceTab('Pre-chat form', 'Users').click()
cy.inboxSection('Pre-chat form')
.find('input[placeholder="Tell us about yourself"]')
.should('have.value', 'Signed-in questions')
})
it('turns the pre-chat form off for visitors while it stays on for users', () => {
cy.intercept('PUT', `**/api/v1/inboxes/${inboxId}`).as('updateInbox')
editInbox(inboxId)
cy.openInboxSection('Pre-chat form')
cy.audienceTab('Pre-chat form', 'Visitors').click()
audienceEnabledSwitch().click()
cy.get('button[type="submit"]').click()
savedConfig('@updateInbox').then((config) => {
expect(config.prechat_form.enabled, 'form still on').to.eq(true)
expect(config.prechat_form.visitors.enabled, 'visitors off').to.eq(false)
expect(config.prechat_form.users.enabled, 'users on').to.eq(true)
})
editInbox(inboxId)
cy.openInboxSection('Pre-chat form')
cy.audienceTab('Pre-chat form', 'Visitors').click()
audienceEnabledSwitch().should('have.attr', 'data-state', 'unchecked')
cy.audienceTab('Pre-chat form', 'Users').click()
audienceEnabledSwitch().should('have.attr', 'data-state', 'checked')
})
it('keeps the pre-chat fields of one audience out of the other', () => {
cy.intercept('PUT', `**/api/v1/inboxes/${inboxId}`).as('updateInbox')
editInbox(inboxId)
cy.openInboxSection('Pre-chat form')
cy.audienceTab('Pre-chat form', 'Users').click()
cy.inboxSection('Pre-chat form').contains('div.font-medium', attributeName).click()
cy.get('button[type="submit"]').click()
savedConfig('@updateInbox').then((config) => {
const userKeys = config.prechat_form.users.fields.map((field) => field.key)
const visitorKeys = config.prechat_form.visitors.fields.map((field) => field.key)
expect(userKeys, 'users carry the attribute').to.include(attributeKey)
expect(visitorKeys, 'visitors do not').to.not.include(attributeKey)
})
})
it('saves branding for each theme and keeps the halves apart', () => {
cy.intercept('PUT', `**/api/v1/inboxes/${inboxId}`).as('updateInbox')
editInbox(inboxId)
cy.openInboxSection('Theme')
cy.get('#theme-system').click()
cy.openInboxSection('Branding')
cy.editBrandingTheme('Light')
cy.get('#bg-solid').click()
cy.get('input[name="config.branding.light.colors.primary"]').invoke(
'val',
'#0f766e'
).trigger('input')
cy.get('#text-black').click()
cy.editBrandingTheme('Dark')
cy.get('#bg-solid').click()
cy.get('input[name="config.branding.dark.colors.primary"]').invoke(
'val',
'#f59e0b'
).trigger('input')
cy.get('#text-white').click()
cy.get('button[type="submit"]').click()
savedConfig('@updateInbox').then((config) => {
expect(config.theme).to.eq('system')
expect(config.branding.light.colors.primary).to.eq('#0f766e')
expect(config.branding.dark.colors.primary).to.eq('#f59e0b')
expect(config.branding.light.home_screen.header_text_color).to.eq('black')
expect(config.branding.dark.home_screen.header_text_color).to.eq('white')
})
editInbox(inboxId)
cy.openInboxSection('Branding')
cy.editBrandingTheme('Light')
cy.get('input[name="config.branding.light.colors.primary"]').should('have.value', '#0f766e')
cy.editBrandingTheme('Dark')
cy.get('input[name="config.branding.dark.colors.primary"]').should('have.value', '#f59e0b')
})
it('edits the dark half when the theme is forced to dark', () => {
cy.intercept('PUT', `**/api/v1/inboxes/${inboxId}`).as('updateInbox')
editInbox(inboxId)
cy.openInboxSection('Theme')
cy.get('#theme-dark').click()
cy.openInboxSection('Branding')
cy.inboxSection('Branding')
.find('[role="tab"][data-state="active"]')
.should('contain.text', 'Dark')
cy.get('input[name="config.branding.dark.logo_url"]').clear()
cy.get('input[name="config.branding.dark.logo_url"]').type('https://cypress.test/dark.png')
cy.get('button[type="submit"]').click()
savedConfig('@updateInbox').then((config) => {
expect(config.theme).to.eq('dark')
expect(config.branding.dark.logo_url).to.eq('https://cypress.test/dark.png')
expect(config.branding.light.colors.primary, 'light half untouched').to.eq('#0f766e')
})
editInbox(inboxId)
cy.openInboxSection('Theme')
cy.get('#theme-light').click()
cy.openInboxSection('Branding')
cy.inboxSection('Branding')
.find('[role="tab"][data-state="active"]')
.should('contain.text', 'Light')
cy.get('button[type="submit"]').click()
savedConfig('@updateInbox').then((config) => {
expect(config.theme).to.eq('light')
expect(config.branding.dark.logo_url, 'dark half kept').to.eq('https://cypress.test/dark.png')
})
})
it('saves both gradient colours of the home screen background', () => {
cy.intercept('PUT', `**/api/v1/inboxes/${inboxId}`).as('updateInbox')
editInbox(inboxId)
cy.openInboxSection('Branding')
cy.editBrandingTheme('Light')
cy.get('#bg-gradient').click()
cy.get('input[name="config.branding.light.home_screen.background.gradient_start"]')
.invoke('val', '#0f766e')
.trigger('input')
cy.get('input[name="config.branding.light.home_screen.background.gradient_end"]')
.invoke('val', '#99f6e4')
.trigger('input')
cy.switchFieldIn('Branding', 'Fade background').click()
cy.get('button[type="submit"]').click()
savedConfig('@updateInbox').then((config) => {
const background = config.branding.light.home_screen.background
expect(background.type).to.eq('gradient')
expect(background.gradient_start).to.eq('#0f766e')
expect(background.gradient_end).to.eq('#99f6e4')
expect(config.branding.light.home_screen.fade_background).to.eq(true)
})
editInbox(inboxId)
cy.openInboxSection('Branding')
cy.editBrandingTheme('Light')
cy.get('input[name="config.branding.light.home_screen.background.gradient_start"]').should(
'have.value',
'#0f766e'
)
cy.get('input[name="config.branding.light.home_screen.background.gradient_end"]').should(
'have.value',
'#99f6e4'
)
})
it('saves the proactive message sender, team, audience and cooldown', () => {
cy.intercept('PUT', `**/api/v1/inboxes/${inboxId}`).as('updateInbox')
editInbox(inboxId)
cy.openInboxSection('Proactive messages')
cy.get('#campaign-cooldown').clear()
cy.get('#campaign-cooldown').type('8h')
cy.contains('button', campaignName).click()
// SelectAgentCombobox and SelectTeamCombobox drop the id they are given.
pickFromLabelledSelect('Sender', 1)
pickFromLabelledSelect('Route replies to team', 1)
selectOption('#campaign-audience', 'Signed-in users')
cy.get('button[type="submit"]').click()
savedConfig('@updateInbox').then((config) => {
expect(config.campaign_cooldown).to.eq('8h')
expect(config.campaigns[0].sender_id, 'sender picked').to.be.greaterThan(0)
expect(config.campaigns[0].team_id, 'team picked').to.be.greaterThan(0)
expect(config.campaigns[0].audience).to.eq('users')
})
editInbox(inboxId)
cy.openInboxSection('Proactive messages')
cy.get('#campaign-cooldown').should('have.value', '8h')
cy.contains('button', campaignName).click()
cy.get('#campaign-audience').should('contain.text', 'Signed-in users')
})
it('rejects a campaign cooldown that is not a duration', () => {
cy.intercept('PUT', `**/api/v1/inboxes/${inboxId}`).as('updateInbox')
editInbox(inboxId)
cy.openInboxSection('Proactive messages')
cy.get('#campaign-cooldown').clear()
cy.get('#campaign-cooldown').type('soon')
cy.get('button[type="submit"]').click()
cy.get('@updateInbox.all').should('have.length', 0)
cy.inboxSection('Proactive messages').should('have.attr', 'data-state', 'open')
})
it('duplicates a proactive message', () => {
cy.intercept('PUT', `**/api/v1/inboxes/${inboxId}`).as('updateInbox')
editInbox(inboxId)
cy.openInboxSection('Proactive messages')
cy.contains('button', campaignName).click()
cy.contains('button', 'Duplicate').click()
cy.get('button[type="submit"]').click()
savedConfig('@updateInbox').then((config) => {
expect(config.campaigns).to.have.length(2)
const ids = config.campaigns.map((campaign) => campaign.id)
expect(new Set(ids).size, 'each copy keeps its own id').to.eq(2)
})
})
it('rejects a home screen app with a malformed link', () => {
cy.intercept('PUT', `**/api/v1/inboxes/${inboxId}`).as('updateInbox')
editInbox(inboxId)
cy.openInboxSection('Home screen apps')
cy.contains('button', 'Add external link').click()
cy.get('[data-section="homeApps"] input[placeholder="Link Text"]').type('Status')
cy.get('[data-section="homeApps"] input[placeholder="https://example.com"]').type('not-a-url')
cy.get('button[type="submit"]').click()
cy.get('@updateInbox.all').should('have.length', 0)
cy.inboxSection('Home screen apps').should('have.attr', 'data-state', 'open')
})
it('rejects a quick reply longer than the limit', () => {
cy.intercept('PUT', `**/api/v1/inboxes/${inboxId}`).as('updateInbox')
editInbox(inboxId)
cy.openInboxSection('Users')
cy.get('textarea[name="config.visitors.quick_replies"]').clear()
cy.get('textarea[name="config.visitors.quick_replies"]').type('x'.repeat(121), { delay: 0 })
cy.get('button[type="submit"]').click()
cy.get('@updateInbox.all').should('have.length', 0)
cy.inboxSection('Users').should('have.attr', 'data-state', 'open')
})
it('saves the auto language option', () => {
cy.intercept('PUT', `**/api/v1/inboxes/${inboxId}`).as('updateInbox')
editInbox(inboxId)
cy.openInboxSection('General')
cy.inboxSection('General').contains('label', 'Language').parent().find('button').click()
cy.get('[role="option"]').contains('Auto').click()
cy.get('[role="option"]').should('not.exist')
cy.get('button[type="submit"]').click()
savedConfig('@updateInbox').then((config) => {
expect(config.language).to.eq('auto')
})
})
it('keeps the help articles card out until a help center is linked', () => {
editInbox(inboxId)
cy.openInboxSection('Home screen apps')
cy.contains('button', 'Add help articles').should('be.disabled')
cy.contains('Choose a help center under Help center before adding this card.').should(
'be.visible'
)
})
})
@@ -173,16 +173,18 @@ describe('Live chat inbox form layout', () => {
})
})
it('renders the preview with its own theme toggle', () => {
it('keeps the preview and the branding tabs on the same theme', () => {
cy.visit(`${listPath}/${inboxId}/edit`)
cy.get('[data-preview-theme="light"]').should('be.visible')
cy.openInboxSection('Branding')
cy.editBrandingTheme('Dark')
cy.get('[data-preview-theme="light"]').should('have.attr', 'data-state', 'active')
cy.get('[data-preview-theme="dark"]').click()
cy.get('[data-preview-theme="dark"]').should('have.attr', 'data-state', 'active')
cy.get('[data-preview-theme="light"]').click()
cy.inboxSection('Branding')
.find('[role="tab"][data-state="active"]')
.should('contain.text', 'Light')
})
it('rejects more than six quick replies', () => {
@@ -2,7 +2,6 @@
const stamp = Date.now()
const inboxName = `Cypress Livechat ${stamp}`
const renamedInbox = `Cypress Livechat ${stamp} edited`
const brandName = `Cypress Brand ${stamp}`
const websiteUrl = `https://cypress.test/${stamp}`
const greeting = `Hello from Cypress ${stamp}`
@@ -12,7 +11,6 @@ const editedBrandName = `Cypress Brand ${stamp} edited`
const homeAppTitle = `Cypress Announcement ${stamp}`
const editedHomeAppTitle = `Cypress Announcement ${stamp} edited`
const secret = `${stamp}`.padEnd(32, 'c').slice(0, 32)
const maskedSecret = '••••••••••'
const newPath = '/admin/inboxes/new'
const listPath = '/admin/inboxes'
@@ -71,52 +69,6 @@ describe('Live chat inbox form', () => {
cy.contains(inboxName).should('exist')
})
it('loads the saved values back into every tab of the edit form', () => {
expect(inboxId, 'inbox from the create step').to.be.a('number')
cy.visit(`${listPath}/${inboxId}/edit`)
cy.openInboxSection('General')
cy.get('input[name="name"]').should('have.value', inboxName)
cy.get('input[name="config.brand_name"]').should('have.value', brandName)
cy.get('input[name="config.website_url"]').should('have.value', websiteUrl)
cy.openInboxSection('Launcher position')
cy.get('input[name="config.launcher.spacing.side"]').should('have.value', '35')
cy.get('input[name="config.launcher.spacing.bottom"]').should('have.value', '45')
cy.get('select[name="config.launcher.position"]').should('have.value', 'left')
cy.openInboxSection('Messages')
cy.get('textarea[name="config.greeting_message"]').should('have.value', greeting)
cy.get('textarea[name="config.chat_introduction"]').should('have.value', chatIntroduction)
cy.openInboxSection('Security')
cy.get('input[name="config.session_duration"]').should('have.value', '4h')
cy.get('textarea[name="config.trusted_domains"]').should('have.value', trustedDomain)
// The signing secret comes back masked, never as the real value.
cy.get('input[name="secret"]').should('have.value', maskedSecret)
})
it('persists a changed name and greeting message', () => {
cy.intercept('PUT', `**/api/v1/inboxes/${inboxId}`).as('updateInbox')
cy.visit(`${listPath}/${inboxId}/edit`)
cy.openInboxSection('General')
cy.get('input[name="name"]').should('have.value', inboxName).clear().type(renamedInbox)
cy.openInboxSection('Messages')
cy.get('textarea[name="config.greeting_message"]').clear().type(`${greeting} v2`)
cy.get('button[type="submit"]').click()
cy.wait('@updateInbox').its('response.statusCode').should('eq', 200)
cy.visit(`${listPath}/${inboxId}/edit`)
cy.openInboxSection('General')
cy.get('input[name="name"]').should('have.value', renamedInbox)
cy.openInboxSection('Messages')
cy.get('textarea[name="config.greeting_message"]').should('have.value', `${greeting} v2`)
})
it('rejects a submit with no name and no brand name', () => {
cy.intercept('POST', '**/api/v1/inboxes').as('createInbox')
@@ -128,24 +80,6 @@ describe('Live chat inbox form', () => {
cy.location('pathname').should('eq', newPath)
})
it('rejects a malformed website url and jumps back to its tab', () => {
cy.intercept('POST', '**/api/v1/inboxes').as('createInbox')
openNewForm()
cy.get('input[name="name"]').type(inboxName)
cy.get('input[name="config.brand_name"]').type(brandName)
cy.get('input[name="config.website_url"]').type('not-a-url')
cy.openInboxSection('Security')
cy.get('button[type="submit"]').click()
cy.contains('Invalid URL').scrollIntoView().should('be.visible')
// The offending field is on another tab, so the form has to switch back to it.
cy.get('input[name="config.website_url"]').scrollIntoView().should('be.visible')
cy.get('[role="tab"][data-state="active"]').should('not.contain', 'Setup')
cy.get('@createInbox.all').should('have.length', 0)
})
it('persists a brand name edit made alongside a home screen app edit', () => {
cy.intercept('PUT', `**/api/v1/inboxes/${inboxId}`).as('updateInbox')
@@ -178,12 +112,12 @@ describe('Live chat inbox form', () => {
cy.intercept('DELETE', `**/api/v1/inboxes/${inboxId}`).as('deleteInbox')
cy.visit(listPath)
filterList(renamedInbox)
cy.contains('tr', renamedInbox).find('button[aria-haspopup="menu"]').click()
filterList(inboxName)
cy.contains('tr', inboxName).find('button[aria-haspopup="menu"]').click()
cy.get('[role="menuitem"]').contains('Delete').click()
cy.get('[role="alertdialog"]').contains('button', 'Delete').click()
cy.wait('@deleteInbox').its('response.statusCode').should('eq', 200)
cy.contains(renamedInbox).should('not.exist')
cy.contains(inboxName).should('not.exist')
})
})
@@ -3,7 +3,6 @@
const stamp = Date.now()
const inboxName = `Cypress Theme ${stamp}`
const brandName = `Cypress Theme Brand ${stamp}`
const listPath = '/admin/inboxes'
const newPath = '/admin/inboxes/new'
const lightPrimary = '#123456'
@@ -24,6 +23,10 @@ describe('Live chat theme and branding', () => {
cy.login()
})
after(() => {
if (inboxId) cy.api('DELETE', `/api/v1/inboxes/${inboxId}`, null, { failOnStatusCode: false })
})
it('saves a separate primary color for each theme', () => {
cy.intercept('POST', '**/api/v1/inboxes').as('createInbox')
@@ -56,19 +59,6 @@ describe('Live chat theme and branding', () => {
})
})
it('loads each theme back into its own tab', () => {
expect(inboxId, 'inbox from the create step').to.be.a('number')
cy.visit(`${listPath}/${inboxId}/edit`)
cy.openInboxSection('Branding')
cy.editBrandingTheme('Light')
cy.get('input[name="config.branding.light.colors.primary"]').should('have.value', lightPrimary)
cy.editBrandingTheme('Dark')
cy.get('input[name="config.branding.dark.colors.primary"]').should('have.value', darkPrimary)
})
it('seeds a dark background when the theme is switched away from light', () => {
cy.intercept('POST', '**/api/v1/inboxes').as('createSeeded')
@@ -97,33 +87,4 @@ describe('Live chat theme and branding', () => {
})
})
it('rejects a launcher icon scale outside its range', () => {
cy.visit(`${listPath}/${inboxId}/edit`)
cy.openInboxSection('Launcher position')
cy.get('input[name="config.launcher.icon_scale"]').clear().type('10')
cy.get('button[type="submit"]').click()
cy.contains('40').should('exist')
})
it('persists the launcher icon scale', () => {
cy.intercept('PUT', `**/api/v1/inboxes/${inboxId}`).as('updateInbox')
cy.visit(`${listPath}/${inboxId}/edit`)
cy.openInboxSection('Launcher position')
cy.get('input[name="config.launcher.icon_scale"]').clear().type('60')
cy.get('button[type="submit"]').click()
cy.wait('@updateInbox').then(({ response }) => {
expect(response.statusCode).to.eq(200)
expect(response.body.data.config.launcher.icon_scale, 'icon scale').to.eq(60)
})
})
after(() => {
if (inboxId) {
cy.login()
cy.api('DELETE', `/api/v1/inboxes/${inboxId}`, null, { failOnStatusCode: false })
}
})
})
@@ -129,9 +129,7 @@ describe('Live chat inbox form: proactive messages and help center tabs', () =>
cy.get('#campaign-exclude_urls').clear().type('/checkout/*')
cy.get('#campaign-delay_seconds').type('{selectall}25')
cy.get('#campaign-event').clear().type('pricing_viewed')
cy.contains(
'Show this message only after your website tracks this event. Leave this field blank to show it without an event.'
).should('be.visible')
cy.contains('Show this message only after your website tracks this event.').should('be.visible')
cy.contains('code', 'window.Libredesk.trackEvent("pricing_viewed")').should('be.visible')
selectOption('#campaign-repeat', 'After an interval')
cy.get('#campaign-repeat_hours').type('{selectall}12')
+8 -8
View File
@@ -406,7 +406,7 @@
"admin.inbox.livechat.preview.sampleAgentReply": "Happy to help, could you share a few details?",
"admin.inbox.livechat.preview.sampleUserMessage": "Hi! How do I get started?",
"admin.inbox.livechat.quickReplies": "Quick replies",
"admin.inbox.livechat.quickReplies.description": "One reply per line, up to 6. Shown when someone starts a new conversation. Tapping one sends it as the first message.",
"admin.inbox.livechat.quickReplies.description": "One reply per line, up to 6. Shown when someone starts a new conversation.",
"admin.inbox.livechat.quickReplies.limit": "Add up to 6 quick replies.",
"admin.inbox.livechat.quickReplies.placeholder": "I have a question about billing\nHow do I reset my password?",
"admin.inbox.livechat.secretKey": "Secret key",
@@ -1275,7 +1275,7 @@
"helpCenter.styling.brand": "Brand",
"helpCenter.styling.darkLogoUrl": "Logo URL (dark mode)",
"helpCenter.styling.showThemeToggle": "Show light and dark mode toggle",
"helpCenter.styling.themeToggleHint": "Visitors start in light mode and can switch to dark. Turn this off to keep the help center in light mode.",
"helpCenter.styling.themeToggleHint": "Visitors start in light mode and can switch to dark.",
"helpCenter.styling.customCode": "Custom code",
"helpCenter.customJS": "Custom JS",
"helpCenter.deleteArticleConfirmation": "This will permanently delete the article.",
@@ -1328,7 +1328,7 @@
"helpCenter.translationsHint": "Write or link this article in other languages here. Readers who switch language land on the linked version, instead of the help center home page.",
"helpCenter.linkExistingArticle": "Link an existing article",
"helpCenter.linkExistingArticleHint": "Pick an article written in another language to join it with this one as a translation.",
"helpCenter.noLinkableArticles": "No articles in the other languages are free to link. Articles already linked as a translation are left out. Write the translation instead.",
"helpCenter.noLinkableArticles": "No articles in the other languages are free to link. Write the translation instead.",
"helpCenter.articleAlreadyTranslated": "That article is already part of another set of translations. Unlink it there first.",
"helpCenter.localeAlreadyTranslated": "This article already has a translation in that language.",
"helpCenter.cannotLinkArticleToItself": "An article cannot be its own translation.",
@@ -1704,9 +1704,9 @@
"widget.campaignContactAttributesEmpty": "Visibility conditions use contact custom attributes. None are available.",
"widget.campaignCooldown": "Minimum time between proactive messages",
"widget.campaignDeviceRequired": "Select at least one device. This proactive message cannot appear until desktop or mobile is enabled.",
"widget.campaignEventHint": "Show this message only after your website tracks this event. Leave this field blank to show it without an event.",
"widget.campaignEventHint": "Show this message only after your website tracks this event.",
"widget.campaignLimitReached": "You have reached the limit of 50 proactive messages. Delete one to add another.",
"widget.campaignPriorityHint": "Messages are checked from top to bottom. The first matching message is shown.",
"widget.campaignPriorityHint": "Messages are checked from top to bottom. The first match is shown.",
"widget.campaignsHint": "Show a message on matching pages before the chat is opened. A conversation is created only when someone replies to it.",
"widget.campaignStats": "Proactive message results",
"widget.campaignUrlLimit": "Add up to 20 page patterns.",
@@ -1717,10 +1717,10 @@
"widget.featuredArticles": "Featured articles",
"widget.featuredArticlesHint": "Choose up to 10 articles. Leave empty to show popular articles.",
"widget.helpFeedbackError": "Feedback could not be saved. Try again.",
"widget.helpHomeAppHint": "Shows a search button and the featured articles set in the Help center tab.",
"widget.helpHomeAppRequiresCenter": "Choose a help center in the Help center tab before adding this card.",
"widget.helpHomeAppHint": "Shows a search button and the featured articles set under Help center.",
"widget.helpHomeAppRequiresCenter": "Choose a help center under Help center before adding this card.",
"widget.helpLoadError": "Help could not be loaded. Try again or send us a message.",
"widget.helpOnHomeHint": "To show help on the Home screen, add the help articles card under Appearance, Home screen apps.",
"widget.helpOnHomeHint": "To show help on the Home screen, add the help articles card under Home screen apps.",
"widget.helpPlacement": "Where help appears",
"widget.invitationExpired": "This invitation is no longer available. Start a new chat to contact us.",
"widget.ipBlocked": "Access denied",
@@ -9,6 +9,7 @@ import (
"sync"
"sync/atomic"
amodels "github.com/abhinavxd/libredesk/internal/automation/models"
"github.com/abhinavxd/libredesk/internal/conversation/models"
"github.com/abhinavxd/libredesk/internal/inbox"
"github.com/abhinavxd/libredesk/internal/inbox/channel/livechat/proactive"
@@ -33,6 +34,13 @@ const (
ThemeDark = "dark"
DefaultLauncherIconScale = 100
DefaultCampaignCooldown = "24h"
DefaultLauncherColor = "#000000"
BackgroundSolid = "solid"
// Matches the locale the widget falls back to when this is unset.
DefaultLanguage = "en-US"
// Matches the session TTL the chat handlers fall back to when this is unset.
DefaultSessionDuration = "4320h"
)
type Colors struct {
@@ -287,9 +295,82 @@ func (c *Config) UnmarshalJSON(data []byte) error {
if c.Launcher.IconScale == 0 {
c.Launcher.IconScale = DefaultLauncherIconScale
}
if c.CampaignCooldown == "" {
c.CampaignCooldown = DefaultCampaignCooldown
}
if c.SessionDuration == "" {
c.SessionDuration = DefaultSessionDuration
}
if c.Language == "" {
c.Language = DefaultLanguage
}
if c.FallbackLanguage == "" {
c.FallbackLanguage = DefaultLanguage
}
fillBrandingDefaults(&c.Branding.Light, ThemeLight)
fillBrandingDefaults(&c.Branding.Dark, ThemeDark)
c.fillEmptyLists()
return nil
}
func fillBrandingDefaults(branding *Branding, theme string) {
if branding.Launcher.Color == "" {
branding.Launcher.Color = DefaultLauncherColor
}
if branding.HomeScreen.Background.Type == "" {
branding.HomeScreen.Background.Type = BackgroundSolid
}
if branding.HomeScreen.HeaderTextColor == "" {
if theme == ThemeDark {
branding.HomeScreen.HeaderTextColor = "white"
} else {
branding.HomeScreen.HeaderTextColor = "black"
}
}
}
// A null list fails the admin form's validation.
func (c *Config) fillEmptyLists() {
if c.Campaigns == nil {
c.Campaigns = []proactive.Campaign{}
}
for i := range c.Campaigns {
if c.Campaigns[i].IncludeURLs == nil {
c.Campaigns[i].IncludeURLs = []string{}
}
if c.Campaigns[i].ExcludeURLs == nil {
c.Campaigns[i].ExcludeURLs = []string{}
}
if c.Campaigns[i].Conditions.Rules == nil {
c.Campaigns[i].Conditions.Rules = []amodels.RuleDetail{}
}
}
if c.Help.FeaturedIDs == nil {
c.Help.FeaturedIDs = []int{}
}
if c.HomeApps == nil {
c.HomeApps = []HomeApp{}
}
if c.TrustedDomains == nil {
c.TrustedDomains = []string{}
}
if c.BlockedIPs == nil {
c.BlockedIPs = []string{}
}
if c.QuickReplies == nil {
c.QuickReplies = []string{}
}
if c.Users.QuickReplies == nil {
c.Users.QuickReplies = []string{}
}
if c.Visitors.QuickReplies == nil {
c.Visitors.QuickReplies = []string{}
}
if c.PreChatForm.Fields == nil {
c.PreChatForm.Fields = []PreChatFormField{}
}
}
func (c Config) ResolvePreChatForm(isVisitor bool) Config {
audience := c.PreChatForm.Users
if isVisitor {
@@ -138,3 +138,64 @@ func TestUnmarshalKeepsExplicitBranding(t *testing.T) {
t.Fatalf("branding = %+v", config.Branding)
}
}
func TestUnmarshalFillsEmptyListsAndCooldown(t *testing.T) {
var config Config
if err := json.Unmarshal([]byte(`{"brand_name":"Acme"}`), &config); err != nil {
t.Fatal(err)
}
encoded, err := json.Marshal(config)
if err != nil {
t.Fatal(err)
}
var decoded map[string]any
if err := json.Unmarshal(encoded, &decoded); err != nil {
t.Fatal(err)
}
for _, key := range []string{"campaigns", "home_apps", "trusted_domains", "blocked_ips", "quick_replies"} {
if decoded[key] == nil {
t.Fatalf("%s encoded as null", key)
}
}
if config.Help.FeaturedIDs == nil || config.PreChatForm.Fields == nil {
t.Fatalf("help featured ids = %v, prechat fields = %v", config.Help.FeaturedIDs, config.PreChatForm.Fields)
}
if config.Users.QuickReplies == nil || config.Visitors.QuickReplies == nil {
t.Fatalf("audience quick replies = %v, %v", config.Users.QuickReplies, config.Visitors.QuickReplies)
}
if config.CampaignCooldown != DefaultCampaignCooldown {
t.Fatalf("campaign cooldown = %q", config.CampaignCooldown)
}
}
func TestUnmarshalFillsValuesAConfigWrittenOutsideTheFormLacks(t *testing.T) {
var config Config
if err := json.Unmarshal([]byte(`{"brand_name":"Acme","colors":{"primary":"#112233"},"launcher":{"position":"right"}}`), &config); err != nil {
t.Fatal(err)
}
if config.SessionDuration != DefaultSessionDuration {
t.Fatalf("session duration = %q", config.SessionDuration)
}
if config.Language != DefaultLanguage || config.FallbackLanguage != DefaultLanguage {
t.Fatalf("language = %q, fallback = %q", config.Language, config.FallbackLanguage)
}
for theme, branding := range map[string]Branding{ThemeLight: config.Branding.Light, ThemeDark: config.Branding.Dark} {
if branding.Launcher.Color != DefaultLauncherColor {
t.Fatalf("%s launcher color = %q", theme, branding.Launcher.Color)
}
if branding.HomeScreen.Background.Type != BackgroundSolid {
t.Fatalf("%s background type = %q", theme, branding.HomeScreen.Background.Type)
}
if branding.Colors.Primary != "#112233" {
t.Fatalf("%s primary = %q", theme, branding.Colors.Primary)
}
}
if config.Branding.Light.HomeScreen.HeaderTextColor != "black" {
t.Fatalf("light header text = %q", config.Branding.Light.HomeScreen.HeaderTextColor)
}
if config.Branding.Dark.HomeScreen.HeaderTextColor != "white" {
t.Fatalf("dark header text = %q", config.Branding.Dark.HomeScreen.HeaderTextColor)
}
}