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