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

+58
View File
@@ -0,0 +1,58 @@
import type { APIRequestContext } from '@playwright/test'
import { env } from '../env'
// The dashboard's live-chat (/agent) socket, driven at the protocol level the
// way another site's page would reach it: with the browser's sign-in cookies
// and an Origin of the page's choosing (or none).
const SOCKET = `${env.backendUrl}/socket.io/?EIO=4`
export type SocketOutcome = 'signed in' | 'refused'
/** The sign-in cookies a browser would attach, as a Cookie header. */
export async function signInCookies(request: APIRequestContext, email: string, password: string): Promise<string> {
const response = await request.post(`${env.apiUrl}/users/login`, { form: { username: email, password } })
if (!response.ok()) throw new Error(`login ${email} → ${response.status()}`)
const { cookies } = await request.storageState()
return cookies.filter((c) => ['access_token', 'refresh_token'].includes(c.name)).map((c) => `${c.name}=${c.value}`).join('; ')
}
/**
* Open /agent over a WebSocket, as the dashboard does. Only the namespace's own
* answer counts: a handshake refused before it (the origin not allowed at all)
* rejects rather than passing as a refusal.
*/
export function agentSocketOverWebSocket(origin: string, cookie: string): Promise<SocketOutcome> {
return new Promise((resolve, reject) => {
// Node's WebSocket takes headers, which a page can't set but a browser sends for it
const ws = new WebSocket(`${SOCKET.replace(/^http/, 'ws')}&transport=websocket`, { headers: { Origin: origin, Cookie: cookie } } as never)
const fail = (why: string) => { clearTimeout(timer); ws.close(); reject(new Error(`${origin}: ${why}`)) }
const timer = setTimeout(() => fail('no answer to the namespace connect'), 10_000)
ws.onmessage = ({ data }) => {
const packet = String(data)
if (packet.startsWith('0')) ws.send('40/agent,')
else if (packet.startsWith('40/agent')) settle('signed in')
else if (packet.startsWith('44/agent')) settle('refused')
}
ws.onerror = () => fail('the socket handshake was refused before the namespace answered')
function settle(outcome: SocketOutcome) { clearTimeout(timer); ws.onerror = null; ws.close(); resolve(outcome) }
})
}
/**
* Open /agent over polling: the handshake with `handshake` headers and `query`
* (a <script> tag sends `j=0`, the cookies and no Origin), then the namespace
* connect and the read from `pageOrigin`. socket.io signs the namespace in with
* the handshake's headers, which is what this exercises.
*/
export async function agentSocketOverPolling(
request: APIRequestContext, cookie: string, pageOrigin: string, handshake: Record<string, string>, query = '',
): Promise<SocketOutcome> {
const opened = await (await request.get(`${SOCKET}&transport=polling${query}`, { headers: { Cookie: cookie, ...handshake } })).text()
const sid = opened.match(/sid\\?":\\?"([^"\\]+)/)?.[1]
if (!sid) throw new Error(`the handshake answered ${opened.slice(0, 80)}`)
const session = `${SOCKET}&transport=polling&sid=${sid}`
await request.post(session, { headers: { Origin: pageOrigin, 'Content-Type': 'text/plain' }, data: '40/agent,' })
const answer = await (await request.get(session, { headers: { Origin: pageOrigin } })).text()
return answer.includes('40/agent,{"sid"') ? 'signed in' : 'refused'
}
+47
View File
@@ -0,0 +1,47 @@
import type { ApiClient } from './client'
export interface Agent {
id: string
name: string
}
export interface NewAgent {
name: string
instructions: string[]
agentType?: 'customer_support' | 'sales' | 'tech_support' | 'general' | 'custom'
transferToHuman?: boolean
/** Handover only happens if the agent has at least one group with an online member. */
groupIds?: string[]
/** The widget asks for a star rating when the agent ends the chat. */
askForRating?: boolean
/** The widget only loads with a conversation token from /generate-token. */
requireTokenAuth?: boolean
/** Offer the AI's quick replies as tappable buttons on messaging channels. */
channelQuickReplies?: boolean
/** Visitors may attach files (once a person has joined the chat). */
allowAttachments?: boolean
}
export async function createAgent(api: ApiClient, agent: NewAgent): Promise<Agent> {
const created = await api.post<Agent>('/agent', {
data: {
name: agent.name,
agent_type: agent.agentType ?? 'customer_support',
instructions: agent.instructions,
transfer_to_human: agent.transferToHuman ?? false,
ask_for_rating: agent.askForRating ?? false,
require_token_auth: agent.requireTokenAuth ?? false,
channel_quick_replies: agent.channelQuickReplies ?? false,
allow_attachments: agent.allowAttachments ?? false,
},
})
if (agent.groupIds?.length) {
await api.put(`/agent/${created.id}/groups`, { data: agent.groupIds })
}
return { id: String(created.id), name: created.name }
}
/** Turns lead capture on without the consent gate, so a scripted reply records the lead. */
export async function enableLeadCapture(api: ApiClient, agentId: string): Promise<void> {
await api.put(`/agent/${agentId}/lead-capture`, { data: { enabled: true, require_consent: false } })
}
+39
View File
@@ -0,0 +1,39 @@
import { env, seedOrg } from '../env'
import type { ApiClient } from './client'
/**
* Points the org's AI at the fake LLM. The backend live-tests the key on save, so
* the fake must already be running (playwright.config.ts starts it).
*/
export async function useFakeLlm(api: ApiClient): Promise<void> {
await api.post('/ai/setup', {
data: {
model_type: 'OPENAI_COMPATIBLE',
model_name: seedOrg.llmModel,
api_key: 'e2e-fake-key',
base_url: `${env.fakeLlmUrl}/v1`,
},
})
}
export interface AiConfig {
model_type: string
model_name: string
}
export async function currentAiConfig(api: ApiClient): Promise<AiConfig> {
return api.get<AiConfig>('/ai/config')
}
/**
* Puts the org back on the AI it had before a test changed it: the fake LLM, or
* (enterprise) the ReplyPier AI its signup created, which reaches the fake too.
*/
export async function restoreAi(api: ApiClient, before: AiConfig): Promise<void> {
if (before.model_type.toUpperCase() === 'REPLYPIER') {
// As the dashboard's onboarding sends it: the backend injects the key.
await api.post('/ai/setup', { data: { model_type: 'REPLYPIER', model_name: 'replypier', api_key: '' } })
} else {
await useFakeLlm(api)
}
}
+25
View File
@@ -0,0 +1,25 @@
import type { ApiClient } from './client'
interface TokenResponse {
access_token: string
user: { id: string; organization_id: string }
}
export interface Session {
token: string
userId: string
organizationId: string
}
/** A client signed in as `email` that signs in again whenever its token expires. */
export async function signedInClient(api: ApiClient, email: string, password: string): Promise<{ api: ApiClient; session: Session }> {
const session = await login(api, email, password)
const renew = async () => (await login(api, email, password)).token
return { api: api.withToken(session.token, renew), session }
}
/** Logs in through the real endpoint; the token is for seeding calls. */
export async function login(api: ApiClient, email: string, password: string): Promise<Session> {
const body = await api.post<TokenResponse>('/users/login', { form: { username: email, password } })
return { token: body.access_token, userId: String(body.user.id), organizationId: String(body.user.organization_id) }
}
+72
View File
@@ -0,0 +1,72 @@
import type { ApiClient } from './client'
import type { Schemas } from './types'
/** A connected account (ChannelAccountOut); webhook_url is set for SMS and email. */
export type ChannelAccount = Schemas['ChannelAccountOut']
async function connect(api: ApiClient, path: string, data: Record<string, unknown>): Promise<ChannelAccount> {
const account = await api.post<ChannelAccount>(path, { data })
return { ...account, id: String(account.id) }
}
/** Connects a Telegram bot (the backend checks the token with getMe and registers its webhook). */
export function connectTelegram(api: ApiClient, botToken: string): Promise<ChannelAccount> {
return connect(api, '/channels/telegram', { bot_token: botToken })
}
/** Manual WhatsApp Cloud API connect: a phone number id and a system-user token, plus the
* app secret when the number is in the customer's own Meta app. */
export function connectWhatsApp(api: ApiClient, phoneNumberId: string, accessToken: string, appSecret?: string): Promise<ChannelAccount> {
return connect(api, '/channels/meta/whatsapp', { phone_number_id: phoneNumberId, access_token: accessToken, app_secret: appSecret })
}
export function connectMessenger(api: ApiClient, pageId: string, pageAccessToken: string, appSecret?: string): Promise<ChannelAccount> {
return connect(api, '/channels/meta/messenger', { page_id: pageId, page_access_token: pageAccessToken, app_secret: appSecret })
}
export function connectInstagram(api: ApiClient, igId: string, pageAccessToken: string): Promise<ChannelAccount> {
return connect(api, '/channels/meta/instagram', { ig_id: igId, page_access_token: pageAccessToken })
}
export function connectLine(api: ApiClient, channelSecret: string, channelAccessToken: string): Promise<ChannelAccount> {
return connect(api, '/channels/line', { channel_secret: channelSecret, channel_access_token: channelAccessToken })
}
/** An email inbox that receives mail through the provider's inbound-parse webhook; replies use platform SMTP. */
export function connectEmail(api: ApiClient, inboundAddress: string): Promise<ChannelAccount> {
return connect(api, '/channels/email', { inbound_address: inboundAddress })
}
export function connectSms(
api: ApiClient,
provider: 'twilio' | 'plivo',
phoneNumber: string,
credentials: Record<string, string>,
): Promise<ChannelAccount> {
return connect(api, '/channels/sms', { provider, phone_number: phoneNumber, credentials })
}
/**
* Slack connects only through OAuth: /install hands out a state, Slack sends the
* browser back to /callback with a code, and the backend trades the code for a
* bot token and stores the workspace. The callback answers with a redirect, not
* the account — find it afterwards with listChannelAccounts.
*/
export async function installSlack(api: ApiClient, code: string): Promise<void> {
const install = await api.raw('GET', '/channels/slack/install', { maxRedirects: 0 })
const state = new URL(install.headers().location ?? 'about:blank').searchParams.get('state')
if (!state) throw new Error(`Slack install → ${install.status()}: no state in the redirect`)
const callback = await api.raw('GET', '/channels/slack/callback', { params: { code, state }, maxRedirects: 0 })
const location = callback.headers().location ?? ''
if (!location.includes('status=success')) throw new Error(`Slack callback → ${location || callback.status()}`)
}
export async function listChannelAccounts(api: ApiClient): Promise<ChannelAccount[]> {
const accounts = await api.get<ChannelAccount[]>('/channels/accounts')
return accounts.map((a) => ({ ...a, id: String(a.id) }))
}
/** Routes a connected channel account's conversations to an AI agent. */
export async function routeToAgent(api: ApiClient, accountId: string, agentId: string): Promise<void> {
await api.post(`/channels/agent-config/${accountId}`, { data: { agent_id: agentId, is_active: true } })
}
+16
View File
@@ -0,0 +1,16 @@
import type { ApiClient } from './client'
import type { Schemas } from './types'
export type ChatOverview = Schemas['ChatOverviewResponse']
/** The org's conversations with one AI agent, newest first (admin view). */
export async function chatsWithAgent(api: ApiClient, agentId: string): Promise<ChatOverview[]> {
return api.get<ChatOverview[]>('/chats/recent', { params: { agent_id: agentId, limit: 100 } })
}
export type ChatMessage = Schemas['Message']
/** Every stored message in one conversation, oldest first (admin view). */
export async function chatMessages(api: ApiClient, sessionId: string): Promise<ChatMessage[]> {
return (await api.get<Schemas['ChatDetailResponse']>(`/chats/${sessionId}`)).messages
}
+78
View File
@@ -0,0 +1,78 @@
import type { APIRequestContext, APIResponse } from '@playwright/test'
import { env } from '../env'
type Body = Record<string, unknown> | unknown[]
interface CallOptions {
data?: Body
form?: Record<string, string>
params?: Record<string, string | number | boolean>
/** 0 to read a redirect's Location instead of following it. */
maxRedirects?: number
/** A multipart/form-data body — files as { name, mimeType, buffer }. */
multipart?: Record<string, string | { name: string; mimeType: string; buffer: Buffer }>
/** Extra request headers, e.g. an Origin for a CORS check. */
headers?: Record<string, string>
}
/**
* Thin JSON client for the backend API, used to SEED data — specs assert through
* the UI. Domain modules (agents.ts, widgets.ts, …) build on this, so a route or
* payload change is one edit there and nowhere else.
*/
export class ApiClient {
readonly request: APIRequestContext
private token?: string
private readonly renew?: () => Promise<string>
/** `renew` fetches a fresh token when the current one is refused (it lives 30 minutes). */
constructor(request: APIRequestContext, token?: string, renew?: () => Promise<string>) {
this.request = request
this.token = token
this.renew = renew
}
withToken(token: string, renew?: () => Promise<string>): ApiClient {
return new ApiClient(this.request, token, renew)
}
get<T>(path: string, options: CallOptions = {}) { return this.call<T>('GET', path, options) }
post<T>(path: string, options: CallOptions = {}) { return this.call<T>('POST', path, options) }
put<T>(path: string, options: CallOptions = {}) { return this.call<T>('PUT', path, options) }
patch<T>(path: string, options: CallOptions = {}) { return this.call<T>('PATCH', path, options) }
delete<T>(path: string, options: CallOptions = {}) { return this.call<T>('DELETE', path, options) }
/** Like call(), but hands back the raw response instead of throwing on a non-2xx. */
async raw(method: string, path: string, options: CallOptions = {}): Promise<APIResponse> {
const response = await this.send(method, path, options)
// A long-lived worker outlasts its token; sign in again once and retry.
if (response.status() === 401 && this.renew) {
this.token = await this.renew()
return this.send(method, path, options)
}
return response
}
private send(method: string, path: string, options: CallOptions): Promise<APIResponse> {
return this.request.fetch(`${env.apiUrl}${path}`, {
method,
data: options.data,
form: options.form,
multipart: options.multipart,
params: options.params,
maxRedirects: options.maxRedirects,
headers: { ...(this.token ? { Authorization: `Bearer ${this.token}` } : {}), ...options.headers },
failOnStatusCode: false,
})
}
private async call<T>(method: string, path: string, options: CallOptions): Promise<T> {
const response = await this.raw(method, path, options)
if (!response.ok()) {
// The status and body in the message are what make a seeding failure debuggable.
throw new Error(`${method} ${path} → ${response.status()}: ${(await response.text()).slice(0, 500)}`)
}
const text = await response.text()
return (text ? JSON.parse(text) : undefined) as T
}
}
+26
View File
@@ -0,0 +1,26 @@
import type { ApiClient } from './client'
export type CrmProvider = 'hubspot' | 'pipedrive'
/**
* CRMs connect through OAuth only: /install hands out a state, the CRM sends the
* browser back to /callback with a code, and the backend trades it for tokens.
* One connection per org and CRM — an org-wide change (`*.org.spec.ts`).
*/
export async function connectCrm(api: ApiClient, provider: CrmProvider, code: string): Promise<void> {
const install = await api.raw('GET', `/crm/${provider}/install`, { maxRedirects: 0 })
const state = new URL(install.headers().location ?? 'about:blank').searchParams.get('state')
if (!state) throw new Error(`${provider} install → ${install.status()}: ${await install.text()}`)
const callback = await api.raw('GET', `/crm/${provider}/callback`, { params: { code, state }, maxRedirects: 0 })
const location = callback.headers().location ?? ''
if (!location.includes('status=success')) throw new Error(`${provider} callback → ${location || callback.status()}`)
}
export async function disconnectCrm(api: ApiClient, provider: CrmProvider): Promise<void> {
await api.raw('DELETE', `/crm/${provider}`)
}
/** "Sync now" on a person: pushes them to every connected CRM, synchronously. */
export async function syncPersonToCrm(api: ApiClient, personId: string): Promise<{ synced: Array<{ provider: string }> }> {
return api.post(`/people/${personId}/crm-sync`)
}
+6
View File
@@ -0,0 +1,6 @@
import type { ApiClient } from './client'
/** The agent's widget look (Agent → Customize), partial updates allowed. */
export async function customizeAgent(api: ApiClient, agentId: string, customization: Record<string, unknown>): Promise<void> {
await api.post(`/agent/${agentId}/customization`, { data: customization })
}
+23
View File
@@ -0,0 +1,23 @@
import { env } from '../env'
import type { ApiClient } from './client'
export interface Article {
id: string
slug: string | null
status: 'draft' | 'published'
}
/** Turns the org's public help center on (idempotent) and returns its public base URL. */
export async function enableHelpCenter(api: ApiClient): Promise<string> {
const settings = await api.put<{ slug: string }>('/help-center/settings', { data: { enabled: true } })
// .env.e2e serves help centers by path (HELP_CENTER_PUBLIC_MODE=path), as self-hosters do.
return `${env.backendUrl}/help/${settings.slug}`
}
export async function createArticle(api: ApiClient, article: { question: string; answer: string }): Promise<Article> {
return api.post<Article>('/help-center/faqs', { data: { ...article, status: 'draft' } })
}
export async function setArticleStatus(api: ApiClient, id: string, status: Article['status']): Promise<Article> {
return api.put<Article>(`/help-center/faqs/${id}`, { data: { status } })
}
+70
View File
@@ -0,0 +1,70 @@
import { createHmac } from 'node:crypto'
import type { ApiClient } from './client'
import { env } from '../env'
// Jira and Shopify connect once per org (`*.org.spec.ts`); both reach the recording
// fake through JIRA_*_BASE / SHOPIFY_ADMIN_BASE in .env.e2e.
/** The project and issue type the fake Jira site has (services/fake-externals). */
export const fakeJira = { projectKey: 'SUP', taskTypeId: '10001' } as const
/**
* Connects Jira through its OAuth callback: /authorize hands out a state, Atlassian
* sends the browser back to /callback with a code, and the backend trades it for a
* token and the site (the fake derives both from the code).
*/
export async function connectJira(api: ApiClient, code: string): Promise<void> {
const authorize = await api.raw('GET', '/jira/authorize', { maxRedirects: 0 })
const state = new URL(authorize.headers().location ?? 'about:blank').searchParams.get('state')
if (!state) throw new Error(`Jira authorize → ${authorize.status()}: ${await authorize.text()}`)
const callback = await api.raw('GET', '/jira/callback', { params: { code, state }, maxRedirects: 0 })
const location = callback.headers().location ?? ''
if (!location.includes('status=success')) throw new Error(`Jira callback → ${location || callback.status()}`)
}
export async function disconnectJira(api: ApiClient): Promise<void> {
await api.raw('DELETE', '/jira/disconnect')
}
/** The agent files Jira issues into the fake site's Support project, as Tasks. */
export async function routeTicketsToJira(api: ApiClient, agentId: string): Promise<void> {
await api.post(`/jira/agent-config/${agentId}`, { data: { enabled: true, projectKey: fakeJira.projectKey, issueTypeId: fakeJira.taskTypeId } })
}
/** An HS256 session token, as Shopify's admin hands the embedded app. */
function shopifySessionToken(shopDomain: string): string {
const encode = (value: object) => Buffer.from(JSON.stringify(value)).toString('base64url')
const now = Math.floor(Date.now() / 1000)
const header = encode({ alg: 'HS256', typ: 'JWT' })
const claims = encode({
iss: `https://${shopDomain}/admin`, dest: `https://${shopDomain}`, aud: env.shopifyApp.key,
sub: '1', exp: now + 60, nbf: now - 5, iat: now, jti: `${shopDomain}-${now}`,
})
const signature = createHmac('sha256', env.shopifyApp.secret).update(`${header}.${claims}`).digest('base64url')
return `${header}.${claims}.${signature}`
}
/**
* Installs the app on `shopDomain` (*.myshopify.com) and links the shop to the
* admin's org, as the embedded app does: the session token is traded for an
* offline token, then the signed-in admin claims the shop. Linking also sets the
* org's domain to the shop's — restore it with `uninstallShopify`.
*/
export async function installShopify(api: ApiClient, shopDomain: string): Promise<string> {
const { shop_id: shopId } = await api.post<{ shop_id: string }>('/shopify/exchange-token', {
// The session token stands in for the admin's: this is the embedded app calling.
headers: { Authorization: `Bearer ${shopifySessionToken(shopDomain)}` },
})
await api.post('/shopify/link-organization', { data: { shop_id: shopId } })
return shopId
}
export async function uninstallShopify(api: ApiClient, shopId: string, orgId: string, orgDomain: string): Promise<void> {
await api.raw('DELETE', `/shopify/shops/${shopId}`)
await api.patch(`/organizations/${orgId}`, { data: { domain: orgDomain } })
}
/** The agent answers order questions from this shop. */
export async function useShopForAgent(api: ApiClient, agentId: string, shopId: string): Promise<void> {
await api.post(`/shopify/agent-config/${agentId}`, { data: { enabled: true, shop_id: shopId } })
}
+47
View File
@@ -0,0 +1,47 @@
import type { ApiClient } from './client'
/**
* Pasted-text knowledge is indexed synchronously, so it is searchable as soon as
* this returns. Returns the source's id.
*/
export async function addKnowledgeText(
api: ApiClient,
source: { orgId: string; agentId: string; title: string; content: string },
): Promise<number> {
const body = await api.post<{ knowledge: { id: number } }>('/knowledge/add/text', {
data: { org_id: source.orgId, agent_id: source.agentId, title: source.title, content: source.content },
})
return body.knowledge.id
}
/** Uploads a PDF to the knowledge base. It is queued; the knowledge worker indexes it. */
export async function uploadKnowledgePdf(
api: ApiClient,
source: { orgId: string; agentId: string; filename: string; pdf: Buffer },
): Promise<void> {
await api.post('/knowledge/upload/pdf', {
multipart: {
org_id: source.orgId,
agent_id: source.agentId,
files: { name: source.filename, mimeType: 'application/pdf', buffer: source.pdf },
},
})
}
/**
* The agent's sources the knowledge worker has not finished: pending or still
* processing, and any that failed (with why). Empty once everything is indexed.
*/
export async function unfinishedKnowledge(
api: ApiClient,
agentId: string,
): Promise<{ source: string; status: string; error: string | null }[]> {
const body = await api.get<{ queue_items: { source: string; status: string; error: string | null }[] }>(
`/knowledge/queue/agent/${agentId}`,
)
return body.queue_items.map(({ source, status, error }) => ({ source, status, error }))
}
export async function deleteKnowledge(api: ApiClient, knowledgeId: number): Promise<void> {
await api.delete(`/knowledge/${knowledgeId}`)
}
+18
View File
@@ -0,0 +1,18 @@
import type { ApiClient } from './client'
export interface McpConnector {
id: number
name: string
url: string | null
headers: Record<string, string> | null
}
/** Adds a streamable-HTTP MCP server to the org's connectors. */
export async function createMcpConnector(api: ApiClient, connector: { name: string; url: string; headers?: Record<string, string> }): Promise<McpConnector> {
return api.post<McpConnector>('/mcp-tools', { data: { transport_type: 'http', enabled: true, timeout: 10, ...connector } })
}
/** Gives an agent the connector's tools. */
export async function attachMcpConnector(api: ApiClient, connectorId: number, agentId: string): Promise<void> {
await api.post('/mcp-tools/agent-association', { data: { mcp_tool_id: connectorId, agent_id: agentId } })
}
+36
View File
@@ -0,0 +1,36 @@
import type { ApiClient } from './client'
const DAYS = ['monday', 'tuesday', 'wednesday', 'thursday', 'friday', 'saturday', 'sunday'] as const
export async function isSetUp(api: ApiClient): Promise<boolean> {
const body = await api.get<{ is_setup: boolean }>('/organizations/setup-status')
return body.is_setup
}
/**
* Open every day, all day. Handover only reaches a human inside business hours, so
* without this a nightly run outside 9–5 would take the "we'll email you" branch.
*/
export async function setAlwaysOpen(api: ApiClient, orgId: string): Promise<void> {
await setBusinessHours(api, orgId, true)
}
/** Closed every day — for after-hours journeys (an org-wide change: `*.org.spec.ts` only). */
export async function setAlwaysClosed(api: ApiClient, orgId: string): Promise<void> {
await setBusinessHours(api, orgId, false)
}
async function setBusinessHours(api: ApiClient, orgId: string, open: boolean): Promise<void> {
const businessHours = Object.fromEntries(DAYS.map((day) => [day, { start: '00:00', end: '23:59', enabled: open }]))
await api.patch(`/organizations/${orgId}`, { data: { business_hours: businessHours } })
}
/** The org's website, to put back after a spec changes it. */
export async function orgDomain(api: ApiClient, orgId: string): Promise<string> {
return (await api.get<{ domain: string }>(`/organizations/${orgId}`)).domain
}
/** Change the org's website, which decides the sites allowed to call the API (`*.org.spec.ts` only). */
export async function setOrgDomain(api: ApiClient, orgId: string, domain: string): Promise<void> {
await api.patch(`/organizations/${orgId}`, { data: { domain } })
}
+16
View File
@@ -0,0 +1,16 @@
import type { ApiClient } from './client'
export interface Person {
id: string
name: string | null
email: string | null
phone: string | null
lead_stage: 'visitor' | 'lead' | 'customer'
}
/** The one person whose email or phone matches `query` (People search). */
export async function findPerson(api: ApiClient, query: string): Promise<Person> {
const { items } = await api.get<{ items: Person[] }>('/people', { params: { search: query } })
if (items.length !== 1) throw new Error(`expected one person matching ${query}, found ${items.length}`)
return api.get<Person>(`/people/${items[0].id}`)
}
+18324
View File
File diff suppressed because it is too large. Load diff
+72
View File
@@ -0,0 +1,72 @@
import type { ApiClient } from './client'
export interface User {
id: string
email: string
full_name: string
}
interface Role {
id: number
name: string
}
interface Group {
id: string
name: string
users?: User[]
}
export async function roleId(api: ApiClient, name: string): Promise<number> {
const roles = await api.get<Role[]>('/roles')
const role = roles.find((r) => r.name === name)
if (!role) throw new Error(`no role named '${name}' (have: ${roles.map((r) => r.name).join(', ')})`)
return role.id
}
export interface NewUser {
email: string
name: string
password: string
role: string
/** The user form's chat-scope toggles; omitted = the role's own scope. */
seeAllAiChats?: boolean
seeAllOrgChats?: boolean
}
export async function createUser(api: ApiClient, user: NewUser): Promise<User> {
const created = await api.post<User>('/users', {
data: {
email: user.email, full_name: user.name, password: user.password, role_id: await roleId(api, user.role),
see_all_ai_chats: user.seeAllAiChats, see_all_org_chats: user.seeAllOrgChats,
},
})
return { ...created, id: String(created.id) }
}
/** A new group with `members`. */
export async function createGroup(api: ApiClient, name: string, members: User[]): Promise<string> {
const group = await api.post<Group>('/groups', { data: { name } })
for (const member of members) await api.post(`/groups/${group.id}/users/${member.id}`)
return String(group.id)
}
/** Creates the user unless one with that email exists — setup reruns against a reused DB. */
export async function ensureUser(
api: ApiClient,
user: NewUser,
): Promise<User> {
const existing = (await api.get<User[]>('/users')).find((u) => u.email === user.email)
if (existing) return { ...existing, id: String(existing.id) }
return createUser(api, user)
}
export async function ensureGroup(api: ApiClient, name: string, members: User[]): Promise<string> {
const groups = await api.get<Group[]>('/groups')
const group = groups.find((g) => g.name === name) ?? (await api.post<Group>('/groups', { data: { name } }))
const memberIds = new Set((group.users ?? []).map((u) => String(u.id)))
for (const member of members) {
if (!memberIds.has(member.id)) await api.post(`/groups/${group.id}/users/${member.id}`)
}
return String(group.id)
}
+9
View File
@@ -0,0 +1,9 @@
import type { ApiClient } from './client'
/**
* New tickets are queued for the AI investigator by default; the e2e stack runs no
* worker, so setup turns that off and tickets stay exactly as the test made them.
*/
export async function disableAutoInvestigation(api: ApiClient): Promise<void> {
await api.put('/tickets/settings', { data: { auto_investigate_on_create: false } })
}
+8
View File
@@ -0,0 +1,8 @@
import type { components } from './schema'
/**
* Request and response shapes straight from the backend's OpenAPI schema
* (regenerate with scripts/api-types.sh; CI fails when it is stale). Use these
* instead of hand-written interfaces, so a renamed field is a type error here.
*/
export type Schemas = components['schemas']
+22
View File
@@ -0,0 +1,22 @@
import type { ApiClient } from './client'
/** A widget app is a customer's server: its API key mints conversation tokens. */
export async function createWidgetApp(api: ApiClient, name: string): Promise<{ apiKey: string }> {
const app = await api.post<{ api_key: string }>('/widget-apps', { data: { name } })
return { apiKey: app.api_key }
}
/** What the customer's server does for a signed-in visitor before rendering the widget. */
export async function issueConversationToken(
api: ApiClient,
apiKey: string,
widgetId: string,
customer: { email: string; name: string },
/** How long the token lives; the backend's minimum is 60 seconds. */
ttlSeconds?: number,
): Promise<string> {
const body = await api.withToken(apiKey).post<{ data: { token: string } }>('/generate-token', {
data: { widget_id: widgetId, customer_email: customer.email, customer_name: customer.name, ttl_seconds: ttlSeconds },
})
return body.data.token
}
+23
View File
@@ -0,0 +1,23 @@
import type { ApiClient } from './client'
export interface Widget {
id: string
}
export async function createWidget(api: ApiClient, agentId: string, name: string): Promise<Widget> {
const created = await api.post<{ id: string }>('/widgets', { data: { name, agent_id: agentId } })
return { id: String(created.id) }
}
/**
* The widget's first request on a page, as a visitor's browser on `origin`
* sends it (through our nginx, which appends the client IP). `api` should be
* anonymous, like a visitor.
*/
export async function loadWidgetFrom(api: ApiClient, widgetId: string, origin: string, visitorIp: string): Promise<void> {
// The endpoint serves the widget's HTML, not JSON: only the status matters
const response = await api.raw('GET', `/widgets/${widgetId}/data`, {
headers: { Origin: origin, 'X-Forwarded-For': visitorIp },
})
if (!response.ok()) throw new Error(`widget load from ${origin} → ${response.status()}`)
}
+19
View File
@@ -0,0 +1,19 @@
import type { ApiClient } from './client'
import { unique } from '../support/ids'
export interface WorkflowGraph {
nodes: Array<{ id: string; node_type: string; name: string; config: Record<string, unknown> }>
connections?: Array<{ id: string; source_node_id: string; target_node_id: string; label?: string }>
}
/**
* Builds a workflow for `agentId` and publishes it, which switches the agent to
* run it instead of the model. A widget session picks the workflow up when it
* connects, so embed the widget after this.
*/
export async function publishWorkflow(api: ApiClient, agentId: string, graph: WorkflowGraph): Promise<string> {
const workflow = await api.post<{ id: string }>('/workflow', { data: { name: unique('E2E flow'), agent_id: agentId } })
await api.put(`/workflow/${workflow.id}/nodes`, { data: { nodes: graph.nodes, connections: graph.connections ?? [] } })
await api.put(`/workflow/${workflow.id}`, { data: { status: 'published' } })
return workflow.id
}
+145
View File
@@ -0,0 +1,145 @@
import { createHmac, randomInt } from 'node:crypto'
import type { APIRequestContext, APIResponse } from '@playwright/test'
import type { ApiClient } from '../api/client'
import type { RecordedCall } from '../fakes/externals'
import { env } from '../env'
import { step } from '../support/step'
/** One customer's conversation with the business on a messaging channel. */
export interface ChannelChat {
/** Delivers a message from the customer to the backend, as the provider would. */
send(text: string): Promise<void>
/** Every text the business sent into this chat, oldest first. */
replies(): Promise<string[]>
}
/** A chat on a channel that can show reply buttons (WhatsApp, Messenger, Instagram, Telegram). */
export interface ButtonChat extends ChannelChat {
/** Titles of the buttons under the business's latest message that has any. */
buttons(): Promise<string[]>
/** Taps one of those buttons, reported the way the provider reports a tap. */
tap(title: string): Promise<void>
}
/** A channel account connected to the test org and routed to the test agent. */
export interface ConnectedChannel<C extends ChannelChat = ChannelChat> {
chatWith(customerName: string): C
}
export interface ConnectContext {
api: ApiClient
request: APIRequestContext
agentId: string
}
/** Connects a fresh account of one channel; every call gets its own account. */
export type ChannelConnector<C extends ChannelChat = ChannelChat> = (ctx: ConnectContext) => Promise<ConnectedChannel<C>>
/**
* A ChannelChat from the two things that differ per channel: how a customer
* message is delivered and where the business's replies are recorded.
*/
export class Chat implements ChannelChat {
protected readonly channel: string
protected readonly customerName: string
private readonly deliver: (text: string) => Promise<void>
private readonly read: () => Promise<string[]>
constructor(channel: string, customerName: string, deliver: (text: string) => Promise<void>, read: () => Promise<string[]>) {
this.channel = channel
this.customerName = customerName
this.deliver = deliver
this.read = read
}
send(text: string): Promise<void> {
return step(`${this.customerName} sends "${text}" on ${this.channel}`, () => this.deliver(text))
}
replies(): Promise<string[]> {
return this.read()
}
}
export interface Button {
id: string
title: string
}
/** A Chat whose business messages can carry buttons the customer taps. `B` is whatever a tap needs to know about a button. */
export class ChatWithButtons<B extends Button = Button> extends Chat implements ButtonChat {
private readonly latestButtons: () => Promise<B[]>
private readonly deliverTap: (button: B) => Promise<void>
constructor(
channel: string,
customerName: string,
io: {
deliver: (text: string) => Promise<void>
read: () => Promise<string[]>
latestButtons: () => Promise<B[]>
deliverTap: (button: B) => Promise<void>
},
) {
super(channel, customerName, io.deliver, io.read)
this.latestButtons = io.latestButtons
this.deliverTap = io.deliverTap
}
async buttons(): Promise<string[]> {
return (await this.latestButtons()).map((b) => b.title)
}
tap(title: string): Promise<void> {
return step(`${this.customerName} taps "${title}" on ${this.channel}`, async () => {
const button = (await this.latestButtons()).find((b) => b.title === title)
if (!button) throw new Error(`no "${title}" button under the latest ${this.channel} message`)
await this.deliverTap(button)
})
}
}
export type WebhookPayload = { json: string } | { form: Record<string, string> }
/**
* POSTs a provider webhook to the backend and hands back the response, whatever
* its status — for tests of what the backend refuses. JSON goes as the exact
* string that was signed: re-serialising would change the bytes the signature covers.
*/
export function sendWebhook(request: APIRequestContext, path: string, payload: WebhookPayload, headers: Record<string, string> = {}): Promise<APIResponse> {
return 'json' in payload
? request.post(`${env.apiUrl}${path}`, { data: payload.json, headers: { 'content-type': 'application/json', ...headers } })
: request.post(`${env.apiUrl}${path}`, { form: payload.form, headers })
}
/** sendWebhook for a delivery that must be accepted. */
export async function postWebhook(request: APIRequestContext, path: string, payload: WebhookPayload, headers: Record<string, string> = {}): Promise<void> {
const response = await sendWebhook(request, path, payload, headers)
if (!response.ok()) throw new Error(`POST ${path} → ${response.status()}: ${(await response.text()).slice(0, 300)}`)
}
export function hmac(algorithm: 'sha1' | 'sha256', secret: string, data: string): Buffer {
return createHmac(algorithm, secret).update(data).digest()
}
/** `n` random digits, never starting with 0 — ids and phone numbers no other test uses. */
export function digits(n: number): string {
let out = String(randomInt(1, 10))
while (out.length < n) out += String(randomInt(0, 10))
return out
}
/** A UK mobile number in E.164 (what the backend stores as the customer's phone). */
export function phoneNumber(): string {
return `+447${digits(9)}`
}
/** True when the backend authenticated `call` with `token` — i.e. it was sent as that account. */
export function sentWith(call: RecordedCall, token: string): boolean {
return call.headers.authorization === `Bearer ${token}`
}
/** Phone numbers compared the way providers differ on them: digits only. */
export function samePhone(a: unknown, b: string): boolean {
return String(a).replace(/\D/g, '') === b.replace(/\D/g, '')
}
+60
View File
@@ -0,0 +1,60 @@
import { connectEmail, routeToAgent } from '../api/channels'
import { env } from '../env'
import { Mailbox } from '../fakes/mailbox'
import { unique } from '../support/ids'
import { step } from '../support/step'
import { Chat, postWebhook, type ChannelChat, type ChannelConnector } from './chat'
/** An email chat, plus what only email has: the subject line every message carries. */
export interface EmailChat extends ChannelChat {
readonly address: string
readonly subject: string
/** Message-ID of the customer's latest email, which a threaded reply points back at. */
readonly lastMessageId: string | undefined
}
class EmailConversation extends Chat implements EmailChat {
readonly address: string
readonly subject: string
private readonly sent: { messageId?: string }
constructor(customerName: string, address: string, subject: string, sent: { messageId?: string }, deliver: (text: string) => Promise<void>, read: () => Promise<string[]>) {
super('email', customerName, deliver, read)
this.address = address
this.subject = subject
this.sent = sent
}
get lastMessageId(): string | undefined {
return this.sent.messageId
}
}
/**
* A support inbox fed by an inbound-parse webhook (the per-account URL carries a
* secret token, there is no signature). Replies go out over SMTP into the stack's
* Mailpit, where the customer's address collects them.
*/
export const email: ChannelConnector<EmailChat> = ({ api, request, agentId }) =>
step('Connect a support inbox', async () => {
const inbox = `${unique('support')}@e2e.example.com`
const account = await connectEmail(api, inbox)
await routeToAgent(api, account.id, agentId)
const token = new URL(account.webhook_url ?? '', env.backendUrl).searchParams.get('token')
if (!token) throw new Error(`no token in the email webhook URL ${account.webhook_url}`)
const mailbox = new Mailbox(request)
return {
chatWith(customerName) {
const address = `${unique('customer').toLowerCase()}@customer.example.com`
const subject = `Question from ${customerName}`
const sent: { messageId?: string } = {}
return new EmailConversation(customerName, address, subject, sent, async (text) => {
sent.messageId = `<${unique('msg')}@customer.example.com>`
await postWebhook(request, `/webhooks/email/${account.id}?token=${token}`, {
json: JSON.stringify({ from: `${customerName} <${address}>`, to: inbox, subject, text, message_id: sent.messageId }),
})
}, async () => (await mailbox.messagesTo(address)).filter((m) => m.from === inbox).map((m) => m.text.trim()))
},
}
})
+35
View File
@@ -0,0 +1,35 @@
import type { ButtonChat, ChannelConnector } from './chat'
import { email } from './email'
import { line } from './line'
import { instagram, messenger, whatsapp } from './meta'
import { plivo, twilio } from './sms'
import { slack } from './slack'
import { telegram } from './telegram'
/**
* Every messaging channel the suite can drive end to end, by display name. A
* journey that should hold on all channels loops over this; adding a channel is
* one driver file and one line here.
*/
export const channels = {
Telegram: telegram,
WhatsApp: whatsapp,
Messenger: messenger,
Instagram: instagram,
Slack: slack,
LINE: line,
'SMS (Twilio)': twilio,
'SMS (Plivo)': plivo,
Email: email,
} satisfies Record<string, ChannelConnector>
/** The channels that show the agent's suggested replies as tappable buttons. */
export const channelsWithButtons = {
Telegram: telegram,
WhatsApp: whatsapp,
Messenger: messenger,
Instagram: instagram,
} satisfies Record<string, ChannelConnector<ButtonChat>>
export type { EmailChat } from './email'
export type { ButtonChat, ChannelChat, ChannelConnector, ConnectedChannel } from './chat'
+43
View File
@@ -0,0 +1,43 @@
import { connectLine, routeToAgent } from '../api/channels'
import { FakeExternals } from '../fakes/externals'
import { unique } from '../support/ids'
import { step } from '../support/step'
import { Chat, digits, hmac, postWebhook, sentWith, type ChannelConnector } from './chat'
interface LinePush { to: string; messages: Array<{ type: string; text?: string }> }
/** A LINE Official Account; the webhook is per account and signed with its channel secret. */
export const line: ChannelConnector = ({ api, request, agentId }) =>
step('Connect a LINE Official Account', async () => {
const channelSecret = unique('line-secret')
const accessToken = unique('line-token')
const account = await connectLine(api, channelSecret, accessToken)
await routeToAgent(api, account.id, agentId)
const externals = new FakeExternals(request)
return {
chatWith(customerName) {
const userId = `U${digits(32)}`
return new Chat('LINE', customerName, async (text) => {
const json = JSON.stringify({
destination: account.external_account_id,
events: [{
type: 'message',
mode: 'active',
timestamp: Date.now(),
source: { type: 'user', userId },
webhookEventId: unique('ev'),
message: { type: 'text', id: digits(14), text },
}],
})
await postWebhook(request, `/webhooks/line/${account.id}`, { json }, {
'x-line-signature': hmac('sha256', channelSecret, json).toString('base64'),
})
}, async () => (await externals.calls('line', 'push', { contains: userId }))
.filter((c) => c.status === 200 && sentWith(c, accessToken))
.map((c) => c.body as LinePush)
.filter((b) => b.to === userId)
.flatMap((b) => b.messages.map((m) => m.text ?? '')))
},
}
})
+232
View File
@@ -0,0 +1,232 @@
import type { APIRequestContext } from '@playwright/test'
import { connectInstagram, connectMessenger, connectWhatsApp, routeToAgent, type ChannelAccount } from '../api/channels'
import type { ApiClient } from '../api/client'
import { env } from '../env'
import { FakeExternals, type RecordedCall } from '../fakes/externals'
import { unique } from '../support/ids'
import { step } from '../support/step'
import { ChatWithButtons, digits, hmac, phoneNumber, postWebhook, samePhone, sentWith, type Button, type ButtonChat, type ChannelConnector } from './chat'
// WhatsApp, Messenger and Instagram share one webhook (/webhooks/meta), signed
// with the Meta app secret; `object` in the body says which product sent it.
// A number or Page from the customer's OWN Meta app is signed with that app's
// secret instead, and reaches its account's own address (/webhooks/meta/<id>).
/** Where Meta posts a webhook, and the app secret it signs it with. */
export interface MetaWebhook {
path: string
secret: string
}
const SHARED_META_WEBHOOK: MetaWebhook = { path: '/webhooks/meta', secret: env.channelSecrets.meta }
/** The X-Hub-Signature-256 header Meta puts on `json`. */
export function metaSignature(json: string, secret: string = env.channelSecrets.meta): Record<string, string> {
return { 'x-hub-signature-256': `sha256=${hmac('sha256', secret, json).toString('hex')}` }
}
/** A Meta webhook POST, signed like Meta signs it. */
export async function deliverMeta(request: APIRequestContext, payload: Record<string, unknown>, to = SHARED_META_WEBHOOK): Promise<void> {
const json = JSON.stringify(payload)
await postWebhook(request, to.path, { json }, metaSignature(json, to.secret))
}
/** A Meta access token that owns `nodes` in the fake Graph (see fake-externals). */
export function metaToken(...nodes: string[]): string {
return `owns:${nodes.join(',')}:${unique('token')}`
}
/** A customer's own Meta app (e.g. the one holding Meta's free test number):
* its tokens name it in the fake Graph, and Meta signs its webhooks with its secret. */
export interface OwnMetaApp {
id: string
secret: string
token(...nodes: string[]): string
}
export function ownMetaApp(): OwnMetaApp {
const id = digits(15)
const secret = unique('appsecret')
return { id, secret, token: (...nodes) => `owns:${nodes.join(',')}:app=${id}.${secret}:${unique('token')}` }
}
/** The backend path of an absolute webhook URL it handed out. */
function webhookPath(url: string): string {
return new URL(url).pathname.replace(/^\/api\/v1/, '')
}
/** Where Meta sends a WhatsApp number's webhooks: the per-number callback the
* backend set (and the fake accepted), else the app's own callback — which
* is the customer's server, so here the shared address, refusing the app's secret. */
export async function whereMetaSends(externals: FakeExternals, phoneNumberId: string, app: OwnMetaApp): Promise<MetaWebhook> {
const overrides = (await externals.calls('graph', phoneNumberId))
.filter((c) => c.method === 'POST' && c.status === 200)
.map((c) => (c.body as { webhook_configuration?: { override_callback_uri?: string } }).webhook_configuration)
.filter((config) => config !== undefined)
const url = overrides.at(-1)?.override_callback_uri
return { path: url ? webhookPath(url) : SHARED_META_WEBHOOK.path, secret: app.secret }
}
/** What Meta does when the customer pastes a callback URL and verify token into
* their app and saves: a GET that must echo the challenge. */
export async function metaVerifiesCallback(request: APIRequestContext, url: string, verifyToken: string): Promise<boolean> {
const challenge = unique('challenge')
const response = await request.get(url, { params: { 'hub.mode': 'subscribe', 'hub.verify_token': verifyToken, 'hub.challenge': challenge } })
return response.ok() && (await response.text()) === challenge
}
/** A pasted callback URL as the webhook Meta posts to, signed with the app's secret. */
export function pastedCallback(url: string, app: OwnMetaApp): MetaWebhook {
return { path: webhookPath(url), secret: app.secret }
}
// ---------- WhatsApp ----------
interface WhatsAppSend {
to: string
type: string
text?: { body: string }
interactive?: {
body: { text: string }
action: { buttons?: Array<{ reply: Button }>; sections?: Array<{ rows: Button[] }> }
}
}
type WhatsAppChoice = Button & { reply: 'button_reply' | 'list_reply' }
export interface WhatsAppNumber {
account: ChannelAccount
phoneNumberId: string
token: string
}
/** Connects a WhatsApp Cloud API number by hand (phone number id + system-user token),
* from the customer's own Meta app when `app` is given (its token, with its secret). */
export async function connectWhatsAppNumber(api: ApiClient, phoneNumberId = digits(15), app?: OwnMetaApp): Promise<WhatsAppNumber> {
const token = app ? app.token(phoneNumberId) : metaToken(phoneNumberId)
const account = await connectWhatsApp(api, phoneNumberId, token, app?.secret)
return { account, phoneNumberId, token }
}
/** The webhook body WhatsApp sends for one inbound message. */
export function whatsappPayload(phoneNumberId: string, waId: string, name: string, message: Record<string, unknown>): Record<string, unknown> {
return {
object: 'whatsapp_business_account',
entry: [{ id: digits(15), changes: [{ field: 'messages', value: {
messaging_product: 'whatsapp',
metadata: { phone_number_id: phoneNumberId },
contacts: [{ wa_id: waId, profile: { name } }],
messages: [{ from: waId, id: `wamid.${unique('in')}`, timestamp: String(Math.floor(Date.now() / 1000)), ...message }],
} }] }],
}
}
/** One WhatsApp user's chat with a connected number. */
export class WhatsAppChat extends ChatWithButtons<WhatsAppChoice> {
readonly waId: string
readonly number: WhatsAppNumber
/** `to` says where Meta sends this number's webhooks (the shared address by default). */
constructor(request: APIRequestContext, externals: FakeExternals, number: WhatsAppNumber, customerName: string,
to: () => Promise<MetaWebhook> = async () => SHARED_META_WEBHOOK) {
const waId = phoneNumber().slice(1) // WhatsApp ids are the number without the +
const inbound = async (message: Record<string, unknown>) =>
deliverMeta(request, whatsappPayload(number.phoneNumberId, waId, customerName, message), await to())
// Only what this number sent: its path, its token. The same endpoint carries
// read receipts and typing indicators, which have no `type`.
const sends = async () => (await externals.calls('graph', 'messages', { contains: waId }))
.filter((c) => c.status === 200 && c.path.includes(`/${number.phoneNumberId}/messages`) && sentWith(c, number.token))
.map((c) => c.body as WhatsAppSend)
.filter((b) => (b.type === 'text' || b.type === 'interactive') && samePhone(b.to, waId))
const latestChoices = async (): Promise<WhatsAppChoice[]> => {
const action = (await sends()).filter((b) => b.interactive).at(-1)?.interactive?.action
if (action?.sections) return action.sections.flatMap((s) => s.rows).map((r) => ({ ...r, reply: 'list_reply' as const }))
return (action?.buttons ?? []).map((b) => ({ ...b.reply, reply: 'button_reply' as const }))
}
super('WhatsApp', customerName, {
deliver: (text) => inbound({ type: 'text', text: { body: text } }),
read: async () => (await sends()).map((b) => (b.type === 'text' ? b.text!.body : b.interactive!.body.text)),
latestButtons: latestChoices,
// A pick from a list arrives as list_reply, a button tap as button_reply.
deliverTap: (button) => inbound({ type: 'interactive', interactive: { type: button.reply, [button.reply]: { id: button.id, title: button.title } } }),
})
this.waId = waId
this.number = number
}
}
export const whatsapp: ChannelConnector<WhatsAppChat> = ({ api, request, agentId }) =>
step('Connect a WhatsApp number', async () => {
const number = await connectWhatsAppNumber(api)
await routeToAgent(api, number.account.id, agentId)
const externals = new FakeExternals(request)
return { chatWith: (customerName) => new WhatsAppChat(request, externals, number, customerName) }
})
// ---------- Messenger and Instagram (the Send API on a page) ----------
interface PageSend {
recipient: { id: string }
message?: { text?: string; quick_replies?: Array<{ title: string; payload: string }> }
}
export function pageChat(
label: string,
request: APIRequestContext,
externals: FakeExternals,
object: 'page' | 'instagram',
pageId: string,
token: string,
customerName: string,
to = SHARED_META_WEBHOOK,
): ChatWithButtons {
const senderId = digits(16)
const provider = object === 'page' ? 'graph' : 'graph-instagram'
const inbound = (message: Record<string, unknown>) => deliverMeta(request, {
object,
entry: [{ id: pageId, time: Date.now(), messaging: [{
sender: { id: senderId },
recipient: { id: pageId },
timestamp: Date.now(),
message: { mid: `m_${unique('in')}`, ...message },
}] }],
}, to)
// `/me/messages` names no page: the token is the only proof of which page sent it.
const sends = async () => (await externals.calls(provider, 'messages', { contains: senderId }))
.filter((c: RecordedCall) => c.status === 200 && sentWith(c, token))
.map((c) => c.body as PageSend)
.filter((b) => b.recipient?.id === senderId && b.message?.text)
return new ChatWithButtons(label, customerName, {
deliver: (text) => inbound({ text }),
read: async () => (await sends()).map((b) => b.message!.text!),
latestButtons: async () => ((await sends())
.filter((b) => b.message?.quick_replies?.length).at(-1)?.message?.quick_replies ?? [])
.map((q) => ({ id: q.payload, title: q.title })),
// A quick-reply tap is a message whose text is the title, with the payload attached.
deliverTap: (button) => inbound({ text: button.title, quick_reply: { payload: button.id } }),
})
}
/** A Facebook Page connected by hand (page id + page access token). */
export const messenger: ChannelConnector<ButtonChat> = ({ api, request, agentId }) =>
step('Connect a Facebook Page', async () => {
const pageId = digits(15)
const token = metaToken(pageId)
const account = await connectMessenger(api, pageId, token)
await routeToAgent(api, account.id, agentId)
const externals = new FakeExternals(request)
return { chatWith: (customerName) => pageChat('Messenger', request, externals, 'page', pageId, token, customerName) }
})
/** An Instagram professional account connected by hand (IG user id + page token). */
export const instagram: ChannelConnector<ButtonChat> = ({ api, request, agentId }) =>
step('Connect an Instagram account', async () => {
const igId = digits(17)
const token = metaToken(igId)
const account = await connectInstagram(api, igId, token)
await routeToAgent(api, account.id, agentId)
const externals = new FakeExternals(request)
return { chatWith: (customerName) => pageChat('Instagram', request, externals, 'instagram', igId, token, customerName) }
})
+70
View File
@@ -0,0 +1,70 @@
import type { APIRequestContext } from '@playwright/test'
import { installSlack, listChannelAccounts, routeToAgent, type ChannelAccount } from '../api/channels'
import type { ApiClient } from '../api/client'
import { env } from '../env'
import { FakeExternals } from '../fakes/externals'
import { unique } from '../support/ids'
import { step } from '../support/step'
import { Chat, digits, hmac, postWebhook, sentWith, type ChannelConnector } from './chat'
interface OAuthAnswer { team: { id: string }; access_token: string }
interface SlackPost { channel: string; text: string }
/** The "typing…" placeholder the backend posts and then edits into the reply. */
const TYPING_PLACEHOLDER = '_typing…_'
/** Slack's v0 request signature over `v0:<timestamp>:<body>`. */
export function slackSignature(json: string, timestamp: string): string {
return `v0=${hmac('sha256', env.channelSecrets.slack, `v0:${timestamp}:${json}`).toString('hex')}`
}
export interface SlackWorkspace {
account: ChannelAccount
teamId: string
botToken: string
}
/** Installs the Slack app in a fresh workspace through the OAuth flow. */
export async function installSlackWorkspace(api: ApiClient, request: APIRequestContext): Promise<SlackWorkspace> {
const externals = new FakeExternals(request)
const code = unique('slack-code')
await installSlack(api, code)
// The workspace is whatever Slack answered the code exchange with.
const exchange = (await externals.calls('slack', 'oauth.v2.access', { contains: code }))[0]
const answer = exchange?.response as OAuthAnswer | undefined
const account = (await listChannelAccounts(api)).find((a) => a.channel_type === 'slack' && a.external_account_id === answer?.team.id)
if (!answer || !account) throw new Error(`no Slack account after the OAuth callback for code ${code}`)
return { account, teamId: answer.team.id, botToken: answer.access_token }
}
/** A Slack workspace installed through the OAuth flow; customers DM the bot. */
export const slack: ChannelConnector = ({ api, request, agentId }) =>
step('Install the Slack app in a workspace', async () => {
const { account, teamId, botToken } = await installSlackWorkspace(api, request)
await routeToAgent(api, account.id, agentId)
const externals = new FakeExternals(request)
return {
chatWith(customerName) {
const user = `U${digits(10)}`
const channel = `D${digits(10)}`
return new Chat('Slack', customerName, async (text) => {
const ts = `${Math.floor(Date.now() / 1000)}.${digits(6)}`
const json = JSON.stringify({
type: 'event_callback',
team_id: teamId,
event_id: `Ev${digits(10)}`,
event: { type: 'message', channel_type: 'im', channel, user, text, ts },
})
const timestamp = String(Math.floor(Date.now() / 1000))
await postWebhook(request, '/webhooks/slack', { json }, { 'x-slack-request-timestamp': timestamp, 'x-slack-signature': slackSignature(json, timestamp) })
}, async () => (await externals.calls('slack', undefined, { contains: channel }))
// A reply is the placeholder edited by chat.update, or a fresh chat.postMessage,
// sent with this workspace's bot token.
.filter((c) => (c.action === 'chat.update' || c.action === 'chat.postMessage') && c.status === 200 && sentWith(c, botToken))
.map((c) => c.body as SlackPost)
.filter((b) => b.channel === channel && b.text !== TYPING_PLACEHOLDER)
.map((b) => b.text))
},
}
})
+72
View File
@@ -0,0 +1,72 @@
import { connectSms, routeToAgent } from '../api/channels'
import { env } from '../env'
import { FakeExternals } from '../fakes/externals'
import { unique } from '../support/ids'
import { step } from '../support/step'
import { Chat, hmac, phoneNumber, postWebhook, samePhone, type ChannelConnector } from './chat'
// SMS webhooks are per account and signed over the public webhook URL, which the
// backend rebuilds from BACKEND_URL — the same URL the test posts to.
interface TwilioSend { From: string; To: string; Body: string }
interface PlivoSend { src: string; dst: string; text: string }
/** Twilio: X-Twilio-Signature = base64(HMAC-SHA1(auth token, url + sorted key/value pairs)). */
export const twilio: ChannelConnector = ({ api, request, agentId }) =>
step('Connect a Twilio number', async () => {
const authToken = unique('twilio-token')
const businessNumber = phoneNumber()
const accountSid = unique('AC')
const account = await connectSms(api, 'twilio', businessNumber, { account_sid: accountSid, auth_token: authToken })
await routeToAgent(api, account.id, agentId)
const externals = new FakeExternals(request)
const path = `/webhooks/sms/twilio/${account.id}`
return {
chatWith(customerName) {
const customerNumber = phoneNumber()
return new Chat('SMS (Twilio)', customerName, async (text) => {
const form = { From: customerNumber, To: businessNumber, Body: text, MessageSid: unique('SM') }
const signed = `${env.apiUrl}${path}` + Object.keys(form).sort().map((k) => k + form[k as keyof typeof form]).join('')
await postWebhook(request, path, { form }, { 'x-twilio-signature': hmac('sha1', authToken, signed).toString('base64') })
}, async () => (await externals.calls('twilio', 'Messages.json', { contains: accountSid }))
// Sent through this account (its SID in the path) from this number.
.filter((c) => c.status === 200 && c.path.includes(`/Accounts/${accountSid}/`))
.map((c) => c.body as TwilioSend)
.filter((b) => samePhone(b.To, customerNumber) && samePhone(b.From, businessNumber))
.map((b) => b.Body))
},
}
})
/** Plivo: X-Plivo-Signature-V3 = base64(HMAC-SHA256(auth token, url + nonce)). */
export const plivo: ChannelConnector = ({ api, request, agentId }) =>
step('Connect a Plivo number', async () => {
const authToken = unique('plivo-token')
const businessNumber = phoneNumber()
const authId = unique('MA')
const account = await connectSms(api, 'plivo', businessNumber, { auth_id: authId, auth_token: authToken })
await routeToAgent(api, account.id, agentId)
const externals = new FakeExternals(request)
const path = `/webhooks/sms/plivo/${account.id}`
return {
chatWith(customerName) {
// Plivo sends numbers without the leading +.
const customerNumber = phoneNumber().slice(1)
return new Chat('SMS (Plivo)', customerName, async (text) => {
const nonce = unique('nonce')
await postWebhook(request, path, {
form: { From: customerNumber, To: businessNumber.slice(1), Text: text, MessageUUID: unique('uuid') },
}, {
'x-plivo-signature-v3': hmac('sha256', authToken, `${env.apiUrl}${path}${nonce}`).toString('base64'),
'x-plivo-signature-v3-nonce': nonce,
})
}, async () => (await externals.calls('plivo', 'Message', { contains: authId }))
.filter((c) => c.status === 200 && c.path.includes(`/Account/${authId}/`))
.map((c) => c.body as PlivoSend)
.filter((b) => samePhone(b.dst, customerNumber) && samePhone(b.src, businessNumber))
.map((b) => b.text))
},
}
})
+140
View File
@@ -0,0 +1,140 @@
import { randomInt } from 'node:crypto'
import type { APIRequestContext } from '@playwright/test'
import { connectTelegram, routeToAgent, type ChannelAccount } from '../api/channels'
import type { ApiClient } from '../api/client'
import { env } from '../env'
import { FakeExternals } from '../fakes/externals'
import { unique } from '../support/ids'
import { step } from '../support/step'
import type { ChannelConnector } from './chat'
interface SetWebhookBody {
url: string
secret_token: string
}
interface SendMessageBody {
chat_id: number | string
text: string
reply_markup?: { inline_keyboard?: Array<Array<{ text: string; callback_data: string }>> }
}
/**
* Plays Telegram for one connected bot: delivers user messages to the backend's
* webhook exactly as Telegram would (secret token header included) and reads the
* bot's replies from what the backend sent to the fake Bot API.
*/
export class TelegramBot {
readonly account: ChannelAccount
private readonly request: APIRequestContext
private readonly externals: FakeExternals
private readonly secret: string
private readonly token: string
private updateId = 0
private constructor(account: ChannelAccount, request: APIRequestContext, externals: FakeExternals, secret: string, token: string) {
this.account = account
this.request = request
this.externals = externals
this.secret = secret
this.token = token
}
/** Connects a fresh bot to the org and routes it to `agentId`. */
static async connect(api: ApiClient, request: APIRequestContext, agentId: string): Promise<TelegramBot> {
return step('Connect a Telegram bot', async () => {
const externals = new FakeExternals(request)
const token = `${randomInt(1e9)}:${unique('E2E')}`
const account = await connectTelegram(api, token)
await routeToAgent(api, account.id, agentId)
// The secret Telegram would echo back — the backend sent it in setWebhook.
const webhook = await externals.waitForCall<SetWebhookBody>('telegram', 'setWebhook', (body) => body.url.includes(account.id))
return new TelegramBot(account, request, externals, webhook.secret_token, token)
})
}
/** A private chat with one Telegram user. */
chatWith(firstName: string): TelegramChat {
return new TelegramChat(this, randomInt(1e9), firstName)
}
async deliver(update: Record<string, unknown>): Promise<void> {
this.updateId += 1
const response = await this.request.post(`${env.apiUrl}/webhooks/telegram/${this.account.id}`, {
headers: { 'x-telegram-bot-api-secret-token': this.secret },
data: { update_id: this.updateId, ...update },
})
if (!response.ok()) throw new Error(`Telegram webhook → ${response.status()}: ${await response.text()}`)
}
/** What the bot sent into `chatId`, with the message_id Telegram assigned each. */
async messagesTo(chatId: number): Promise<Array<SendMessageBody & { message_id: number }>> {
// Only what this bot sent: the Bot API puts the token in the path.
const calls = await this.externals.calls('telegram', 'sendMessage', { contains: `/bot${this.token}/` })
return calls
.filter((c) => c.status === 200 && String((c.body as SendMessageBody).chat_id) === String(chatId))
.map((c) => ({ ...(c.body as SendMessageBody), message_id: (c.response as { result: { message_id: number } }).result.message_id }))
}
}
export class TelegramChat {
private readonly bot: TelegramBot
readonly chatId: number
readonly firstName: string
private messageId = 0
constructor(bot: TelegramBot, chatId: number, firstName: string) {
this.bot = bot
this.chatId = chatId
this.firstName = firstName
}
async send(text: string): Promise<void> {
await step(`${this.firstName} sends "${text}" on Telegram`, async () => {
this.messageId += 1
const user = { id: this.chatId, is_bot: false, first_name: this.firstName }
await this.bot.deliver({
message: { message_id: this.messageId, date: Math.floor(Date.now() / 1000), chat: { ...user, type: 'private' }, from: user, text },
})
})
}
/** Every message the bot has sent into this chat, oldest first. */
async replies(): Promise<string[]> {
return (await this.bot.messagesTo(this.chatId)).map((m) => m.text)
}
/** The buttons under the bot's latest message that has any. */
async buttons(): Promise<string[]> {
const withKeyboard = (await this.bot.messagesTo(this.chatId)).filter((m) => m.reply_markup?.inline_keyboard?.length)
return (withKeyboard.at(-1)?.reply_markup?.inline_keyboard ?? []).flat().map((b) => b.text)
}
/** Taps a button under the bot's latest keyboard, as Telegram reports it (callback_query). */
async tap(title: string): Promise<void> {
await step(`${this.firstName} taps "${title}" on Telegram`, async () => {
const keyboardMessage = (await this.bot.messagesTo(this.chatId)).filter((m) => m.reply_markup?.inline_keyboard?.length).at(-1)
const button = (keyboardMessage?.reply_markup?.inline_keyboard ?? []).flat().find((b) => b.text === title)
if (!keyboardMessage || !button) throw new Error(`no "${title}" button in the latest Telegram keyboard`)
const user = { id: this.chatId, is_bot: false, first_name: this.firstName }
await this.bot.deliver({
callback_query: {
id: `cb-${Date.now()}`,
from: user,
// The message the keyboard is attached to — the bot's, not the user's —
// with its keyboard, which is where the backend reads the button title.
message: {
message_id: keyboardMessage.message_id,
date: Math.floor(Date.now() / 1000),
chat: { ...user, type: 'private' },
reply_markup: keyboardMessage.reply_markup,
},
data: button.callback_data,
},
})
})
}
}
/** Telegram as a ChannelConnector, for journeys that loop over every channel. */
export const telegram: ChannelConnector<TelegramChat> = ({ api, request, agentId }) => TelegramBot.connect(api, request, agentId)
+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*$`) })
}
}
+58
View File
@@ -0,0 +1,58 @@
import { fileURLToPath } from 'node:url'
// The ONLY place the suite reads configuration. Ports must match scripts/stack.sh
// and .env.e2e — they are fixed so the stack never collides with a dev setup on
// localhost (different host and ports → its own cookies, DB and Redis).
const backendUrl = 'http://127.0.0.1:18000'
export const env = {
edition: (process.env.E2E_EDITION ?? 'oss') as 'oss' | 'enterprise',
/** The stack runs with Redis (scripts/stack.sh E2E_REDIS_URL): dedupe and rate limits are live. */
redis: Boolean(process.env.E2E_REDIS_URL),
/** The backend runs several workers (stack.sh E2E_BACKEND_WORKERS); socket.io polling isn't sticky across them. */
multiWorker: Boolean(process.env.E2E_BACKEND_WORKERS),
frontendUrl: 'http://127.0.0.1:15173',
backendUrl,
apiUrl: `${backendUrl}/api/v1`,
fakeLlmUrl: 'http://127.0.0.1:14010',
/** Recording stand-in for Telegram, Meta, Slack, … (services/fake-externals). */
fakeExternalsUrl: 'http://127.0.0.1:14020',
/** A real MCP server with a lookup_order tool (services/fake-mcp). */
fakeMcpUrl: 'http://127.0.0.1:14030',
/** Mailpit's web/API port (its SMTP side is 127.0.0.1:11025, where .env.e2e sends all mail). */
mailUrl: 'http://127.0.0.1:18025',
/** The pretend customer website the widget is embedded on (services/customer-site). */
customerSiteUrl: 'http://127.0.0.1:13000',
/** The throwaway database stack.sh manages (same E2E_PG_URL convention). */
databaseUrl: `${(process.env.E2E_PG_URL ?? `postgresql://${process.env.USER}@127.0.0.1:5432`).replace(/\/$/, '')}/replypier_e2e`,
/** The private enterprise suite, present only where the enterprise_frontend submodule is checked out. */
enterpriseSpecDir: fileURLToPath(new URL('../../frontend/src/modules/enterprise/e2e/', import.meta.url)),
/** stack.sh, for `stack.sh python …` one-off jobs against the running stack. */
stackScript: fileURLToPath(new URL('../scripts/stack.sh', import.meta.url)),
/** Where the setup project writes the org contract (see src/org-state.ts). */
authDir: fileURLToPath(new URL('../.auth/', import.meta.url)),
/** App-wide webhook signing secrets; the same values are set in .env.e2e. */
channelSecrets: {
meta: 'e2e-meta-app-secret',
slack: 'e2e-slack-signing-secret',
},
/** The Shopify app's key and secret, which sign embedded-app session tokens. */
shopifyApp: { key: 'e2e-shopify-api-key', secret: 'e2e-shopify-api-secret' },
} as const
/**
* Throwaway credentials for the throwaway e2e database. Passwords satisfy the
* backend's strength rules; example.com passes the email validators.
*/
export const seedUsers = {
admin: { name: 'Erin Admin', email: 'admin@e2e.example.com', password: 'E2e-Admin-Passw0rd!' },
humanAgent: { name: 'Sam Agent', email: 'sam.agent@e2e.example.com', password: 'E2e-Agent-Passw0rd!' },
} as const
export const seedOrg = {
name: 'E2E Org',
domain: 'e2e.example.com',
supportGroup: 'E2E Support',
llmModel: 'e2e-fake',
} as const
+79
View File
@@ -0,0 +1,79 @@
import { expect, type APIRequestContext } from '@playwright/test'
import type { Fault, RecordedCall } from '../../services/fake-externals/server'
import { env } from '../env'
import { unique } from '../support/ids'
import { AI_REPLY } from '../support/timeouts'
export type { RecordedCall }
export interface CallFilter {
/** Only calls whose path or body contains this — e.g. a customer id or the account's phone number id. */
contains?: string
/** Only calls recorded at or after this moment. */
since?: Date
}
/** Test-side client for services/fake-externals: what the backend sent to third parties. */
export class FakeExternals {
private readonly request: APIRequestContext
constructor(request: APIRequestContext) {
this.request = request
}
/** Recorded calls to `provider` (optionally one `action`), oldest first. Filtering happens in the fake. */
async calls(provider: string, action?: string, { contains, since }: CallFilter = {}): Promise<RecordedCall[]> {
const params: Record<string, string> = { provider }
if (action) params.action = action
if (contains) params.contains = contains
if (since) params.since = since.toISOString()
const response = await this.request.get(`${env.fakeExternalsUrl}/__calls`, { params })
return (await response.json()) as RecordedCall[]
}
/** Every call to any provider since `since` — failure diagnostics. */
async callsSince(since: Date): Promise<RecordedCall[]> {
const response = await this.request.get(`${env.fakeExternalsUrl}/__calls`, { params: { since: since.toISOString() } })
return (await response.json()) as RecordedCall[]
}
/** Waits for the first call matching `match` and returns its body. */
async waitForCall<T = Record<string, unknown>>(
provider: string,
action: string,
match: (body: T) => boolean = () => true,
): Promise<T> {
let found: T | undefined
await expect.poll(async () => {
found = (await this.calls(provider, action)).map((c) => c.body as T).find(match)
return found !== undefined
}, { message: `a ${provider} ${action} call`, ...AI_REPLY }).toBe(true)
return found!
}
/**
* Makes matching calls fail the way the provider would (e.g. WhatsApp's 24-hour
* window error) until the returned function is called. Scope it with `contains`
* so parallel tests are untouched.
*/
async fail(fault: Fault): Promise<() => Promise<void>> {
return this.script(fault)
}
/**
* Answers matching calls with exactly this — for a provider the fake has no
* built-in answers for (e.g. one only a private suite uses). Same scoping rules.
*/
async respond(answer: Fault): Promise<() => Promise<void>> {
return this.script(answer)
}
private async script(fault: Fault): Promise<() => Promise<void>> {
const id = unique('fault')
const response = await this.request.put(`${env.fakeExternalsUrl}/__faults/${id}`, { data: fault })
if (!response.ok()) throw new Error(`fake-externals rejected the fault: ${await response.text()}`)
return async () => {
await this.request.delete(`${env.fakeExternalsUrl}/__faults/${id}`)
}
}
}
+138
View File
@@ -0,0 +1,138 @@
import type { APIRequestContext } from '@playwright/test'
import type { RecordedRequest, Reply, ScenarioRule, ScenarioSpec } from '../../services/fake-llm/protocol'
import { env } from '../env'
/** What a script needs to know about the agent it drives (see fixtures/resources.ts). */
export interface ScriptedAgent {
name: string
scenarioId: string
}
/** The text an agent's instructions carry so the fake LLM can find its script. */
export function scenarioMarker(scenarioId: string): string {
return `[[e2e:${scenarioId}]]`
}
class RuleBuilder {
private readonly script: Script
private readonly match: ScenarioRule['match']
private readonly delayMs?: number
private readonly usage?: Reply['usage']
constructor(script: Script, match: ScenarioRule['match'], delayMs?: number, usage?: Reply['usage']) {
this.script = script
this.match = match
this.delayMs = delayMs
this.usage = usage
}
/** Answer only after `ms` — past AGENT_RUN_TIMEOUT (.env.e2e) to make the backend give up. */
after(ms: number): RuleBuilder {
return new RuleBuilder(this.script, this.match, ms, this.usage)
}
/** Report these token counts with the answer, as a real provider's `usage` would. */
costing(promptTokens: number, completionTokens: number): RuleBuilder {
return new RuleBuilder(this.script, this.match, this.delayMs, { prompt_tokens: promptTokens, completion_tokens: completionTokens })
}
/** A normal answer. `fields` sets any other reply field, e.g. { end_chat: true }. */
reply(message: string, fields?: Record<string, unknown>): Script {
return this.script.add({ match: this.match, reply: { message, fields, usage: this.usage }, ...this.fault() })
}
/** The provider fails the request with `status` (500, 429, …) and an OpenAI-style error. */
fail(status: number, message?: string): Script {
return this.script.add({ match: this.match, reply: {}, fault: { status, message, delayMs: this.delayMs } })
}
/** The provider returns content that isn't the JSON the backend asked for. */
malformed(): Script {
return this.script.add({ match: this.match, reply: {}, fault: { malformed: true, delayMs: this.delayMs } })
}
private fault(): Pick<ScenarioRule, 'fault'> {
return this.delayMs ? { fault: { delayMs: this.delayMs } } : {}
}
/** The agent decides to hand the chat to a human. */
handOver(message: string): Script {
return this.reply(message, { transfer_to_human: true, transfer_reason: 'DIRECT_REQUEST' })
}
/** The agent opens a support ticket through the backend's create_ticket tool. */
createTicket(message: string, summary: string): Script {
return this.callTool('create_ticket', { summary, description: summary }).thenReply(message)
}
/** The agent wraps up the chat (the widget then asks for a rating if the agent is set to). */
endChat(message: string): Script {
return this.reply(message, { end_chat: true, end_chat_reason: 'ISSUE_RESOLVED', request_rating: true })
}
/** The agent records a lead from details the visitor gave (with their consent). */
captureLead(message: string, lead: { email: string; name?: string; company?: string }): Script {
return this.reply(message, {
request_lead_capture: true,
lead_email: lead.email,
lead_name: lead.name ?? null,
lead_company: lead.company ?? null,
lead_consent: true,
})
}
/** The model calls a backend tool first, then answers with `reply` once the result is back. */
callTool(name: string, args: Record<string, unknown>) {
return {
thenReply: (message: string, fields?: Record<string, unknown>): Script =>
this.script.add({ match: this.match, toolCall: { name, arguments: args }, reply: { message, fields, usage: this.usage }, ...this.fault() }),
}
}
}
/** Ordered rules: the first whose pattern matches the customer's last message wins. */
export class Script {
readonly rules: ScenarioRule[] = []
when(pattern: RegExp): RuleBuilder {
return new RuleBuilder(this, { source: pattern.source, flags: pattern.flags })
}
otherwise(): RuleBuilder {
return new RuleBuilder(this, undefined)
}
add(rule: ScenarioRule): Script {
this.rules.push(rule)
return this
}
}
/** Test-side client for services/fake-llm. */
export class FakeLlm {
private readonly request: APIRequestContext
/** Every agent this client scripted — what failure diagnostics report on. */
readonly scripted: ScriptedAgent[] = []
constructor(request: APIRequestContext) {
this.request = request
}
/**
* Scripts what `agent` says. Example:
* await llm.script(agent, (s) => s.when(/human/i).handOver('Connecting you.').otherwise().reply('Hi!'))
*/
async script(agent: ScriptedAgent, build: (script: Script) => Script): Promise<void> {
const spec: ScenarioSpec = { agentName: agent.name, rules: build(new Script()).rules }
const response = await this.request.put(`${env.fakeLlmUrl}/__scenarios/${agent.scenarioId}`, { data: spec })
if (!response.ok()) throw new Error(`fake LLM rejected the script: ${await response.text()}`)
if (!this.scripted.includes(agent)) this.scripted.push(agent)
}
/** Every request the backend sent for this agent — to assert on what reached the model. */
async requests(agent: ScriptedAgent): Promise<RecordedRequest[]> {
const response = await this.request.get(`${env.fakeLlmUrl}/__scenarios/${agent.scenarioId}/requests`)
return (await response.json()) as RecordedRequest[]
}
}
+79
View File
@@ -0,0 +1,79 @@
import { expect, type APIRequestContext } from '@playwright/test'
import { env } from '../env'
interface MailpitMessage {
ID: string
Subject: string
Created: string
From: { Address: string; Name: string }
}
export interface Email {
id: string
from: string
subject: string
text: string
}
interface Lookup {
/** Only emails received after this moment — e.g. a second code to the same address. */
since?: Date
}
/**
* Client for the stack's Mailpit (SMTP capture). Everything the backend emails —
* verification codes, notifications — lands here instead of a real inbox.
*/
export class Mailbox {
private readonly request: APIRequestContext
constructor(request: APIRequestContext) {
this.request = request
}
/** The newest email to `address`, waiting for it to arrive. */
async latestTo(address: string, { since }: Lookup = {}): Promise<{ subject: string; text: string }> {
let latest: MailpitMessage | undefined
await expect.poll(async () => {
const newest = (await this.search(`to:"${address}"`))[0]
latest = newest && (!since || new Date(newest.Created) >= since) ? newest : undefined
return latest?.ID
}, { message: `an email to ${address}`, timeout: 15_000 }).toBeTruthy()
const message = await (await this.request.get(`${env.mailUrl}/api/v1/message/${latest!.ID}`)).json()
return { subject: latest!.Subject, text: String(message.Text ?? '') }
}
/** Subjects of every email to `address` (newest first), optionally only those since a time. */
async subjectsTo(address: string, { since }: Lookup = {}): Promise<string[]> {
return (await this.search(`to:"${address}"`)).filter((m) => !since || new Date(m.Created) >= since).map((m) => m.Subject)
}
/** Every email to `address` so far, oldest first — no waiting; poll it. */
async messagesTo(address: string): Promise<Email[]> {
const messages = await this.search(`to:"${address}"`)
return Promise.all(messages.reverse().map(async (m) => ({
id: m.ID,
from: m.From.Address,
subject: m.Subject,
text: String((await (await this.request.get(`${env.mailUrl}/api/v1/message/${m.ID}`)).json()).Text ?? ''),
})))
}
/** One email's raw headers, e.g. In-Reply-To for threading. */
async headers(id: string): Promise<Record<string, string[]>> {
return (await this.request.get(`${env.mailUrl}/api/v1/message/${id}/headers`)).json()
}
private async search(query: string): Promise<MailpitMessage[]> {
const response = await this.request.get(`${env.mailUrl}/api/v1/search`, { params: { query } })
return ((await response.json()) as { messages: MailpitMessage[] }).messages
}
/** The 6-digit verification code in the newest email to `address`. */
async verificationCode(address: string, lookup: Lookup = {}): Promise<string> {
const { text } = await this.latestTo(address, lookup)
const code = /\b(\d{6})\b/.exec(text)?.[1]
if (!code) throw new Error(`no 6-digit code in the email to ${address}:\n${text.slice(0, 500)}`)
return code
}
}
+22
View File
@@ -0,0 +1,22 @@
import type { APIRequestContext } from '@playwright/test'
import { env } from '../env'
export interface McpRequest {
at: string
authorization: string | null
rpcMethod: string | null
}
/** Test-side client for services/fake-mcp: what reached the MCP server. */
export class FakeMcp {
readonly url = `${env.fakeMcpUrl}/mcp`
private readonly request: APIRequestContext
constructor(request: APIRequestContext) {
this.request = request
}
async requests(): Promise<McpRequest[]> {
return (await (await this.request.get(`${env.fakeMcpUrl}/__requests`)).json()) as McpRequest[]
}
}
+60
View File
@@ -0,0 +1,60 @@
import { existsSync, openSync, readSync, closeSync, statSync } from 'node:fs'
import { fileURLToPath } from 'node:url'
import type { TestInfo } from '@playwright/test'
import { FakeExternals } from '../fakes/externals'
import type { FakeLlm } from '../fakes/llm'
/** Where scripts/stack.sh writes each service's output (absent when Playwright started the services). */
const BACKEND_LOG = fileURLToPath(new URL('../../.stack/backend.log', import.meta.url))
const MAX_LOG_BYTES = 200_000
function logSize(): number {
return existsSync(BACKEND_LOG) ? statSync(BACKEND_LOG).size : 0
}
function logSince(offset: number): string {
if (!existsSync(BACKEND_LOG)) return ''
const end = statSync(BACKEND_LOG).size
const start = Math.max(offset, end - MAX_LOG_BYTES)
if (end <= start) return ''
const buffer = Buffer.alloc(end - start)
const fd = openSync(BACKEND_LOG, 'r')
try {
readSync(fd, buffer, 0, buffer.length, start)
} finally {
closeSync(fd)
}
return buffer.toString('utf8')
}
/** Marks where a test starts, so a failure can attach only what happened during it. */
export function startDiagnostics(): { since: Date; logOffset: number } {
return { since: new Date(), logOffset: logSize() }
}
/**
* On failure, attaches what the backend did while the test ran: its log, the
* model requests for every agent the test scripted, and every third-party call
* (other parallel tests' calls included — they are timestamped). Passing tests
* attach nothing.
*/
export async function attachDiagnostics(
testInfo: TestInfo,
start: { since: Date; logOffset: number },
llm: FakeLlm,
externals: FakeExternals,
): Promise<void> {
if (testInfo.status === testInfo.expectedStatus) return
const json = (value: unknown) => JSON.stringify(value, null, 2)
const log = logSince(start.logOffset)
if (log) await testInfo.attach('backend.log', { body: log, contentType: 'text/plain' })
for (const agent of llm.scripted) {
const requests = await llm.requests(agent).catch((e: unknown) => [`unavailable: ${e}`])
await testInfo.attach(`llm-requests-${agent.scenarioId}.json`, { body: json(requests), contentType: 'application/json' })
}
const calls = await externals.callsSince(start.since).catch(() => [])
if (calls.length) await testInfo.attach('third-party-calls.json', { body: json(calls), contentType: 'application/json' })
}
+274
View File
@@ -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 }
+13
View File
@@ -0,0 +1,13 @@
import type { BrowserContext } from '@playwright/test'
/**
* Everything off the local stack — fonts, analytics, geo-IP lookups, CDNs. No
* journey depends on them, and blocking them keeps runs hermetic: nothing leaves
* the machine, and an outage elsewhere can't fail a test. Text still renders
* with fallback fonts.
*/
const OFF_STACK = /^https?:\/\/(?!(127\.0\.0\.1|localhost)[:/])/
export async function blockThirdParty(context: BrowserContext): Promise<void> {
await context.route(OFF_STACK, (route) => route.abort())
}
+61
View File
@@ -0,0 +1,61 @@
// The harness public API. Specs (and the enterprise suite, via the `@e2e` alias)
// import from here only — never from '@playwright/test' directly, so there is one
// Playwright instance and one place to change shared behaviour.
export { test, expect, type AgentSettings, type HandoverTeam, type Member, type MemberOptions, type TestAgent } from './fixtures'
export { ApiClient } from './api/client'
export { createGroup, createUser, roleId } from './api/team'
export { orgDomain, setAlwaysClosed, setAlwaysOpen, setOrgDomain } from './api/organizations'
export { chatMessages, chatsWithAgent, type ChatMessage, type ChatOverview } from './api/chats'
export { findPerson, type Person } from './api/people'
export { customizeAgent } from './api/customization'
export { connectCrm, disconnectCrm, syncPersonToCrm, type CrmProvider } from './api/crm'
export { connectJira, disconnectJira, fakeJira, installShopify, routeTicketsToJira, uninstallShopify, useShopForAgent } from './api/integrations'
export { publishWorkflow, type WorkflowGraph } from './api/workflows'
export { createArticle, enableHelpCenter, setArticleStatus, type Article } from './api/help-center'
export { connectEmail, listChannelAccounts } from './api/channels'
export { currentAiConfig, restoreAi, useFakeLlm, type AiConfig } from './api/ai'
export { createAgent, enableLeadCapture } from './api/agents'
export { addKnowledgeText, deleteKnowledge, unfinishedKnowledge, uploadKnowledgePdf } from './api/knowledge'
export { createWidget, loadWidgetFrom } from './api/widgets'
export { textPdf } from './support/pdf'
export { createWidgetApp, issueConversationToken } from './api/widget-apps'
export { ChatWidget, type EmbedOptions } from './components/chat-widget'
export { Sidebar } from './components/sidebar'
export { env, seedOrg, seedUsers } from './env'
export { FakeLlm, Script, scenarioMarker, type ScriptedAgent } from './fakes/llm'
export { Mailbox, type Email } from './fakes/mailbox'
export { FakeExternals } from './fakes/externals'
export { FakeMcp } from './fakes/mcp'
export { attachMcpConnector, createMcpConnector, type McpConnector } from './api/mcp'
export { connectLine, connectMessenger, connectSms, connectTelegram, routeToAgent, type ChannelAccount } from './api/channels'
export { channels, channelsWithButtons, type ButtonChat, type EmailChat, type ChannelChat, type ChannelConnector, type ConnectedChannel } from './channels'
export { TelegramBot, TelegramChat } from './channels/telegram'
export { hmac, phoneNumber, digits, sendWebhook, type WebhookPayload } from './channels/chat'
export {
connectWhatsAppNumber, deliverMeta, metaSignature, metaToken, metaVerifiesCallback, ownMetaApp, pageChat, pastedCallback,
whatsappPayload, whereMetaSends, WhatsAppChat, type MetaWebhook, type OwnMetaApp, type WhatsAppNumber,
} from './channels/meta'
export { installSlackWorkspace, slackSignature } from './channels/slack'
export { login, signedInClient } from './api/auth'
export { agentSocketOverPolling, agentSocketOverWebSocket, signInCookies, type SocketOutcome } from './api/agent-socket'
export { prepareOrg, type PreparedOrg } from './org-setup'
export { readOrgState, writeOrgState, storageStatePath, type OrgState } from './org-state'
export { AgentsPage } from './pages/agents.page'
export { AiConfigPage } from './pages/ai-config.page'
export { Dashboard } from './pages/dashboard.page'
export { IntegrationsPage } from './pages/integrations.page'
export { HumanAgentsPage, type NewTeamMember } from './pages/human-agents.page'
export { InboxPage } from './pages/inbox.page'
export { LoginPage } from './pages/login.page'
export { PeoplePage } from './pages/people.page'
export { SetupPage } from './pages/setup.page'
export { SetupWizard } from './pages/setup-wizard.page'
export { TicketsPage } from './pages/tickets.page'
export { unique } from './support/ids'
export { step } from './support/step'
export { AI_REPLY, KNOWLEDGE_INDEXED } from './support/timeouts'
export { expectUnchanged } from './support/unchanged'
export { expectNoNewA11yViolations } from './support/a11y'
export { runWorkerOnce, type Worker } from './support/workers'
export { sql, uuid } from './support/db'
export type { Page, Locator, BrowserContext, APIRequestContext, APIResponse } from '@playwright/test'
+58
View File
@@ -0,0 +1,58 @@
import { expect, type Browser } from '@playwright/test'
import { useFakeLlm } from './api/ai'
import type { ApiClient } from './api/client'
import { setAlwaysOpen } from './api/organizations'
import { ensureGroup, ensureUser } from './api/team'
import { disableAutoInvestigation } from './api/tickets'
import { env, seedOrg, seedUsers } from './env'
import { storageStatePath, writeOrgState } from './org-state'
import { LoginPage } from './pages/login.page'
export interface PreparedOrg {
orgId: string
/** Signed-in admin seeding client. */
api: ApiClient
/** storageState file already saved for the signed-in admin. */
adminStorageState: string
/**
* OSS points the org at the fake LLM itself. Enterprise orgs keep the
* ReplyPier AI their signup created, which reaches the fake through
* OPENAI_BASE_URL.
*/
configureAi: boolean
}
/**
* Everything both editions' setup projects do once an admin is signed in: make
* the org deterministic, create Sam Agent and the support group handover needs,
* save Sam's signed-in state, and write the org contract every spec reads.
* Idempotent, so it is safe on a database left by an earlier run.
*/
export async function prepareOrg(browser: Browser, org: PreparedOrg): Promise<void> {
const { api, orgId } = org
const { admin, humanAgent } = seedUsers
if (org.configureAi) await useFakeLlm(api)
await setAlwaysOpen(api, orgId)
await disableAutoInvestigation(api)
const sam = await ensureUser(api, { ...humanAgent, role: 'Agent' })
const supportGroupId = await ensureGroup(api, seedOrg.supportGroup, [sam])
// Signing in is what marks Sam online — handover needs an online group member.
const agentContext = await browser.newContext({ baseURL: env.frontendUrl })
const agentLogin = new LoginPage(await agentContext.newPage())
await agentLogin.goto()
await agentLogin.signIn(humanAgent.email, humanAgent.password)
await expect(agentLogin.page).not.toHaveURL(/\/login/)
const agentState = storageStatePath('human-agent')
await agentContext.storageState({ path: agentState })
await agentContext.close()
writeOrgState({
edition: env.edition,
orgId,
supportGroupId,
admin: { ...admin, storageState: org.adminStorageState },
humanAgent: { ...humanAgent, id: sam.id, storageState: agentState },
})
}
+38
View File
@@ -0,0 +1,38 @@
import { mkdirSync, readFileSync, writeFileSync } from 'node:fs'
import { join } from 'node:path'
import { env } from './env'
/**
* The contract between an edition's setup project and every spec: who the org is
* and where the signed-in browser states live. Specs only READ this, so the same
* OSS spec runs unchanged whichever edition's setup wrote it.
*/
export interface OrgState {
edition: 'oss' | 'enterprise'
orgId: string
supportGroupId: string
admin: { email: string; password: string; name: string; storageState: string }
humanAgent: { id: string; email: string; password: string; name: string; storageState: string }
}
const file = join(env.authDir, 'org.json')
export const storageStatePath = (role: string) => join(env.authDir, `${role}.json`)
export function writeOrgState(state: OrgState): void {
mkdirSync(env.authDir, { recursive: true })
writeFileSync(file, JSON.stringify(state, null, 2))
}
let cached: OrgState | undefined
export function readOrgState(): OrgState {
if (!cached) {
try {
cached = JSON.parse(readFileSync(file, 'utf8')) as OrgState
} catch {
throw new Error(`${file} is missing — run the 'setup' project first (it is a dependency of every project)`)
}
}
return cached
}
+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')
}
}
+52
View File
@@ -0,0 +1,52 @@
import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs'
import { fileURLToPath } from 'node:url'
import AxeBuilder from '@axe-core/playwright'
import { expect, test, type Page } from '@playwright/test'
import { step } from './step'
/**
* Accessibility checks against a baseline. Each screen may keep the serious and
* critical axe violations it has today (a11y-baseline/<screen>.json); anything
* new fails, and fixing one shrinks the baseline. Regenerate after a fix with
* UPDATE_A11Y_BASELINE=1 npx playwright test specs/a11y. One file per screen, so
* parallel workers updating different screens never overwrite each other.
*/
const BASELINE_DIR = fileURLToPath(new URL('../../a11y-baseline/', import.meta.url))
const SERIOUS = new Set(['serious', 'critical'])
function baselineFile(screen: string): string {
return `${BASELINE_DIR}${screen}.json`
}
function readBaseline(screen: string): string[] {
const file = baselineFile(screen)
return existsSync(file) ? (JSON.parse(readFileSync(file, 'utf8')) as string[]) : []
}
/**
* Rule ids of the serious and critical violations on the page now (iframes
* included). Each offending element, with axe's explanation (for contrast: the
* colours and the ratio), is attached to the test so a failure says what to fix.
*/
async function violations(page: Page, screen: string): Promise<string[]> {
const results = await new AxeBuilder({ page }).withTags(['wcag2a', 'wcag2aa', 'wcag21a', 'wcag21aa']).analyze()
const serious = results.violations.filter((v) => SERIOUS.has(v.impact ?? ''))
if (serious.length) {
const details = serious.map((v) => ({ rule: v.id, nodes: v.nodes.map((n) => ({ target: n.target, why: n.failureSummary })) }))
await test.info().attach(`axe-${screen}.json`, { body: JSON.stringify(details, null, 2), contentType: 'application/json' })
}
return [...new Set(serious.map((v) => v.id))].sort()
}
export async function expectNoNewA11yViolations(page: Page, screen: string): Promise<void> {
await step(`Accessibility check: ${screen}`, async () => {
const found = await violations(page, screen)
if (process.env.UPDATE_A11Y_BASELINE) {
mkdirSync(BASELINE_DIR, { recursive: true })
writeFileSync(baselineFile(screen), `${JSON.stringify(found)}\n`)
return
}
const known = new Set(readBaseline(screen))
expect(found.filter((id) => !known.has(id)), `new accessibility violations on ${screen}`).toEqual([])
})
}
+18
View File
@@ -0,0 +1,18 @@
import { execFileSync } from 'node:child_process'
import { env } from '../env'
/**
* Direct SQL on the throwaway e2e database, for state no API sets — e.g. moving
* a timestamp past a deadline a worker checks. Keep it rare; seed through src/api.
*/
export function sql(query: string): string {
return execFileSync('psql', [env.databaseUrl, '-v', 'ON_ERROR_STOP=1', '-qtA', '-c', query], { encoding: 'utf8' }).trim()
}
const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i
/** Guards every id interpolated into SQL. */
export function uuid(value: string): string {
if (!UUID.test(value)) throw new Error(`not a UUID: ${value}`)
return value
}
+13
View File
@@ -0,0 +1,13 @@
import { randomBytes } from 'node:crypto'
let counter = 0
/**
* A name no other test (or earlier run against a reused database) will have.
* Every resource a test creates gets one, so tests never depend on each other's data.
*/
export function unique(prefix: string): string {
counter += 1
const random = randomBytes(3).toString('hex')
return `${prefix}-${Date.now().toString(36)}${counter}${random}`
}
+26
View File
@@ -0,0 +1,26 @@
/**
* A minimal, valid one-page PDF whose text layer is `lines` — enough for the
* knowledge processor's PDF reader, with no PDF library.
*/
export function textPdf(lines: string[]): Buffer {
const escape = (text: string) => text.replace(/[\\()]/g, (c) => `\\${c}`)
const content = ['BT /F1 12 Tf 72 720 Td 16 TL', ...lines.map((l) => `(${escape(l)}) Tj T*`), 'ET'].join('\n')
const objects = [
'<< /Type /Catalog /Pages 2 0 R >>',
'<< /Type /Pages /Kids [3 0 R] /Count 1 >>',
'<< /Type /Page /Parent 2 0 R /MediaBox [0 0 612 792] /Contents 4 0 R /Resources << /Font << /F1 5 0 R >> >> >>',
`<< /Length ${Buffer.byteLength(content)} >>\nstream\n${content}\nendstream`,
'<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>',
]
let pdf = '%PDF-1.4\n'
const offsets: number[] = []
objects.forEach((body, i) => {
offsets.push(Buffer.byteLength(pdf))
pdf += `${i + 1} 0 obj\n${body}\nendobj\n`
})
const xref = Buffer.byteLength(pdf)
pdf += `xref\n0 ${objects.length + 1}\n0000000000 65535 f \n`
pdf += offsets.map((o) => `${String(o).padStart(10, '0')} 00000 n \n`).join('')
pdf += `trailer\n<< /Size ${objects.length + 1} /Root 1 0 R >>\nstartxref\n${xref}\n%%EOF\n`
return Buffer.from(pdf)
}
+10
View File
@@ -0,0 +1,10 @@
import { test } from '@playwright/test'
/**
* Runs `body` as a named report step, so traces and reports read as user actions
* ("Customer sends 'I want a human'") instead of raw clicks. Page objects and
* components wrap every public action in this.
*/
export function step<T>(title: string, body: () => Promise<T>): Promise<T> {
return test.step(title, body, { box: true })
}
+9
View File
@@ -0,0 +1,9 @@
/**
* How long a test waits for an AI answer: a cold embedding model plus a couple of
* model calls. Longer than AGENT_RUN_TIMEOUT in .env.e2e, so a run the backend
* gives up on surfaces as its fallback message rather than as a test timeout.
*/
export const AI_REPLY = { timeout: 30_000 } as const
/** How long a test waits for the knowledge worker to index a small source. */
export const KNOWLEDGE_INDEXED = { timeout: 60_000 } as const
+18
View File
@@ -0,0 +1,18 @@
import { expect } from '@playwright/test'
const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms))
/**
* Asserts that `read()` keeps returning what it returns now, for `ms`. For the
* "and nothing else happens" half of a journey — the AI staying quiet after a
* takeover, a rejected webhook producing no reply — which no single check proves.
* Call it after a positive signal that the backend has handled the event.
*/
export async function expectUnchanged<T>(what: string, read: () => Promise<T>, ms = 3_000): Promise<void> {
const initial = await read()
const until = Date.now() + ms
while (Date.now() < until) {
await sleep(250)
expect(await read() as unknown, `${what} changed`).toEqual(initial)
}
}
+17
View File
@@ -0,0 +1,17 @@
import { execFileSync } from 'node:child_process'
import { fileURLToPath } from 'node:url'
import { step } from './step'
const STACK = fileURLToPath(new URL('../../scripts/stack.sh', import.meta.url))
export type Worker = 'crm-sync' | 'knowledge' | 'ticket-lifecycle'
/**
* Runs one pass of a background worker now (scripts/stack.sh tick), so a journey
* that production completes on a worker's next poll completes here, at once.
*/
export async function runWorkerOnce(worker: Worker): Promise<void> {
await step(`The ${worker} worker runs`, async () => {
execFileSync(STACK, ['tick', worker], { env: process.env, stdio: 'pipe', timeout: 120_000 })
})
}