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

+57
View File
@@ -0,0 +1,57 @@
import { expect, type Locator, type Page } from '@playwright/test'
import { step } from '../support/step'
/** /ai-agents — the agent list and the in-page agent editor (?agent=<id>&tab=<tab>). */
export class AgentsPage {
readonly page: Page
constructor(page: Page) {
this.page = page
}
/** Needs at least one agent in the org, otherwise the onboarding wizard shows instead. */
async goto(): Promise<void> {
await this.page.goto('/ai-agents')
await expect(this.page.getByPlaceholder('Search agents...')).toBeVisible()
}
/** Creates an agent through the "Create Agent" dialog; returns its id. */
async createAgent(name: string): Promise<string> {
return step(`Create agent "${name}"`, async () => {
await this.page.getByTitle('Create a new agent').click()
const dialog = this.page.getByRole('dialog', { name: 'Create New Agent' })
await dialog.getByLabel('Agent Name').fill(name)
await dialog.getByRole('button', { name: 'Create Agent' }).click()
await expect(dialog).toBeHidden()
await expect(this.page).toHaveURL(/[?&]agent=/)
return new URL(this.page.url()).searchParams.get('agent')!
})
}
async openAgent(agentId: string, tab = 'agent'): Promise<void> {
await this.page.goto(`/ai-agents?agent=${agentId}&tab=${tab}`)
await expect(this.page.getByRole('button', { name: 'Back to agents' })).toBeVisible()
}
async setInstructions(instructions: string): Promise<void> {
await step('Edit the agent instructions', async () => {
await this.page.getByPlaceholder('Enter instructions for the agent...').fill(instructions)
await this.page.getByRole('button', { name: 'Save Changes' }).click()
await expect(this.page.getByText('Agent updated successfully')).toBeVisible()
})
}
/** The list's "Create Agent" button; locked (disabled, titled with the limit) at the plan's cap. */
get createAgentButton(): Locator {
return this.page.getByRole('button', { name: /Create Agent/ })
}
/** Shown instead of the list while the org has no agents. */
get onboardingHeading(): Locator {
return this.page.getByRole('heading', { name: "Let's get your first agent live" })
}
card(name: string): Locator {
return this.page.getByRole('heading', { name, exact: true })
}
}
+42
View File
@@ -0,0 +1,42 @@
import { expect, type Locator, type Page } from '@playwright/test'
import { step } from '../support/step'
export interface ModelSettings {
baseUrl: string
model: string
apiKey: string
}
/** Settings → AI configuration ("Bring your own model"). */
export class AiConfigPage {
readonly page: Page
constructor(page: Page) {
this.page = page
}
async goto(): Promise<void> {
await this.page.goto('/settings/ai-config')
await expect(this.page.getByRole('main').getByRole('heading', { name: 'AI Configuration' })).toBeVisible()
}
async useOpenAiCompatible(settings: ModelSettings): Promise<void> {
await step(`Save an OpenAI-compatible model (${settings.model})`, async () => {
// With the enterprise module the page can open on the "ReplyPier AI" tab.
await this.page.getByRole('tab', { name: /Bring Your Own Model/ }).click()
await this.page.getByRole('radio', { name: /OpenAI-compatible/ }).click()
await this.page.getByLabel('Base URL').fill(settings.baseUrl)
await this.page.getByLabel('Model Name').selectOption('__custom__')
await this.page.getByPlaceholder('Enter the exact model ID (e.g. gpt-4o)').fill(settings.model)
await this.page.getByLabel('API Key').fill(settings.apiKey)
// Wait for the save itself; the "Saving..." label can come and go before a check sees it.
const saved = this.page.waitForResponse((r) => /\/ai\/(setup|config)$/.test(new URL(r.url()).pathname) && r.request().method() !== 'GET')
await this.page.getByRole('button', { name: /^(Save|Update) Configuration$/ }).click()
await saved
})
}
get error(): Locator {
return this.page.getByRole('alert')
}
}
+24
View File
@@ -0,0 +1,24 @@
import { expect, type Locator, type Page } from '@playwright/test'
import { step } from '../support/step'
/** The signed-in shell around every dashboard screen. */
export class Dashboard {
readonly page: Page
constructor(page: Page) {
this.page = page
}
async logout(): Promise<void> {
await step('Log out', async () => {
// The profile menu trigger is an avatar with no accessible name.
await this.page.locator('.profile-trigger').click()
await this.page.getByRole('button', { name: 'Logout' }).click()
await expect(this.page).toHaveURL(/\/login/)
})
}
get accessDenied(): Locator {
return this.page.getByRole('heading', { name: 'Access Denied' })
}
}
+41
View File
@@ -0,0 +1,41 @@
import { expect, type Locator, type Page } from '@playwright/test'
import { step } from '../support/step'
export interface NewTeamMember {
name: string
email: string
password: string
role: 'Admin' | 'Agent'
}
/** /human-agents — people, teams and roles. */
export class HumanAgentsPage {
readonly page: Page
constructor(page: Page) {
this.page = page
}
async goto(): Promise<void> {
await this.page.goto('/human-agents')
await expect(this.page.getByRole('main').getByRole('heading', { name: 'Human Agents', exact: true })).toBeVisible()
}
async invite(member: NewTeamMember): Promise<void> {
await step(`Add ${member.email} as ${member.role}`, async () => {
await this.page.getByRole('button', { name: 'Invite agent' }).click()
const dialog = this.page.getByRole('dialog', { name: 'Create New User' })
await dialog.getByLabel('Full Name').fill(member.name)
await dialog.getByLabel('Email').fill(member.email)
await dialog.getByLabel('Role').selectOption({ label: member.role })
await dialog.getByLabel('Password', { exact: true }).fill(member.password)
await dialog.getByLabel('Confirm Password').fill(member.password)
await dialog.getByRole('button', { name: 'Create', exact: true }).click()
await expect(dialog).toBeHidden()
})
}
member(email: string): Locator {
return this.page.getByRole('main').getByText(email)
}
}
+65
View File
@@ -0,0 +1,65 @@
import { expect, type Locator, type Page } from '@playwright/test'
import { step } from '../support/step'
/** /conversations — where human agents pick up chats. */
export class InboxPage {
readonly page: Page
constructor(page: Page) {
this.page = page
}
async goto(): Promise<void> {
await this.page.goto('/conversations')
await expect(this.page.getByRole('heading', { name: 'Conversations', exact: true })).toBeVisible()
}
/**
* The list is not pushed new chats over the socket, so keep reloading until the
* conversation shows up — the same thing an agent would do.
*/
async openConversationWith(customerName: string): Promise<void> {
await step(`Agent opens the chat with ${customerName}`, async () => {
const item = this.page.getByRole('button', { name: `Conversation with ${customerName}`, exact: true })
await expect(async () => {
await this.page.reload()
await item.click({ timeout: 2_000 })
}).toPass({ timeout: 30_000 })
await expect(this.page.getByRole('heading', { name: customerName })).toBeVisible()
})
}
async takeOver(): Promise<void> {
await step('Agent takes over the chat', async () => {
await this.page.getByRole('button', { name: 'Take over chat', exact: true }).click()
await expect(this.messageInput).toBeEditable()
})
}
async reply(text: string): Promise<void> {
await step(`Agent replies "${text}"`, async () => {
await this.messageInput.fill(text)
await this.page.getByRole('button', { name: 'Send', exact: true }).click()
await expect(this.page.locator('.message.agent .message-bubble').last()).toContainText(text)
})
}
get messageInput(): Locator {
return this.page.getByPlaceholder('Type a message or paste a screenshot')
}
/** The agent's message bubble that carries `text`. */
agentMessage(text: string): Locator {
return this.page.locator('.message.agent').filter({ hasText: text })
}
/** The download link for an attachment in the open chat. */
attachment(filename: string): Locator {
return this.page.locator('.attachment-link').filter({ hasText: filename })
}
/** The bubble also shows a relative time ("less than a minute ago"), so assert with toContainText. */
lastCustomerMessage(): Locator {
return this.page.locator('.message.user .message-bubble').last()
}
}
+50
View File
@@ -0,0 +1,50 @@
import { expect, type Locator, type Page } from '@playwright/test'
import { step } from '../support/step'
/** /settings/integrations: one card per integration, with Connect or Manage/Disconnect. */
export class IntegrationsPage {
readonly page: Page
constructor(page: Page) {
this.page = page
}
async goto(): Promise<void> {
await this.page.goto('/settings/integrations')
await expect(this.card('Jira')).toBeVisible()
}
card(name: string): Locator {
return this.page.locator('.integration-card').filter({ has: this.page.getByText(name, { exact: true }) })
}
/**
* Matches the card's "Access expired for …" warning when it names `account`.
* The warning lists every account that needs reconnecting, and other tests
* share the org, so this checks for one name within it.
*/
expiredAccessFor(account: string): RegExp {
const name = account.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
return new RegExp(`Access expired for [^—]*${name}[^—]*— reconnect to resume replies\\.`)
}
/** Opens the card's Manage dialog. */
async manage(name: string): Promise<void> {
await step(`Manage ${name}`, async () => {
await this.card(name).getByRole('button', { name: 'Manage' }).click()
})
}
/** The Manage dialog's prompt to reconnect an account Meta stopped accepting. */
reconnectPrompt(): Locator {
return this.page.getByText('Meta stopped accepting this account\'s access. Reconnect to resume replies.')
}
/** Starts the integration's OAuth flow; the provider's consent screen sends the browser back here. */
async connect(name: string): Promise<void> {
await step(`Connect ${name}`, async () => {
await this.card(name).getByRole('button', { name: 'Connect' }).click()
await expect(this.page).toHaveURL(/\/settings\/integrations/)
})
}
}
+27
View File
@@ -0,0 +1,27 @@
import { expect, type Locator, type Page } from '@playwright/test'
import { step } from '../support/step'
export class LoginPage {
readonly page: Page
constructor(page: Page) {
this.page = page
}
async goto(): Promise<void> {
await this.page.goto('/login')
await expect(this.page.getByRole('heading', { name: 'Welcome back' })).toBeVisible()
}
async signIn(email: string, password: string): Promise<void> {
await step(`Sign in as ${email}`, async () => {
await this.page.getByLabel('Email', { exact: true }).fill(email)
await this.page.getByLabel('Password', { exact: true }).fill(password)
await this.page.getByRole('button', { name: 'Sign In' }).click()
})
}
get error(): Locator {
return this.page.getByRole('alert')
}
}
+28
View File
@@ -0,0 +1,28 @@
import { expect, type Locator, type Page } from '@playwright/test'
import { step } from '../support/step'
/** /people — visitors, leads and customers. */
export class PeoplePage {
readonly page: Page
constructor(page: Page) {
this.page = page
}
async goto(): Promise<void> {
await this.page.goto('/people')
await expect(this.page.getByRole('main').getByRole('heading', { name: 'People', exact: true })).toBeVisible()
}
async findLead(email: string): Promise<void> {
await step(`Look up lead ${email}`, async () => {
await this.page.getByRole('button', { name: 'Leads', exact: true }).click()
await this.page.getByPlaceholder('Search name, email or company…').fill(email)
})
}
/** Each person is a row button whose text includes their email and stage badge. */
person(email: string): Locator {
return this.page.getByRole('button').filter({ hasText: email })
}
}
+109
View File
@@ -0,0 +1,109 @@
import { expect, type Locator, type Page } from '@playwright/test'
import { step } from '../support/step'
/**
* The first-agent setup wizard on /ai-agents: Create -> Teach -> Test -> Launch.
* Shows by itself while the org has no agents; /ai-agents?setup=1 reopens it at
* the step the server says the org has reached.
*/
export class SetupWizard {
readonly page: Page
constructor(page: Page) {
this.page = page
}
async resume(): Promise<void> {
await this.page.goto('/ai-agents?setup=1')
}
async createAgent(name: string): Promise<void> {
await step(`Create the first agent "${name}"`, async () => {
await expect(this.createHeading).toBeVisible()
await this.page.getByLabel('Agent name').fill(name)
await this.page.getByRole('button', { name: 'Continue' }).click()
await expect(this.teachHeading).toBeVisible()
})
}
/** Continue on the Teach step, sending whatever sources are staged. */
async continueFromTeach(): Promise<void> {
await step('Continue from the Teach step', async () => {
await this.page.getByRole('button', { name: /^Continue/ }).click()
})
}
async pasteTextInstead(text: string): Promise<void> {
await step('Paste some text instead of the website', async () => {
await this.openPasteText()
await this.pasteTextBox.fill(text)
await this.page.getByRole('button', { name: 'Add this text' }).click()
})
}
async openPasteText(): Promise<void> {
await step('Open "Paste some text instead"', async () => {
await this.pasteTextChoice.click()
})
}
async cancelPasteText(): Promise<void> {
await step('Cancel pasting text', async () => {
await this.page.getByRole('button', { name: 'Cancel' }).click()
})
}
/** Picks a PDF in the Teach step's file picker. */
async uploadPdf(name: string, pdf: Buffer): Promise<void> {
await step(`Pick the PDF "${name}"`, async () => {
// The picker is a hidden <input type=file> behind the "Upload PDF" button
await this.page.locator('input[type="file"]').setInputFiles({ name, mimeType: 'application/pdf', buffer: pdf })
})
}
async back(): Promise<void> {
await step('Go back a step', async () => {
await this.page.getByRole('button', { name: 'Back' }).click()
})
}
/** The step's main button: "Continue", "Continue without it", "Looks good"… */
get continueButton(): Locator {
return this.page.getByRole('button', { name: /^Continue/ })
}
/** The Test step's note when the agent has learned nothing yet (and why). */
get knowledgeNotice(): Locator {
return this.page.getByRole('status').filter({ hasText: "hasn't learned anything" })
}
get pasteTextChoice(): Locator {
return this.page.getByRole('button', { name: 'Paste some text instead' })
}
get pasteTextBox(): Locator {
return this.page.getByLabel('Text about your business')
}
/** A staged source on the Teach step, e.g. the signup domain. */
stagedSource(url: string): Locator {
return this.page.getByRole('listitem').filter({ hasText: url })
}
/** The explanation shown when a website couldn't be read. */
get crawlFailure(): Locator {
return this.page.getByRole('alert').filter({ hasText: "We couldn't read" })
}
get createHeading(): Locator {
return this.page.getByRole('heading', { name: 'Create your agent' })
}
get teachHeading(): Locator {
return this.page.getByRole('heading', { name: 'Teach it your business' })
}
get testHeading(): Locator {
return this.page.getByRole('heading', { name: 'Try it out' })
}
}
+38
View File
@@ -0,0 +1,38 @@
import { expect, type Page } from '@playwright/test'
import { step } from '../support/step'
export interface OrgDetails {
orgName: string
domain: string
adminName: string
adminEmail: string
adminPassword: string
}
/** First-run setup: the form a self-hoster fills in on an empty install. */
export class SetupPage {
readonly page: Page
constructor(page: Page) {
this.page = page
}
async goto(): Promise<void> {
await this.page.goto('/setup')
await expect(this.page.getByRole('heading', { name: 'Welcome to ReplyPier' })).toBeVisible()
}
async createOrganization(details: OrgDetails): Promise<void> {
await step('Create the organization', async () => {
const form = this.page
await form.getByLabel('Organization Name', { exact: true }).fill(details.orgName)
await form.getByLabel('Domain', { exact: true }).fill(details.domain)
await form.getByLabel('Admin Name', { exact: true }).fill(details.adminName)
await form.getByLabel('Admin Email', { exact: true }).fill(details.adminEmail)
await form.getByLabel('Admin Password', { exact: true }).fill(details.adminPassword)
await form.getByLabel('Confirm Password', { exact: true }).fill(details.adminPassword)
await form.getByRole('button', { name: 'Create Organization' }).click()
await expect(this.page).toHaveURL(/\/ai-agents/)
})
}
}
+46
View File
@@ -0,0 +1,46 @@
import { expect, type Locator, type Page } from '@playwright/test'
import { step } from '../support/step'
/** /tickets and /tickets/:id. */
export class TicketsPage {
readonly page: Page
constructor(page: Page) {
this.page = page
}
async goto(): Promise<void> {
await this.page.goto('/tickets')
await expect(this.page.getByRole('main').getByRole('heading', { name: 'Tickets', exact: true })).toBeVisible()
}
/** Ticket rows are clickable divs; the title is the summary the AI gave. */
row(title: string): Locator {
return this.page.locator('.table-row').filter({ hasText: title })
}
async open(title: string): Promise<void> {
await step(`Open ticket "${title}"`, async () => {
await this.row(title).click()
await expect(this.page).toHaveURL(/\/tickets\/[^/]+$/)
await expect(this.status).toBeEnabled()
})
}
get status(): Locator {
return this.page.getByLabel('Status', { exact: true })
}
async setStatus(label: string): Promise<void> {
await step(`Set ticket status to ${label}`, async () => {
const saved = this.page.waitForResponse((r) => r.request().method() === 'PATCH' && /\/tickets\/[^/]+$/.test(r.url()))
await this.status.selectOption({ label })
expect((await saved).ok()).toBe(true)
})
}
/** The label of the selected status option. */
get statusLabel(): Locator {
return this.status.locator('option:checked')
}
}