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,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 })
|
||||
}
|
||||
}
|
||||
@@ -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')
|
||||
}
|
||||
}
|
||||
@@ -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' })
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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/)
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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')
|
||||
}
|
||||
}
|
||||
@@ -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 })
|
||||
}
|
||||
}
|
||||
@@ -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' })
|
||||
}
|
||||
}
|
||||
@@ -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/)
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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')
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user