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

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

No files matched your search

+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 ReplyPier on connect @channels', async ({ agent, llm, adminApi, request }) => {
await llm.script(agent, (s) => s
.when(/opening hours/i).reply('We are open 9am to 5pm, Monday to Friday.')
.otherwise().reply('Happy to help!'))
const app = ownMetaApp()
const number = await connectWhatsAppNumber(adminApi, digits(15), app)
// Meta took the per-number callback, after checking it answers: nothing to paste
expect(number.account.webhook_auto_configured).toBe(true)
await routeToAgent(adminApi, number.account.id, agent.id)
const externals = new FakeExternals(request)
const chat = new WhatsAppChat(request, externals, number, 'Tess', () => whereMetaSends(externals, number.phoneNumberId, app))
await chat.send('What are your opening hours?')
await expect.poll(() => chat.replies(), AI_REPLY).toEqual(['We are open 9am to 5pm, Monday to Friday.'])
})
test('a Messenger Page receives messages once its callback URL and verify token are pasted into the app @channels', async ({ agent, llm, adminApi, request }) => {
await llm.script(agent, (s) => s.otherwise().reply('Hi! How can we help?'))
const app = ownMetaApp()
const pageId = digits(15)
const token = app.token(pageId)
const account = await connectMessenger(adminApi, pageId, token, app.secret)
await routeToAgent(adminApi, account.id, agent.id)
const url = account.webhook_url ?? ''
const verifyToken = account.webhook_verify_token ?? ''
// The customer pastes both into their app; Meta checks the address before saving
expect(await metaVerifiesCallback(request, url, verifyToken)).toBe(true)
expect(await metaVerifiesCallback(request, url, 'a-guessed-token')).toBe(false)
const chat = pageChat('Messenger', request, new FakeExternals(request), 'page', pageId, token, 'Remi', pastedCallback(url, app))
await chat.send('Hello there')
await expect.poll(() => chat.replies(), AI_REPLY).toEqual(['Hi! How can we help?'])
})
test('a wrong app secret is refused when connecting @channels', async ({ adminApi }) => {
const app = ownMetaApp()
await expect(connectWhatsAppNumber(adminApi, digits(15), { ...app, secret: 'not-the-app-secret' }))
.rejects.toThrow(/400: .*That app secret is not the one for the Meta app this token comes from/)
})
test('its address takes only its own app\'s accounts, never another app\'s number @channels @security', async ({ agent, llm, adminApi, request }) => {
await llm.script(agent, (s) => s.otherwise().reply('This should never be sent.'))
const mine = ownMetaApp()
const ourNumber = await connectWhatsAppNumber(adminApi, digits(15), mine)
const theirNumber = await connectWhatsAppNumber(adminApi, digits(15), ownMetaApp())
await routeToAgent(adminApi, theirNumber.account.id, agent.id)
const externals = new FakeExternals(request)
// Signed with our app's secret and sent to our address, but naming their number
const forged = new WhatsAppChat(request, externals, theirNumber, 'Mallory', () => whereMetaSends(externals, ourNumber.phoneNumberId, mine))
await forged.send('Hi, I am not who I say I am')
await expectUnchanged('replies on the other app\'s number', () => forged.replies())
})
test('without the app secret a Page has no address of its own @channels', async ({ adminApi }) => {
const pageId = digits(15)
const account = await connectMessenger(adminApi, pageId, ownMetaApp().token(pageId))
expect(account.webhook_url).toBeFalsy()
expect(account.webhook_verify_token).toBeFalsy()
})
})
+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)
})