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

+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.ReplyPier @widget', async ({ widget }) => {
await widget.embed()
await widget.api('open')
await expect(widget.panel).toHaveClass(/\bactive\b/)
await widget.expectReady()
expect(await widget.api('isOpen')).toBe(true)
await widget.api('close')
await expect(widget.panel).not.toHaveClass(/\bactive\b/)
expect(await widget.api('isOpen')).toBe(false)
})
test('a site can hide the launcher and open the chat from its own button @widget', async ({ widget }) => {
await widget.embed({ config: { launcher: false }, withTriggerButton: true })
await expect(widget.launcher).toBeHidden()
await widget.hostChatButton.click()
await expect(widget.panel).toHaveClass(/\bactive\b/)
await widget.expectReady()
})
})
+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)
})
})