From cb4894ddc3035064d744b65f22cd778055026c2e Mon Sep 17 00:00:00 2001 From: saridsa2 Date: Sat, 11 Apr 2026 12:12:22 +0530 Subject: [PATCH] feat: Global E2E tests, multi-agent fixes, SIP agent tracing - 13 Global Hospital smoke tests (CC Agent + Supervisor) - Auto-unlock agent session in test setup via maint API - agent-status-toggle sends agentId from localStorage (was missing) - maint-otp-modal injects agentId from localStorage into all calls - SIP manager logs agent identity on connect/disconnect/state changes - seed-data.ts: added CC agent + marketing users, idempotent member creation, cleanup phase before seeding - .gitignore: exclude test-results/ and playwright-report/ Co-Authored-By: Claude Opus 4.6 (1M context) --- .gitignore | 4 + e2e/auth.setup.ts | 9 +- e2e/global-setup.ts | 25 ++++ e2e/global-smoke.spec.ts | 120 ++++++++++++++++++ playwright.config.ts | 16 ++- scripts/seed-data.ts | 53 ++++++++ .../call-desk/agent-status-toggle.tsx | 4 +- src/components/modals/maint-otp-modal.tsx | 8 +- src/state/sip-manager.ts | 16 ++- 9 files changed, 247 insertions(+), 8 deletions(-) create mode 100644 e2e/global-setup.ts create mode 100644 e2e/global-smoke.spec.ts diff --git a/.gitignore b/.gitignore index 7ceb59f..141f482 100644 --- a/.gitignore +++ b/.gitignore @@ -23,3 +23,7 @@ dist-ssr *.sln *.sw? .env + +# Playwright +test-results/ +playwright-report/ diff --git a/e2e/auth.setup.ts b/e2e/auth.setup.ts index d0a8944..e5405f2 100644 --- a/e2e/auth.setup.ts +++ b/e2e/auth.setup.ts @@ -5,7 +5,14 @@ import { fileURLToPath } from 'url'; const __dirname = path.dirname(fileURLToPath(import.meta.url)); const authFile = path.join(__dirname, '.auth/agent.json'); -setup('login as CC Agent', async ({ page }) => { +setup('login as CC Agent', async ({ page, request, baseURL }) => { + // Clear any stale session lock before login + const url = baseURL ?? 'https://ramaiah.engage.healix360.net'; + await request.post(`${url}/api/maint/unlock-agent`, { + headers: { 'Content-Type': 'application/json', 'x-maint-otp': '400168' }, + data: { agentId: 'ramaiahadmin' }, + }).catch(() => {}); + await page.goto('/login'); await page.locator('input[type="email"], input[name="email"], input[placeholder*="@"]').first().fill('ccagent@ramaiahcare.com'); diff --git a/e2e/global-setup.ts b/e2e/global-setup.ts new file mode 100644 index 0000000..7655de8 --- /dev/null +++ b/e2e/global-setup.ts @@ -0,0 +1,25 @@ +import { test as setup, expect } from '@playwright/test'; +import path from 'path'; +import { fileURLToPath } from 'url'; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +const authFile = path.join(__dirname, '.auth/global-agent.json'); + +setup('login as Global CC Agent', async ({ page, request }) => { + // Clear any stale session lock before login + await request.post('https://global.engage.healix360.net/api/maint/unlock-agent', { + headers: { 'Content-Type': 'application/json', 'x-maint-otp': '400168' }, + data: { agentId: 'global' }, + }).catch(() => {}); + + await page.goto('https://global.engage.healix360.net/login'); + + await page.locator('input[type="email"], input[placeholder*="@"]').first().fill('rekha.cc@globalcare.com'); + await page.locator('input[type="password"]').first().fill('Global@123'); + await page.getByRole('button', { name: 'Sign in', exact: true }).click(); + + await expect(page).not.toHaveURL(/\/login/, { timeout: 20_000 }); + await expect(page.locator('aside').first()).toBeVisible({ timeout: 10_000 }); + + await page.context().storageState({ path: authFile }); +}); diff --git a/e2e/global-smoke.spec.ts b/e2e/global-smoke.spec.ts new file mode 100644 index 0000000..6237f13 --- /dev/null +++ b/e2e/global-smoke.spec.ts @@ -0,0 +1,120 @@ +/** + * Global Hospital — happy-path smoke tests. + * + * Uses saved auth state from global-setup.ts (same pattern as Ramaiah). + * Last test signs out to release the agent session. + */ +import { test, expect } from '@playwright/test'; +import { loginAs, waitForApp } from './helpers'; + +const BASE = 'https://global.engage.healix360.net'; + +test.describe('Global — CC Agent', () => { + + test('landing page loads', async ({ page }) => { + await page.goto(BASE + '/'); + await waitForApp(page); + await expect(page.locator('aside').first()).toBeVisible(); + }); + + test('Call History page loads', async ({ page }) => { + await page.goto(BASE + '/call-history'); + await waitForApp(page); + await expect(page.locator('text="Call History"').first()).toBeVisible(); + }); + + test('Patients page loads', async ({ page }) => { + await page.goto(BASE + '/patients'); + await waitForApp(page); + const search = page.getByPlaceholder(/search/i).or(page.getByLabel(/search/i)); + await expect(search.first()).toBeVisible(); + }); + + test('Appointments page loads', async ({ page }) => { + await page.goto(BASE + '/appointments'); + await waitForApp(page); + await expect( + page.locator('text=/Appointment|Schedule|No appointment/i').first(), + ).toBeVisible({ timeout: 10_000 }); + }); + + test('My Performance page loads', async ({ page }) => { + await page.goto(BASE + '/my-performance'); + await waitForApp(page); + await expect(page.getByRole('button', { name: 'Today' })).toBeVisible(); + }); + + test('sidebar has CC Agent nav items', async ({ page }) => { + await page.goto(BASE + '/'); + await waitForApp(page); + const sidebar = page.locator('aside').first(); + for (const item of ['Call Desk', 'Call History', 'Patients', 'Appointments', 'My Performance']) { + await expect(sidebar.locator(`text="${item}"`)).toBeVisible(); + } + }); + + // Last test — sign out to release session + test('sign-out completes', async ({ page }) => { + await page.goto(BASE + '/'); + await waitForApp(page); + + const sidebar = page.locator('aside').first(); + const accountArea = sidebar.locator('[class*="account"], [class*="avatar"]').last(); + if (await accountArea.isVisible()) await accountArea.click(); + + const signOutBtn = page.locator('button, [role="menuitem"]').filter({ hasText: /sign out/i }).first(); + if (await signOutBtn.isVisible({ timeout: 5_000 }).catch(() => false)) { + await signOutBtn.click(); + const modal = page.locator('[role="dialog"]'); + await expect(modal).toBeVisible({ timeout: 5_000 }); + await modal.getByRole('button', { name: /sign out/i }).click(); + await expect(page).toHaveURL(/\/login/, { timeout: 15_000 }); + } + }); +}); + +test.describe('Global — Supervisor', () => { + test.beforeEach(async ({ page }) => { + await page.goto(BASE + '/login'); + await page.locator('input[type="email"], input[placeholder*="@"]').first().fill('dr.ramesh@globalcare.com'); + await page.locator('input[type="password"]').first().fill('Global@123'); + await page.getByRole('button', { name: 'Sign in', exact: true }).click(); + await expect(page).not.toHaveURL(/\/login/, { timeout: 20_000 }); + await waitForApp(page); + }); + + test('landing page loads', async ({ page }) => { + await expect(page.locator('aside').first()).toBeVisible(); + }); + + test('Patients page loads', async ({ page }) => { + await page.goto(BASE + '/patients'); + await waitForApp(page); + const search = page.getByPlaceholder(/search/i).or(page.getByLabel(/search/i)); + await expect(search.first()).toBeVisible(); + }); + + test('Appointments page loads', async ({ page }) => { + await page.goto(BASE + '/appointments'); + await waitForApp(page); + await expect( + page.locator('text=/Appointment|Schedule|No appointment/i').first(), + ).toBeVisible({ timeout: 10_000 }); + }); + + test('Campaigns page loads', async ({ page }) => { + await page.goto(BASE + '/campaigns'); + await waitForApp(page); + await expect( + page.locator('text=/Campaign|No campaign/i').first(), + ).toBeVisible({ timeout: 10_000 }); + }); + + test('Settings page loads', async ({ page }) => { + await page.goto(BASE + '/settings'); + await waitForApp(page); + await expect( + page.locator('text=/Settings|Configuration/i').first(), + ).toBeVisible({ timeout: 10_000 }); + }); +}); diff --git a/playwright.config.ts b/playwright.config.ts index 7f53c79..3ce4f1b 100644 --- a/playwright.config.ts +++ b/playwright.config.ts @@ -14,7 +14,7 @@ export default defineConfig({ use: { baseURL: process.env.E2E_BASE_URL ?? 'https://ramaiah.engage.healix360.net', headless: true, - screenshot: 'only-on-failure', + screenshot: 'on', trace: 'on-first-retry', actionTimeout: 8_000, navigationTimeout: 15_000, @@ -47,5 +47,19 @@ export default defineConfig({ testMatch: /supervisor-smoke\.spec\.ts/, use: { browserName: 'chromium' }, }, + // Global Hospital — auth setup + smoke tests + { + name: 'global-setup', + testMatch: /global-setup\.ts/, + }, + { + name: 'global', + dependencies: ['global-setup'], + testMatch: /global-smoke\.spec\.ts/, + use: { + storageState: path.join(__dirname, 'e2e/.auth/global-agent.json'), + browserName: 'chromium', + }, + }, ], }); diff --git a/scripts/seed-data.ts b/scripts/seed-data.ts index a2f617f..74346af 100644 --- a/scripts/seed-data.ts +++ b/scripts/seed-data.ts @@ -68,6 +68,14 @@ async function ensureWorkspaceContext() { async function mkMember(email: string, password: string, firstName: string, lastName: string, roleName?: string): Promise { await ensureWorkspaceContext(); + // Check if already exists + const existing = await gql('{ workspaceMembers { edges { node { id userEmail } } } }'); + const found = existing.workspaceMembers.edges.find((e: any) => e.node.userEmail.toLowerCase() === email.toLowerCase()); + if (found) { + console.log(` (exists) ${email} → ${found.node.id}`); + return found.node.id; + } + // Create the user + link to workspace await gql( `mutation($email: String!, $password: String!, $workspaceId: UUID!, $workspaceInviteHash: String!) { @@ -99,11 +107,37 @@ async function mkMember(email: string, password: string, firstName: string, last return memberId; } +async function clearAll() { + // Delete in reverse dependency order + const entities = ['followUp', 'leadActivity', 'call', 'appointment', 'lead', 'patient', 'doctorVisitSlot', 'doctor', 'campaign', 'clinic']; + for (const entity of entities) { + const cap = entity[0].toUpperCase() + entity.slice(1); + try { + const data = await gql(`{ ${entity}s(first: 100) { edges { node { id } } } }`); + const ids: string[] = data[`${entity}s`].edges.map((e: any) => e.node.id); + if (ids.length === 0) { console.log(` ${cap}: 0 records`); continue; } + for (const id of ids) { + await gql(`mutation { delete${cap}(id: "${id}") { id } }`); + } + console.log(` ${cap}: deleted ${ids.length}`); + } catch (err: any) { + console.log(` ${cap}: skip (${err.message?.slice(0, 60)})`); + } + } +} + async function main() { console.log('🌱 Seeding Helix Engage demo data...\n'); await auth(); console.log('✅ Auth OK\n'); + // Clean slate — remove all existing entity data (not users) + console.log('🧹 Clearing existing data...'); + await clearAll(); + console.log(''); + + await auth(); + // ═══════════════════════════════════════════ // CLINICS (needed for doctor visit slots) // ═══════════════════════════════════════════ @@ -136,6 +170,25 @@ async function main() { await auth(); + // ═══════════════════════════════════════════ + // CALL CENTER & MARKETING STAFF + // + // CC agents (HelixEngage User role) handle inbound/outbound calls. + // Marketing executives and supervisors use HelixEngage Supervisor role. + // Email domain uses globalcare.com to match the deployment. + // ═══════════════════════════════════════════ + console.log('📞 Call center & marketing staff'); + const wmRekha = await mkMember('rekha.cc@globalcare.com', 'Global@123', 'Rekha', 'Nair', 'HelixEngage User'); + console.log(` Rekha (CC Agent): ${wmRekha}`); + const wmGanesh = await mkMember('ganesh.cc@globalcare.com', 'Global@123', 'Ganesh', 'Iyer', 'HelixEngage User'); + console.log(` Ganesh (CC Agent): ${wmGanesh}`); + const wmSanjay = await mkMember('sanjay.marketing@globalcare.com', 'Global@123', 'Sanjay', 'Verma', 'HelixEngage Supervisor'); + console.log(` Sanjay (Marketing): ${wmSanjay}`); + const wmRamesh = await mkMember('dr.ramesh@globalcare.com', 'Global@123', 'Ramesh', 'Gupta', 'HelixEngage Supervisor'); + console.log(` Dr. Ramesh (Supervisor): ${wmRamesh}\n`); + + await auth(); + // ═══════════════════════════════════════════ // DOCTOR WORKSPACE MEMBERS // diff --git a/src/components/call-desk/agent-status-toggle.tsx b/src/components/call-desk/agent-status-toggle.tsx index d7c6b4e..171a718 100644 --- a/src/components/call-desk/agent-status-toggle.tsx +++ b/src/components/call-desk/agent-status-toggle.tsx @@ -46,12 +46,12 @@ export const AgentStatusToggle = ({ isRegistered, connectionStatus }: AgentStatu try { if (newStatus === 'ready') { console.log('[AGENT-STATE] Changing to Ready'); - const res = await apiClient.post('/api/ozonetel/agent-state', { state: 'Ready' }); + const res = await apiClient.post('/api/ozonetel/agent-state', { agentId, state: 'Ready' }); console.log('[AGENT-STATE] Ready response:', JSON.stringify(res)); } else { const pauseReason = newStatus === 'break' ? 'Break' : 'Training'; console.log(`[AGENT-STATE] Changing to Pause: ${pauseReason}`); - const res = await apiClient.post('/api/ozonetel/agent-state', { state: 'Pause', pauseReason }); + const res = await apiClient.post('/api/ozonetel/agent-state', { agentId, state: 'Pause', pauseReason }); console.log('[AGENT-STATE] Pause response:', JSON.stringify(res)); } // Don't setStatus — SSE will push the real state diff --git a/src/components/modals/maint-otp-modal.tsx b/src/components/modals/maint-otp-modal.tsx index 545dc2b..51cf0d7 100644 --- a/src/components/modals/maint-otp-modal.tsx +++ b/src/components/modals/maint-otp-modal.tsx @@ -59,14 +59,18 @@ export const MaintOtpModal = ({ isOpen, onOpenChange, action, preStepContent, pr onOpenChange(false); setOtp(''); } else { - // Standard sidecar endpoint + // Standard sidecar endpoint — include agentId from agent config + const agentCfg = localStorage.getItem('helix_agent_config'); + const agentId = agentCfg ? JSON.parse(agentCfg).ozonetelAgentId : undefined; + const payload = { ...preStepPayload, ...(agentId ? { agentId } : {}) }; + const res = await fetch(`${API_URL}/api/maint/${action.endpoint}`, { method: 'POST', headers: { 'Content-Type': 'application/json', 'x-maint-otp': otp, }, - ...(preStepPayload ? { body: JSON.stringify(preStepPayload) } : {}), + body: JSON.stringify(payload), }); const data = await res.json(); if (res.ok) { diff --git a/src/state/sip-manager.ts b/src/state/sip-manager.ts index 0edc9eb..f2638a9 100644 --- a/src/state/sip-manager.ts +++ b/src/state/sip-manager.ts @@ -6,6 +6,7 @@ let sipClient: SIPClient | null = null; let connected = false; let outboundPending = false; let outboundActive = false; +let activeAgentId: string | null = null; type StateUpdater = { setConnectionStatus: (status: ConnectionStatus) => void; @@ -42,6 +43,16 @@ export function connectSip(config: SIPConfig): void { sipClient.disconnect(); } + // Resolve agent identity for logging + try { + const agentCfg = JSON.parse(localStorage.getItem('helix_agent_config') ?? '{}'); + activeAgentId = agentCfg.ozonetelAgentId ?? null; + const ext = config.uri?.match(/sip:(\d+)@/)?.[1] ?? 'unknown'; + console.log(`[SIP] Connecting agent=${activeAgentId} ext=${ext} ws=${config.wsServer}`); + } catch { + console.log(`[SIP] Connecting uri=${config.uri}`); + } + connected = true; stateUpdater?.setConnectionStatus('connecting'); @@ -62,7 +73,7 @@ export function connectSip(config: SIPConfig): void { return; } - console.log(`[SIP-MGR] State: ${state} | caller=${number ?? 'none'} | ucid=${ucid ?? 'none'} | outboundActive=${outboundActive}`); + console.log(`[SIP] ${activeAgentId} | state=${state} | caller=${number ?? 'none'} | ucid=${ucid ?? 'none'} | outbound=${outboundActive}`); stateUpdater?.setCallState(state); if (!outboundActive && number !== undefined) { @@ -90,12 +101,13 @@ export function disconnectSip(force = false): void { console.log('[SIP-MGR] Disconnect blocked — call in progress'); return; } - console.log('[SIP-MGR] Disconnecting SIP' + (force ? ' (forced)' : '')); + console.log(`[SIP] Disconnecting agent=${activeAgentId}` + (force ? ' (forced)' : '')); sipClient?.disconnect(); sipClient = null; connected = false; outboundPending = false; outboundActive = false; + activeAgentId = null; stateUpdater?.setConnectionStatus('disconnected'); stateUpdater?.setCallUcid(null); }