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 } 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, groupIds?: string[]) => Promise /** 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 /** * 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 /** * 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 /** 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 /** 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({ 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 | 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 }