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

+250
View File
@@ -0,0 +1,250 @@
import { expect, type FrameLocator, type Locator, type Page } from '@playwright/test'
import { env } from '../env'
import { unique } from '../support/ids'
import { step } from '../support/step'
export interface EmbedOptions {
/** window.replypierConfig — launcher, displayMode, position, trigger, … */
config?: Record<string, unknown>
/** window.replypierToken, for agents that require token auth. */
token?: string
/** Put a `[data-replypier-open]` button on the page. */
withTriggerButton?: boolean
/** The dashboard's iframe snippet instead of the script: the chat is already open. */
snippet?: 'script' | 'iframe'
}
type JsApiMethod = 'open' | 'close' | 'toggle' | 'isOpen' | 'showLauncher' | 'hideLauncher' | 'identify'
/**
* The customer's view: a pretend website with the widget embedded exactly the way the
* dashboard's install snippet does it, and the chat inside the widget's iframe.
*
* Why CSS here: the loader's launcher (#replypier-button) and the widget's message
* bubbles have no accessible role or name yet. Keeping those selectors in this one
* file means adding aria-labels later is a one-file change.
*/
export class ChatWidget {
readonly page: Page
readonly widgetId: string
/** Who this visitor says they are when the widget asks (contact form, lead details). */
readonly customer: { name: string; email: string }
private readonly frame: FrameLocator
/** `customer` defaults to a new, unique visitor. */
constructor(page: Page, widgetId: string, customer?: { name: string; email: string }) {
this.page = page
this.widgetId = widgetId
const id = unique('customer')
this.customer = customer ?? { name: `Customer ${id}`, email: `${id}@example.com` }
this.frame = page.frameLocator('iframe.replypier-iframe')
}
/**
* The first authorization answer that would bind the visitor to a customer
* arrives without its token (a browser served an earlier stored answer, as one
* did in production), so the widget dials the socket with the page's anonymous
* token. Call before embed().
*/
async loseFirstAuthorization(): Promise<void> {
let lost = false
await this.page.route(new RegExp(`/api/v1/widgets/${this.widgetId}(\\?|$)`), async (route) => {
if (lost || route.request().method() !== 'GET') return route.fallback()
const response = await route.fetch()
const answer = await response.json()
if (answer.token) {
lost = true
delete answer.token
}
await route.fulfill({ response, json: answer })
})
}
/** Opens the pretend customer site (services/customer-site) with this widget embedded. */
async embed(options: EmbedOptions = {}): Promise<void> {
await step('Customer opens a site with the widget', async () => {
const params = new URLSearchParams({ widget: this.widgetId })
if (options.config) params.set('config', JSON.stringify(options.config))
if (options.token) params.set('token', options.token)
if (options.withTriggerButton) params.set('trigger', '1')
if (options.snippet) params.set('snippet', options.snippet)
await this.page.goto(`${env.customerSiteUrl}/?${params}`)
})
}
async open(): Promise<void> {
await step('Customer opens the chat', async () => {
await this.launcher.click()
await this.expectReady()
})
}
/** Opens the chat of an agent that asks for an email before the conversation. */
async openToEmailGate(): Promise<void> {
await step('Customer opens the chat and is asked for an email', async () => {
await this.launcher.click()
await expect(this.emailGate).toBeVisible()
})
}
/** Answers the email gate with this visitor's email; the chat is then ready. */
async giveEmail(): Promise<void> {
await step(`Customer gives ${this.customer.email}`, async () => {
await this.emailGate.fill(this.customer.email)
await this.frame.getByRole('button', { name: 'Continue to chat' }).click()
await this.expectReady()
})
}
/** Opens an "Ask anything" agent: its answer panel, not the chat window. */
async openAskAnything(): Promise<void> {
await step('Customer opens the Ask anything panel', async () => {
await this.launcher.click()
await expect(this.askBox).toBeEditable()
})
}
async ask(text: string): Promise<void> {
await step(`Customer asks "${text}"`, async () => {
await this.askBox.fill(text)
await this.askBox.press('Enter')
})
}
/** The chat is connected and accepting messages ("Connecting..." until the socket is up). */
async expectReady(): Promise<void> {
await expect(this.messageInput).toBeEditable()
}
async send(text: string): Promise<void> {
await step(`Customer sends "${text}"`, async () => {
await this.messageInput.fill(text)
await this.messageInput.press('Enter')
await expect(this.customerMessages.last()).toHaveText(text)
})
}
/** Attaches a file and sends it with `text`, as a visitor picking it from their device. */
async sendFile(file: { name: string; mimeType: string; content: string }, text: string): Promise<void> {
await step(`Customer sends ${file.name}`, async () => {
await this.frame.locator('input[type="file"]').setInputFiles({ name: file.name, mimeType: file.mimeType, buffer: Buffer.from(file.content) })
await this.messageInput.fill(text)
await this.messageInput.press('Enter')
await expect(this.customerMessages.last()).toContainText(text)
})
}
/** Fills the "Before we connect you to a teammate" form the widget shows on handover. */
async shareContactDetails(): Promise<void> {
await step('Customer shares contact details', async () => {
const form = this.contactForm
await expect(form).toBeVisible()
await form.getByLabel('Email').fill(this.customer.email)
await form.getByLabel('Name').fill(this.customer.name)
await form.getByRole('button', { name: 'Submit' }).click()
await expect(form.getByRole('button', { name: 'Submit' })).toBeHidden()
})
}
/** Rates the finished chat; `stars` is 1–5. */
async rate(stars: number, feedback?: string): Promise<void> {
await step(`Customer rates the chat ${stars}/5`, async () => {
// The star buttons are plain "★" glyphs with no accessible name.
await this.frame.locator('.star-button').nth(stars - 1).click()
if (feedback) await this.frame.getByPlaceholder('Please share your feedback (optional)').fill(feedback)
await this.frame.getByRole('button', { name: 'Submit Rating' }).click()
})
}
/** The same visitor in another tab of the same browser: same site storage, same token. */
async inAnotherTab(): Promise<ChatWidget> {
return new ChatWidget(await this.page.context().newPage(), this.widgetId, this.customer)
}
/** Calls the public `window.ReplyPier` API the host page uses. */
async api(method: JsApiMethod, ...args: unknown[]): Promise<unknown> {
await this.page.waitForFunction(() => 'ReplyPier' in window)
return this.page.evaluate(
({ method, args }) => (window as unknown as { ReplyPier: Record<string, (...a: unknown[]) => unknown> }).ReplyPier[method](...args),
{ method, args },
)
}
/** The page's own "Chat with us" button (embed with withTriggerButton). */
get hostChatButton(): Locator {
return this.page.getByRole('button', { name: 'Chat with us' })
}
get launcher(): Locator {
return this.page.locator('#replypier-button')
}
/** The chat panel; it carries `.active` while open. */
get panel(): Locator {
return this.page.locator('#replypier-container')
}
/** Shown by the loader instead of a chat when the widget refuses to load (e.g. no token). */
get unavailableNotice(): Locator {
return this.page.getByText('Chat Unavailable')
}
/** The email field an email-first agent shows before the chat. */
get emailGate(): Locator {
return this.frame.getByPlaceholder('you@example.com', { exact: true })
}
/** The "Ask anything" panel's question box ("Connecting..." until the socket is up). */
get askBox(): Locator {
return this.frame.getByPlaceholder('Ask me anything...', { exact: true })
}
/** Answers in the "Ask anything" panel. CSS: the answers have no role or name. */
lastAnswer(): Locator {
return this.frame.locator('.askai__answer').last()
}
get messageInput(): Locator {
return this.frame.getByPlaceholder('Type a message...', { exact: true })
}
/** The conversation — the widget's message log. */
get messageLog(): Locator {
return this.frame.getByRole('log', { name: 'Messages' })
}
/** Bot and human-agent replies — the widget renders both the same way. */
get agentMessages(): Locator {
return this.messageLog.locator('.message.agent-message .message-bubble')
}
lastAgentMessage(): Locator {
return this.agentMessages.last()
}
get customerMessages(): Locator {
return this.messageLog.locator('.message.user-message .message-bubble')
}
/** The agent's name in the chat header. */
get agentName(): Locator {
return this.frame.locator('.header-info h3')
}
/** The agent's welcome message, shown when the chat opens. */
get welcomeMessage(): Locator {
return this.frame.locator('.rp-welcome-block .message-bubble')
}
get contactForm(): Locator {
return this.frame.locator('.form-message').filter({ hasText: 'Before we connect you to a teammate' })
}
get ratingThanks(): Locator {
return this.frame.getByText('Thank you for your rating!')
}
get ratingPrompt(): Locator {
return this.frame.getByText(/Rate the chat session/)
}
}
+20
View File
@@ -0,0 +1,20 @@
import type { Locator, Page } from '@playwright/test'
/** The dashboard's left navigation. */
export class Sidebar {
readonly page: Page
constructor(page: Page) {
this.page = page
}
/** Every nav link the signed-in user can see, in order. */
get links(): Locator {
// Scoped to the desktop sidebar; the mobile bottom bar repeats some links.
return this.page.locator('nav.sidebar-nav').getByRole('link')
}
link(name: string): Locator {
return this.links.filter({ hasText: new RegExp(`^\\s*${name}\\s*$`) })
}
}