docs: 交付文档、维护指引与端到端回归
补齐 README、开发计划、维护交接与 E2E 用例,执行最终验收。
This commit is contained in:
1 parent
18732801b5
commit
6cd904d460
216 files changed
+37038
No files matched your search
@@ -0,0 +1,45 @@
|
||||
import { test, expect, expectNoNewA11yViolations, InboxPage } from '@e2e'
|
||||
|
||||
// Serious and critical axe findings, per screen, held to a baseline
|
||||
// (a11y-baseline/): today's known issues pass, anything new fails, and the
|
||||
// baseline shrinks as they are fixed. The widget is checked in both themes.
|
||||
|
||||
test.describe('accessibility', () => {
|
||||
test('sign-in page @a11y', async ({ anonymousPage }) => {
|
||||
await anonymousPage.goto('/login')
|
||||
await expect(anonymousPage.getByRole('button', { name: /sign in/i })).toBeVisible()
|
||||
await expectNoNewA11yViolations(anonymousPage, 'login')
|
||||
})
|
||||
|
||||
// The dashboard is dark unless the user picks light (stored as rp-theme); both are checked.
|
||||
for (const scheme of ['dark', 'light'] as const) {
|
||||
const suffix = scheme === 'dark' ? '' : '-light'
|
||||
|
||||
test(`dashboard screens, ${scheme} theme @a11y`, async ({ page, agent, agentsPage, aiConfigPage, ticketsPage, peoplePage }) => {
|
||||
// An agent of its own, so the agents screen is the list (with a card to check), not first-run setup.
|
||||
expect(agent.id).toBeTruthy()
|
||||
await page.addInitScript((theme) => localStorage.setItem('rp-theme', theme), scheme)
|
||||
await new InboxPage(page).goto()
|
||||
expect(await page.evaluate(() => document.documentElement.dataset.theme)).toBe(scheme)
|
||||
await expectNoNewA11yViolations(page, `inbox${suffix}`)
|
||||
await agentsPage.goto()
|
||||
await expectNoNewA11yViolations(page, `ai-agents${suffix}`)
|
||||
await aiConfigPage.goto()
|
||||
await expectNoNewA11yViolations(page, `ai-config${suffix}`)
|
||||
await ticketsPage.goto()
|
||||
await expectNoNewA11yViolations(page, `tickets${suffix}`)
|
||||
await peoplePage.goto()
|
||||
await expectNoNewA11yViolations(page, `people${suffix}`)
|
||||
})
|
||||
}
|
||||
|
||||
for (const scheme of ['light', 'dark'] as const) {
|
||||
test(`widget, ${scheme} theme @a11y @widget`, async ({ widget }) => {
|
||||
await widget.page.emulateMedia({ colorScheme: scheme })
|
||||
await widget.embed()
|
||||
await widget.open()
|
||||
await expect(widget.messageInput).toBeEditable()
|
||||
await expectNoNewA11yViolations(widget.page, `widget-${scheme}`)
|
||||
})
|
||||
}
|
||||
})
|
||||
@@ -0,0 +1,25 @@
|
||||
import { test, expect, scenarioMarker, unique, AI_REPLY } from '@e2e'
|
||||
|
||||
test.describe('agent editor', () => {
|
||||
test('an agent created and edited in the dashboard answers with its new instructions @agents', async ({ makeAgent, agentsPage, makeWidget, llm }) => {
|
||||
// With at least one agent, /ai-agents shows the list rather than onboarding.
|
||||
await makeAgent()
|
||||
const scenarioId = unique('scn')
|
||||
const name = `UI Agent ${scenarioId}`
|
||||
|
||||
await agentsPage.goto()
|
||||
const agentId = await agentsPage.createAgent(name)
|
||||
await agentsPage.openAgent(agentId)
|
||||
await agentsPage.setInstructions(`Always sign off as the Acme team. ${scenarioMarker(scenarioId)}`)
|
||||
|
||||
await llm.script({ name, scenarioId }, (s) => s.otherwise().reply('Hello from the Acme team!'))
|
||||
const widget = await makeWidget(agentId)
|
||||
await widget.embed()
|
||||
await widget.open()
|
||||
await widget.send('Hi there')
|
||||
|
||||
await expect(widget.lastAgentMessage()).toHaveText('Hello from the Acme team!', AI_REPLY)
|
||||
const prompts = (await llm.requests({ name, scenarioId })).map((request) => request.system)
|
||||
expect(prompts.join('\n')).toContain('Always sign off as the Acme team.')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,25 @@
|
||||
import { test, expect, AI_REPLY } from '@e2e'
|
||||
|
||||
interface AgentPerformance { bot_agents: Array<{ id: string; total_chats: number }> }
|
||||
|
||||
test.describe('analytics', () => {
|
||||
test('a conversation shows up in the agent\'s numbers, and the dashboard renders without errors @analytics', async ({ agent, widget, llm, adminApi, page }) => {
|
||||
await llm.script(agent, (s) => s.otherwise().reply('Happy to help!'))
|
||||
await widget.embed()
|
||||
await widget.open()
|
||||
await widget.send('Hello')
|
||||
await expect(widget.lastAgentMessage()).toHaveText('Happy to help!', AI_REPLY)
|
||||
|
||||
const chatsOf = async () => (await adminApi.get<AgentPerformance>('/analytics/agent-performance', { params: { time_range: '24h' } }))
|
||||
.bot_agents.find((a) => a.id === agent.id)?.total_chats
|
||||
await expect.poll(chatsOf).toBe(1)
|
||||
|
||||
const errors: string[] = []
|
||||
page.on('pageerror', (error) => errors.push(error.message))
|
||||
await page.goto('/analytics')
|
||||
await expect(page.getByRole('main').getByRole('heading', { name: 'Total Conversations' })).toBeVisible()
|
||||
// The shared test org has hundreds of agents; the dashboard lists the busiest,
|
||||
// so this one's row is checked through the API above.
|
||||
expect(errors).toEqual([])
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,38 @@
|
||||
import { test, expect, storageStatePath } from '@e2e'
|
||||
|
||||
test.describe('signed out', () => {
|
||||
test.use({ storageState: { cookies: [], origins: [] } })
|
||||
|
||||
test('admin signs in and lands on the dashboard @smoke @auth', async ({ loginPage, org, page }) => {
|
||||
await loginPage.goto()
|
||||
await loginPage.signIn(org.admin.email, org.admin.password)
|
||||
await expect(page).toHaveURL(/\/ai-agents/)
|
||||
})
|
||||
|
||||
test('a wrong password keeps you on the sign-in page with an error @auth', async ({ loginPage, org, page }) => {
|
||||
await loginPage.goto()
|
||||
await loginPage.signIn(org.admin.email, 'not-the-password')
|
||||
await expect(loginPage.error).toHaveText('Incorrect email or password')
|
||||
await expect(page).toHaveURL(/\/login/)
|
||||
})
|
||||
|
||||
test('signing out returns to the sign-in page @auth', async ({ loginPage, dashboard, org, page }) => {
|
||||
// A fresh session, not the shared admin state — logging out must not affect other tests.
|
||||
await loginPage.goto()
|
||||
await loginPage.signIn(org.admin.email, org.admin.password)
|
||||
await expect(page).toHaveURL(/\/ai-agents/)
|
||||
await dashboard.logout()
|
||||
await page.goto('/conversations')
|
||||
await expect(page).toHaveURL(/\/login/)
|
||||
})
|
||||
})
|
||||
|
||||
test.describe('as a human agent', () => {
|
||||
// A path, not a read: spec files load before the setup project writes the state.
|
||||
test.use({ storageState: storageStatePath('human-agent') })
|
||||
|
||||
test('the Agent role cannot open AI settings @auth', async ({ dashboard, page }) => {
|
||||
await page.goto('/settings/ai-config')
|
||||
await expect(dashboard.accessDenied).toBeVisible()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,88 @@
|
||||
import { test, expect, env, ApiClient, createUser, login, unique, type APIRequestContext } from '@e2e'
|
||||
|
||||
// Sessions end on the server, not just in the browser: a copied token stops
|
||||
// working the moment its session is signed out, replaced or deactivated.
|
||||
|
||||
const PASSWORD = 'E2e-Session-Passw0rd!'
|
||||
|
||||
interface Device { api: ApiClient; accessToken: string; refreshToken: string }
|
||||
|
||||
test.describe('sessions', () => {
|
||||
let contexts: APIRequestContext[] = []
|
||||
let newContext: () => Promise<APIRequestContext>
|
||||
|
||||
test.beforeEach(({ playwright }) => {
|
||||
newContext = async () => {
|
||||
const context = await playwright.request.newContext()
|
||||
contexts.push(context)
|
||||
return context
|
||||
}
|
||||
})
|
||||
|
||||
test.afterEach(async () => {
|
||||
await Promise.all(contexts.map((c) => c.dispose()))
|
||||
contexts = []
|
||||
})
|
||||
|
||||
/** Signs `email` in on a browser-like context of its own, as one device would. */
|
||||
async function signIn(email: string, password = PASSWORD): Promise<Device> {
|
||||
const context = await newContext()
|
||||
const { token } = await login(new ApiClient(context), email, password)
|
||||
const refreshToken = (await context.storageState()).cookies.find((c) => c.name === 'refresh_token')!.value
|
||||
return { api: new ApiClient(context, token), accessToken: token, refreshToken }
|
||||
}
|
||||
|
||||
/** What a stolen copy of the access token gets: a bare Bearer request, no cookies, no renewal. */
|
||||
async function withStolen(accessToken: string): Promise<number> {
|
||||
return (await new ApiClient(await newContext(), accessToken).raw('GET', '/chats/recent')).status()
|
||||
}
|
||||
|
||||
/** What a stolen copy of the refresh cookie gets from /users/refresh. */
|
||||
async function refreshWithStolen(refreshToken: string): Promise<number> {
|
||||
const response = await (await newContext()).post(`${env.apiUrl}/users/refresh`, { headers: { Cookie: `refresh_token=${refreshToken}` } })
|
||||
return response.status()
|
||||
}
|
||||
|
||||
async function newTeammate(adminApi: ApiClient): Promise<{ id: string; email: string }> {
|
||||
const email = `${unique('session')}@e2e.example.com`
|
||||
const user = await createUser(adminApi, { name: 'Session Teammate', email, password: PASSWORD, role: 'Agent' })
|
||||
return { id: user.id, email }
|
||||
}
|
||||
|
||||
test('signing out ends the session: copies of both tokens are refused @auth @security', async ({ adminApi }) => {
|
||||
const device = await signIn((await newTeammate(adminApi)).email)
|
||||
expect(await withStolen(device.accessToken)).toBe(200)
|
||||
|
||||
await device.api.post('/users/logout')
|
||||
|
||||
expect(await withStolen(device.accessToken)).toBe(401)
|
||||
expect(await refreshWithStolen(device.refreshToken)).toBe(401)
|
||||
})
|
||||
|
||||
test('a refresh token is not accepted as an access token @auth @security', async ({ adminApi }) => {
|
||||
const device = await signIn((await newTeammate(adminApi)).email)
|
||||
expect(await withStolen(device.refreshToken)).toBe(401)
|
||||
})
|
||||
|
||||
test('deactivating a teammate cuts them off at once @auth @security', async ({ adminApi }) => {
|
||||
const teammate = await newTeammate(adminApi)
|
||||
const device = await signIn(teammate.email)
|
||||
|
||||
await adminApi.put(`/users/${teammate.id}`, { data: { is_active: false } })
|
||||
|
||||
expect(await withStolen(device.accessToken)).toBe(401)
|
||||
expect(await refreshWithStolen(device.refreshToken)).toBe(401)
|
||||
})
|
||||
|
||||
test('changing your password signs out your other devices, not this one @auth @security', async ({ adminApi }) => {
|
||||
const { email } = await newTeammate(adminApi)
|
||||
const laptop = await signIn(email)
|
||||
const phone = await signIn(email)
|
||||
|
||||
await laptop.api.patch('/users/me', { data: { current_password: PASSWORD, password: 'E2e-Session-Passw0rd-2!' } })
|
||||
|
||||
expect(await withStolen(laptop.accessToken)).toBe(200)
|
||||
expect(await withStolen(phone.accessToken)).toBe(401)
|
||||
expect(await refreshWithStolen(phone.refreshToken)).toBe(401)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,72 @@
|
||||
import { test, expect, channels, FakeExternals, unique, AI_REPLY } from '@e2e'
|
||||
|
||||
test.describe('when a channel refuses a message', () => {
|
||||
test.use({ agentSettings: { transferToHuman: true } })
|
||||
|
||||
test('the teammate is told it was not delivered, and the next one goes through @channels @handover', async ({ agent, llm, adminApi, request, inbox }) => {
|
||||
await llm.script(agent, (s) => s.otherwise().handOver('Let me connect you with a teammate.'))
|
||||
const customer = unique('Wanda')
|
||||
const chat = (await channels.WhatsApp({ api: adminApi, request, agentId: agent.id })).chatWith(customer)
|
||||
await chat.send('I need a person')
|
||||
await expect.poll(() => chat.replies(), AI_REPLY).toHaveLength(1)
|
||||
|
||||
await inbox.goto()
|
||||
await inbox.openConversationWith(customer)
|
||||
await inbox.takeOver()
|
||||
|
||||
// WhatsApp refuses free-form messages outside the 24-hour window with error 131047.
|
||||
const restore = await new FakeExternals(request).fail({
|
||||
provider: 'graph',
|
||||
action: 'messages',
|
||||
contains: chat.waId,
|
||||
status: 400,
|
||||
body: { error: { message: 'Re-engagement message', type: 'OAuthException', code: 131047, fbtrace_id: 'e2e' } },
|
||||
times: 1,
|
||||
})
|
||||
await inbox.reply('Are you still there?')
|
||||
await expect(inbox.agentMessage('Are you still there?')).toContainText('Not delivered')
|
||||
await restore()
|
||||
|
||||
await inbox.reply('Here is your tracking link.')
|
||||
await expect.poll(() => chat.replies(), AI_REPLY).toContain('Here is your tracking link.')
|
||||
await expect(inbox.agentMessage('Here is your tracking link.')).not.toContainText('Not delivered')
|
||||
expect(await chat.replies()).not.toContain('Are you still there?')
|
||||
})
|
||||
})
|
||||
|
||||
test.describe('choices that do not fit a channel\'s buttons', () => {
|
||||
test.use({ agentSettings: { channelQuickReplies: true } })
|
||||
|
||||
test('five options become a WhatsApp list within its limits, and a pick still gets its answer @channels', async ({ agent, llm, adminApi, request }) => {
|
||||
const options = ['Track my order', 'Returns', 'Change my delivery address', 'Billing question', 'Talk to someone']
|
||||
await llm.script(agent, (s) => s
|
||||
.when(/change my delivery address/i).reply('Sure — what is the new address?')
|
||||
.otherwise().reply('What can I help you with?', { quick_replies: options }))
|
||||
|
||||
const chat = (await channels.WhatsApp({ api: adminApi, request, agentId: agent.id })).chatWith('Tess')
|
||||
await chat.send('Hi')
|
||||
// More than three buttons is a list; the fake rejects anything past WhatsApp's real limits.
|
||||
await expect.poll(() => chat.buttons(), AI_REPLY).toHaveLength(5)
|
||||
const titles = await chat.buttons()
|
||||
for (const title of titles) expect([...title].length).toBeLessThanOrEqual(24)
|
||||
|
||||
await chat.tap(titles[2])
|
||||
await expect.poll(async () => (await chat.replies()).at(-1), AI_REPLY).toBe('Sure — what is the new address?')
|
||||
})
|
||||
|
||||
test('a button title longer than WhatsApp allows is shortened, and the tap still works @channels', async ({ agent, llm, adminApi, request }) => {
|
||||
const long = 'Change my delivery address please'
|
||||
await llm.script(agent, (s) => s
|
||||
.when(/change my delivery address/i).reply('Sure — what is the new address?')
|
||||
.otherwise().reply('What can I help you with?', { quick_replies: [long, 'Returns'] }))
|
||||
|
||||
const chat = (await channels.WhatsApp({ api: adminApi, request, agentId: agent.id })).chatWith('Tess')
|
||||
await chat.send('Hi')
|
||||
await expect.poll(() => chat.buttons(), AI_REPLY).toHaveLength(2)
|
||||
const [shortened] = await chat.buttons()
|
||||
expect([...shortened].length).toBeLessThanOrEqual(24)
|
||||
|
||||
await chat.tap(shortened)
|
||||
await expect.poll(async () => (await chat.replies()).at(-1), AI_REPLY).toBe('Sure — what is the new address?')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,38 @@
|
||||
import { test, expect, channels, expectUnchanged, Mailbox, AI_REPLY } from '@e2e'
|
||||
|
||||
test('an email reply stays in the customer\'s thread @channels', async ({ agent, llm, adminApi, request }) => {
|
||||
await llm.script(agent, (s) => s.otherwise().reply('Happy to help with that.'))
|
||||
|
||||
const chat = (await channels.Email({ api: adminApi, request, agentId: agent.id })).chatWith('Tess')
|
||||
await chat.send('Can I change my delivery address?')
|
||||
|
||||
const mailbox = new Mailbox(request)
|
||||
await expect.poll(async () => (await mailbox.messagesTo(chat.address)).length, AI_REPLY).toBe(1)
|
||||
// One question, one model call, one email — a duplicate would arrive later.
|
||||
expect(await llm.requests(agent)).toHaveLength(1)
|
||||
await expectUnchanged('emails to the customer', async () => (await mailbox.messagesTo(chat.address)).length)
|
||||
const [reply] = await mailbox.messagesTo(chat.address)
|
||||
expect(reply.subject).toBe(`Re: ${chat.subject}`)
|
||||
// In-Reply-To is what makes mail clients file the answer under the question.
|
||||
expect((await mailbox.headers(reply.id))['In-Reply-To']).toEqual([chat.lastMessageId])
|
||||
})
|
||||
|
||||
test('a customer\'s follow-up email continues the same conversation @channels', async ({ agent, llm, adminApi, request }) => {
|
||||
await llm.script(agent, (s) => s
|
||||
.when(/tuesday/i).reply('Tuesday works — I have noted it.')
|
||||
.otherwise().reply('Happy to help with that. Which day suits you?'))
|
||||
|
||||
const chat = (await channels.Email({ api: adminApi, request, agentId: agent.id })).chatWith('Tess')
|
||||
await chat.send('Can I change my delivery day?')
|
||||
await expect.poll(() => chat.replies(), AI_REPLY).toHaveLength(1)
|
||||
await chat.send('Tuesday please')
|
||||
await expect.poll(() => chat.replies(), AI_REPLY).toEqual([
|
||||
'Happy to help with that. Which day suits you?',
|
||||
'Tuesday works — I have noted it.',
|
||||
])
|
||||
|
||||
// One conversation for this sender, holding both exchanges — not two.
|
||||
const conversations = await adminApi.get<Array<{ message_count: number }>>('/chats/recent', { params: { customer_email: chat.address } })
|
||||
expect(conversations).toHaveLength(1)
|
||||
expect(conversations[0].message_count).toBe(4)
|
||||
})
|
||||
@@ -0,0 +1,100 @@
|
||||
import {
|
||||
test, expect, connectEmail, connectLine, connectMessenger, connectSms, connectWhatsAppNumber, digits,
|
||||
expectUnchanged, hmac, installSlackWorkspace, metaSignature, metaToken, phoneNumber, routeToAgent, sendWebhook,
|
||||
slackSignature, unique, whatsappPayload, TelegramBot,
|
||||
} from '@e2e'
|
||||
|
||||
// Anyone can POST to a webhook URL. These are the requests a provider would never
|
||||
// send: unsigned, wrongly signed, replayed — and events that are signed but must
|
||||
// never start an AI turn (our own echoes, delivery receipts, other bots).
|
||||
|
||||
test.describe('forged webhooks are refused', () => {
|
||||
test('Meta (WhatsApp, Messenger, Instagram): a wrong or missing signature @channels @security', async ({ adminApi, request }) => {
|
||||
const { phoneNumberId } = await connectWhatsAppNumber(adminApi)
|
||||
const json = JSON.stringify(whatsappPayload(phoneNumberId, phoneNumber().slice(1), 'Mallory', { type: 'text', text: { body: 'hi' } }))
|
||||
|
||||
const wrongKey = `sha256=${hmac('sha256', 'not-the-app-secret', json).toString('hex')}`
|
||||
expect((await sendWebhook(request, '/webhooks/meta', { json }, { 'x-hub-signature-256': wrongKey })).status()).toBe(403)
|
||||
expect((await sendWebhook(request, '/webhooks/meta', { json })).status()).toBe(403)
|
||||
})
|
||||
|
||||
test('Slack: a wrong signature, or a correctly signed request replayed later @channels @security', async ({ request }) => {
|
||||
const json = JSON.stringify({ type: 'event_callback', team_id: 'T0FORGED', event: { type: 'message', channel: 'D1', user: 'U1', text: 'hi', ts: '1.1' } })
|
||||
const now = String(Math.floor(Date.now() / 1000))
|
||||
const tenMinutesAgo = String(Math.floor(Date.now() / 1000) - 600)
|
||||
|
||||
const wrong = { 'x-slack-request-timestamp': now, 'x-slack-signature': 'v0=00' }
|
||||
expect((await sendWebhook(request, '/webhooks/slack', { json }, wrong)).status()).toBe(403)
|
||||
const replayed = { 'x-slack-request-timestamp': tenMinutesAgo, 'x-slack-signature': slackSignature(json, tenMinutesAgo) }
|
||||
expect((await sendWebhook(request, '/webhooks/slack', { json }, replayed)).status()).toBe(403)
|
||||
})
|
||||
|
||||
test('Telegram: a wrong webhook secret @channels @security', async ({ adminApi, request, agent }) => {
|
||||
const { account } = await TelegramBot.connect(adminApi, request, agent.id)
|
||||
const response = await sendWebhook(request, `/webhooks/telegram/${account.id}`, {
|
||||
json: JSON.stringify({ update_id: 1, message: { message_id: 1, date: 0, chat: { id: 1, type: 'private' }, from: { id: 1, is_bot: false, first_name: 'M' }, text: 'hi' } }),
|
||||
}, { 'x-telegram-bot-api-secret-token': 'guessed' })
|
||||
expect(response.status()).toBe(403)
|
||||
})
|
||||
|
||||
test('LINE: a signature made with the wrong channel secret @channels @security', async ({ adminApi, request }) => {
|
||||
const account = await connectLine(adminApi, unique('line-secret'), unique('line-token'))
|
||||
const json = JSON.stringify({ destination: account.external_account_id, events: [{ type: 'message', source: { type: 'user', userId: `U${digits(32)}` }, message: { type: 'text', id: '1', text: 'hi' } }] })
|
||||
const response = await sendWebhook(request, `/webhooks/line/${account.id}`, { json }, {
|
||||
'x-line-signature': hmac('sha256', 'not-the-channel-secret', json).toString('base64'),
|
||||
})
|
||||
expect(response.status()).toBe(403)
|
||||
})
|
||||
|
||||
test('SMS: Twilio and Plivo signatures made with the wrong auth token @channels @security', async ({ adminApi, request }) => {
|
||||
const number = phoneNumber()
|
||||
const twilio = await connectSms(adminApi, 'twilio', number, { account_sid: unique('AC'), auth_token: unique('token') })
|
||||
const form = { From: phoneNumber(), To: number, Body: 'hi', MessageSid: unique('SM') }
|
||||
const forgedTwilio = await sendWebhook(request, `/webhooks/sms/twilio/${twilio.id}`, { form }, { 'x-twilio-signature': hmac('sha1', 'guessed', 'x').toString('base64') })
|
||||
expect(forgedTwilio.status()).toBe(403)
|
||||
|
||||
const plivo = await connectSms(adminApi, 'plivo', phoneNumber(), { auth_id: unique('MA'), auth_token: unique('token') })
|
||||
const forgedPlivo = await sendWebhook(request, `/webhooks/sms/plivo/${plivo.id}`, { form: { From: '447700900000', To: '447700900001', Text: 'hi', MessageUUID: unique('u') } }, {
|
||||
'x-plivo-signature-v3': hmac('sha256', 'guessed', 'x').toString('base64'), 'x-plivo-signature-v3-nonce': 'n',
|
||||
})
|
||||
expect(forgedPlivo.status()).toBe(403)
|
||||
})
|
||||
|
||||
test('email: an inbound-parse post without the inbox\'s token @channels @security', async ({ adminApi, request }) => {
|
||||
const inbox = await connectEmail(adminApi, `${unique('support')}@e2e.example.com`)
|
||||
const json = JSON.stringify({ from: 'mallory@example.com', to: inbox.external_account_id, subject: 'hi', text: 'hi' })
|
||||
expect((await sendWebhook(request, `/webhooks/email/${inbox.id}?token=guessed`, { json })).status()).toBe(403)
|
||||
expect((await sendWebhook(request, `/webhooks/email/${inbox.id}`, { json })).status()).toBe(403)
|
||||
})
|
||||
})
|
||||
|
||||
test.describe('signed events that must not start an AI turn', () => {
|
||||
test('a Messenger echo of our own message, a WhatsApp delivery receipt and another bot\'s Slack message @channels', async ({ adminApi, request, agent, llm }) => {
|
||||
await llm.script(agent, (s) => s.otherwise().reply('This should never be sent.'))
|
||||
|
||||
// Messenger: Meta echoes the page's own sends back with is_echo.
|
||||
const pageId = digits(15)
|
||||
const page = await connectMessenger(adminApi, pageId, metaToken(pageId))
|
||||
await routeToAgent(adminApi, page.id, agent.id)
|
||||
const echo = { object: 'page', entry: [{ id: pageId, messaging: [{ sender: { id: pageId }, recipient: { id: digits(16) }, timestamp: Date.now(), message: { mid: unique('m'), text: 'Hello from us', is_echo: true } }] }] }
|
||||
expect((await sendWebhook(request, '/webhooks/meta', { json: JSON.stringify(echo) }, metaSignature(JSON.stringify(echo)))).ok()).toBe(true)
|
||||
|
||||
// WhatsApp: delivery/read receipts arrive as `statuses`, with no messages.
|
||||
const number = await connectWhatsAppNumber(adminApi)
|
||||
await routeToAgent(adminApi, number.account.id, agent.id)
|
||||
const receipt = { object: 'whatsapp_business_account', entry: [{ id: '1', changes: [{ field: 'messages', value: { messaging_product: 'whatsapp', metadata: { phone_number_id: number.phoneNumberId }, statuses: [{ id: 'wamid.x', status: 'read', timestamp: '1', recipient_id: '447700900000' }] } }] }] }
|
||||
expect((await sendWebhook(request, '/webhooks/meta', { json: JSON.stringify(receipt) }, metaSignature(JSON.stringify(receipt)))).ok()).toBe(true)
|
||||
|
||||
// Slack: messages posted by a bot (including ours) carry bot_id.
|
||||
const workspace = await installSlackWorkspace(adminApi, request)
|
||||
await routeToAgent(adminApi, workspace.account.id, agent.id)
|
||||
const teamId = workspace.teamId
|
||||
const now = String(Math.floor(Date.now() / 1000))
|
||||
const botMessage = JSON.stringify({ type: 'event_callback', team_id: teamId, event_id: unique('Ev'), event: { type: 'message', channel_type: 'im', channel: 'D1', bot_id: 'B1', text: 'beep', ts: `${now}.000100` } })
|
||||
expect((await sendWebhook(request, '/webhooks/slack', { json: botMessage }, { 'x-slack-request-timestamp': now, 'x-slack-signature': slackSignature(botMessage, now) })).ok()).toBe(true)
|
||||
|
||||
// Each was accepted (a provider would retry a refusal) and none reached the model.
|
||||
await expectUnchanged('model calls', async () => (await llm.requests(agent)).length, 5_000)
|
||||
expect(await llm.requests(agent)).toHaveLength(0)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,37 @@
|
||||
import { test, expect, channels, expectUnchanged, unique, AI_REPLY } from '@e2e'
|
||||
|
||||
// The inbox side is the same for every channel, and a human's reply leaves through
|
||||
// the same adapter call as the AI's (covered per channel in replies.spec), so one
|
||||
// channel is enough here.
|
||||
test.describe('human handover on a channel', () => {
|
||||
test.use({ agentSettings: { transferToHuman: true } })
|
||||
|
||||
test('a WhatsApp customer asks for a person, the teammate\'s reply reaches them on WhatsApp, and the AI stays out of it @channels @handover', async ({ agent, llm, adminApi, request, inbox }) => {
|
||||
await llm.script(agent, (s) => s
|
||||
.when(/human|person/i).handOver('Let me connect you with a teammate.')
|
||||
.otherwise().reply('Happy to help!'))
|
||||
|
||||
const customer = unique('Wanda')
|
||||
const chat = (await channels.WhatsApp({ api: adminApi, request, agentId: agent.id })).chatWith(customer)
|
||||
await chat.send('I want to talk to a person')
|
||||
await expect.poll(() => chat.replies(), AI_REPLY).toEqual(['Let me connect you with a teammate.'])
|
||||
|
||||
await inbox.goto()
|
||||
await inbox.openConversationWith(customer)
|
||||
await expect(inbox.lastCustomerMessage()).toContainText('I want to talk to a person')
|
||||
await inbox.takeOver()
|
||||
await inbox.reply('Hi, this is Sam. How can I help?')
|
||||
// The takeover itself tells the customer a person has joined.
|
||||
await expect.poll(() => chat.replies(), AI_REPLY).toEqual([
|
||||
'Let me connect you with a teammate.',
|
||||
'You\'re now connected with a member of our team.',
|
||||
'Hi, this is Sam. How can I help?',
|
||||
])
|
||||
|
||||
const modelCalls = (await llm.requests(agent)).length
|
||||
await chat.send('My parcel never arrived')
|
||||
await expect(inbox.lastCustomerMessage()).toContainText('My parcel never arrived')
|
||||
await expectUnchanged('WhatsApp replies after takeover', () => chat.replies())
|
||||
expect((await llm.requests(agent))).toHaveLength(modelCalls)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,94 @@
|
||||
import {
|
||||
test, expect, connectWhatsAppNumber, expectUnchanged, listChannelAccounts, routeToAgent,
|
||||
ApiClient, FakeExternals, IntegrationsPage, WhatsAppChat, AI_REPLY,
|
||||
} from '@e2e'
|
||||
|
||||
// When Meta stops accepting a number's access token (an expired temporary
|
||||
// token, a revoked system user), every reply fails until someone reconnects
|
||||
// the number. The account is flagged so the Integrations card can say so,
|
||||
// rather than replies failing with nothing on screen.
|
||||
//
|
||||
// Other tests share the org and its WhatsApp card, so the card is checked for
|
||||
// this test's number by name, never for the absence of any warning at all.
|
||||
|
||||
const TOKEN_REFUSED = { error: { message: 'Error validating access token: Session has expired', type: 'OAuthException', code: 190, fbtrace_id: 'e2e' } }
|
||||
// Each read lists every account in the shared org; once a second is plenty
|
||||
const FLAG_POLL = { ...AI_REPLY, intervals: [1_000] }
|
||||
const WINDOW_CLOSED = { error: { message: 'Re-engagement message', type: 'OAuthException', code: 131047, fbtrace_id: 'e2e' } }
|
||||
|
||||
async function needsReconnect(api: ApiClient, accountId: string): Promise<boolean> {
|
||||
const account = (await listChannelAccounts(api)).find((a) => a.id === accountId)
|
||||
return account?.needs_reconnect ?? false
|
||||
}
|
||||
|
||||
test.describe('a WhatsApp number whose token Meta refuses', () => {
|
||||
test('is flagged, the Integrations card names it, and reconnecting clears it @channels', async ({ agent, llm, adminApi, request, page }) => {
|
||||
await llm.script(agent, (s) => s.otherwise().reply('Happy to help!'))
|
||||
const number = await connectWhatsAppNumber(adminApi)
|
||||
await routeToAgent(adminApi, number.account.id, agent.id)
|
||||
const externals = new FakeExternals(request)
|
||||
const chat = new WhatsAppChat(request, externals, number, 'Tess')
|
||||
|
||||
const restore = await externals.fail({ provider: 'graph', action: 'messages', contains: chat.waId, status: 401, body: TOKEN_REFUSED })
|
||||
await chat.send('Hello?')
|
||||
await expect.poll(() => needsReconnect(adminApi, number.account.id), FLAG_POLL).toBe(true)
|
||||
expect(await chat.replies()).toEqual([])
|
||||
|
||||
const integrations = new IntegrationsPage(page)
|
||||
const warning = integrations.expiredAccessFor(number.account.display_name ?? '')
|
||||
await integrations.goto()
|
||||
await expect(integrations.card('WhatsApp')).toContainText(warning)
|
||||
// Other numbers are connected too; Manage still leads to the fix, not the first number
|
||||
await integrations.manage('WhatsApp')
|
||||
await expect(integrations.reconnectPrompt()).toBeVisible()
|
||||
|
||||
// The customer swaps in a working token: same number, connected again
|
||||
await restore()
|
||||
const reconnected = await connectWhatsAppNumber(adminApi, number.phoneNumberId)
|
||||
expect(reconnected.account.id).toBe(number.account.id)
|
||||
expect(reconnected.account.needs_reconnect).toBe(false)
|
||||
|
||||
await integrations.goto()
|
||||
await expect(integrations.card('WhatsApp')).not.toContainText(warning)
|
||||
// Replies now go out with the new token
|
||||
const afterReconnect = new WhatsAppChat(request, externals, reconnected, 'Tess')
|
||||
await afterReconnect.send('Are you there now?')
|
||||
await expect.poll(() => afterReconnect.replies(), AI_REPLY).toEqual(['Happy to help!'])
|
||||
})
|
||||
|
||||
test('is healthy again as soon as a reply goes through @channels', async ({ agent, llm, adminApi, request }) => {
|
||||
await llm.script(agent, (s) => s.otherwise().reply('Happy to help!'))
|
||||
const number = await connectWhatsAppNumber(adminApi)
|
||||
await routeToAgent(adminApi, number.account.id, agent.id)
|
||||
const externals = new FakeExternals(request)
|
||||
const chat = new WhatsAppChat(request, externals, number, 'Tess')
|
||||
|
||||
// Meta refuses the token once, then accepts it (it was refreshed on Meta's side)
|
||||
await externals.fail({ provider: 'graph', action: 'messages', contains: chat.waId, status: 401, body: TOKEN_REFUSED, times: 1 })
|
||||
await chat.send('Hello?')
|
||||
await expect.poll(() => needsReconnect(adminApi, number.account.id), FLAG_POLL).toBe(true)
|
||||
|
||||
await chat.send('Hello again?')
|
||||
await expect.poll(() => chat.replies(), AI_REPLY).toEqual(['Happy to help!'])
|
||||
expect(await needsReconnect(adminApi, number.account.id)).toBe(false)
|
||||
})
|
||||
|
||||
test('is not flagged when Meta refuses one message for its own reason @channels', async ({ agent, llm, adminApi, request }) => {
|
||||
await llm.script(agent, (s) => s.otherwise().reply('Happy to help!'))
|
||||
const number = await connectWhatsAppNumber(adminApi)
|
||||
await routeToAgent(adminApi, number.account.id, agent.id)
|
||||
const externals = new FakeExternals(request)
|
||||
const chat = new WhatsAppChat(request, externals, number, 'Tess')
|
||||
|
||||
// A closed 24-hour window refuses the message, not the token
|
||||
const restore = await externals.fail({ provider: 'graph', action: 'messages', contains: chat.waId, status: 400, body: WINDOW_CLOSED })
|
||||
await chat.send('Hello?')
|
||||
const refused = async () => (await externals.calls('graph', 'messages', { contains: chat.waId }))
|
||||
.filter((c) => c.status === 400).length
|
||||
await expect.poll(refused, AI_REPLY).toBeGreaterThan(0)
|
||||
|
||||
await expectUnchanged('the number\'s reconnect flag', () => needsReconnect(adminApi, number.account.id))
|
||||
expect(await needsReconnect(adminApi, number.account.id)).toBe(false)
|
||||
await restore()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,80 @@
|
||||
import {
|
||||
test, expect, connectMessenger, connectWhatsAppNumber, digits, expectUnchanged, metaVerifiesCallback, ownMetaApp,
|
||||
pageChat, pastedCallback, routeToAgent, whereMetaSends, FakeExternals, WhatsAppChat, AI_REPLY,
|
||||
} from '@e2e'
|
||||
|
||||
// A WhatsApp number or Facebook Page connected with credentials from the
|
||||
// customer's OWN Meta app — the only way to try Meta's free test number. Meta
|
||||
// signs that app's webhooks with its own secret, so each such account has its
|
||||
// own webhook address, checked with that secret only (issue #353).
|
||||
//
|
||||
// Connected through the API: the org is shared, and once any test has a number
|
||||
// the Integrations card offers Manage, not Connect. The card itself is covered
|
||||
// by the dashboard's unit tests.
|
||||
|
||||
test.describe('a number or Page from the customer\'s own Meta app', () => {
|
||||
test('a WhatsApp number receives messages: its webhooks are pointed at ReplyPier on connect @channels', async ({ agent, llm, adminApi, request }) => {
|
||||
await llm.script(agent, (s) => s
|
||||
.when(/opening hours/i).reply('We are open 9am to 5pm, Monday to Friday.')
|
||||
.otherwise().reply('Happy to help!'))
|
||||
const app = ownMetaApp()
|
||||
const number = await connectWhatsAppNumber(adminApi, digits(15), app)
|
||||
// Meta took the per-number callback, after checking it answers: nothing to paste
|
||||
expect(number.account.webhook_auto_configured).toBe(true)
|
||||
await routeToAgent(adminApi, number.account.id, agent.id)
|
||||
|
||||
const externals = new FakeExternals(request)
|
||||
const chat = new WhatsAppChat(request, externals, number, 'Tess', () => whereMetaSends(externals, number.phoneNumberId, app))
|
||||
await chat.send('What are your opening hours?')
|
||||
|
||||
await expect.poll(() => chat.replies(), AI_REPLY).toEqual(['We are open 9am to 5pm, Monday to Friday.'])
|
||||
})
|
||||
|
||||
test('a Messenger Page receives messages once its callback URL and verify token are pasted into the app @channels', async ({ agent, llm, adminApi, request }) => {
|
||||
await llm.script(agent, (s) => s.otherwise().reply('Hi! How can we help?'))
|
||||
const app = ownMetaApp()
|
||||
const pageId = digits(15)
|
||||
const token = app.token(pageId)
|
||||
const account = await connectMessenger(adminApi, pageId, token, app.secret)
|
||||
await routeToAgent(adminApi, account.id, agent.id)
|
||||
const url = account.webhook_url ?? ''
|
||||
const verifyToken = account.webhook_verify_token ?? ''
|
||||
|
||||
// The customer pastes both into their app; Meta checks the address before saving
|
||||
expect(await metaVerifiesCallback(request, url, verifyToken)).toBe(true)
|
||||
expect(await metaVerifiesCallback(request, url, 'a-guessed-token')).toBe(false)
|
||||
|
||||
const chat = pageChat('Messenger', request, new FakeExternals(request), 'page', pageId, token, 'Remi', pastedCallback(url, app))
|
||||
await chat.send('Hello there')
|
||||
|
||||
await expect.poll(() => chat.replies(), AI_REPLY).toEqual(['Hi! How can we help?'])
|
||||
})
|
||||
|
||||
test('a wrong app secret is refused when connecting @channels', async ({ adminApi }) => {
|
||||
const app = ownMetaApp()
|
||||
await expect(connectWhatsAppNumber(adminApi, digits(15), { ...app, secret: 'not-the-app-secret' }))
|
||||
.rejects.toThrow(/400: .*That app secret is not the one for the Meta app this token comes from/)
|
||||
})
|
||||
|
||||
test('its address takes only its own app\'s accounts, never another app\'s number @channels @security', async ({ agent, llm, adminApi, request }) => {
|
||||
await llm.script(agent, (s) => s.otherwise().reply('This should never be sent.'))
|
||||
const mine = ownMetaApp()
|
||||
const ourNumber = await connectWhatsAppNumber(adminApi, digits(15), mine)
|
||||
const theirNumber = await connectWhatsAppNumber(adminApi, digits(15), ownMetaApp())
|
||||
await routeToAgent(adminApi, theirNumber.account.id, agent.id)
|
||||
const externals = new FakeExternals(request)
|
||||
|
||||
// Signed with our app's secret and sent to our address, but naming their number
|
||||
const forged = new WhatsAppChat(request, externals, theirNumber, 'Mallory', () => whereMetaSends(externals, ourNumber.phoneNumberId, mine))
|
||||
await forged.send('Hi, I am not who I say I am')
|
||||
|
||||
await expectUnchanged('replies on the other app\'s number', () => forged.replies())
|
||||
})
|
||||
|
||||
test('without the app secret a Page has no address of its own @channels', async ({ adminApi }) => {
|
||||
const pageId = digits(15)
|
||||
const account = await connectMessenger(adminApi, pageId, ownMetaApp().token(pageId))
|
||||
expect(account.webhook_url).toBeFalsy()
|
||||
expect(account.webhook_verify_token).toBeFalsy()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,19 @@
|
||||
import { test, expect, channels, expectUnchanged, AI_REPLY } from '@e2e'
|
||||
|
||||
// The core promise of every channel: a customer writes in, the agent's answer goes
|
||||
// back out on the same channel, to that customer.
|
||||
for (const [name, connect] of Object.entries(channels)) {
|
||||
test(`a customer on ${name} gets the agent's answer back there @channels`, async ({ agent, llm, adminApi, request }) => {
|
||||
await llm.script(agent, (s) => s
|
||||
.when(/opening hours/i).reply('We are open 9am to 5pm, Monday to Friday.')
|
||||
.otherwise().reply('Happy to help!'))
|
||||
|
||||
const channel = await connect({ api: adminApi, request, agentId: agent.id })
|
||||
const chat = channel.chatWith('Tess')
|
||||
await chat.send('What are your opening hours?')
|
||||
|
||||
// Exactly one answer, the scripted one, from this account — no duplicate, no default.
|
||||
await expect.poll(() => chat.replies(), AI_REPLY).toEqual(['We are open 9am to 5pm, Monday to Friday.'])
|
||||
await expectUnchanged(`${name} replies`, () => chat.replies())
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
import { test, expect, channelsWithButtons, AI_REPLY } from '@e2e'
|
||||
|
||||
test.describe('reply buttons', () => {
|
||||
test.use({ agentSettings: { channelQuickReplies: true } })
|
||||
|
||||
for (const [name, connect] of Object.entries(channelsWithButtons)) {
|
||||
test(`the AI offers buttons on ${name} and a tap gets the next answer @channels`, async ({ agent, llm, adminApi, request }) => {
|
||||
await llm.script(agent, (s) => s
|
||||
.when(/track my order/i).reply('Your order ships tomorrow.')
|
||||
.otherwise().reply('What can I help you with?', { quick_replies: ['Track my order', 'Returns'] }))
|
||||
|
||||
const chat = (await connect({ api: adminApi, request, agentId: agent.id })).chatWith('Tess')
|
||||
await chat.send('Hi')
|
||||
await expect.poll(() => chat.buttons(), AI_REPLY).toEqual(['Track my order', 'Returns'])
|
||||
|
||||
await chat.tap('Track my order')
|
||||
await expect.poll(async () => (await chat.replies()).at(-1), AI_REPLY).toContain('Your order ships tomorrow.')
|
||||
expect(await chat.replies()).toHaveLength(2)
|
||||
})
|
||||
}
|
||||
})
|
||||
@@ -0,0 +1,23 @@
|
||||
import { test, expect, connectWhatsAppNumber, deliverMeta, expectUnchanged, phoneNumber, routeToAgent, whatsappPayload, FakeExternals, AI_REPLY } from '@e2e'
|
||||
|
||||
// Providers retry a webhook they think failed. Dedupe lives in Redis, which only
|
||||
// the nightly Redis run turns on (scripts/stack.sh E2E_REDIS_URL); playwright.config.ts
|
||||
// leaves @redis tests out otherwise.
|
||||
|
||||
test('a provider delivering the same message twice gets one answer @channels @redis', async ({ agent, llm, adminApi, request }) => {
|
||||
await llm.script(agent, (s) => s.otherwise().reply('Answered once.'))
|
||||
const number = await connectWhatsAppNumber(adminApi)
|
||||
await routeToAgent(adminApi, number.account.id, agent.id)
|
||||
const waId = phoneNumber().slice(1)
|
||||
|
||||
// The exact same delivery, message id included, twice.
|
||||
const payload = whatsappPayload(number.phoneNumberId, waId, 'Rita', { type: 'text', text: { body: 'Hello?' } })
|
||||
await deliverMeta(request, payload)
|
||||
await deliverMeta(request, payload)
|
||||
|
||||
const sent = async () => (await new FakeExternals(request).calls('graph', 'messages', { contains: waId }))
|
||||
.filter((c) => (c.body as { type?: string }).type === 'text').length
|
||||
await expect.poll(sent, AI_REPLY).toBe(1)
|
||||
await expectUnchanged('replies to the retried message', sent)
|
||||
expect(await llm.requests(agent)).toHaveLength(1)
|
||||
})
|
||||
@@ -0,0 +1,63 @@
|
||||
import { test, expect, connectCrm, disconnectCrm, findPerson, FakeExternals, runWorkerOnce, syncPersonToCrm, unique, AI_REPLY } from '@e2e'
|
||||
|
||||
// One HubSpot connection per org, so this runs in the serial `org` project and
|
||||
// disconnects afterwards.
|
||||
|
||||
test.describe('HubSpot', () => {
|
||||
test.use({ agentSettings: { leadCapture: true } })
|
||||
|
||||
test.afterEach(async ({ adminApi }) => { await disconnectCrm(adminApi, 'hubspot') })
|
||||
|
||||
test('a captured lead is pushed to HubSpot with what they told us @crm', async ({ agent, widget, llm, adminApi, request }) => {
|
||||
const code = unique('hs-code')
|
||||
await connectCrm(adminApi, 'hubspot', code)
|
||||
|
||||
const { email, name } = widget.customer
|
||||
await llm.script(agent, (s) => s
|
||||
.when(/@/).captureLead('Thanks! Someone from sales will reach out.', { email, name, company: 'Acme Corp' })
|
||||
.otherwise().reply('Happy to set that up — what is your email?'))
|
||||
await widget.embed()
|
||||
await widget.open()
|
||||
await widget.send('I would like a demo')
|
||||
await expect(widget.lastAgentMessage()).toHaveText('Happy to set that up — what is your email?', AI_REPLY)
|
||||
await widget.send(`Sure, I am ${name}, ${email}`)
|
||||
await expect(widget.lastAgentMessage()).toHaveText('Thanks! Someone from sales will reach out.', AI_REPLY)
|
||||
|
||||
const person = await findPerson(adminApi, email)
|
||||
const status = await syncPersonToCrm(adminApi, person.id)
|
||||
expect(status.synced.map((s) => s.provider)).toContain('hubspot')
|
||||
|
||||
// What HubSpot received: the contact keyed by email, as a lead, with their company,
|
||||
// sent with the token this org's install was granted.
|
||||
const externals = new FakeExternals(request)
|
||||
const [upsert] = await externals.calls('hubspot', 'upsert', { contains: email })
|
||||
const [grant] = await externals.calls('hubspot', 'token', { contains: code })
|
||||
expect(upsert.headers.authorization).toBe(`Bearer ${(grant.response as { access_token: string }).access_token}`)
|
||||
expect(upsert.body).toMatchObject({
|
||||
inputs: [{ id: email, idProperty: 'email', properties: { email, company: 'Acme Corp', lifecyclestage: 'lead' } }],
|
||||
})
|
||||
})
|
||||
|
||||
test('with HubSpot as the agent\'s CRM, a captured lead is pushed on its own @crm @workers', async ({ agent, widget, llm, adminApi, request }) => {
|
||||
await connectCrm(adminApi, 'hubspot', unique('hs-code'))
|
||||
await adminApi.put(`/agent/${agent.id}/lead-capture`, { data: { enabled: true, require_consent: false, crm_sync_target: 'hubspot' } })
|
||||
|
||||
const { email, name } = widget.customer
|
||||
await llm.script(agent, (s) => s
|
||||
.when(/@/).captureLead('Thanks! Someone from sales will reach out.', { email, name, company: 'Globex' })
|
||||
.otherwise().reply('Happy to set that up — what is your email?'))
|
||||
await widget.embed()
|
||||
await widget.open()
|
||||
await widget.send('I would like a demo')
|
||||
await expect(widget.lastAgentMessage()).toHaveText('Happy to set that up — what is your email?', AI_REPLY)
|
||||
await widget.send(`Sure, I am ${name}, ${email}`)
|
||||
await expect(widget.lastAgentMessage()).toHaveText('Thanks! Someone from sales will reach out.', AI_REPLY)
|
||||
|
||||
// Capture queues a sync job; the CRM worker pushes it on its next pass.
|
||||
const externals = new FakeExternals(request)
|
||||
expect(await externals.calls('hubspot', 'upsert', { contains: email })).toHaveLength(0)
|
||||
await runWorkerOnce('crm-sync')
|
||||
const [upsert] = await externals.calls('hubspot', 'upsert', { contains: email })
|
||||
expect(upsert.body).toMatchObject({ inputs: [{ id: email, properties: { company: 'Globex', lifecyclestage: 'lead' } }] })
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,57 @@
|
||||
import { test, expect, connectCrm, disconnectCrm, findPerson, FakeExternals, syncPersonToCrm, unique, AI_REPLY } from '@e2e'
|
||||
|
||||
// One Pipedrive connection per org, so this runs in the serial `org` project and
|
||||
// disconnects afterwards.
|
||||
|
||||
interface PipedriveGrant { access_token: string; api_domain: string }
|
||||
|
||||
test.describe('Pipedrive', () => {
|
||||
test.use({ agentSettings: { leadCapture: true } })
|
||||
|
||||
test.afterEach(async ({ adminApi }) => { await disconnectCrm(adminApi, 'pipedrive') })
|
||||
|
||||
test('a captured lead becomes a Pipedrive person, organisation and lead with a note @crm', async ({ agent, widget, llm, adminApi, request }) => {
|
||||
const code = unique('pd-code')
|
||||
await connectCrm(adminApi, 'pipedrive', code)
|
||||
|
||||
const { email, name } = widget.customer
|
||||
const company = unique('Initech')
|
||||
await llm.script(agent, (s) => s
|
||||
.when(/@/).captureLead('Thanks! Someone from sales will reach out.', { email, name, company })
|
||||
.otherwise().reply('Happy to set that up — what is your email?'))
|
||||
await widget.embed()
|
||||
await widget.open()
|
||||
await widget.send('I would like a demo')
|
||||
await expect(widget.lastAgentMessage()).toHaveText('Happy to set that up — what is your email?', AI_REPLY)
|
||||
await widget.send(`Sure, I am ${name}, ${email}`)
|
||||
await expect(widget.lastAgentMessage()).toHaveText('Thanks! Someone from sales will reach out.', AI_REPLY)
|
||||
|
||||
const person = await findPerson(adminApi, email)
|
||||
const status = await syncPersonToCrm(adminApi, person.id)
|
||||
expect(status.synced.map((s) => s.provider)).toContain('pipedrive')
|
||||
|
||||
// The install traded the code with the app's own credentials (Basic auth), and
|
||||
// every API call went to the company's api_domain with the token it was granted.
|
||||
const externals = new FakeExternals(request)
|
||||
const [grant] = await externals.calls('pipedrive-oauth', 'token', { contains: code })
|
||||
const basic = `Basic ${Buffer.from('e2e-pipedrive-client-id:e2e-pipedrive-client-secret').toString('base64')}`
|
||||
expect(grant.headers.authorization).toBe(basic)
|
||||
const { access_token: token } = grant.response as PipedriveGrant
|
||||
|
||||
const [organization] = await externals.calls('pipedrive', 'organizations', { contains: company })
|
||||
expect(organization.body).toEqual({ name: company })
|
||||
const [created] = await externals.calls('pipedrive', 'persons', { contains: email })
|
||||
expect(created.method).toBe('POST')
|
||||
expect(created.headers.authorization).toBe(`Bearer ${token}`)
|
||||
expect(created.body).toMatchObject({
|
||||
name, emails: [{ value: email, primary: true }], org_id: (organization.response as { data: { id: number } }).data.id,
|
||||
})
|
||||
|
||||
const personId = (created.response as { data: { id: number } }).data.id
|
||||
const [lead] = (await externals.calls('pipedrive', 'leads', { contains: `"person_id":${personId}` })).filter((c) => c.method === 'POST')
|
||||
expect(lead.body).toMatchObject({ title: `${name} — ReplyPier lead`, person_id: personId })
|
||||
const leadId = (lead.response as { data: { id: string } }).data.id
|
||||
const [note] = await externals.calls('pipedrive', 'notes', { contains: leadId })
|
||||
expect(note.body).toMatchObject({ lead_id: leadId, content: expect.stringContaining('Lead captured by ReplyPier') })
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,26 @@
|
||||
import { test, expect, channels, chatsWithAgent, setAlwaysClosed, setAlwaysOpen, unique, AI_REPLY } from '@e2e'
|
||||
|
||||
// Changes the org's business hours, so it runs in the serial `org` project and
|
||||
// reopens the org afterwards (setup leaves it open 24/7).
|
||||
|
||||
test.describe('outside business hours', () => {
|
||||
test.use({ agentSettings: { transferToHuman: true } })
|
||||
|
||||
test.afterEach(async ({ adminApi, org }) => { await setAlwaysOpen(adminApi, org.orgId) })
|
||||
|
||||
test('a customer asking for a person is told the team will follow up, and the chat waits in the team\'s queue @handover', async ({ agent, llm, adminApi, request, org, handoverTeam }) => {
|
||||
await setAlwaysClosed(adminApi, org.orgId)
|
||||
await llm.script(agent, (s) => s.otherwise().handOver('We are closed now; the team will get back to you.'))
|
||||
|
||||
const chat = (await channels.WhatsApp({ api: adminApi, request, agentId: agent.id })).chatWith(unique('Nora'))
|
||||
await chat.send('I need to talk to someone')
|
||||
await expect.poll(() => chat.replies(), AI_REPLY).toEqual(['We are closed now; the team will get back to you.'])
|
||||
|
||||
// Queued for the team, unclaimed — whoever opens up next picks it up.
|
||||
const [conversation] = await chatsWithAgent(adminApi, agent.id)
|
||||
expect(conversation).toMatchObject({ status: 'transferred', group_id: (await handoverTeam()).groupId, user_id: null })
|
||||
// The reply was written knowing the team is closed.
|
||||
const prompts = (await llm.requests(agent)).map((r) => r.lastUser).join('\n')
|
||||
expect(prompts).toContain('Currently within business hours: False')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,30 @@
|
||||
import { test, expect, env, unique, AI_REPLY } from '@e2e'
|
||||
|
||||
test.describe('attachments', () => {
|
||||
test.use({ agentSettings: { transferToHuman: true, allowAttachments: true } })
|
||||
|
||||
test('a customer sends a file to the teammate, who can open it — and only through a signed link @handover', async ({ agent, widget, inbox, llm, request }) => {
|
||||
await llm.script(agent, (s) => s.otherwise().handOver('Let me connect you with a teammate.'))
|
||||
await widget.embed()
|
||||
await widget.open()
|
||||
await widget.send('I need a person')
|
||||
await widget.shareContactDetails()
|
||||
await inbox.goto()
|
||||
await inbox.openConversationWith(widget.customer.name)
|
||||
await inbox.takeOver()
|
||||
await inbox.reply('Please send me the receipt.')
|
||||
await expect(widget.lastAgentMessage()).toHaveText('Please send me the receipt.', AI_REPLY)
|
||||
|
||||
const content = `Receipt ${unique('R')} — total 42.00`
|
||||
await widget.sendFile({ name: 'receipt.txt', mimeType: 'text/plain', content }, 'Here it is')
|
||||
|
||||
const link = inbox.attachment('receipt.txt')
|
||||
await expect(link).toBeVisible(AI_REPLY)
|
||||
const href = await link.getAttribute('href')
|
||||
expect((await (await inbox.page.request.get(href!)).text())).toBe(content)
|
||||
|
||||
// The stored file is not reachable without the signature.
|
||||
const unsigned = new URL(href!, 'http://x').pathname
|
||||
expect((await request.get(`${env.backendUrl}${unsigned}`)).status()).toBe(403)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,30 @@
|
||||
import { test, expect, expectUnchanged, AI_REPLY } from '@e2e'
|
||||
|
||||
test.describe('human handover', () => {
|
||||
test.use({ agentSettings: { transferToHuman: true } })
|
||||
|
||||
test('a customer asks for a person, the teammate\'s reply reaches them, and the AI stays out of it @smoke @handover', async ({ agent, widget, inbox, llm }) => {
|
||||
await llm.script(agent, (s) => s
|
||||
.when(/human|person/i).handOver('Let me connect you with a teammate.')
|
||||
.otherwise().reply('Happy to help!'))
|
||||
|
||||
await widget.embed()
|
||||
await widget.open()
|
||||
await widget.send('I want to talk to a human')
|
||||
await widget.shareContactDetails()
|
||||
|
||||
await inbox.goto()
|
||||
await inbox.openConversationWith(widget.customer.name)
|
||||
await inbox.takeOver()
|
||||
await inbox.reply('Hi, this is Sam. How can I help?')
|
||||
await expect(widget.lastAgentMessage()).toHaveText('Hi, this is Sam. How can I help?', AI_REPLY)
|
||||
|
||||
const modelCalls = (await llm.requests(agent)).length
|
||||
await widget.send('My parcel never arrived')
|
||||
await expect(inbox.lastCustomerMessage()).toContainText('My parcel never arrived')
|
||||
// A person owns the chat now: the model is not asked, and no bot answer appears.
|
||||
await expectUnchanged('model calls after takeover', async () => (await llm.requests(agent)).length)
|
||||
expect((await llm.requests(agent))).toHaveLength(modelCalls)
|
||||
await expect(widget.lastAgentMessage()).toHaveText('Hi, this is Sam. How can I help?')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,20 @@
|
||||
import { test, expect, channels, chatsWithAgent, createGroup, createUser, unique, AI_REPLY } from '@e2e'
|
||||
|
||||
test('with nobody from the team online, the customer is told the team will follow up, and the chat waits in its queue @handover', async ({ makeAgent, llm, adminApi, request }) => {
|
||||
// A team whose only member has never signed in, so nobody is online.
|
||||
const id = unique('offline')
|
||||
const member = await createUser(adminApi, { name: `Offline ${id}`, email: `${id}@e2e.example.com`, password: 'E2e-Offline-Passw0rd!', role: 'Agent' })
|
||||
const groupId = await createGroup(adminApi, `Team ${id}`, [member])
|
||||
const agent = await makeAgent({ transferToHuman: true }, [groupId])
|
||||
await llm.script(agent, (s) => s.otherwise().handOver('Our team will get back to you shortly.'))
|
||||
|
||||
const chat = (await channels.WhatsApp({ api: adminApi, request, agentId: agent.id })).chatWith(unique('Nora'))
|
||||
await chat.send('I need to talk to someone')
|
||||
await expect.poll(() => chat.replies(), AI_REPLY).toEqual(['Our team will get back to you shortly.'])
|
||||
|
||||
const [conversation] = await chatsWithAgent(adminApi, agent.id)
|
||||
expect(conversation).toMatchObject({ status: 'transferred', group_id: groupId, user_id: null })
|
||||
// The reply was written knowing nobody is online.
|
||||
const prompts = (await llm.requests(agent)).map((r) => r.lastUser).join('\n')
|
||||
expect(prompts).toContain('(0 online)')
|
||||
})
|
||||
@@ -0,0 +1,31 @@
|
||||
import { test, expect, createArticle, enableHelpCenter, setArticleStatus, unique } from '@e2e'
|
||||
|
||||
// Turning the help center on is an org-wide setting, so this runs in the serial
|
||||
// `org` project. The public site is unauthenticated: exactly what is published,
|
||||
// nothing else.
|
||||
|
||||
test('only published articles are on the public help center @help-center', async ({ adminApi, anonymousPage }) => {
|
||||
const site = await enableHelpCenter(adminApi)
|
||||
const code = unique('HC')
|
||||
const article = await createArticle(adminApi, {
|
||||
question: `How do I reset my router ${code}?`,
|
||||
answer: `Hold the reset button for ten seconds. Reference ${code}.`,
|
||||
})
|
||||
const articleUrl = `${site}/a/${article.slug}`
|
||||
|
||||
// A draft is invisible to the public: not listed, and its URL is not found.
|
||||
await anonymousPage.goto(site)
|
||||
await expect(anonymousPage.getByText(code)).toHaveCount(0)
|
||||
expect((await anonymousPage.request.get(articleUrl)).status()).toBe(404)
|
||||
|
||||
await setArticleStatus(adminApi, article.id, 'published')
|
||||
await anonymousPage.goto(articleUrl)
|
||||
await expect(anonymousPage.getByRole('heading', { name: `How do I reset my router ${code}?` })).toBeVisible()
|
||||
await expect(anonymousPage.getByText('Hold the reset button for ten seconds.')).toBeVisible()
|
||||
await anonymousPage.goto(site)
|
||||
await expect(anonymousPage.getByText(`How do I reset my router ${code}?`)).toBeVisible()
|
||||
|
||||
// Unpublishing takes it down again.
|
||||
await setArticleStatus(adminApi, article.id, 'draft')
|
||||
expect((await anonymousPage.request.get(articleUrl)).status()).toBe(404)
|
||||
})
|
||||
@@ -0,0 +1,60 @@
|
||||
import { test, expect, connectJira, disconnectJira, fakeJira, routeTicketsToJira, FakeExternals, IntegrationsPage, unique, AI_REPLY } from '@e2e'
|
||||
|
||||
// One Jira site per org, so this runs in the serial `org` project and disconnects afterwards.
|
||||
|
||||
interface JiraGrant { access_token: string }
|
||||
interface JiraSite { id: string; url: string }
|
||||
|
||||
test.describe('Jira', () => {
|
||||
test.afterEach(async ({ adminApi }) => { await disconnectJira(adminApi) })
|
||||
|
||||
test('an admin connects Jira from Settings and the card links to their site @integrations', async ({ page, request }) => {
|
||||
const since = new Date()
|
||||
const integrations = new IntegrationsPage(page)
|
||||
await integrations.goto()
|
||||
await integrations.connect('Jira')
|
||||
await expect(page.getByText('Jira connected successfully!')).toBeVisible()
|
||||
|
||||
// The code Atlassian's consent screen sent back was traded with the app's own
|
||||
// credentials, and the site is the one that token reaches.
|
||||
const externals = new FakeExternals(request)
|
||||
const [grant] = (await externals.callsSince(since)).filter((c) => c.provider === 'jira-auth' && c.action === 'token')
|
||||
const [resources] = (await externals.callsSince(since)).filter((c) => c.action === 'accessible-resources')
|
||||
expect(grant.body).toMatchObject({ grant_type: 'authorization_code', client_id: 'e2e-jira-client-id', redirect_uri: 'http://127.0.0.1:18000/api/v1/jira/callback' })
|
||||
expect(resources.headers.authorization).toBe(`Bearer ${(grant.response as JiraGrant).access_token}`)
|
||||
const [site] = resources.response as JiraSite[]
|
||||
await expect(integrations.card('Jira').getByRole('link', { name: /Visit Jira Site/ })).toHaveAttribute('href', site.url)
|
||||
})
|
||||
|
||||
test('the AI files a Jira issue from a chat, in the project the agent is set up for @integrations', async ({ agent, widget, llm, adminApi, request }) => {
|
||||
const code = unique('jira-code')
|
||||
await connectJira(adminApi, code)
|
||||
await routeTicketsToJira(adminApi, agent.id)
|
||||
|
||||
const summary = unique('Refund not received')
|
||||
await llm.script(agent, (s) => s
|
||||
.when(/refund/i).callTool('create_jira_ticket', { summary, description: 'Charged twice for one order; wants one payment back.', priority: 'High' })
|
||||
.thenReply('I have raised this with our billing team.')
|
||||
.otherwise().reply('How can I help?'))
|
||||
await widget.embed()
|
||||
await widget.open()
|
||||
await widget.send('My refund never arrived')
|
||||
await expect(widget.lastAgentMessage()).toHaveText('I have raised this with our billing team.', AI_REPLY)
|
||||
|
||||
// Jira received the issue in the agent's project and type, at the priority the
|
||||
// model chose, sent with this org's token.
|
||||
const externals = new FakeExternals(request)
|
||||
const [grant] = await externals.calls('jira-auth', 'token', { contains: code })
|
||||
const [created] = await externals.calls('jira', 'issue', { contains: summary })
|
||||
expect(created.method).toBe('POST')
|
||||
expect(created.headers.authorization).toBe(`Bearer ${(grant.response as JiraGrant).access_token}`)
|
||||
expect(created.body).toMatchObject({
|
||||
fields: { summary, project: { key: fakeJira.projectKey }, issuetype: { id: fakeJira.taskTypeId }, priority: { id: '2' } },
|
||||
})
|
||||
|
||||
// The model is told the issue key and its link on the connected site.
|
||||
const { key } = created.response as { key: string }
|
||||
const toolResults = (await llm.requests(agent)).flatMap((r) => r.toolResults).join('\n')
|
||||
expect(toolResults).toContain(`.atlassian.net/browse/${key}`)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,62 @@
|
||||
import { test, expect, installShopify, uninstallShopify, useShopForAgent, FakeExternals, seedOrg, unique, AI_REPLY } from '@e2e'
|
||||
|
||||
// Linking a shop sets the org's domain, so this runs in the serial `org` project and
|
||||
// uninstalls (restoring the domain) afterwards.
|
||||
|
||||
test.describe('Shopify', () => {
|
||||
let shop: string
|
||||
let shopId: string | undefined
|
||||
|
||||
test.beforeEach(async ({ agent, adminApi }) => {
|
||||
shop = `${unique('e2e-shop')}.myshopify.com`
|
||||
shopId = await installShopify(adminApi, shop)
|
||||
await useShopForAgent(adminApi, agent.id, shopId)
|
||||
})
|
||||
|
||||
test.afterEach(async ({ adminApi, org }) => {
|
||||
if (shopId) await uninstallShopify(adminApi, shopId, org.orgId, seedOrg.domain)
|
||||
})
|
||||
|
||||
test('a customer asks where their order is and the AI answers from the shop @integrations', async ({ agent, widget, llm, request }) => {
|
||||
const email = widget.customer.email
|
||||
await llm.script(agent, (s) => s
|
||||
.when(/1042/).callTool('get_order_status', { order_number: '#1042', customer_email: email })
|
||||
.thenReply('Order #1042 has shipped with UPS.')
|
||||
.otherwise().reply('Happy to check — what is your order number and email?'))
|
||||
await widget.embed()
|
||||
await widget.open()
|
||||
await widget.send('Where is my order?')
|
||||
await expect(widget.lastAgentMessage()).toHaveText('Happy to check — what is your order number and email?', AI_REPLY)
|
||||
await widget.send(`It is 1042, ${email}`)
|
||||
await expect(widget.lastAgentMessage()).toHaveText('Order #1042 has shipped with UPS.', AI_REPLY)
|
||||
|
||||
// The shop was searched for this customer's order with the token its install
|
||||
// was granted, then the order itself was read.
|
||||
const externals = new FakeExternals(request)
|
||||
const [install] = await externals.calls('shopify', 'access_token', { contains: shop })
|
||||
const [search, read] = await externals.calls('shopify', 'graphql.json', { contains: shop })
|
||||
expect(search.headers['x-shopify-access-token']).toBe((install.response as { access_token: string }).access_token)
|
||||
expect(search.body).toMatchObject({ variables: { query: `email:${email.toLowerCase()} AND name:1042` } })
|
||||
expect(read.body).toMatchObject({ query: expect.stringContaining('query GetOrder') })
|
||||
|
||||
// What the model was told: the shipment and its tracking number.
|
||||
const toolResults = (await llm.requests(agent)).flatMap((r) => r.toolResults).join('\n')
|
||||
expect(toolResults).toContain('FULFILLED')
|
||||
expect(toolResults).toMatch(/1ZE2E\d+/)
|
||||
})
|
||||
|
||||
test('an order lookup without the customer\'s email never reaches the shop @integrations', async ({ agent, widget, llm, request }) => {
|
||||
await llm.script(agent, (s) => s
|
||||
.otherwise().callTool('get_order_status', { order_number: '1042', customer_email: '' })
|
||||
.thenReply('Could you tell me the email you ordered with?'))
|
||||
await widget.embed()
|
||||
await widget.open()
|
||||
await widget.send('Where is order 1042?')
|
||||
await expect(widget.lastAgentMessage()).toHaveText('Could you tell me the email you ordered with?', AI_REPLY)
|
||||
|
||||
const externals = new FakeExternals(request)
|
||||
expect(await externals.calls('shopify', 'graphql.json', { contains: shop })).toHaveLength(0)
|
||||
const toolResults = (await llm.requests(agent)).flatMap((r) => r.toolResults).join('\n')
|
||||
expect(toolResults).toContain('requires_user_input')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,27 @@
|
||||
import { test, expect, addKnowledgeText, unique, AI_REPLY } from '@e2e'
|
||||
|
||||
test.describe('knowledge base', () => {
|
||||
test('an answer is grounded in text added to the knowledge base @smoke @knowledge', async ({ agent, widget, llm, adminApi, org }) => {
|
||||
// A token no model could guess, so finding it in the search result proves the
|
||||
// text was indexed and retrieved — not echoed from the prompt.
|
||||
const policyCode = unique('RET')
|
||||
await addKnowledgeText(adminApi, {
|
||||
orgId: org.orgId,
|
||||
agentId: agent.id,
|
||||
title: `Returns policy ${policyCode}`, // titles are unique per org
|
||||
content: `Returns policy: items can be returned within 45 days of delivery. Quote reference ${policyCode} on the return label.`,
|
||||
})
|
||||
await llm.script(agent, (s) => s
|
||||
.when(/return/i).callTool('search_knowledge_base', { query: 'returns policy' })
|
||||
.thenReply('You can return items within 45 days of delivery.')
|
||||
.otherwise().reply('Happy to help!'))
|
||||
|
||||
await widget.embed()
|
||||
await widget.open()
|
||||
await widget.send('What is your returns policy?')
|
||||
|
||||
await expect(widget.lastAgentMessage()).toHaveText('You can return items within 45 days of delivery.', AI_REPLY)
|
||||
const searchResults = (await llm.requests(agent)).flatMap((request) => request.toolResults)
|
||||
expect(searchResults.join('\n')).toContain(policyCode)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,41 @@
|
||||
import { test, expect, addKnowledgeText, deleteKnowledge, unique, AI_REPLY, type TestAgent, type FakeLlm, type ChatWidget } from '@e2e'
|
||||
|
||||
// What the model is shown is what the knowledge base holds for *this* agent, now.
|
||||
|
||||
async function askAboutReturns(llm: FakeLlm, agent: TestAgent, widget: ChatWidget): Promise<string> {
|
||||
await llm.script(agent, (s) => s
|
||||
.when(/return/i).callTool('search_knowledge_base', { query: 'returns policy' })
|
||||
.thenReply('Let me check our policy.')
|
||||
.otherwise().reply('Happy to help!'))
|
||||
await widget.embed()
|
||||
await widget.open()
|
||||
await widget.send('What is your returns policy?')
|
||||
await expect(widget.lastAgentMessage()).toHaveText('Let me check our policy.', AI_REPLY)
|
||||
return (await llm.requests(agent)).flatMap((r) => r.toolResults).join('\n')
|
||||
}
|
||||
|
||||
test.describe('knowledge base lifecycle', () => {
|
||||
test('deleted knowledge is no longer what the AI is shown @knowledge', async ({ agent, widget, llm, adminApi, org, makeWidget }) => {
|
||||
const code = unique('RET')
|
||||
const id = await addKnowledgeText(adminApi, {
|
||||
orgId: org.orgId, agentId: agent.id, title: `Returns policy ${code}`,
|
||||
content: `Returns policy: items can be returned within 45 days. Reference ${code}.`,
|
||||
})
|
||||
expect(await askAboutReturns(llm, agent, widget)).toContain(code)
|
||||
|
||||
await deleteKnowledge(adminApi, id)
|
||||
const freshVisitor = await makeWidget(agent.id)
|
||||
expect(await askAboutReturns(llm, agent, freshVisitor)).not.toContain(code)
|
||||
})
|
||||
|
||||
test('one agent\'s knowledge never reaches another agent\'s answers @knowledge', async ({ agent, widget, llm, adminApi, org, makeAgent }) => {
|
||||
const other = await makeAgent()
|
||||
const secret = unique('INTERNAL')
|
||||
await addKnowledgeText(adminApi, {
|
||||
orgId: org.orgId, agentId: other.id, title: `Staff returns rules ${secret}`,
|
||||
content: `Staff-only returns rules: discount code ${secret} for internal returns.`,
|
||||
})
|
||||
// `agent` searches the same org's knowledge base, for the same words.
|
||||
expect(await askAboutReturns(llm, agent, widget)).not.toContain(secret)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,24 @@
|
||||
import { test, expect, runWorkerOnce, textPdf, unique, unfinishedKnowledge, uploadKnowledgePdf, AI_REPLY, KNOWLEDGE_INDEXED } from '@e2e'
|
||||
|
||||
test('an uploaded PDF becomes knowledge the AI answers from @knowledge @workers', async ({ agent, widget, llm, adminApi, org }) => {
|
||||
const code = unique('WARRANTY')
|
||||
await uploadKnowledgePdf(adminApi, {
|
||||
orgId: org.orgId, agentId: agent.id, filename: `${code}.pdf`,
|
||||
pdf: textPdf(['Warranty policy', `Every product carries a two-year warranty. Reference ${code}.`]),
|
||||
})
|
||||
// Production indexes it on the knowledge worker's next poll; here, now.
|
||||
await runWorkerOnce('knowledge')
|
||||
// Tests run in parallel: another test's pass may be the one indexing it.
|
||||
await expect.poll(() => unfinishedKnowledge(adminApi, agent.id), KNOWLEDGE_INDEXED).toEqual([])
|
||||
|
||||
await llm.script(agent, (s) => s
|
||||
.when(/warranty/i).callTool('search_knowledge_base', { query: 'warranty policy' })
|
||||
.thenReply('Every product has a two-year warranty.')
|
||||
.otherwise().reply('Happy to help!'))
|
||||
await widget.embed()
|
||||
await widget.open()
|
||||
await widget.send('What warranty do you offer?')
|
||||
|
||||
await expect(widget.lastAgentMessage()).toHaveText('Every product has a two-year warranty.', AI_REPLY)
|
||||
expect((await llm.requests(agent)).flatMap((r) => r.toolResults).join('\n')).toContain(code)
|
||||
})
|
||||
@@ -0,0 +1,33 @@
|
||||
import { test, expect, AI_REPLY } from '@e2e'
|
||||
|
||||
interface People { items: Array<{ id: string; email: string | null }> }
|
||||
interface Person { name: string | null; email: string | null; lead_stage: string; captured_attributes: Record<string, unknown> }
|
||||
|
||||
test.describe('lead capture', () => {
|
||||
test.use({ agentSettings: { leadCapture: true } })
|
||||
|
||||
test('a visitor who shares their details shows up as a lead, with what they told us @leads', async ({ agent, widget, llm, peoplePage, adminApi }) => {
|
||||
const { email, name } = widget.customer
|
||||
await llm.script(agent, (s) => s
|
||||
.when(/@/).captureLead('Thanks! Someone from sales will reach out.', { email, name, company: 'Acme Corp' })
|
||||
.otherwise().reply('Happy to set that up — what is your email?'))
|
||||
|
||||
await widget.embed()
|
||||
await widget.open()
|
||||
await widget.send('I would like a demo')
|
||||
// A visitor answers the question once it is asked; replies to back-to-back
|
||||
// messages are produced concurrently and may arrive in either order.
|
||||
await expect(widget.lastAgentMessage()).toHaveText('Happy to set that up — what is your email?', AI_REPLY)
|
||||
await widget.send(`Sure, I am ${name}, ${email}`)
|
||||
await expect(widget.lastAgentMessage()).toHaveText('Thanks! Someone from sales will reach out.', AI_REPLY)
|
||||
|
||||
await peoplePage.goto()
|
||||
await peoplePage.findLead(email)
|
||||
await expect(peoplePage.person(email)).toContainText('Lead')
|
||||
|
||||
const [found] = (await adminApi.get<People>('/people', { params: { search: email } })).items
|
||||
const person = await adminApi.get<Person>(`/people/${found.id}`)
|
||||
expect(person).toMatchObject({ name, email })
|
||||
expect(JSON.stringify(person.captured_attributes)).toContain('Acme Corp')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,58 @@
|
||||
import { test, expect, channels, findPerson, phoneNumber, AI_REPLY } from '@e2e'
|
||||
|
||||
// The People directory's edit rules, at the API the drawer uses.
|
||||
|
||||
test.describe('People', () => {
|
||||
test('a contact can be renamed, given a phone and marked as a customer @leads', async ({ agent, llm, adminApi, request }) => {
|
||||
await llm.script(agent, (s) => s.otherwise().reply('Thanks for writing in.'))
|
||||
const chat = (await channels.Email({ api: adminApi, request, agentId: agent.id })).chatWith('Tess')
|
||||
await chat.send('Hello')
|
||||
await expect.poll(() => chat.replies(), AI_REPLY).toHaveLength(1)
|
||||
const person = await findPerson(adminApi, chat.address)
|
||||
|
||||
const phone = phoneNumber()
|
||||
await adminApi.patch(`/people/${person.id}`, { data: { full_name: 'Tess Tester', phone } })
|
||||
await adminApi.post(`/people/${person.id}/mark-customer`)
|
||||
|
||||
expect(await adminApi.get(`/people/${person.id}`)).toMatchObject({ name: 'Tess Tester', phone, lead_stage: 'customer' })
|
||||
})
|
||||
|
||||
test('a phone number must be international and can belong to only one person @leads', async ({ agent, llm, adminApi, request }) => {
|
||||
await llm.script(agent, (s) => s.otherwise().reply('Thanks for writing in.'))
|
||||
const inbox = await channels.Email({ api: adminApi, request, agentId: agent.id })
|
||||
const ann = inbox.chatWith('Ann')
|
||||
const ben = inbox.chatWith('Ben')
|
||||
await ann.send('Hello')
|
||||
await ben.send('Hello')
|
||||
await expect.poll(() => ann.replies(), AI_REPLY).toHaveLength(1)
|
||||
await expect.poll(() => ben.replies(), AI_REPLY).toHaveLength(1)
|
||||
const annPerson = await findPerson(adminApi, ann.address)
|
||||
const benPerson = await findPerson(adminApi, ben.address)
|
||||
|
||||
const local = await adminApi.raw('PATCH', `/people/${annPerson.id}`, { data: { phone: '07700 900123' } })
|
||||
expect(local.status()).toBe(400)
|
||||
expect(await local.text()).toContain('international format')
|
||||
|
||||
const shared = phoneNumber()
|
||||
await adminApi.patch(`/people/${annPerson.id}`, { data: { phone: shared } })
|
||||
const taken = await adminApi.raw('PATCH', `/people/${benPerson.id}`, { data: { phone: shared } })
|
||||
expect(taken.status()).toBe(400)
|
||||
expect(await taken.text()).toContain('already belongs to another person')
|
||||
})
|
||||
|
||||
test('once a WhatsApp contact has given a real email, their number can no longer be changed away @leads', async ({ makeAgent, llm, adminApi, request }) => {
|
||||
// Their phone is now the only thing an incoming WhatsApp message can find them by.
|
||||
const agent = await makeAgent({ leadCapture: true })
|
||||
const email = `wes.${Date.now()}@example.com`
|
||||
await llm.script(agent, (s) => s.otherwise().captureLead('Thanks, noted.', { email, name: 'Wes' }))
|
||||
const chat = (await channels.WhatsApp({ api: adminApi, request, agentId: agent.id })).chatWith('Wes')
|
||||
await chat.send(`It is ${email}`)
|
||||
await expect.poll(() => chat.replies(), AI_REPLY).toHaveLength(1)
|
||||
const person = await findPerson(adminApi, email)
|
||||
|
||||
const response = await adminApi.raw('PATCH', `/people/${person.id}`, { data: { phone: phoneNumber() } })
|
||||
expect(response.status()).toBe(400)
|
||||
expect(await response.text()).toContain('how their WhatsApp messages find them')
|
||||
expect((await adminApi.get<{ phone: string }>(`/people/${person.id}`)).phone).toContain(chat.waId)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,72 @@
|
||||
import { test, expect, attachMcpConnector, createMcpConnector, FakeMcp, unique, AI_REPLY } from '@e2e'
|
||||
|
||||
test.describe('MCP connectors', () => {
|
||||
test('an agent answers with a tool from a connected MCP server @mcp', async ({ agent, widget, llm, adminApi, request }) => {
|
||||
const mcp = new FakeMcp(request)
|
||||
const connector = await createMcpConnector(adminApi, { name: unique('orders'), url: mcp.url })
|
||||
await attachMcpConnector(adminApi, connector.id, agent.id)
|
||||
const orderId = unique('A')
|
||||
await llm.script(agent, (s) => s
|
||||
.when(/order/i).callTool('lookup_order', { order_id: orderId }).thenReply('Your order shipped on 2 May via DHL.')
|
||||
.otherwise().reply('Happy to help!'))
|
||||
|
||||
await widget.embed()
|
||||
await widget.open()
|
||||
await widget.send(`Where is my order ${orderId}?`)
|
||||
|
||||
await expect(widget.lastAgentMessage()).toHaveText('Your order shipped on 2 May via DHL.', AI_REPLY)
|
||||
// The answer came from the MCP server, through the backend, back to the model.
|
||||
const toolResults = (await llm.requests(agent)).flatMap((r) => r.toolResults).join('\n')
|
||||
expect(toolResults).toContain(`Order ${orderId}: shipped on 2 May via DHL`)
|
||||
})
|
||||
|
||||
test('a connector\'s secrets are masked when read back, but still reach the server @mcp @security', async ({ agent, widget, llm, adminApi, request }) => {
|
||||
const mcp = new FakeMcp(request)
|
||||
const secret = `Bearer ${unique('mcp-secret')}`
|
||||
const connector = await createMcpConnector(adminApi, { name: unique('orders'), url: mcp.url, headers: { Authorization: secret } })
|
||||
|
||||
expect(connector.headers).toEqual({ Authorization: '********' })
|
||||
expect(JSON.stringify(await adminApi.get(`/mcp-tools/${connector.id}`))).not.toContain(secret)
|
||||
|
||||
// Saving the masked value back keeps the real one.
|
||||
await adminApi.put(`/mcp-tools/${connector.id}`, { data: { headers: { Authorization: '********' } } })
|
||||
await attachMcpConnector(adminApi, connector.id, agent.id)
|
||||
await llm.script(agent, (s) => s.otherwise().callTool('lookup_order', { order_id: 'A1' }).thenReply('Checked.'))
|
||||
await widget.embed()
|
||||
await widget.open()
|
||||
await widget.send('Where is my order?')
|
||||
await expect(widget.lastAgentMessage()).toHaveText('Checked.', AI_REPLY)
|
||||
expect((await mcp.requests()).map((r) => r.authorization)).toContain(secret)
|
||||
})
|
||||
|
||||
// Nothing listens on port 1: the connection is refused at once.
|
||||
const DEAD_SERVER = 'http://127.0.0.1:1/mcp'
|
||||
|
||||
test('a server that is down costs only its own tools, not the agent\'s other connectors @mcp', async ({ agent, widget, llm, adminApi, request }) => {
|
||||
const mcp = new FakeMcp(request)
|
||||
const down = await createMcpConnector(adminApi, { name: unique('down'), url: DEAD_SERVER })
|
||||
const up = await createMcpConnector(adminApi, { name: unique('orders'), url: mcp.url })
|
||||
await attachMcpConnector(adminApi, down.id, agent.id)
|
||||
await attachMcpConnector(adminApi, up.id, agent.id)
|
||||
const orderId = unique('A')
|
||||
await llm.script(agent, (s) => s
|
||||
.when(/order/i).callTool('lookup_order', { order_id: orderId }).thenReply('Your order shipped on 2 May via DHL.')
|
||||
.otherwise().reply('Happy to help!'))
|
||||
|
||||
await widget.embed()
|
||||
await widget.open()
|
||||
await widget.send(`Where is my order ${orderId}?`)
|
||||
|
||||
await expect(widget.lastAgentMessage()).toHaveText('Your order shipped on 2 May via DHL.', AI_REPLY)
|
||||
const toolResults = (await llm.requests(agent)).flatMap((r) => r.toolResults).join('\n')
|
||||
expect(toolResults).toContain(`Order ${orderId}: shipped on 2 May via DHL`)
|
||||
})
|
||||
|
||||
test('testing a server that is down says it could not connect @mcp', async ({ adminApi }) => {
|
||||
const down = await createMcpConnector(adminApi, { name: unique('down'), url: DEAD_SERVER })
|
||||
|
||||
const result = await adminApi.post<{ success: boolean; error: string | null }>(`/mcp-tools/${down.id}/test`)
|
||||
|
||||
expect(result).toMatchObject({ success: false, error: expect.stringContaining('Could not connect to the server') })
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,40 @@
|
||||
import { test, expect, env, AI_REPLY } from '@e2e'
|
||||
|
||||
// Size and speed budgets for what every customer's visitor downloads and waits
|
||||
// for. About 15% headroom over today; raise a budget on purpose, in review, never
|
||||
// to make a red run green.
|
||||
|
||||
const KB = 1024
|
||||
const BUDGETS = [
|
||||
{ file: 'embed loader (replypier.min.js)', url: `${env.frontendUrl}/webclient/replypier.min.js`, maxBytes: 60 * KB },
|
||||
{ file: 'widget script (widget.js)', url: `${env.backendUrl}/assets/widget.js`, maxBytes: 500 * KB },
|
||||
{ file: 'widget styles (widget.css)', url: `${env.backendUrl}/assets/widget.css`, maxBytes: 135 * KB },
|
||||
]
|
||||
|
||||
test.describe('performance budgets', () => {
|
||||
for (const { file, url, maxBytes } of BUDGETS) {
|
||||
test(`${file} stays under ${maxBytes / KB} KB @perf`, async ({ request }) => {
|
||||
const body = await (await request.get(url, { headers: { 'Accept-Encoding': 'identity' } })).body()
|
||||
expect(body.length, `${file} is ${Math.round(body.length / KB)} KB`).toBeLessThanOrEqual(maxBytes)
|
||||
})
|
||||
}
|
||||
|
||||
test('the widget is ready and answers within budget, with an instant model @perf @widget', async ({ agent, llm, widget }) => {
|
||||
await llm.script(agent, (s) => s.otherwise().reply('Happy to help!'))
|
||||
|
||||
const loadStart = Date.now()
|
||||
await widget.embed()
|
||||
await widget.open()
|
||||
const readyMs = Date.now() - loadStart
|
||||
|
||||
// The fake model answers at once, so this is the platform's own overhead.
|
||||
const sendStart = Date.now()
|
||||
await widget.send('Hello')
|
||||
await expect(widget.lastAgentMessage()).toHaveText('Happy to help!', AI_REPLY)
|
||||
const replyMs = Date.now() - sendStart
|
||||
|
||||
test.info().annotations.push({ type: 'timing', description: `ready ${readyMs} ms, reply ${replyMs} ms` })
|
||||
expect(readyMs, 'embed → chat ready').toBeLessThan(8_000)
|
||||
expect(replyMs, 'send → reply').toBeLessThan(5_000)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,33 @@
|
||||
import { test, expect, env, seedOrg, seedUsers, signInCookies, agentSocketOverWebSocket, agentSocketOverPolling } from '@e2e'
|
||||
|
||||
// The dashboard's live-chat socket signs in with the sign-in cookies, which
|
||||
// browsers send on every socket request whatever the page. Sockets get no CORS
|
||||
// check, so only our own pages may use them: a page on an org's website (an
|
||||
// unverified signup domain, allowed to call the API) must not get a socket
|
||||
// signed in as whoever is visiting it.
|
||||
|
||||
const DASHBOARD = env.frontendUrl
|
||||
const ORG_SITE = `https://${seedOrg.domain}`
|
||||
|
||||
test.describe('the dashboard live-chat socket', () => {
|
||||
test('signs in from the dashboard but never from an org\'s website @security', async ({ playwright }) => {
|
||||
const context = await playwright.request.newContext()
|
||||
const cookie = await signInCookies(context, seedUsers.admin.email, seedUsers.admin.password)
|
||||
await context.dispose()
|
||||
|
||||
expect(await agentSocketOverWebSocket(DASHBOARD, cookie)).toBe('signed in')
|
||||
expect(await agentSocketOverWebSocket(ORG_SITE, cookie)).toBe('refused')
|
||||
})
|
||||
|
||||
// Polling needs every request on one worker; the prod-like run has two
|
||||
test('a <script>-tag (JSONP) handshake carries the cookies but not the page\'s origin, so it never signs in @security @one-worker', async ({ playwright, request }) => {
|
||||
const context = await playwright.request.newContext()
|
||||
const cookie = await signInCookies(context, seedUsers.admin.email, seedUsers.admin.password)
|
||||
await context.dispose()
|
||||
|
||||
// The same flow does sign in when the handshake came from the dashboard
|
||||
expect(await agentSocketOverPolling(request, cookie, ORG_SITE, { Origin: DASHBOARD })).toBe('signed in')
|
||||
expect(await agentSocketOverPolling(request, cookie, ORG_SITE, {}, '&j=0')).toBe('refused')
|
||||
expect(await agentSocketOverPolling(request, cookie, ORG_SITE, { 'Sec-Fetch-Site': 'cross-site', 'Sec-Fetch-Dest': 'script' })).toBe('refused')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,38 @@
|
||||
import { test, expect, env, seedUsers, ApiClient } from '@e2e'
|
||||
|
||||
// What the backend itself promises about cookies and cross-origin access.
|
||||
// (Security headers such as HSTS and CSP come from the host's nginx, which is not
|
||||
// in this repo, so they are checked against a deployment, not here.)
|
||||
|
||||
test.describe('security basics', () => {
|
||||
test('sign-in tokens are HttpOnly, Secure cookies @security', async ({ playwright }) => {
|
||||
const context = await playwright.request.newContext()
|
||||
const response = await context.post(`${env.apiUrl}/users/login`, {
|
||||
form: { username: seedUsers.admin.email, password: seedUsers.admin.password },
|
||||
})
|
||||
expect(response.ok()).toBe(true)
|
||||
const cookies = response.headersArray().filter((h) => h.name.toLowerCase() === 'set-cookie').map((h) => h.value)
|
||||
for (const name of ['access_token', 'refresh_token']) {
|
||||
const cookie = cookies.find((c) => c.startsWith(`${name}=`))
|
||||
expect(cookie, `${name} cookie`).toBeDefined()
|
||||
expect(cookie).toMatch(/;\s*HttpOnly/i)
|
||||
expect(cookie).toMatch(/;\s*Secure/i)
|
||||
}
|
||||
await context.dispose()
|
||||
})
|
||||
|
||||
test('another website cannot call the API with a visitor\'s credentials @security', async ({ request }) => {
|
||||
const response = await new ApiClient(request).raw('OPTIONS', '/users/me', {
|
||||
headers: { Origin: 'https://evil.example', 'Access-Control-Request-Method': 'GET' },
|
||||
})
|
||||
expect(response.headers()['access-control-allow-origin']).toBeUndefined()
|
||||
})
|
||||
|
||||
test('the dashboard\'s own origin is allowed @security', async ({ request }) => {
|
||||
const response = await new ApiClient(request).raw('OPTIONS', '/users/me', {
|
||||
headers: { Origin: env.frontendUrl, 'Access-Control-Request-Method': 'GET' },
|
||||
})
|
||||
expect(response.headers()['access-control-allow-origin']).toBe(env.frontendUrl)
|
||||
expect(response.headers()['access-control-allow-credentials']).toBe('true')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,66 @@
|
||||
import { test, expect, env, orgDomain, setOrgDomain, ApiClient } from '@e2e'
|
||||
|
||||
// The org's website is what lets its site call the API. Changing it is org-wide,
|
||||
// so this runs in the serial `org` project and puts the website back. With
|
||||
// several backend workers the others learn of a change over Redis a moment
|
||||
// later, so every check polls.
|
||||
|
||||
const OLD_SITE = 'https://old-site.e2e.example'
|
||||
const NEW_SITE = 'https://new-site.e2e.example'
|
||||
|
||||
async function allowedOrigin(api: ApiClient, origin: string): Promise<string | undefined> {
|
||||
// A plain request, not a preflight: every CORS layer adds its header to these,
|
||||
// so a stale copy of the old origin list would still answer for the old site.
|
||||
const response = await api.raw('GET', '/organizations/setup-status', { headers: { Origin: origin } })
|
||||
return response.headers()['access-control-allow-origin']
|
||||
}
|
||||
|
||||
let website: string
|
||||
|
||||
test.beforeEach(async ({ adminApi, org }) => { website = await orgDomain(adminApi, org.orgId) })
|
||||
|
||||
test.afterEach(async ({ adminApi, org }) => { await setOrgDomain(adminApi, org.orgId, website) })
|
||||
|
||||
test.describe('organization website and API access', () => {
|
||||
test('changing the website moves API access to the new site and revokes the old one @security', async ({ adminApi, org, request }) => {
|
||||
const anonymous = new ApiClient(request)
|
||||
|
||||
await setOrgDomain(adminApi, org.orgId, new URL(OLD_SITE).host)
|
||||
await expect.poll(() => allowedOrigin(anonymous, OLD_SITE)).toBe(OLD_SITE)
|
||||
|
||||
await setOrgDomain(adminApi, org.orgId, new URL(NEW_SITE).host)
|
||||
await expect.poll(() => allowedOrigin(anonymous, NEW_SITE)).toBe(NEW_SITE)
|
||||
await expect.poll(() => allowedOrigin(anonymous, OLD_SITE)).toBeUndefined()
|
||||
|
||||
// Saving the same website again, then switching back, still allows only the current site
|
||||
await setOrgDomain(adminApi, org.orgId, new URL(NEW_SITE).host)
|
||||
await expect.poll(() => allowedOrigin(anonymous, NEW_SITE)).toBe(NEW_SITE)
|
||||
await setOrgDomain(adminApi, org.orgId, new URL(OLD_SITE).host)
|
||||
await expect.poll(() => allowedOrigin(anonymous, OLD_SITE)).toBe(OLD_SITE)
|
||||
await expect.poll(() => allowedOrigin(anonymous, NEW_SITE)).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
test.describe('organization website and sign-in cookies', () => {
|
||||
test('an org\'s website can call the API but never with a signed-in user\'s cookies @security', async ({ adminApi, org, request }) => {
|
||||
// Any signed-up domain lands in the CORS list, unverified: it must not be
|
||||
// able to read the dashboard's cookie-authenticated responses
|
||||
await setOrgDomain(adminApi, org.orgId, new URL(NEW_SITE).host)
|
||||
const anonymous = new ApiClient(request)
|
||||
await expect.poll(() => allowedOrigin(anonymous, NEW_SITE)).toBe(NEW_SITE)
|
||||
|
||||
const simple = await anonymous.raw('GET', '/users/me', { headers: { Origin: NEW_SITE, Cookie: 'access_token=x' } })
|
||||
expect(simple.headers()['access-control-allow-origin']).toBe(NEW_SITE)
|
||||
expect(simple.headers()['access-control-allow-credentials']).toBeUndefined()
|
||||
|
||||
const preflight = await anonymous.raw('OPTIONS', '/users/me', {
|
||||
headers: { Origin: NEW_SITE, 'Access-Control-Request-Method': 'GET' },
|
||||
})
|
||||
expect(preflight.headers()['access-control-allow-credentials']).toBeUndefined()
|
||||
|
||||
// and the rebuilt CORS layer still lets the dashboard use them
|
||||
const dashboard = await anonymous.raw('GET', '/users/me', { headers: { Origin: env.frontendUrl } })
|
||||
expect(dashboard.headers()['access-control-allow-origin']).toBe(env.frontendUrl)
|
||||
expect(dashboard.headers()['access-control-allow-credentials']).toBe('true')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,47 @@
|
||||
import { test, expect, env, seedOrg, currentAiConfig, restoreAi, AI_REPLY, type AiConfig } from '@e2e'
|
||||
|
||||
// Changes the org's AI provider, so it runs in the serial `org` project, and puts
|
||||
// it back afterwards so later specs (and later local runs) see the usual AI.
|
||||
|
||||
const fakeModel = { baseUrl: `${env.fakeLlmUrl}/v1`, model: seedOrg.llmModel }
|
||||
const INVALID_KEY_MESSAGE = 'The provided API key is invalid or does not have access to the selected model.'
|
||||
|
||||
test.describe('AI configuration', () => {
|
||||
let before: AiConfig
|
||||
|
||||
test.beforeEach(async ({ adminApi }) => { before = await currentAiConfig(adminApi) })
|
||||
|
||||
test.afterEach(async ({ adminApi }) => { await restoreAi(adminApi, before) })
|
||||
|
||||
test('a key the provider rejects is reported and the agents keep working @settings', async ({ aiConfigPage, agent, widget, llm }) => {
|
||||
await aiConfigPage.goto()
|
||||
await aiConfigPage.useOpenAiCompatible({ ...fakeModel, apiKey: 'e2e-invalid-key' })
|
||||
await expect(aiConfigPage.error).toHaveText(INVALID_KEY_MESSAGE)
|
||||
|
||||
// The rejected key was not saved: a customer still gets answers, through the old key.
|
||||
await llm.script(agent, (s) => s.otherwise().reply('Still here to help.'))
|
||||
await widget.embed()
|
||||
await widget.open()
|
||||
await widget.send('Hello?')
|
||||
await expect(widget.lastAgentMessage()).toHaveText('Still here to help.', AI_REPLY)
|
||||
expect((await llm.requests(agent)).map((r) => r.keySuffix)).not.toContain('id-key')
|
||||
})
|
||||
|
||||
test('a working key is saved and is the one the agents use @settings', async ({ aiConfigPage, agent, widget, llm }) => {
|
||||
await aiConfigPage.goto()
|
||||
await aiConfigPage.useOpenAiCompatible({ ...fakeModel, apiKey: 'e2e-fake-key-rotated' })
|
||||
await expect(aiConfigPage.error).toBeHidden()
|
||||
|
||||
await aiConfigPage.goto()
|
||||
await expect(aiConfigPage.page.getByLabel('Base URL')).toHaveValue(fakeModel.baseUrl)
|
||||
|
||||
await llm.script(agent, (s) => s.otherwise().reply('Answered with the new key.'))
|
||||
await widget.embed()
|
||||
await widget.open()
|
||||
await widget.send('Hello?')
|
||||
await expect(widget.lastAgentMessage()).toHaveText('Answered with the new key.', AI_REPLY)
|
||||
const lastRequest = (await llm.requests(agent)).at(-1)
|
||||
expect(lastRequest?.keySuffix).toBe('otated')
|
||||
expect(lastRequest?.model).toBe(fakeModel.model)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,47 @@
|
||||
import { test as setup, expect } from '@playwright/test'
|
||||
import { login } from '../../src/api/auth'
|
||||
import { ApiClient } from '../../src/api/client'
|
||||
import { isSetUp } from '../../src/api/organizations'
|
||||
import { seedOrg, seedUsers } from '../../src/env'
|
||||
import { storageStatePath } from '../../src/org-state'
|
||||
import { prepareOrg } from '../../src/org-setup'
|
||||
import { LoginPage } from '../../src/pages/login.page'
|
||||
import { SetupPage } from '../../src/pages/setup.page'
|
||||
|
||||
// OSS edition setup. Runs once per run, before every other project, and writes the
|
||||
// org contract (src/org-state.ts) that all specs read.
|
||||
//
|
||||
// Idempotent: on a fresh database it goes through the real first-run form (which is
|
||||
// also the test that the form works); on a database left by an earlier run it signs in.
|
||||
|
||||
setup('organization, AI and support team are ready', async ({ page, request, browser }) => {
|
||||
const anonymous = new ApiClient(request)
|
||||
const { admin } = seedUsers
|
||||
|
||||
if (await isSetUp(anonymous)) {
|
||||
const loginPage = new LoginPage(page)
|
||||
await loginPage.goto()
|
||||
await loginPage.signIn(admin.email, admin.password)
|
||||
await expect(page).not.toHaveURL(/\/login/)
|
||||
} else {
|
||||
const setupPage = new SetupPage(page)
|
||||
await setupPage.goto()
|
||||
await setupPage.createOrganization({
|
||||
orgName: seedOrg.name,
|
||||
domain: seedOrg.domain,
|
||||
adminName: admin.name,
|
||||
adminEmail: admin.email,
|
||||
adminPassword: admin.password,
|
||||
})
|
||||
}
|
||||
const adminStorageState = storageStatePath('admin')
|
||||
await page.context().storageState({ path: adminStorageState })
|
||||
|
||||
const session = await login(anonymous, admin.email, admin.password)
|
||||
await prepareOrg(browser, {
|
||||
orgId: session.organizationId,
|
||||
api: anonymous.withToken(session.token),
|
||||
adminStorageState,
|
||||
configureAi: true,
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,76 @@
|
||||
import { test, expect, channels, createGroup, connectEmail, listChannelAccounts, unique, AI_REPLY } from '@e2e'
|
||||
|
||||
// What an Agent-role teammate may do, checked at the API — the UI hiding a link
|
||||
// proves nothing about who the server lets through.
|
||||
|
||||
/** Admin-only operations, each with a body the endpoint would accept from an admin. */
|
||||
const ADMIN_ONLY: Array<{ method: string; path: string; data?: Record<string, unknown> }> = [
|
||||
{ method: 'GET', path: '/ai/config' },
|
||||
{ method: 'POST', path: '/ai/setup', data: { model_type: 'OPENAI', model_name: 'gpt-4o', api_key: 'sk-test' } },
|
||||
{ method: 'GET', path: '/users' },
|
||||
{ method: 'POST', path: '/users', data: { email: 'blocked@e2e.example.com', full_name: 'Blocked', password: 'E2e-Blocked-Passw0rd!', role_id: 1 } },
|
||||
{ method: 'GET', path: '/roles' },
|
||||
{ method: 'GET', path: '/groups' },
|
||||
{ method: 'POST', path: '/groups', data: { name: 'Blocked group' } },
|
||||
{ method: 'GET', path: '/agent/list' },
|
||||
{ method: 'POST', path: '/agent', data: { name: 'Blocked agent', instructions: ['x'] } },
|
||||
{ method: 'GET', path: '/widget-apps' },
|
||||
{ method: 'GET', path: '/tickets/settings' },
|
||||
{ method: 'PUT', path: '/tickets/settings', data: {} },
|
||||
{ method: 'POST', path: '/channels/telegram', data: { bot_token: '1:blocked' } },
|
||||
{ method: 'GET', path: '/mcp-tools' },
|
||||
]
|
||||
|
||||
test.describe('Agent role', () => {
|
||||
test('is refused every admin operation by the API @team', async ({ makeMember }) => {
|
||||
const agent = await makeMember({ role: 'Agent' })
|
||||
|
||||
const refused = await Promise.all(ADMIN_ONLY.map(async ({ method, path, data }) =>
|
||||
`${method} ${path} → ${(await agent.api.raw(method, path, { data })).status()}`))
|
||||
|
||||
expect(refused).toEqual(ADMIN_ONLY.map(({ method, path }) => `${method} ${path} → 403`))
|
||||
// The same token works where an agent belongs.
|
||||
expect((await agent.api.raw('GET', '/chats/recent')).status()).toBe(200)
|
||||
})
|
||||
|
||||
test('sees the connected channels for the inbox, but never their webhook secrets @team', async ({ adminApi, makeMember }) => {
|
||||
const inbox = await connectEmail(adminApi, `${unique('support')}@e2e.example.com`)
|
||||
expect(inbox.webhook_url).toContain('token=')
|
||||
|
||||
const agent = await makeMember({ role: 'Agent' })
|
||||
const seen = (await listChannelAccounts(agent.api)).find((a) => a.id === inbox.id)
|
||||
|
||||
expect(seen).toBeDefined()
|
||||
expect(seen?.webhook_url ?? null).toBeNull()
|
||||
})
|
||||
|
||||
test('with "assigned only" sees their own group\'s handed-over chats and not another group\'s @team @handover', async ({ adminApi, makeMember, makeAgent, llm, request }) => {
|
||||
// Two teams, each with an AI agent that hands over to it.
|
||||
const groupA = await createGroup(adminApi, unique('Team A'), [])
|
||||
const groupB = await createGroup(adminApi, unique('Team B'), [])
|
||||
const alice = await makeMember({ role: 'Agent', groupIds: [groupA], seeAllAiChats: false })
|
||||
await makeMember({ role: 'Agent', groupIds: [groupB] }) // someone online in B, so B's handover happens
|
||||
const agentA = await makeAgent({ transferToHuman: true }, [groupA])
|
||||
const agentB = await makeAgent({ transferToHuman: true }, [groupB])
|
||||
for (const agent of [agentA, agentB]) {
|
||||
await llm.script(agent, (s) => s.otherwise().handOver('Let me connect you with a teammate.'))
|
||||
}
|
||||
|
||||
const forA = (await channels.WhatsApp({ api: adminApi, request, agentId: agentA.id })).chatWith(unique('Ana'))
|
||||
const forB = (await channels.WhatsApp({ api: adminApi, request, agentId: agentB.id })).chatWith(unique('Ben'))
|
||||
await forA.send('I need a person')
|
||||
await forB.send('I need a person')
|
||||
await expect.poll(() => forA.replies(), AI_REPLY).toHaveLength(1)
|
||||
await expect.poll(() => forB.replies(), AI_REPLY).toHaveLength(1)
|
||||
|
||||
const visibleTo = async () => (await alice.api.get<Array<{ agent: { id: string }; session_id: string }>>('/chats/recent', { params: { limit: 100 } }))
|
||||
const agentsAliceSees = (await visibleTo()).map((c) => String(c.agent.id))
|
||||
expect(agentsAliceSees).toContain(agentA.id)
|
||||
expect(agentsAliceSees).not.toContain(agentB.id)
|
||||
|
||||
// Opening B's chat directly is refused too, not just hidden from the list.
|
||||
const allChats = await adminApi.get<Array<{ agent: { id: string }; session_id: string }>>('/chats/recent', { params: { limit: 100, agent_id: agentB.id } })
|
||||
const bSession = allChats.find((c) => String(c.agent.id) === agentB.id)!.session_id
|
||||
expect([403, 404]).toContain((await alice.api.raw('GET', `/chats/${bSession}`)).status())
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,31 @@
|
||||
import { test, expect, storageStatePath, unique, LoginPage, Sidebar } from '@e2e'
|
||||
|
||||
test.describe('team', () => {
|
||||
test('an admin adds an agent who can sign in to the inbox @team', async ({ humanAgentsPage, anonymousPage }) => {
|
||||
const id = unique('agent')
|
||||
const member = { name: `Alex ${id}`, email: `${id}@e2e.example.com`, password: 'E2e-New-Agent-Passw0rd!', role: 'Agent' as const }
|
||||
|
||||
await humanAgentsPage.goto()
|
||||
await humanAgentsPage.invite(member)
|
||||
await expect(humanAgentsPage.member(member.email)).toBeVisible()
|
||||
|
||||
const login = new LoginPage(anonymousPage)
|
||||
await login.goto()
|
||||
await login.signIn(member.email, member.password)
|
||||
await expect(anonymousPage).toHaveURL(/\/conversations/)
|
||||
})
|
||||
})
|
||||
|
||||
test.describe('as a human agent', () => {
|
||||
test.use({ storageState: storageStatePath('human-agent') })
|
||||
|
||||
test('the Agent role does not see admin areas in the navigation @team', async ({ page }) => {
|
||||
// Not /conversations: the inbox collapses the sidebar to icons.
|
||||
await page.goto('/people')
|
||||
const sidebar = new Sidebar(page)
|
||||
await expect(sidebar.link('Inbox')).toBeVisible()
|
||||
for (const adminArea of ['AI Agents', 'Human Agents', 'Knowledge', 'AI Configuration', 'Organization']) {
|
||||
await expect(sidebar.link(adminArea)).toBeHidden()
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,83 @@
|
||||
import { test, expect, chatMessages, chatsWithAgent, runWorkerOnce, sql, unique, uuid, AI_REPLY } from '@e2e'
|
||||
|
||||
test.describe('tickets', () => {
|
||||
test('the AI opens a ticket that an agent then works in the dashboard @tickets', async ({ agent, widget, llm, ticketsPage }) => {
|
||||
const summary = `Checkout button does nothing ${unique('T')}`
|
||||
await llm.script(agent, (s) => s
|
||||
.when(/broken|does nothing/i).createTicket('I have logged this for our team.', summary)
|
||||
.otherwise().reply('Happy to help!'))
|
||||
|
||||
await widget.embed()
|
||||
await widget.open()
|
||||
await widget.send('The checkout button does nothing when I click it')
|
||||
await expect(widget.lastAgentMessage()).toHaveText('I have logged this for our team.', AI_REPLY)
|
||||
|
||||
await ticketsPage.goto()
|
||||
await ticketsPage.open(summary)
|
||||
await ticketsPage.setStatus('In progress')
|
||||
await ticketsPage.page.reload()
|
||||
await expect(ticketsPage.statusLabel).toHaveText('In progress')
|
||||
})
|
||||
})
|
||||
|
||||
test.describe('resolving a ticket', () => {
|
||||
test('the customer hears about the fix in the chat the ticket came from @tickets', async ({ agent, widget, llm, ticketsPage, adminApi }) => {
|
||||
const summary = `Coupon code rejected ${unique('T')}`
|
||||
await llm.script(agent, (s) => s
|
||||
.when(/coupon/i).createTicket('I have logged this for our team.', summary)
|
||||
.otherwise().reply('Happy to help!'))
|
||||
await widget.embed()
|
||||
await widget.open()
|
||||
await widget.send('My coupon code is rejected at checkout')
|
||||
await expect(widget.lastAgentMessage()).toHaveText('I have logged this for our team.', AI_REPLY)
|
||||
|
||||
await ticketsPage.goto()
|
||||
await ticketsPage.open(summary)
|
||||
const ticketId = new URL(ticketsPage.page.url()).pathname.split('/').at(-1)
|
||||
await adminApi.post(`/tickets/${ticketId}/resolve`, {
|
||||
data: { outcome: 'fixed', resolution_summary: 'Expired promotion table', customer_message: 'Good news: your coupon works again.' },
|
||||
})
|
||||
|
||||
await expect(widget.lastAgentMessage()).toContainText('Good news: your coupon works again.', AI_REPLY)
|
||||
await ticketsPage.page.reload()
|
||||
await expect(ticketsPage.statusLabel).toHaveText('Resolved · confirming')
|
||||
})
|
||||
})
|
||||
|
||||
// The rating request comes from the ticket worker, a separate process; it reaches
|
||||
// the visitor's open widget through Redis, as in production — so @redis (nightly).
|
||||
test.describe('ticket satisfaction', () => {
|
||||
test.use({ agentSettings: { askForRating: true } })
|
||||
|
||||
test('a resolved ticket closes after its confirmation window and asks the customer for a rating, which is kept @tickets @workers @redis', async ({ agent, widget, llm, ticketsPage, adminApi }) => {
|
||||
const summary = `Invoice shows the wrong VAT ${unique('T')}`
|
||||
await llm.script(agent, (s) => s
|
||||
.when(/invoice/i).createTicket('I have logged this for our team.', summary)
|
||||
.otherwise().reply('Happy to help!'))
|
||||
await widget.embed()
|
||||
await widget.open()
|
||||
await widget.send('My invoice shows the wrong VAT')
|
||||
await expect(widget.lastAgentMessage()).toHaveText('I have logged this for our team.', AI_REPLY)
|
||||
|
||||
await ticketsPage.goto()
|
||||
await ticketsPage.open(summary)
|
||||
const ticketId = uuid(new URL(ticketsPage.page.url()).pathname.split('/').at(-1)!)
|
||||
await adminApi.post(`/tickets/${ticketId}/resolve`, { data: { outcome: 'fixed', customer_message: 'Fixed: a corrected invoice is on its way.' } })
|
||||
await expect(widget.lastAgentMessage()).toContainText('Fixed: a corrected invoice is on its way.', AI_REPLY)
|
||||
|
||||
// The customer never replies; the confirmation window (72h by default) runs out.
|
||||
sql(`UPDATE tickets SET confirmation_requested_at = now() - interval '73 hours' WHERE id = '${ticketId}'`)
|
||||
await runWorkerOnce('ticket-lifecycle')
|
||||
|
||||
await expect(widget.ratingPrompt).toBeVisible(AI_REPLY)
|
||||
await widget.rate(4)
|
||||
await expect(widget.ratingThanks).toBeVisible()
|
||||
await expect.poll(async () => (await adminApi.get<{ ticket: { status: string; csat_score: number | null } }>(`/tickets/${ticketId}`)).ticket)
|
||||
.toMatchObject({ status: 'closed', csat_score: 4 })
|
||||
|
||||
// The ask is part of the conversation, so agents see it in the thread too.
|
||||
const [chat] = await chatsWithAgent(adminApi, agent.id)
|
||||
const asks = (await chatMessages(adminApi, chat.session_id)).filter((m) => m.message?.startsWith('How did we do on ticket'))
|
||||
expect(asks).toHaveLength(1)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,25 @@
|
||||
import { test, expect, customizeAgent } from '@e2e'
|
||||
|
||||
// Screenshot baselines are generated on Linux in CI (the nightly job, or the e2e
|
||||
// workflow run by hand with "update snapshots") and committed from its artifact.
|
||||
// Never commit a baseline made on a Mac: fonts render differently.
|
||||
|
||||
test.describe('widget appearance', () => {
|
||||
for (const scheme of ['light', 'dark'] as const) {
|
||||
test(`launcher and open chat, ${scheme} @visual @widget`, async ({ agent, adminApi, widget }) => {
|
||||
// Fixed name and text, so nothing in the shot changes from run to run (the
|
||||
// avatar is drawn from the name, too).
|
||||
await adminApi.put(`/agent/${agent.id}`, { data: { display_name: 'Acme Support' } })
|
||||
await customizeAgent(adminApi, agent.id, { welcome_message: 'Hi! How can we help today?' })
|
||||
await widget.page.emulateMedia({ colorScheme: scheme })
|
||||
await widget.embed()
|
||||
await expect(widget.launcher).toBeVisible()
|
||||
await expect(widget.launcher).toHaveScreenshot(`launcher-${scheme}.png`)
|
||||
|
||||
await widget.open()
|
||||
await expect(widget.welcomeMessage).toBeVisible()
|
||||
await expect(widget.agentName).toHaveText('Acme Support')
|
||||
await expect(widget.panel).toHaveScreenshot(`chat-${scheme}.png`)
|
||||
})
|
||||
}
|
||||
})
|
||||
@@ -0,0 +1,49 @@
|
||||
import { test, expect, AI_REPLY } from '@e2e'
|
||||
|
||||
// When the model fails, the customer gets an apology rather than silence or debris,
|
||||
// and the very next message is answered normally.
|
||||
|
||||
const APOLOGY = 'I apologize, but I encountered an error, please try again later.'
|
||||
|
||||
test.describe('when the AI provider fails', () => {
|
||||
test('an error from the provider becomes an apology, and the chat carries on @widget', async ({ agent, llm, widget }) => {
|
||||
await llm.script(agent, (s) => s
|
||||
.when(/broken/).fail(500, 'upstream exploded')
|
||||
.otherwise().reply('Back to normal.'))
|
||||
|
||||
await widget.embed()
|
||||
await widget.open()
|
||||
await widget.send('this is broken')
|
||||
await expect(widget.lastAgentMessage()).toHaveText(APOLOGY, AI_REPLY)
|
||||
await widget.send('hello again')
|
||||
await expect(widget.lastAgentMessage()).toHaveText('Back to normal.', AI_REPLY)
|
||||
})
|
||||
|
||||
test('a provider that never answers is given up on, with an apology @widget @slow', async ({ agent, llm, widget }) => {
|
||||
// Longer than AGENT_RUN_TIMEOUT (.env.e2e), so the backend stops waiting.
|
||||
await llm.script(agent, (s) => s
|
||||
.when(/broken/).after(25_000).reply('Far too late.')
|
||||
.otherwise().reply('Back to normal.'))
|
||||
|
||||
await widget.embed()
|
||||
await widget.open()
|
||||
await widget.send('this is broken')
|
||||
await expect(widget.lastAgentMessage()).toHaveText(APOLOGY, AI_REPLY)
|
||||
await widget.send('hello again')
|
||||
await expect(widget.lastAgentMessage()).toHaveText('Back to normal.', AI_REPLY)
|
||||
await expect(widget.agentMessages.filter({ hasText: 'Far too late.' })).toHaveCount(0)
|
||||
})
|
||||
|
||||
test('a reply that is not the JSON asked for never reaches the customer raw @widget', async ({ agent, llm, widget }) => {
|
||||
await llm.script(agent, (s) => s
|
||||
.when(/broken/).malformed()
|
||||
.otherwise().reply('Back to normal.'))
|
||||
|
||||
await widget.embed()
|
||||
await widget.open()
|
||||
await widget.send('this is broken')
|
||||
await expect(widget.lastAgentMessage()).toHaveText(APOLOGY, AI_REPLY)
|
||||
await widget.send('hello again')
|
||||
await expect(widget.lastAgentMessage()).toHaveText('Back to normal.', AI_REPLY)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,58 @@
|
||||
import { test, expect, AI_REPLY, customizeAgent, findPerson } from '@e2e'
|
||||
|
||||
// Each way a visitor reaches the chat, and each again when the first answer that
|
||||
// binds them to a customer goes missing (the socket then gets the page's anonymous
|
||||
// token and the widget has to authorize again).
|
||||
for (const lost of [false, true]) {
|
||||
const when = lost ? ', even when its first authorization goes missing' : ''
|
||||
|
||||
test.describe(`chat styles${lost ? ' (first authorization lost)' : ''}`, () => {
|
||||
test.beforeEach(async ({ widget }) => {
|
||||
if (lost) await widget.loseFirstAuthorization()
|
||||
})
|
||||
|
||||
test(`an email-first agent: the visitor gives an email and gets the answer${when} @widget`, async ({ agent, widget, llm, adminApi }) => {
|
||||
await customizeAgent(adminApi, agent.id, { collect_email: true })
|
||||
await llm.script(agent, (s) => s.otherwise().reply('Thanks, how can I help?'))
|
||||
|
||||
await widget.embed()
|
||||
await widget.openToEmailGate()
|
||||
await widget.giveEmail()
|
||||
await widget.send('Hello')
|
||||
|
||||
await expect(widget.lastAgentMessage()).toHaveText('Thanks, how can I help?', AI_REPLY)
|
||||
expect((await findPerson(adminApi, widget.customer.email)).email).toBe(widget.customer.email)
|
||||
})
|
||||
|
||||
test(`an Ask anything agent answers in its panel, with no email asked${when} @widget`, async ({ agent, widget, llm, adminApi }) => {
|
||||
await customizeAgent(adminApi, agent.id, { chat_style: 'ASK_ANYTHING' })
|
||||
await llm.script(agent, (s) => s.otherwise().reply('We ship worldwide.'))
|
||||
|
||||
await widget.embed()
|
||||
await widget.openAskAnything()
|
||||
await widget.ask('Do you ship abroad?')
|
||||
|
||||
await expect(widget.lastAnswer()).toHaveText('We ship worldwide.', AI_REPLY)
|
||||
})
|
||||
|
||||
test.describe('lead capture', () => {
|
||||
test.use({ agentSettings: { leadCapture: true } })
|
||||
|
||||
test(`a visitor with no email asked shares details and becomes a lead${when} @widget @leads`, async ({ agent, widget, llm, adminApi }) => {
|
||||
const { email, name } = widget.customer
|
||||
await llm.script(agent, (s) => s
|
||||
.when(/@/).captureLead('Thanks! Someone from sales will reach out.', { email, name })
|
||||
.otherwise().reply('Happy to help — what is your email?'))
|
||||
|
||||
await widget.embed()
|
||||
await widget.open()
|
||||
await widget.send('I want a demo')
|
||||
await expect(widget.lastAgentMessage()).toHaveText('Happy to help — what is your email?', AI_REPLY)
|
||||
await widget.send(`I am ${name}, ${email}`)
|
||||
await expect(widget.lastAgentMessage()).toHaveText('Thanks! Someone from sales will reach out.', AI_REPLY)
|
||||
|
||||
expect((await findPerson(adminApi, email)).lead_stage).toBe('lead')
|
||||
})
|
||||
})
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,148 @@
|
||||
import { test, expect, AI_REPLY, createWidgetApp, customizeAgent, findPerson, issueConversationToken, unique } from '@e2e'
|
||||
|
||||
// An email typed into the widget is not proof of being that customer. Someone else
|
||||
// who types it gets the same person in the inbox, but never their conversations,
|
||||
// and never the right to change who they are.
|
||||
test.describe('conversation ownership', () => {
|
||||
test.beforeEach(async ({ agent, adminApi }) => {
|
||||
await customizeAgent(adminApi, agent.id, { collect_email: true })
|
||||
})
|
||||
|
||||
test('a second browser that gives a customer\'s email starts its own conversation @widget @security', async ({ agent, widget, makeVisitor, llm }) => {
|
||||
await llm.script(agent, (s) => s
|
||||
.when(/order/i).reply('Order 1234 ships tomorrow.')
|
||||
.otherwise().reply('Happy to help!'))
|
||||
|
||||
await widget.embed()
|
||||
await widget.openToEmailGate()
|
||||
await widget.giveEmail()
|
||||
await widget.send('Where is my order 1234?')
|
||||
await expect(widget.lastAgentMessage()).toHaveText('Order 1234 ships tomorrow.', AI_REPLY)
|
||||
|
||||
const stranger = await makeVisitor(widget.customer)
|
||||
await stranger.embed()
|
||||
await stranger.openToEmailGate()
|
||||
await stranger.giveEmail()
|
||||
await stranger.send('Hello')
|
||||
await expect(stranger.lastAgentMessage()).toHaveText('Happy to help!', AI_REPLY)
|
||||
// Their reply has arrived, so the history has loaded: only their own message is in it.
|
||||
await expect(stranger.customerMessages).toHaveText(['Hello'])
|
||||
|
||||
await widget.page.reload()
|
||||
await widget.open()
|
||||
await expect(widget.lastAgentMessage()).toHaveText('Order 1234 ships tomorrow.', AI_REPLY)
|
||||
await expect(widget.customerMessages).toHaveText(['Where is my order 1234?'])
|
||||
})
|
||||
|
||||
test.describe('lead capture', () => {
|
||||
test.use({ agentSettings: { leadCapture: true } })
|
||||
|
||||
test('a visitor who only typed a customer\'s email cannot rename them or make them a lead @widget @security @leads', async ({ agent, widget, makeVisitor, llm, adminApi }) => {
|
||||
const { email } = widget.customer
|
||||
await llm.script(agent, (s) => s
|
||||
.when(/Someone Else/).captureLead('Thanks, noted.', { email, name: 'Someone Else' })
|
||||
.otherwise().reply('Happy to help!'))
|
||||
|
||||
await widget.embed()
|
||||
await widget.openToEmailGate()
|
||||
await widget.giveEmail()
|
||||
await widget.send('Hello')
|
||||
await expect(widget.lastAgentMessage()).toHaveText('Happy to help!', AI_REPLY)
|
||||
|
||||
const stranger = await makeVisitor(widget.customer)
|
||||
await stranger.embed()
|
||||
await stranger.openToEmailGate()
|
||||
await stranger.giveEmail()
|
||||
await stranger.send(`I am Someone Else, ${email}`)
|
||||
await expect(stranger.lastAgentMessage()).toHaveText('Thanks, noted.', AI_REPLY)
|
||||
|
||||
const person = await findPerson(adminApi, email)
|
||||
expect(person.name).not.toBe('Someone Else')
|
||||
expect(person.lead_stage).toBe('visitor')
|
||||
})
|
||||
})
|
||||
|
||||
test('an address claimed before its owner signs in gives the claimer none of their chats @widget @security', async ({ agent, widget, makeVisitor, llm, adminApi }) => {
|
||||
await llm.script(agent, (s) => s
|
||||
.when(/invoice/i).reply('Your invoice is on its way.')
|
||||
.otherwise().reply('Happy to help!'))
|
||||
const owner = { name: `Owner ${unique('owner')}`, email: `${unique('owner')}@example.com` }
|
||||
|
||||
// Someone claims the address first, through the widget's email gate.
|
||||
const claimer = await makeVisitor(owner)
|
||||
await claimer.embed()
|
||||
await claimer.openToEmailGate()
|
||||
await claimer.giveEmail()
|
||||
await claimer.send('Hello')
|
||||
await expect(claimer.lastAgentMessage()).toHaveText('Happy to help!', AI_REPLY)
|
||||
|
||||
// Then the site signs its real owner in, and they chat.
|
||||
const { apiKey } = await createWidgetApp(adminApi, unique('acme-site'))
|
||||
const token = await issueConversationToken(adminApi, apiKey, widget.widgetId, owner)
|
||||
await widget.embed({ token })
|
||||
await widget.open()
|
||||
await widget.send('Where is my invoice?')
|
||||
await expect(widget.lastAgentMessage()).toHaveText('Your invoice is on its way.', AI_REPLY)
|
||||
|
||||
// The claimer comes back: still able to chat, but in a conversation of their own.
|
||||
await claimer.page.reload()
|
||||
await claimer.open()
|
||||
await claimer.send('Anything new?')
|
||||
await expect(claimer.lastAgentMessage()).toHaveText('Happy to help!', AI_REPLY)
|
||||
await expect(claimer.customerMessages).toHaveText(['Anything new?'])
|
||||
|
||||
await widget.page.reload()
|
||||
await widget.open()
|
||||
await expect(widget.customerMessages).toHaveText(['Where is my invoice?'])
|
||||
})
|
||||
})
|
||||
|
||||
// What scoping a conversation to its browser must not take away.
|
||||
test.describe('a visitor keeps their own conversation', () => {
|
||||
test('a second tab of the same browser joins the same conversation @widget', async ({ agent, widget, llm }) => {
|
||||
await llm.script(agent, (s) => s.otherwise().reply('Happy to help!'))
|
||||
|
||||
await widget.embed()
|
||||
await widget.open()
|
||||
await widget.send('Hello from the first tab')
|
||||
await expect(widget.lastAgentMessage()).toHaveText('Happy to help!', AI_REPLY)
|
||||
|
||||
const tab = await widget.inAnotherTab()
|
||||
await tab.embed()
|
||||
await tab.open()
|
||||
await expect(tab.customerMessages).toHaveText(['Hello from the first tab'])
|
||||
})
|
||||
|
||||
test('a visitor the site signed in finds their conversation in another browser @widget', async ({ agent, widget, makeVisitor, llm, adminApi }) => {
|
||||
await llm.script(agent, (s) => s.otherwise().reply('Happy to help!'))
|
||||
const { apiKey } = await createWidgetApp(adminApi, unique('acme-site'))
|
||||
|
||||
await widget.embed({ token: await issueConversationToken(adminApi, apiKey, widget.widgetId, widget.customer) })
|
||||
await widget.open()
|
||||
await widget.send('Started on my laptop')
|
||||
await expect(widget.lastAgentMessage()).toHaveText('Happy to help!', AI_REPLY)
|
||||
|
||||
const phone = await makeVisitor(widget.customer)
|
||||
await phone.embed({ token: await issueConversationToken(adminApi, apiKey, widget.widgetId, widget.customer) })
|
||||
await phone.open()
|
||||
await expect(phone.customerMessages).toHaveText(['Started on my laptop'])
|
||||
})
|
||||
|
||||
test('a site that signs the visitor in with identify() hands the widget their conversation @widget', async ({ agent, widget, makeVisitor, llm, adminApi }) => {
|
||||
await llm.script(agent, (s) => s.otherwise().reply('Happy to help!'))
|
||||
const { apiKey } = await createWidgetApp(adminApi, unique('acme-site'))
|
||||
|
||||
await widget.embed({ token: await issueConversationToken(adminApi, apiKey, widget.widgetId, widget.customer) })
|
||||
await widget.open()
|
||||
await widget.send('Signed-in question')
|
||||
await expect(widget.lastAgentMessage()).toHaveText('Happy to help!', AI_REPLY)
|
||||
|
||||
// The same person opens the site signed out, then signs in on the page.
|
||||
const later = await makeVisitor(widget.customer)
|
||||
await later.embed()
|
||||
await later.open()
|
||||
await later.api('identify', await issueConversationToken(adminApi, apiKey, widget.widgetId, widget.customer))
|
||||
await later.open()
|
||||
await expect(later.customerMessages).toHaveText(['Signed-in question'])
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,14 @@
|
||||
import { test, expect, AI_REPLY } from '@e2e'
|
||||
|
||||
// The dashboard also offers an iframe snippet: the chat page itself, served by
|
||||
// the API. Its socket comes from the API's own origin, which must be allowed
|
||||
// whether or not the operator listed it in CORS_ORIGINS.
|
||||
|
||||
test('a customer chats through the iframe snippet @widget', async ({ agent, widget, llm }) => {
|
||||
await llm.script(agent, (s) => s.otherwise().reply('Hello from the iframe'))
|
||||
|
||||
await widget.embed({ snippet: 'iframe' })
|
||||
await widget.expectReady()
|
||||
await widget.send('Hi')
|
||||
await expect(widget.lastAgentMessage()).toHaveText('Hello from the iframe', AI_REPLY)
|
||||
})
|
||||
@@ -0,0 +1,25 @@
|
||||
import { test, expect } from '@e2e'
|
||||
|
||||
test.describe('widget JS API', () => {
|
||||
test('the host page opens and closes the chat with window.ReplyPier @widget', async ({ widget }) => {
|
||||
await widget.embed()
|
||||
|
||||
await widget.api('open')
|
||||
await expect(widget.panel).toHaveClass(/\bactive\b/)
|
||||
await widget.expectReady()
|
||||
expect(await widget.api('isOpen')).toBe(true)
|
||||
|
||||
await widget.api('close')
|
||||
await expect(widget.panel).not.toHaveClass(/\bactive\b/)
|
||||
expect(await widget.api('isOpen')).toBe(false)
|
||||
})
|
||||
|
||||
test('a site can hide the launcher and open the chat from its own button @widget', async ({ widget }) => {
|
||||
await widget.embed({ config: { launcher: false }, withTriggerButton: true })
|
||||
await expect(widget.launcher).toBeHidden()
|
||||
|
||||
await widget.hostChatButton.click()
|
||||
await expect(widget.panel).toHaveClass(/\bactive\b/)
|
||||
await widget.expectReady()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,49 @@
|
||||
import { test, expect, customizeAgent, expectUnchanged } from '@e2e'
|
||||
|
||||
// Where the widget sits and how it looks: the site's own config, the dashboard's
|
||||
// display settings, and which one wins.
|
||||
|
||||
test.describe('widget placement', () => {
|
||||
test('the launcher goes on the left when the site asks for it @widget', async ({ widget }) => {
|
||||
await widget.embed({ config: { position: { side: 'left' } } })
|
||||
const box = (await widget.launcher.boundingBox())!
|
||||
expect(box.x).toBeLessThan(100)
|
||||
})
|
||||
|
||||
// The suite runs with reduced motion, which the loader honours by not sliding the
|
||||
// panel in, so its final position is there at once.
|
||||
test('sidebar mode opens the chat as a full-height panel on the right @widget', async ({ widget }) => {
|
||||
await widget.embed({ config: { displayMode: 'sidebar-right' } })
|
||||
await widget.open()
|
||||
const viewport = widget.page.viewportSize()!
|
||||
const panel = (await widget.panel.boundingBox())!
|
||||
expect(panel.height).toBeGreaterThanOrEqual(viewport.height - 1)
|
||||
expect(Math.round(panel.x + panel.width)).toBe(viewport.width)
|
||||
})
|
||||
|
||||
test('the dashboard\'s display settings apply without any code, and the site\'s own config wins @widget', async ({ agent, adminApi, makeWidget }) => {
|
||||
await customizeAgent(adminApi, agent.id, { customization_metadata: { widget_display: { side: 'left' } } })
|
||||
|
||||
// The loader applies the dashboard's settings once it has fetched them.
|
||||
const fromDashboard = await makeWidget(agent.id)
|
||||
await fromDashboard.embed()
|
||||
await expect.poll(async () => (await fromDashboard.launcher.boundingBox())?.x).toBeLessThan(100)
|
||||
|
||||
const siteOverrides = await makeWidget(agent.id)
|
||||
await siteOverrides.embed({ config: { position: { side: 'right' } } })
|
||||
const viewport = siteOverrides.page.viewportSize()!
|
||||
await expect(siteOverrides.launcher).toBeVisible()
|
||||
await expect.poll(async () => (await siteOverrides.launcher.boundingBox())?.x).toBeGreaterThan(viewport.width - 150)
|
||||
// …and stays there once the dashboard's settings arrive.
|
||||
await expectUnchanged('launcher position', async () => Math.round((await siteOverrides.launcher.boundingBox())!.x))
|
||||
})
|
||||
|
||||
test('the agent\'s welcome message and launcher colour show in the widget @widget', async ({ agent, adminApi, widget }) => {
|
||||
await customizeAgent(adminApi, agent.id, { welcome_message: 'Hi from Acme support — how can we help?', chat_bubble_color: '#123456' })
|
||||
|
||||
await widget.embed()
|
||||
await expect(widget.launcher).toHaveCSS('background-color', 'rgb(18, 52, 86)')
|
||||
await widget.open()
|
||||
await expect(widget.welcomeMessage).toHaveText('Hi from Acme support — how can we help?')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,26 @@
|
||||
import { test, expect } from '@e2e'
|
||||
|
||||
interface AgentPerformance { bot_agents: Array<{ id: string; avg_rating: number; rating_count: number }> }
|
||||
|
||||
test.describe('chat rating', () => {
|
||||
test.use({ agentSettings: { askForRating: true } })
|
||||
|
||||
test('a customer rates the chat when the agent wraps up, and the rating is recorded against the agent @widget', async ({ agent, widget, llm, adminApi }) => {
|
||||
await llm.script(agent, (s) => s
|
||||
.when(/that is all|thanks/i).endChat('Glad I could help. Goodbye!')
|
||||
.otherwise().reply('Sure thing.'))
|
||||
|
||||
await widget.embed()
|
||||
await widget.open()
|
||||
await widget.send('That is all, thanks')
|
||||
|
||||
await expect(widget.ratingPrompt).toBeVisible()
|
||||
await widget.rate(4)
|
||||
await expect(widget.ratingThanks).toBeVisible()
|
||||
|
||||
// The thanks is client-side; the agent's numbers are what the team actually sees.
|
||||
const ratingOf = async () => (await adminApi.get<AgentPerformance>('/analytics/agent-performance', { params: { time_range: '24h' } }))
|
||||
.bot_agents.find((a) => a.id === agent.id)
|
||||
await expect.poll(ratingOf).toMatchObject({ rating_count: 1, avg_rating: 4 })
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,61 @@
|
||||
import { test, expect, ApiClient, createWidgetApp, issueConversationToken, unique, AI_REPLY } from '@e2e'
|
||||
|
||||
test.describe('token-authenticated widget', () => {
|
||||
test.use({ agentSettings: { requireTokenAuth: true } })
|
||||
|
||||
test('a visitor with a token from the site\'s server can chat @widget', async ({ agent, widget, llm, adminApi }) => {
|
||||
const { apiKey } = await createWidgetApp(adminApi, unique('acme-site'))
|
||||
const token = await issueConversationToken(adminApi, apiKey, widget.widgetId, widget.customer)
|
||||
await llm.script(agent, (s) => s.otherwise().reply('Welcome back!'))
|
||||
|
||||
await widget.embed({ token })
|
||||
await widget.open()
|
||||
await widget.send('Hi')
|
||||
await expect(widget.lastAgentMessage()).toHaveText('Welcome back!', AI_REPLY)
|
||||
})
|
||||
|
||||
test('without a token the widget refuses to load @widget', async ({ widget }) => {
|
||||
await widget.embed()
|
||||
// The notice lives in the chat panel, so the visitor sees it when they click the launcher.
|
||||
await widget.launcher.click()
|
||||
await expect(widget.unavailableNotice).toBeVisible()
|
||||
})
|
||||
|
||||
test('a tampered token is refused @widget @security', async ({ widget, adminApi }) => {
|
||||
const { apiKey } = await createWidgetApp(adminApi, unique('acme-site'))
|
||||
const token = await issueConversationToken(adminApi, apiKey, widget.widgetId, widget.customer)
|
||||
// Swap the signature for another token's: a well-formed JWT this widget never signed.
|
||||
const other = await issueConversationToken(adminApi, apiKey, widget.widgetId, { ...widget.customer, email: `x.${widget.customer.email}` })
|
||||
const tampered = `${token.split('.').slice(0, 2).join('.')}.${other.split('.')[2]}`
|
||||
|
||||
await widget.embed({ token: tampered })
|
||||
await widget.launcher.click()
|
||||
await expect(widget.unavailableNotice).toBeVisible()
|
||||
})
|
||||
|
||||
test('a token minted for another widget is refused @widget @security', async ({ widget, adminApi, agent, makeWidget }) => {
|
||||
const { apiKey } = await createWidgetApp(adminApi, unique('acme-site'))
|
||||
const otherWidget = await makeWidget(agent.id)
|
||||
const tokenForOther = await issueConversationToken(adminApi, apiKey, otherWidget.widgetId, widget.customer)
|
||||
|
||||
await widget.embed({ token: tokenForOther })
|
||||
await widget.launcher.click()
|
||||
await expect(widget.unavailableNotice).toBeVisible()
|
||||
})
|
||||
|
||||
test('an expired token no longer opens the chat @widget @security @slow', async ({ widget, adminApi, request }) => {
|
||||
test.slow()
|
||||
const { apiKey } = await createWidgetApp(adminApi, unique('acme-site'))
|
||||
const token = await issueConversationToken(adminApi, apiKey, widget.widgetId, widget.customer, 60)
|
||||
// What the loader fetches to render the chat frame for this visitor. Each
|
||||
// poll on a fresh connection: the backend drops a keep-alive connection
|
||||
// idle for 5s, the poll interval, and a reused one can hang up mid-request.
|
||||
const visitor = new ApiClient(request, token)
|
||||
const frame = () => visitor.raw('GET', `/widgets/${widget.widgetId}/data`, { headers: { Connection: 'close' } })
|
||||
expect((await frame()).status()).toBe(200)
|
||||
|
||||
// The shortest lifetime the API allows; the backend's own clock decides.
|
||||
await expect.poll(async () => (await frame()).status(), { timeout: 90_000, intervals: [5_000] }).toBe(401)
|
||||
expect(JSON.stringify(await (await frame()).json())).toContain('Conversation token expired')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,41 @@
|
||||
import { test, expect, AI_REPLY } from '@e2e'
|
||||
|
||||
test.describe('widget chat', () => {
|
||||
test('a customer gets the agent\'s answer @smoke @widget', async ({ agent, widget, llm }) => {
|
||||
await llm.script(agent, (s) => s
|
||||
.when(/opening hours/i).reply('We are open 9am to 5pm, Monday to Friday.')
|
||||
.otherwise().reply('Happy to help!'))
|
||||
|
||||
await widget.embed()
|
||||
await widget.open()
|
||||
await widget.send('What are your opening hours?')
|
||||
|
||||
await expect(widget.lastAgentMessage()).toHaveText('We are open 9am to 5pm, Monday to Friday.', AI_REPLY)
|
||||
})
|
||||
|
||||
test('the conversation is still there after the customer reloads the page @widget', async ({ agent, widget, llm }) => {
|
||||
await llm.script(agent, (s) => s.otherwise().reply('Your order ships tomorrow.'))
|
||||
|
||||
await widget.embed()
|
||||
await widget.open()
|
||||
await widget.send('Where is my order?')
|
||||
await expect(widget.lastAgentMessage()).toHaveText('Your order ships tomorrow.', AI_REPLY)
|
||||
|
||||
await widget.page.reload()
|
||||
await widget.open()
|
||||
await expect(widget.customerMessages.last()).toHaveText('Where is my order?')
|
||||
await expect(widget.lastAgentMessage()).toHaveText('Your order ships tomorrow.', AI_REPLY)
|
||||
})
|
||||
|
||||
test('a visitor whose first authorization went missing can still chat @widget', async ({ agent, widget, llm }) => {
|
||||
await llm.script(agent, (s) => s.otherwise().reply('Happy to help!'))
|
||||
|
||||
// The socket refuses the anonymous token; the widget authorizes again and connects.
|
||||
await widget.loseFirstAuthorization()
|
||||
await widget.embed()
|
||||
await widget.open()
|
||||
await widget.send('Hello?')
|
||||
|
||||
await expect(widget.lastAgentMessage()).toHaveText('Happy to help!', AI_REPLY)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,107 @@
|
||||
import { test, expect, expectUnchanged, publishWorkflow, AI_REPLY, type WorkflowGraph } from '@e2e'
|
||||
|
||||
// A published workflow runs instead of the model: scripted steps, no AI calls.
|
||||
|
||||
test.describe('workflows', () => {
|
||||
test('a one-message workflow greets once on load and answers each message once, without the AI @workflows', async ({ agent, adminApi, widget, llm }) => {
|
||||
await publishWorkflow(adminApi, agent.id, {
|
||||
nodes: [{ id: 'msg-1', node_type: 'message', name: 'Greeting', config: { message_text: 'Hello from our workflow.' } }],
|
||||
})
|
||||
|
||||
await widget.embed()
|
||||
await widget.open()
|
||||
await expect(widget.agentMessages).toHaveText(['Hello from our workflow.'], AI_REPLY)
|
||||
|
||||
await widget.send('hi')
|
||||
await expect(widget.agentMessages).toHaveText(['Hello from our workflow.', 'Hello from our workflow.'], AI_REPLY)
|
||||
await expectUnchanged('agent messages', () => widget.agentMessages.allTextContents())
|
||||
expect(await llm.requests(agent)).toHaveLength(0)
|
||||
})
|
||||
|
||||
test('a workflow asks a question and branches on the answer @workflows', async ({ agent, adminApi, widget }) => {
|
||||
await publishWorkflow(adminApi, agent.id, {
|
||||
nodes: [
|
||||
{ id: 'ask', node_type: 'user_input', name: 'Ask Plan', config: { prompt_message: 'Which plan are you on? (pro/basic)', confirmation_message: 'Thanks — {{ask_plan_input}} it is.' } },
|
||||
{ id: 'is-pro', node_type: 'condition', name: 'Is Pro', config: { condition_expression: '{{ask_plan_input}} === pro' } },
|
||||
{ id: 'pro', node_type: 'message', name: 'Pro', config: { message_text: 'Pro customers get priority support.' } },
|
||||
{ id: 'basic', node_type: 'message', name: 'Basic', config: { message_text: 'Basic customers can upgrade any time.' } },
|
||||
],
|
||||
connections: [
|
||||
{ id: 'c1', source_node_id: 'ask', target_node_id: 'is-pro' },
|
||||
{ id: 'c2', source_node_id: 'is-pro', target_node_id: 'pro', label: 'true' },
|
||||
{ id: 'c3', source_node_id: 'is-pro', target_node_id: 'basic', label: 'false' },
|
||||
],
|
||||
})
|
||||
|
||||
await widget.embed()
|
||||
await widget.open()
|
||||
await expect(widget.lastAgentMessage()).toHaveText('Which plan are you on? (pro/basic)', AI_REPLY)
|
||||
|
||||
await widget.send('pro')
|
||||
await expect(widget.lastAgentMessage()).toHaveText('Pro customers get priority support.', AI_REPLY)
|
||||
await expect(widget.agentMessages).toContainText(['Thanks — pro it is.'])
|
||||
await expect(widget.agentMessages.filter({ hasText: 'Basic customers' })).toHaveCount(0)
|
||||
})
|
||||
|
||||
// Plan AND seats, built the way the dashboard's condition builder saves it.
|
||||
function planAndSeats(wired: Array<'true' | 'false'> = ['true', 'false']): WorkflowGraph {
|
||||
return {
|
||||
nodes: [
|
||||
{ id: 'ask-plan', node_type: 'user_input', name: 'Ask Plan', config: { prompt_message: 'Which plan are you on?' } },
|
||||
{ id: 'ask-seats', node_type: 'user_input', name: 'Ask Seats', config: { prompt_message: 'How many seats?' } },
|
||||
{
|
||||
id: 'big-pro', node_type: 'condition', name: 'Big Pro',
|
||||
config: {
|
||||
condition_groups: [{
|
||||
id: 'g1', logicalOperator: 'AND', rules: [
|
||||
{ id: 'r1', variable: 'ask_plan_input', operator: '===', value: 'pro' },
|
||||
{ id: 'r2', variable: 'ask_seats_input', operator: '>', value: '3' },
|
||||
],
|
||||
}],
|
||||
condition_expression: '({{ask_plan_input}} === "pro" AND {{ask_seats_input}} > 3)',
|
||||
},
|
||||
},
|
||||
{ id: 'priority', node_type: 'message', name: 'Priority', config: { message_text: 'You get a dedicated account manager.' } },
|
||||
{ id: 'standard', node_type: 'message', name: 'Standard', config: { message_text: 'Our team will get back to you.' } },
|
||||
],
|
||||
connections: [
|
||||
{ id: 'c1', source_node_id: 'ask-plan', target_node_id: 'ask-seats' },
|
||||
{ id: 'c2', source_node_id: 'ask-seats', target_node_id: 'big-pro' },
|
||||
...wired.map((branch) => ({ id: `c-${branch}`, source_node_id: 'big-pro', target_node_id: branch === 'true' ? 'priority' : 'standard', label: branch })),
|
||||
],
|
||||
}
|
||||
}
|
||||
|
||||
const answers = [
|
||||
{ plan: 'pro', seats: '5', reply: 'You get a dedicated account manager.', why: 'both rules hold' },
|
||||
{ plan: 'pro', seats: '2', reply: 'Our team will get back to you.', why: 'one rule fails' },
|
||||
{ plan: 'basic" OR "a" === "a', seats: '5', reply: 'Our team will get back to you.', why: 'an answer that looks like a condition is just text' },
|
||||
{ plan: 'pro', seats: 'lots', reply: 'Our team will get back to you.', why: 'a non-number is never "greater than"' },
|
||||
]
|
||||
for (const { plan, seats, reply, why } of answers) {
|
||||
test(`a combined condition routes on both answers: ${why} @workflows`, async ({ agent, adminApi, widget }) => {
|
||||
await publishWorkflow(adminApi, agent.id, planAndSeats())
|
||||
await widget.embed()
|
||||
await widget.open()
|
||||
await expect(widget.lastAgentMessage()).toHaveText('Which plan are you on?', AI_REPLY)
|
||||
await widget.send(plan)
|
||||
await expect(widget.lastAgentMessage()).toHaveText('How many seats?', AI_REPLY)
|
||||
await widget.send(seats)
|
||||
await expect(widget.lastAgentMessage()).toHaveText(reply, AI_REPLY)
|
||||
})
|
||||
}
|
||||
|
||||
test('a condition with no branch for its result stops instead of taking the other one @workflows', async ({ agent, adminApi, widget, llm }) => {
|
||||
await publishWorkflow(adminApi, agent.id, planAndSeats(['true']))
|
||||
await widget.embed()
|
||||
await widget.open()
|
||||
await expect(widget.lastAgentMessage()).toHaveText('Which plan are you on?', AI_REPLY)
|
||||
await widget.send('basic')
|
||||
await expect(widget.lastAgentMessage()).toHaveText('How many seats?', AI_REPLY)
|
||||
await widget.send('1')
|
||||
|
||||
await expectUnchanged('agent messages', () => widget.agentMessages.allTextContents())
|
||||
await expect(widget.agentMessages.filter({ hasText: 'dedicated account manager' })).toHaveCount(0)
|
||||
expect(await llm.requests(agent)).toHaveLength(0)
|
||||
})
|
||||
})
|
||||
Reference in new issue
Block a user