docs: 交付文档、维护指引与端到端回归
补齐 README、开发计划、维护交接与 E2E 用例,执行最终验收。
This commit is contained in:
1 parent
18732801b5
commit
6cd904d460
216 files changed
+37038
No files matched your search
@@ -0,0 +1,274 @@
|
||||
import { test as base, expect, type APIRequestContext, type Browser, type BrowserContext, type BrowserContextOptions, type Page, type TestInfo } from '@playwright/test'
|
||||
import { ApiClient } from '../api/client'
|
||||
import { signedInClient } from '../api/auth'
|
||||
import { createAgent, enableLeadCapture } from '../api/agents'
|
||||
import { createGroup, createUser } from '../api/team'
|
||||
import { createWidget } from '../api/widgets'
|
||||
import { ChatWidget } from '../components/chat-widget'
|
||||
import { Sidebar } from '../components/sidebar'
|
||||
import { FakeExternals } from '../fakes/externals'
|
||||
import { FakeLlm, scenarioMarker, type ScriptedAgent } from '../fakes/llm'
|
||||
import { attachDiagnostics, startDiagnostics } from './diagnostics'
|
||||
import { readOrgState, type OrgState } from '../org-state'
|
||||
import { AgentsPage } from '../pages/agents.page'
|
||||
import { AiConfigPage } from '../pages/ai-config.page'
|
||||
import { Dashboard } from '../pages/dashboard.page'
|
||||
import { HumanAgentsPage } from '../pages/human-agents.page'
|
||||
import { InboxPage } from '../pages/inbox.page'
|
||||
import { LoginPage } from '../pages/login.page'
|
||||
import { PeoplePage } from '../pages/people.page'
|
||||
import { TicketsPage } from '../pages/tickets.page'
|
||||
import { unique } from '../support/ids'
|
||||
import { blockThirdParty } from './network'
|
||||
|
||||
export interface AgentSettings {
|
||||
/** Hand chats to the org's support group (Sam Agent) when the model asks to. */
|
||||
transferToHuman: boolean
|
||||
/** The widget asks for a star rating when the agent ends the chat. */
|
||||
askForRating: boolean
|
||||
/** The widget only loads with a conversation token (see api/widget-apps.ts). */
|
||||
requireTokenAuth: boolean
|
||||
/** Lead capture on, without the consent gate. */
|
||||
leadCapture: boolean
|
||||
/** Quick replies become tappable buttons on messaging channels. */
|
||||
channelQuickReplies: boolean
|
||||
/** Visitors may attach files once a person has joined the chat. */
|
||||
allowAttachments: boolean
|
||||
}
|
||||
|
||||
const DEFAULT_AGENT_SETTINGS: AgentSettings = {
|
||||
transferToHuman: false,
|
||||
askForRating: false,
|
||||
requireTokenAuth: false,
|
||||
leadCapture: false,
|
||||
channelQuickReplies: false,
|
||||
allowAttachments: false,
|
||||
}
|
||||
|
||||
export type TestAgent = ScriptedAgent & { id: string }
|
||||
|
||||
export interface MemberOptions {
|
||||
role?: string
|
||||
groupIds?: string[]
|
||||
/** "Assigned only" when false: the user form's AI-queue toggle. */
|
||||
seeAllAiChats?: boolean
|
||||
}
|
||||
|
||||
/** A team member a test created, with a seeding client signed in as them. */
|
||||
export interface Member {
|
||||
id: string
|
||||
name: string
|
||||
email: string
|
||||
password: string
|
||||
api: ApiClient
|
||||
}
|
||||
|
||||
/** The human team a handover test's chats go to: one online Agent-role member in their own group. */
|
||||
export interface HandoverTeam {
|
||||
groupId: string
|
||||
member: { id: string; name: string; email: string; password: string }
|
||||
}
|
||||
|
||||
interface Options {
|
||||
/** Override per describe: test.use({ agentSettings: { transferToHuman: true } }). */
|
||||
agentSettings: Partial<AgentSettings>
|
||||
}
|
||||
|
||||
interface WorkerFixtures {
|
||||
org: OrgState
|
||||
/** Seeding client signed in as the org admin. */
|
||||
adminApi: ApiClient
|
||||
}
|
||||
|
||||
interface TestFixtures {
|
||||
/** Automatic: on failure, attaches the backend log, model requests and third-party calls. */
|
||||
diagnostics: void
|
||||
llm: FakeLlm
|
||||
/** A fresh AI agent per test, scripted through `llm`. */
|
||||
agent: TestAgent
|
||||
/** Another AI agent — e.g. one per group in a routing test. `groupIds` are its handover groups. */
|
||||
makeAgent: (settings?: Partial<AgentSettings>, groupIds?: string[]) => Promise<TestAgent>
|
||||
/** The customer-facing widget for `agent`, on `customerPage`. */
|
||||
widget: ChatWidget
|
||||
/** A visitor's browser: its own context, never signed in to the dashboard. */
|
||||
customerPage: Page
|
||||
/** A widget for any agent id — e.g. one a test created through the UI. */
|
||||
makeWidget: (agentId: string) => Promise<ChatWidget>
|
||||
/**
|
||||
* Another visitor on `widget`'s site, in a browser of their own. Pass `customer`
|
||||
* to have them claim someone's name and email.
|
||||
*/
|
||||
makeVisitor: (customer?: { name: string; email: string }) => Promise<ChatWidget>
|
||||
/**
|
||||
* The team this test's agent hands over to (with transferToHuman): a member and
|
||||
* group of its own, so no other test's chats crowd its inbox or change its member.
|
||||
* Created on first call — tests that never hand over never pay for one.
|
||||
*/
|
||||
handoverTeam: () => Promise<HandoverTeam>
|
||||
/** The handover team member's dashboard inbox, in a separate signed-in browser. */
|
||||
inbox: InboxPage
|
||||
/**
|
||||
* A new user in the org (Agent role unless told otherwise), signed in through
|
||||
* the API on a request context of their own — the backend prefers a cookie over
|
||||
* a Bearer token, so two people must never share one. Signing in marks them online.
|
||||
*/
|
||||
makeMember: (options?: MemberOptions) => Promise<Member>
|
||||
/** A second, signed-out browser — for signing in as someone a test just created. */
|
||||
anonymousPage: Page
|
||||
loginPage: LoginPage
|
||||
dashboard: Dashboard
|
||||
sidebar: Sidebar
|
||||
aiConfigPage: AiConfigPage
|
||||
agentsPage: AgentsPage
|
||||
ticketsPage: TicketsPage
|
||||
peoplePage: PeoplePage
|
||||
humanAgentsPage: HumanAgentsPage
|
||||
}
|
||||
|
||||
/**
|
||||
* Every extra browser a fixture opens gets the running project's browser settings
|
||||
* (viewport, device, motion, timezone, …) — never its storageState, which is the admin's.
|
||||
*/
|
||||
async function newContext(browser: Browser, testInfo: TestInfo, options: BrowserContextOptions = {}) {
|
||||
const { contextOptions, baseURL, serviceWorkers, timezoneId, locale, colorScheme, viewport, userAgent,
|
||||
deviceScaleFactor, isMobile, hasTouch } = testInfo.project.use
|
||||
const context = await browser.newContext({
|
||||
...contextOptions,
|
||||
baseURL, serviceWorkers, timezoneId, locale, colorScheme, viewport, userAgent,
|
||||
deviceScaleFactor, isMobile, hasTouch,
|
||||
...options,
|
||||
})
|
||||
await blockThirdParty(context)
|
||||
return context
|
||||
}
|
||||
|
||||
export const test = base.extend<TestFixtures & Options, WorkerFixtures>({
|
||||
agentSettings: [{}, { option: true }],
|
||||
|
||||
org: [async ({}, use) => use(readOrgState()), { scope: 'worker', box: true }],
|
||||
|
||||
adminApi: [async ({ playwright, org }, use) => {
|
||||
const request = await playwright.request.newContext()
|
||||
const { api } = await signedInClient(new ApiClient(request), org.admin.email, org.admin.password)
|
||||
await use(api)
|
||||
await request.dispose()
|
||||
}, { scope: 'worker', box: true }],
|
||||
|
||||
context: async ({ context }, use) => {
|
||||
await blockThirdParty(context)
|
||||
await use(context)
|
||||
},
|
||||
|
||||
llm: [async ({ request }, use) => use(new FakeLlm(request)), { box: true }],
|
||||
|
||||
diagnostics: [async ({ llm, request }, use, testInfo) => {
|
||||
const start = startDiagnostics()
|
||||
await use()
|
||||
await attachDiagnostics(testInfo, start, llm, new FakeExternals(request))
|
||||
}, { auto: true, box: true }],
|
||||
|
||||
makeMember: [async ({ adminApi, playwright }, use) => {
|
||||
const contexts: APIRequestContext[] = []
|
||||
await use(async ({ role = 'Agent', groupIds = [], seeAllAiChats } = {}) => {
|
||||
const id = unique('member')
|
||||
const person = { name: `Member ${id}`, email: `${id}@e2e.example.com`, password: 'E2e-Member-Passw0rd!' }
|
||||
const user = await createUser(adminApi, { ...person, role, seeAllAiChats })
|
||||
for (const groupId of groupIds) await adminApi.post(`/groups/${groupId}/users/${user.id}`)
|
||||
const context = await playwright.request.newContext()
|
||||
contexts.push(context)
|
||||
const { api } = await signedInClient(new ApiClient(context), person.email, person.password)
|
||||
return { ...person, id: user.id, api }
|
||||
})
|
||||
await Promise.all(contexts.map((c) => c.dispose()))
|
||||
}, { box: true }],
|
||||
|
||||
handoverTeam: [async ({ adminApi }, use) => {
|
||||
let team: Promise<HandoverTeam> | undefined
|
||||
await use(() => (team ??= (async () => {
|
||||
const id = unique('teammate')
|
||||
const member = { name: `Teammate ${id}`, email: `${id}@e2e.example.com`, password: 'E2e-Agent-Passw0rd!' }
|
||||
const user = await createUser(adminApi, { ...member, role: 'Agent' })
|
||||
const groupId = await createGroup(adminApi, `Team ${id}`, [user])
|
||||
return { groupId, member: { ...member, id: user.id } }
|
||||
})()))
|
||||
}, { box: true }],
|
||||
|
||||
makeAgent: [async ({ adminApi }, use) => {
|
||||
await use(async (agentSettings = {}, groupIds = []) => {
|
||||
const settings = { ...DEFAULT_AGENT_SETTINGS, ...agentSettings }
|
||||
const scenarioId = unique('scn')
|
||||
const created = await createAgent(adminApi, {
|
||||
name: `E2E Agent ${scenarioId}`,
|
||||
instructions: [`You are a helpful support agent. ${scenarioMarker(scenarioId)}`],
|
||||
transferToHuman: settings.transferToHuman,
|
||||
groupIds,
|
||||
askForRating: settings.askForRating,
|
||||
requireTokenAuth: settings.requireTokenAuth,
|
||||
channelQuickReplies: settings.channelQuickReplies,
|
||||
allowAttachments: settings.allowAttachments,
|
||||
})
|
||||
if (settings.leadCapture) await enableLeadCapture(adminApi, created.id)
|
||||
return { ...created, scenarioId }
|
||||
})
|
||||
}, { box: true }],
|
||||
|
||||
agent: [async ({ makeAgent, agentSettings, handoverTeam }, use) => {
|
||||
const groupIds = agentSettings.transferToHuman ? [(await handoverTeam()).groupId] : []
|
||||
await use(await makeAgent(agentSettings, groupIds))
|
||||
}, { box: true }],
|
||||
|
||||
customerPage: [async ({ browser }, use, testInfo) => {
|
||||
const context = await newContext(browser, testInfo)
|
||||
await use(await context.newPage())
|
||||
await context.close()
|
||||
}, { box: true }],
|
||||
|
||||
makeWidget: [async ({ adminApi, customerPage }, use) => {
|
||||
await use(async (agentId) => {
|
||||
const widget = await createWidget(adminApi, agentId, unique('widget'))
|
||||
return new ChatWidget(customerPage, widget.id)
|
||||
})
|
||||
}, { box: true }],
|
||||
|
||||
widget: [async ({ agent, makeWidget }, use) => use(await makeWidget(agent.id)), { box: true }],
|
||||
|
||||
makeVisitor: [async ({ browser, widget }, use, testInfo) => {
|
||||
const contexts: BrowserContext[] = []
|
||||
await use(async (customer) => {
|
||||
const context = await newContext(browser, testInfo)
|
||||
contexts.push(context)
|
||||
return new ChatWidget(await context.newPage(), widget.widgetId, customer)
|
||||
})
|
||||
await Promise.all(contexts.map((context) => context.close()))
|
||||
}, { box: true }],
|
||||
|
||||
inbox: [async ({ browser, handoverTeam }, use, testInfo) => {
|
||||
const { member } = await handoverTeam()
|
||||
const context = await newContext(browser, testInfo)
|
||||
// Signing in through the form is also what marks the member online — handover
|
||||
// only routes to a group with someone online.
|
||||
const loginPage = new LoginPage(await context.newPage())
|
||||
await loginPage.goto()
|
||||
await loginPage.signIn(member.email, member.password)
|
||||
await expect(loginPage.page).not.toHaveURL(/\/login/)
|
||||
await use(new InboxPage(loginPage.page))
|
||||
await context.close()
|
||||
}, { box: true }],
|
||||
|
||||
anonymousPage: [async ({ browser }, use, testInfo) => {
|
||||
const context = await newContext(browser, testInfo)
|
||||
await use(await context.newPage())
|
||||
await context.close()
|
||||
}, { box: true }],
|
||||
|
||||
loginPage: async ({ page }, use) => use(new LoginPage(page)),
|
||||
dashboard: async ({ page }, use) => use(new Dashboard(page)),
|
||||
sidebar: async ({ page }, use) => use(new Sidebar(page)),
|
||||
aiConfigPage: async ({ page }, use) => use(new AiConfigPage(page)),
|
||||
agentsPage: async ({ page }, use) => use(new AgentsPage(page)),
|
||||
ticketsPage: async ({ page }, use) => use(new TicketsPage(page)),
|
||||
peoplePage: async ({ page }, use) => use(new PeoplePage(page)),
|
||||
humanAgentsPage: async ({ page }, use) => use(new HumanAgentsPage(page)),
|
||||
})
|
||||
|
||||
export { expect }
|
||||
Reference in new issue
Block a user