Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
35 commits
Select commit Hold shift + click to select a range
1ad6cb4
popup-rules: evaluate page display rules in the runtime
anyelopetit Sep 7, 2026
0e995f2
popup-rules: harden runtime rules for SPA merchants
anyelopetit Sep 7, 2026
80f9f3a
popup-rules: evaluate current-visit activity rules
anyelopetit Sep 9, 2026
1c0863d
popup-rules: extend runtime targeting and frequency
anyelopetit Sep 9, 2026
c98aaf7
popup-rules: harden client rule evaluation
anyelopetit Sep 13, 2026
20ba9b0
popup-rules: parse complete UTM query parameters
anyelopetit Sep 14, 2026
54d71a1
popup-rules: evaluate UTM conditions from current URL
anyelopetit Sep 14, 2026
1655615
popup-rules: align browser language catalog
anyelopetit Sep 14, 2026
9369cc7
popup-rules: fix SDK test import after rebase
anyelopetit Sep 14, 2026
040ea84
popup-rules: rebuild runtime artifacts after rebase
anyelopetit Sep 14, 2026
5d5384c
popup-rules: normalize page URL matching
anyelopetit Sep 15, 2026
442e27c
popup-rules: rebuild runtime artifacts
anyelopetit Sep 15, 2026
8b894fd
popup-rules: harden Page URL runtime matching
anyelopetit Sep 15, 2026
844911e
popup-rules: rebuild Page URL runtime artifacts
anyelopetit Sep 15, 2026
a6e47f7
popup-rules: remove display frequency runtime
anyelopetit Sep 15, 2026
b5b64bb
popup-rules: normalize current UTM targeting
anyelopetit Sep 15, 2026
8c600cc
popup-rules: scope UTM targeting to the visit
anyelopetit Sep 16, 2026
973e92e
popup-rules: build display rules before connecting in tests
anyelopetit Sep 16, 2026
beec397
popup-rules: rebuild runtime artifacts after rebase
anyelopetit Sep 16, 2026
1c483f4
popup-rules: preserve literal plus signs in UTM values
anyelopetit Sep 16, 2026
a0d0795
popup-rules: harden popup runtime behavior
anyelopetit Sep 16, 2026
38acfae
popup-rules: tighten current visit runtime signals
anyelopetit Sep 16, 2026
2f8d352
popup-rules: keep cart activity integration-owned
anyelopetit Sep 16, 2026
e714858
popup-rules: harden browser visit signals
anyelopetit Sep 16, 2026
c53ecc3
popup-rules: resolve query and hash campaign precedence
anyelopetit Sep 16, 2026
f048e59
popup-rules: prepare sdk version 2.6.1
anyelopetit Sep 16, 2026
3f619ad
popup-rules: keep runtime state within its visit
anyelopetit Sep 16, 2026
ae6e9d1
popup-rules: fail closed on invalid form rules
anyelopetit Sep 16, 2026
70df204
popup-rules: rebuild sdk artifacts
anyelopetit Sep 16, 2026
933197b
popup-rules: wait for identification before evaluation
anyelopetit Sep 16, 2026
ad782e0
popup-rules: rebuild sdk artifacts
anyelopetit Sep 16, 2026
0145e13
popup-rules: follow asynchronous page updates
anyelopetit Sep 16, 2026
d15985a
popup-rules: rebuild sdk artifacts
anyelopetit Sep 16, 2026
59dc54f
popup-rules: declare the rules runtime contract
anyelopetit Sep 17, 2026
6c0848e
popup-rules: rebuild sdk artifacts
anyelopetit Sep 17, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 11 additions & 1 deletion __tests__/alert_initialization_test.js
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { Application } from '@hotwired/stimulus'
import Hellotext from '../src/hellotext'
import API from '../src/api'
import { Business, Push } from '../src/models'
import { Business, Push, Webchat } from '../src/models'
import AlertController from '../src/controllers/alert_controller'

const html = `
Expand Down Expand Up @@ -129,6 +129,16 @@ describe('Smart Alert initialization', () => {
expect(document.querySelector('article')).toBeNull()
})

it('does not mount an alert when another widget fails to load', async () => {
hydrate(businessData({ webchat: { id: 'broken-webchat' } }))
jest.spyOn(Webchat, 'load').mockRejectedValue(new Error('Unable to load webchat'))

await expect(initialize()).rejects.toThrow('Unable to load webchat')

expect(Hellotext.alert).toBeNull()
expect(document.querySelector('article')).toBeNull()
})

it('removes the old alert when reinitialized with Push disabled', async () => {
await initialize()
const previous = Hellotext.alert
Expand Down
39 changes: 39 additions & 0 deletions __tests__/api/identifications_test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
/**
* @jest-environment jsdom
*/

import IdentificationsAPI from '../../src/api/identifications'
import Hellotext from '../../src/hellotext'
import { Configuration } from '../../src/core'

describe('IdentificationsAPI', () => {
beforeEach(() => {
Configuration.apiRoot = 'https://api.hellotext.test/v1'
Hellotext.business = { id: 'business-id' }
jest.spyOn(Hellotext, 'session', 'get').mockReturnValue('session-123')
global.fetch = jest.fn().mockResolvedValue({
ok: true,
status: 200,
json: jest.fn().mockResolvedValue({ status: 'completed' }),
})
})

afterEach(() => {
jest.restoreAllMocks()
Configuration.apiRoot = 'https://api.hellotext.com/v1'
})

it('checks a receipt within the current business and session', async () => {
const response = await IdentificationsAPI.status('receipt-1')
const [request, options] = global.fetch.mock.calls[0]
const url = new URL(request)

expect(url.pathname).toBe('/v1/public/identifications/receipt-1')
expect(url.search).toBe('')
expect(options).toEqual({
method: 'GET',
headers: { ...Hellotext.headers, 'X-Hellotext-Session': 'session-123' },
})
expect(response.succeeded).toBe(true)
})
})
3 changes: 3 additions & 0 deletions __tests__/api/popups_test.js
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,9 @@ describe('PopupsAPI', () => {
expect(url.searchParams.get('locale')).toBe('es')
expect(url.searchParams.get('device')).toBe('desktop')
expect(global.fetch.mock.calls[0][1].headers.Authorization).toBe('Bearer business-id')
// Rails withholds a popup that depends on browser rules from a runtime that does not
// declare it can check them.
expect(global.fetch.mock.calls[0][1].headers['X-Hellotext-Popup-Rules']).toBe('1')
expect(element.id).toBe('popup-widget')
expect(Hellotext.business.setData).toHaveBeenCalledWith({ id: 'business-id' })
expect(Hellotext.business.setLocale).toHaveBeenCalledWith('es')
Expand Down
9 changes: 9 additions & 0 deletions __tests__/controllers/message_controller_test.js
Original file line number Diff line number Diff line change
Expand Up @@ -378,6 +378,15 @@ describe('MessageController', () => {
expect(Hellotext.track).not.toHaveBeenCalled()
})

it('does not record cart activity before the platform confirms success', () => {
const recordActivity = jest.spyOn(Hellotext, 'recordActivity').mockImplementation(() => {})

controller.addToCart({ currentTarget: mockButton })

expect(recordActivity).not.toHaveBeenCalled()
recordActivity.mockRestore()
})

it('saves the message UTM before dispatching the cart addition', () => {
const originalPage = Hellotext.page
const save = jest.fn()
Expand Down
264 changes: 264 additions & 0 deletions __tests__/controllers/popup_controller_test.js
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
import PopupController from '../../src/controllers/popup_controller'
import PopupsAPI from '../../src/api/popups'
import Hellotext from '../../src/hellotext'
import { Cookies } from '../../src/models/cookies'

describe('PopupController', () => {
let controller
Expand Down Expand Up @@ -89,6 +90,8 @@ describe('PopupController', () => {
controller.captureValue = { capture_id: 'capture-id' }
controller.deviceValue = 'all'
controller.idValue = id
// Before initialize(): that is where the controller builds its display rules.
controller.rulesValue = { lanes: [] }
controller.initialize()

return {
Expand Down Expand Up @@ -132,6 +135,186 @@ describe('PopupController', () => {
document.body.innerHTML = ''
})

// A rule may target any of the three campaign parameters. The URL the visitor is on answers
// for itself, and what it carried is remembered for the rest of the visit — persisted
// attribution, which outlives the visit by years, is never the fallback.
describe('UTM rules', () => {
const utmRule = (field, value) => ({
lanes: [[{ type: 'condition', field, operator: 'is', values: [value] }]],
})
const flushTimers = () => new Promise(resolve => setTimeout(resolve, 0))
let originalPage

// The rule has to be in place before initialize(), which is where the controller builds
// it. Assigning rulesValue after that leaves the controller evaluating an empty rule set,
// which matches every page and would let these tests pass without reading their rule.
const connectWith = rules => {
const built = buildController({ hasBubble: false })
controller.rulesValue = rules
controller.initialize()
controller.connect()

return built
}

beforeEach(() => {
originalPage = Hellotext.page
// Set, and expected to stay unread: this is the attribution the browser persisted.
Hellotext.page = { utmParams: { source: 'google', medium: 'cpc' } }
Hellotext.visitBusinessId = 'business-1'
Hellotext.visitCampaign = {}
window.sessionStorage.clear()
})

afterEach(() => {
controller?.disconnect()
Hellotext.page = originalPage
Hellotext.visitCampaign = {}
window.history.replaceState({}, '', '/')
})

it('matches a campaign the URL carries without a source or medium', () => {
window.history.replaceState({}, '', '/landing?utm_campaign=spring')

const { element } = connectWith(utmRule('session.utm_campaign', 'spring'))

expect(element.hidden).toBe(false)
})

it('ignores capitalization while keeping each value as the link wrote it', () => {
window.history.replaceState({}, '', '/landing?utm_source=Google&utm_medium=Paid_Social&utm_campaign=Spring')

const { element } = connectWith(utmRule('session.utm_source', 'google'))

expect(element.hidden).toBe(false)
expect(controller.pageContext().utm).toEqual({
source: 'Google',
medium: 'Paid_Social',
campaign: 'Spring',
})

controller.disconnect()
const campaign = connectWith(utmRule('session.utm_campaign', 'spring'))

expect(campaign.element.hidden).toBe(false)
})

it('keeps an encoded literal plus distinct from a space in campaign values', () => {
window.history.replaceState({}, '', '/landing?utm_campaign=Black%2BFriday')

const plus = connectWith(utmRule('session.utm_campaign', 'black+friday'))

expect(plus.element.hidden).toBe(false)
expect(controller.pageContext().utm).toEqual({ campaign: 'Black+Friday' })

controller.disconnect()
const words = connectWith(utmRule('session.utm_campaign', 'black friday'))

expect(words.element.hidden).toBe(true)
})

it('keeps the campaign this visit arrived with once the URL drops it', () => {
Hellotext.rememberVisitCampaign({ campaign: 'spring' })
window.history.replaceState({}, '', '/products/42')

const { element } = connectWith(utmRule('session.utm_campaign', 'spring'))

expect(element.hidden).toBe(false)
})

// `hello_utm` records only a complete source and medium pair and survives for years, so
// an old campaign must never decide a popup for a visit that arrived some other way.
it('never falls back to the attribution persisted for the browser', () => {
window.history.replaceState({}, '', '/landing')

const { element } = connectWith(utmRule('session.utm_source', 'google'))

expect(element.hidden).toBe(true)
expect(controller.pageContext().utm).toEqual({})
})

it('lets the URL replace the remembered campaign rather than merge with it', () => {
Hellotext.rememberVisitCampaign({ source: 'google', medium: 'cpc' })
window.history.replaceState({}, '', '/landing?utm_campaign=spring')

const { element } = connectWith(utmRule('session.utm_source', 'google'))

expect(element.hidden).toBe(true)
expect(controller.pageContext().utm).toEqual({ campaign: 'spring' })
})

it('ignores parameters that name no campaign', () => {
Hellotext.rememberVisitCampaign({ source: 'google', medium: 'cpc' })
window.history.replaceState({}, '', '/landing?utm_term=shoes')

const { element } = connectWith(utmRule('session.utm_source', 'google'))

expect(element.hidden).toBe(false)
})

it('falls back when UTM values are blank and reads campaign parameters from a hash route', () => {
Hellotext.rememberVisitCampaign({ source: 'Google', medium: 'CPC' })
window.history.replaceState({}, '', '/landing?utm_campaign=%20')

const { element } = connectWith(utmRule('session.utm_source', 'google'))
expect(element.hidden).toBe(false)

controller.disconnect()
window.history.replaceState({}, '', '/?affiliate=1#/landing?utm_campaign=spring')
const hashRoute = connectWith(utmRule('session.utm_source', 'google'))

expect(hashRoute.element.hidden).toBe(true)
})

it('prefers a document campaign over campaign parameters inside the hash route', () => {
window.history.replaceState(
{},
'',
'/?utm_source=paid#/landing?utm_campaign=spring',
)

const { element } = connectWith(utmRule('session.utm_source', 'paid'))

expect(element.hidden).toBe(false)
expect(controller.pageContext().utm).toEqual({ source: 'paid' })
})

it('uses the first duplicate UTM parameter without changing persisted attribution', () => {
const set = jest.spyOn(Cookies, 'set')
window.history.replaceState({}, '', '/landing?utm_source=First&utm_source=Second')

const { element } = connectWith(utmRule('session.utm_source', 'first'))

expect(element.hidden).toBe(false)
expect(controller.pageContext().utm).toEqual({ source: 'First' })
expect(set).not.toHaveBeenCalled()
})

it('re-reads the URL after a SPA route adds a campaign', async () => {
window.history.replaceState({}, '', '/landing')

const { element } = connectWith(utmRule('session.utm_source', 'newsletter'))
expect(element.hidden).toBe(true)

window.history.pushState({}, '', '/offer?utm_source=newsletter')
await flushTimers()

expect(element.hidden).toBe(false)
})
})

describe('browser detection', () => {
it.each([
['Opera', 'Mozilla/5.0 Chrome/120.0.0.0 Safari/537.36 OPR/106.0.0.0'],
['Samsung Internet', 'Mozilla/5.0 Chrome/120.0.0.0 Mobile Safari/537.36 SamsungBrowser/23.0'],
])('does not classify %s as Chrome', (_browser, userAgent) => {
jest.spyOn(window.navigator, 'userAgent', 'get').mockReturnValue(userAgent)
buildController()

expect(controller.browserName()).toBeUndefined()
})
})

it('shows the bubble first and opens the dialog when clicked', () => {
const { element, bubble, dialog } = buildController()

Expand Down Expand Up @@ -309,6 +492,49 @@ describe('PopupController', () => {
expect(controller.submitButtonTargets.every(button => !button.disabled)).toBe(true)
})

it('returns to the step containing a field the server rejects', async () => {
const { emailInput, phoneInput, stepOne, stepTwo } = buildController({ hasBubble: false })
PopupsAPI.submit.mockResolvedValueOnce({
failed: true,
json: jest.fn().mockResolvedValue({
errors: [{ parameter: 'email', description: 'Email is already in use.' }],
}),
})

controller.connect()
emailInput.value = 'customer@example.com'
await controller.next()
phoneInput.value = '+15551234567'
await controller.submit()

expect(controller.stepIndex).toBe(0)
expect(stepOne.hidden).toBe(false)
expect(stepTwo.hidden).toBe(true)
expect(emailInput.validationMessage).toBe('Email is already in use.')
})

it('returns to the step associated with a rejected field outside its layout wrapper', async () => {
const { emailInput, phoneInput, stepOne, stepTwo } = buildController({ hasBubble: false })
stepOne.removeChild(emailInput)
controller.element.appendChild(emailInput)
PopupsAPI.submit.mockResolvedValueOnce({
failed: true,
json: jest.fn().mockResolvedValue({
errors: [{ parameter: 'email', description: 'Email is already in use.' }],
}),
})

controller.connect()
emailInput.value = 'customer@example.com'
phoneInput.value = '+15551234567'
controller.showStep(1)
await controller.submit()

expect(controller.stepIndex).toBe(0)
expect(stepOne.hidden).toBe(false)
expect(stepTwo.hidden).toBe(true)
})

it('shows a one-minute resend cooldown and the change action for the submitted identity', async () => {
jest.useFakeTimers()
jest.setSystemTime(new Date('2026-08-24T12:00:00Z'))
Expand Down Expand Up @@ -480,6 +706,44 @@ describe('PopupController', () => {
)
})

it('submits a prefixed phone value in the identity and metadata fields', () => {
const { emailInput, phoneInput } = buildController({ hasBubble: false })

emailInput.required = false
phoneInput.dataset.popupPhonePrefix = '+58'
phoneInput.value = '04126625353'

expect(controller.submissionPayload()).toEqual(
expect.objectContaining({
phone: '+584126625353',
metadata: expect.objectContaining({
fields: expect.objectContaining({ phone: '+584126625353' }),
steps: expect.arrayContaining([
expect.objectContaining({ fields: expect.objectContaining({ phone: '+584126625353' }) }),
]),
}),
}),
)
})

it('keeps an empty optional phone blank when it has a country prefix', () => {
const { emailInput, phoneInput } = buildController({ hasBubble: false })

phoneInput.required = false
phoneInput.dataset.popupPhonePrefix = '+58'
phoneInput.value = ''
emailInput.value = 'customer@example.com'

expect(controller.submissionPayload()).toEqual(
expect.objectContaining({
phone: '',
metadata: expect.objectContaining({
fields: expect.objectContaining({ phone: '' }),
}),
}),
)
})

it('uses the backend delivery channel and destination in the completed step', () => {
const { completed, emailInput, phoneInput } = buildController({ hasBubble: false })

Expand Down
Loading