docs: 交付文档、维护指引与端到端回归

补齐 README、开发计划、维护交接与 E2E 用例,执行最终验收。
This commit is contained in:
WangShen007 committed 2026-10-21 21:40:00 +08:00
1 parent 5be5ca3376
commit 308332eb94
216 files changed
+37038

No files matched your search

+45
View File
@@ -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 cm-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('cm-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}`)
})
}
})
+25
View File
@@ -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.')
})
})
+25
View File
@@ -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([])
})
})
+38
View File
@@ -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()
})
})
+88
View File
@@ -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)
})
})
+72
View File
@@ -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?')
})
})
+38
View File
@@ -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)
})
+100
View File
@@ -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)
})
})
+37
View File
@@ -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()
})
})
+80
View File
@@ -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 ChatterMate 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()
})
})
+19
View File
@@ -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())
})
}
+21
View File
@@ -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)
})
}
})
+23
View File
@@ -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)
})
+63
View File
@@ -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' } }] })
})
})
+57
View File
@@ -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} — ChatterMate 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 ChatterMate') })
})
})
@@ -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')
})
})
+30
View File
@@ -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)
})
})
+30
View File
@@ -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?')
})
})
+20
View File
@@ -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)
})
+60
View File
@@ -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)
})
})
+24
View File
@@ -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)
})
+33
View File
@@ -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')
})
})
+58
View File
@@ -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)
})
})
+72
View File
@@ -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') })
})
})
+40
View File
@@ -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 (chattermate.min.js)', url: `${env.frontendUrl}/webclient/chattermate.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')
})
})
+38
View File
@@ -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')
})
})
+47
View File
@@ -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)
})
})
+47
View File
@@ -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,
})
})
+76
View File
@@ -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())
})
})
+31
View File
@@ -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()
}
})
})
+83
View File
@@ -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)
})
})
+25
View File
@@ -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`)
})
}
})
+49
View File
@@ -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)
})
})
+58
View File
@@ -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'])
})
})
+14
View File
@@ -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)
})
+25
View File
@@ -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.ChatterMate @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()
})
})
+49
View File
@@ -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?')
})
})
+26
View File
@@ -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 })
})
})
+61
View File
@@ -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')
})
})
+41
View File
@@ -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)
})
})
+107
View File
@@ -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)
})
})