diff --git a/packages/calling/playwright.config.ts b/packages/calling/playwright.config.ts index e0cf3cdad41..8315dead126 100644 --- a/packages/calling/playwright.config.ts +++ b/packages/calling/playwright.config.ts @@ -123,18 +123,35 @@ export default defineConfig({ testMatch: USER_SETS.SET_CALL.testSuite, use: {...browserOptions[PW_BROWSER], testEnv: 'int'} as any, }, + // Call History has its own suite and can run in parallel with SET_CALL - PROD + // because it uses USER_1+USER_2 after those single-user suites complete. + { + name: 'SET_CALL_HISTORY - PROD', + dependencies: ['SET_REGISTRATION_1 - PROD', 'SET_REGISTRATION_2 - PROD'], + testDir: './playwright/suites', + testMatch: USER_SETS.SET_CALL_HISTORY.testSuite, + use: browserOptions[PW_BROWSER], + }, + // INT aliases overlap between USER_1/2 and USER_4/5, so keep INT ordered. + { + name: 'SET_CALL_HISTORY - INT', + dependencies: ['SET_CALL - INT'], + testDir: './playwright/suites', + testMatch: USER_SETS.SET_CALL_HISTORY.testSuite, + use: {...browserOptions[PW_BROWSER], testEnv: 'int'} as any, + }, - // 3-user transfer tests — waits for call tests + // 3-user transfer tests — waits for call history because both suites use USER_1/USER_2. { name: 'SET_CALL_TRANSFER_CONSULT - PROD', - dependencies: ['SET_CALL - PROD'], + dependencies: ['SET_CALL - PROD', 'SET_CALL_HISTORY - PROD'], testDir: './playwright/suites', testMatch: USER_SETS.SET_CALL_TRANSFER_CONSULT.testSuite, use: browserOptions[PW_BROWSER], }, { name: 'SET_CALL_TRANSFER_CONSULT - INT', - dependencies: ['SET_CALL - INT'], + dependencies: ['SET_CALL - INT', 'SET_CALL_HISTORY - INT'], testDir: './playwright/suites', testMatch: USER_SETS.SET_CALL_TRANSFER_CONSULT.testSuite, use: {...browserOptions[PW_BROWSER], testEnv: 'int'} as any, diff --git a/packages/calling/playwright/constants/call-history.ts b/packages/calling/playwright/constants/call-history.ts new file mode 100644 index 00000000000..6bc47b0c080 --- /dev/null +++ b/packages/calling/playwright/constants/call-history.ts @@ -0,0 +1,13 @@ +export const CALL_HISTORY_ANSWERED_DISPOSITIONS = ['ANSWERED', 'INITIATED']; +export const CALL_HISTORY_MISSED_CALLER_DISPOSITIONS = ['CANCELED', 'INITIATED']; +export const CALL_HISTORY_REJECTED_CALLER_DISPOSITIONS = ['CANCELED', 'INITIATED']; +export const CALL_HISTORY_REJECTED_CALLEE_DISPOSITIONS = ['REJECTED', 'MISSED', 'CANCELED']; + +export const CALL_HISTORY_TIME_LOOKBACK_MS = 5000; +export const CALL_HISTORY_RECENT_RECORD_TOLERANCE_MS = 120000; +export const CALL_HISTORY_COUNTERPART_MATCH_MIN_DIGITS = 4; +export const CALL_HISTORY_URL_PATTERN = '**/history/userSessions**'; +export const CALL_HISTORY_EVENTUAL_CONSISTENCY_TIMEOUT = 150000; +export const CALL_HISTORY_POLL_INTERVALS = [5000, 5000, 10000, 10000, 15000]; +export const CALL_HISTORY_TIMING_TOLERANCE_MS = 120000; +export const CALL_HISTORY_DURATION_TOLERANCE_SECONDS = 120; diff --git a/packages/calling/playwright/constants/index.ts b/packages/calling/playwright/constants/index.ts index 23e882244ee..6cdd0a93b92 100644 --- a/packages/calling/playwright/constants/index.ts +++ b/packages/calling/playwright/constants/index.ts @@ -48,3 +48,17 @@ export { POST_ACTION_SETTLE_MS, TRANSFER_SUITE_TIMEOUT, } from './timeouts'; +export { + CALL_HISTORY_ANSWERED_DISPOSITIONS, + CALL_HISTORY_MISSED_CALLER_DISPOSITIONS, + CALL_HISTORY_REJECTED_CALLER_DISPOSITIONS, + CALL_HISTORY_REJECTED_CALLEE_DISPOSITIONS, + CALL_HISTORY_TIME_LOOKBACK_MS, + CALL_HISTORY_RECENT_RECORD_TOLERANCE_MS, + CALL_HISTORY_COUNTERPART_MATCH_MIN_DIGITS, + CALL_HISTORY_URL_PATTERN, + CALL_HISTORY_EVENTUAL_CONSISTENCY_TIMEOUT, + CALL_HISTORY_POLL_INTERVALS, + CALL_HISTORY_TIMING_TOLERANCE_MS, + CALL_HISTORY_DURATION_TOLERANCE_SECONDS, +} from './call-history'; diff --git a/packages/calling/playwright/constants/selectors.ts b/packages/calling/playwright/constants/selectors.ts index 736d3716298..55aa28796e0 100644 --- a/packages/calling/playwright/constants/selectors.ts +++ b/packages/calling/playwright/constants/selectors.ts @@ -51,5 +51,10 @@ export const CALLING_SELECTORS = { INCOMING_CALL: '#incoming-call', CALL_QUALITY_METRICS: '#call-quality-metrics', + // Call History + CALL_HISTORY_BTN: '#Call-history', + CALL_HISTORY_HEADER: '#callHistoryHeaderId', + CALL_HISTORY_TABLE_BODY: '#callHistoryTableId', + END_BTN: '#end', }; diff --git a/packages/calling/playwright/suites/call-history.spec.ts b/packages/calling/playwright/suites/call-history.spec.ts new file mode 100644 index 00000000000..5f3e0bccbaa --- /dev/null +++ b/packages/calling/playwright/suites/call-history.spec.ts @@ -0,0 +1,4 @@ +import {callHistoryTests} from '../test-groups/call-history'; + +// Account roles resolved from testInfo.project.name -> USER_SETS. +callHistoryTests(); diff --git a/packages/calling/playwright/test-data.ts b/packages/calling/playwright/test-data.ts index 5cc3c68bbba..f93ad60e34d 100644 --- a/packages/calling/playwright/test-data.ts +++ b/packages/calling/playwright/test-data.ts @@ -114,6 +114,14 @@ export const USER_SETS: Record = { testSuite: 'set-call.spec.ts', }, + // Call History uses USER_1+USER_2 after their single-user suites complete, + // so it can run alongside the SET_CALL call lifecycle suite in PROD. + SET_CALL_HISTORY: { + name: 'SET_CALL_HISTORY', + accounts: ['USER_1', 'USER_2'], + testSuite: 'call-history.spec.ts', + }, + // 3-user transfer tests (PROD — dedicated accounts, parallel with registration) SET_CALL_TRANSFER_CONSULT: { name: 'SET_CALL_TRANSFER_CONSULT', diff --git a/packages/calling/playwright/test-groups/call-history.ts b/packages/calling/playwright/test-groups/call-history.ts new file mode 100644 index 00000000000..4fe96c2e301 --- /dev/null +++ b/packages/calling/playwright/test-groups/call-history.ts @@ -0,0 +1,275 @@ +import {test, expect} from '@playwright/test'; +import {TestManager} from '../test-manager'; +import {getPhoneNumber} from '../test-data'; +import { + cleanupActiveCalls, + endCall, + endCallerIfStillActive, + establishCall, + makeCall, + rejectCall, + waitForCallDisconnect, + waitForIncomingCall, +} from '../utils/call'; +import { + CALL_HISTORY_ANSWERED_DISPOSITIONS, + CALL_HISTORY_MISSED_CALLER_DISPOSITIONS, + CALL_HISTORY_REJECTED_CALLEE_DISPOSITIONS, + CALL_HISTORY_REJECTED_CALLER_DISPOSITIONS, + CALL_HISTORY_TIME_LOOKBACK_MS, +} from '../constants'; +import { + attachCallHistorySummary, + expectHistoryTiming, + expectUiShowsHistoryRecord, + expectUiShowsHistoryRecords, + getCallHistoryRecords, + getDisplayHistoryRecords, + openCallHistoryList, + waitForCallHistoryCase, +} from '../utils/call-history'; +import {runBidirectionalHistoryJourney} from '../utils/call-history-journey'; + +export function callHistoryTests() { + test.describe('Call History Query', () => { + test('CH-QUERY-001: Call history query helper passes pagination and sorting options', async ({ + page, + }) => { + await page.evaluate(() => { + (window as any).__callHistoryQueryArgs = []; + (window as any).callHistory = { + getCallHistoryData: async (...args: unknown[]) => { + (window as any).__callHistoryQueryArgs.push(args); + + return {data: {userSessions: [{sessionId: 'query-record'}]}}; + }, + }; + }); + + const records = await getCallHistoryRecords(page, { + days: 3, + limit: 5, + sort: 'ASC', + sortBy: 'startTime', + }); + + const queryArgs = await page.evaluate(() => (window as any).__callHistoryQueryArgs[0]); + + expect(records).toEqual([{sessionId: 'query-record'}]); + expect(queryArgs).toEqual([3, 5, 'ASC', 'startTime']); + }); + }); + + test.describe('Call History', () => { + test.describe.configure({mode: 'serial', timeout: 300000}); + + let tm: TestManager; + let callerNumber: string; + let calleeNumber: string; + + test.beforeAll(async ({browser}, testInfo) => { + tm = new TestManager(testInfo.project.name); + await tm.setupContext(browser, 0, { + initSDK: true, + service: 'calling', + register: true, + media: true, + }); + await tm.setupContext(browser, 1, { + initSDK: true, + service: 'calling', + register: true, + media: true, + }); + callerNumber = getPhoneNumber(tm.userSet.accounts[0]); + calleeNumber = getPhoneNumber(tm.userSet.accounts[1]); + }); + + test.afterEach(async () => { + await Promise.all([ + cleanupActiveCalls(tm.getPage(tm.userSet.accounts[0])), + cleanupActiveCalls(tm.getPage(tm.userSet.accounts[1])), + ]); + if (!tm.page.isClosed()) { + await tm.page.waitForTimeout(3000); + } + }); + + test.afterAll(async () => { + await tm.cleanup(); + }); + + /* eslint-disable no-empty-pattern */ + test('CH-CALL-001: Answered call creates exact per-user history records', async ({}, testInfo) => { + const callerPage = tm.getPage(tm.userSet.accounts[0]); + const calleePage = tm.getPage(tm.userSet.accounts[1]); + const startedAt = new Date(Date.now() - CALL_HISTORY_TIME_LOOKBACK_MS); + + await establishCall(callerPage, calleePage, calleeNumber); + await callerPage.waitForTimeout(2000); + await endCall(callerPage); + await Promise.all([waitForCallDisconnect(callerPage), waitForCallDisconnect(calleePage)]); + const endedAt = new Date(); + + const callerRecord = await waitForCallHistoryCase( + callerPage, + { + counterpartNumber: calleeNumber, + direction: 'OUTGOING', + startedAt, + dispositions: CALL_HISTORY_ANSWERED_DISPOSITIONS, + }, + 'caller outgoing answered call' + ); + const calleeRecord = await waitForCallHistoryCase( + calleePage, + { + counterpartNumber: callerNumber, + direction: 'INCOMING', + startedAt, + dispositions: CALL_HISTORY_ANSWERED_DISPOSITIONS, + }, + 'callee incoming answered call' + ); + + expectHistoryTiming(callerRecord, {notBefore: startedAt, notAfter: endedAt}); + expectHistoryTiming(calleeRecord, {notBefore: startedAt, notAfter: endedAt}); + await attachCallHistorySummary(testInfo, 'answered-picked', [ + {user: 'user1', expectedDisposition: 'ANSWERED', record: callerRecord}, + {user: 'user2', expectedDisposition: 'ANSWERED', record: calleeRecord}, + ]); + + const callerRows = await openCallHistoryList(callerPage); + expect(callerRows.length).toBeGreaterThan(0); + + await expectUiShowsHistoryRecord(callerPage, callerRecord); + await expectUiShowsHistoryRecord(calleePage, calleeRecord); + }); + + test('CH-CALL-002: Missed call creates exact callee MISSED history', async ({}, testInfo) => { + const callerPage = tm.getPage(tm.userSet.accounts[0]); + const calleePage = tm.getPage(tm.userSet.accounts[1]); + const startedAt = new Date(Date.now() - CALL_HISTORY_TIME_LOOKBACK_MS); + + await makeCall(callerPage, calleeNumber); + await waitForIncomingCall(calleePage); + await callerPage.waitForTimeout(5000); + await endCall(callerPage); + await Promise.all([waitForCallDisconnect(callerPage), waitForCallDisconnect(calleePage)]); + const endedAt = new Date(); + + const calleeMissedRecord = await waitForCallHistoryCase( + calleePage, + { + counterpartNumber: callerNumber, + direction: 'INCOMING', + startedAt, + dispositions: ['MISSED'], + }, + 'callee incoming missed call' + ); + const callerCanceledRecord = await waitForCallHistoryCase( + callerPage, + { + counterpartNumber: calleeNumber, + direction: 'OUTGOING', + startedAt, + dispositions: CALL_HISTORY_MISSED_CALLER_DISPOSITIONS, + }, + 'caller outgoing unanswered call' + ); + + expectHistoryTiming(calleeMissedRecord, {notBefore: startedAt, notAfter: endedAt}); + expectHistoryTiming(callerCanceledRecord, {notBefore: startedAt, notAfter: endedAt}); + await attachCallHistorySummary(testInfo, 'missed-not-picked', [ + {user: 'user1', expectedDisposition: 'CANCELED', record: callerCanceledRecord}, + {user: 'user2', expectedDisposition: 'MISSED', record: calleeMissedRecord}, + ]); + + await expectUiShowsHistoryRecord(calleePage, calleeMissedRecord); + await expectUiShowsHistoryRecord(callerPage, callerCanceledRecord); + }); + + test('CH-CALL-003: Rejected call creates exact per-user history records', async ({}, testInfo) => { + const callerPage = tm.getPage(tm.userSet.accounts[0]); + const calleePage = tm.getPage(tm.userSet.accounts[1]); + const startedAt = new Date(Date.now() - CALL_HISTORY_TIME_LOOKBACK_MS); + + await makeCall(callerPage, calleeNumber); + await waitForIncomingCall(calleePage); + await rejectCall(calleePage); + await endCallerIfStillActive(callerPage); + await Promise.all([waitForCallDisconnect(callerPage), waitForCallDisconnect(calleePage)]); + const endedAt = new Date(); + + const callerRecord = await waitForCallHistoryCase( + callerPage, + { + counterpartNumber: calleeNumber, + direction: 'OUTGOING', + startedAt, + dispositions: CALL_HISTORY_REJECTED_CALLER_DISPOSITIONS, + }, + 'caller outgoing rejected call' + ); + const calleeRecord = await waitForCallHistoryCase( + calleePage, + { + counterpartNumber: callerNumber, + direction: 'INCOMING', + startedAt, + dispositions: CALL_HISTORY_REJECTED_CALLEE_DISPOSITIONS, + }, + 'callee incoming rejected call' + ); + + expectHistoryTiming(callerRecord, {notBefore: startedAt, notAfter: endedAt}); + expectHistoryTiming(calleeRecord, {notBefore: startedAt, notAfter: endedAt}); + await attachCallHistorySummary(testInfo, 'rejected', [ + {user: 'user1', expectedDisposition: 'CANCELED', record: callerRecord}, + {user: 'user2', expectedDisposition: 'REJECTED', record: calleeRecord}, + ]); + + await expectUiShowsHistoryRecord(callerPage, callerRecord); + await expectUiShowsHistoryRecord(calleePage, calleeRecord); + }); + + test('CH-LIST-001: Bidirectional journey renders in Call History UI', async ({}, testInfo) => { + test.setTimeout(1800000); + + const callerPage = tm.getPage(tm.userSet.accounts[0]); + const calleePage = tm.getPage(tm.userSet.accounts[1]); + + const journey = await runBidirectionalHistoryJourney( + { + user1Page: callerPage, + user1Number: callerNumber, + user2Page: calleePage, + user2Number: calleeNumber, + }, + testInfo + ); + + expect(journey.user1Records).toHaveLength(6); + expect(journey.user2Records).toHaveLength(6); + + await attachCallHistorySummary( + testInfo, + 'both-users-call-history-journey', + journey.debugRecords + ); + + await Promise.all([ + expectUiShowsHistoryRecords( + callerPage, + getDisplayHistoryRecords(journey.debugRecords, 'user1') + ), + expectUiShowsHistoryRecords( + calleePage, + getDisplayHistoryRecords(journey.debugRecords, 'user2') + ), + ]); + }); + /* eslint-enable no-empty-pattern */ + }); +} diff --git a/packages/calling/playwright/utils/call-history-journey.ts b/packages/calling/playwright/utils/call-history-journey.ts new file mode 100644 index 00000000000..755aa2fa904 --- /dev/null +++ b/packages/calling/playwright/utils/call-history-journey.ts @@ -0,0 +1,322 @@ +import {expect} from '@playwright/test'; +import type {Page, TestInfo} from '@playwright/test'; +import { + CALL_HISTORY_ANSWERED_DISPOSITIONS, + CALL_HISTORY_MISSED_CALLER_DISPOSITIONS, + CALL_HISTORY_REJECTED_CALLEE_DISPOSITIONS, + CALL_HISTORY_REJECTED_CALLER_DISPOSITIONS, + CALL_HISTORY_TIME_LOOKBACK_MS, +} from '../constants'; +import { + cleanupActiveCalls, + endCall, + endCallerIfStillActive, + establishCall, + makeCall, + rejectCall, + waitForCallDisconnect, + waitForIncomingCall, +} from './call'; +import { + attachCallHistorySummary, + expectHistoryTiming, + expectUiShowsHistoryRecords, + getCallHistoryRecords, + getDisplayHistoryRecords, + recordMatchesCallCase, + waitForCallHistoryRecord, +} from './call-history'; +import type { + BidirectionalHistoryJourneyOptions, + BidirectionalHistoryJourneyResult, + CallHistoryRecord, + CallJourneyLeg, + HistoryDebugRecord, + HistoryMatcherOptions, +} from './call-history-types'; + +const getHistoryRecordKey = (record: CallHistoryRecord): string => + record.sessionId ?? + [ + record.direction, + record.disposition, + record.startTime, + record.endTime, + record.sessionType, + record.other?.phoneNumber, + record.other?.callbackAddress, + record.links?.callbackAddress, + record.other?.name, + ].join('|'); + +const rememberCurrentHistoryRecords = async (page: Page, seenKeys: Set): Promise => { + const records = await getCallHistoryRecords(page).catch(() => []); + + records.forEach((record) => seenKeys.add(getHistoryRecordKey(record))); +}; + +const waitForNewHistoryRecord = async ( + page: Page, + seenHistoryKeys: Set, + matcherOptions: HistoryMatcherOptions, + description: string +): Promise => { + const record = await waitForCallHistoryRecord( + page, + (candidate) => + !seenHistoryKeys.has(getHistoryRecordKey(candidate)) && + recordMatchesCallCase(candidate, matcherOptions), + description + ); + + seenHistoryKeys.add(getHistoryRecordKey(record)); + + return record; +}; + +const getIncomingDispositionsForOutcome = (outcome: CallJourneyLeg['outcome']): string[] => { + if (outcome === 'ANSWERED') { + return CALL_HISTORY_ANSWERED_DISPOSITIONS; + } + + if (outcome === 'REJECTED') { + return CALL_HISTORY_REJECTED_CALLEE_DISPOSITIONS; + } + + return ['MISSED']; +}; + +const getOutgoingDispositionsForOutcome = (outcome: CallJourneyLeg['outcome']): string[] => { + if (outcome === 'ANSWERED') { + return CALL_HISTORY_ANSWERED_DISPOSITIONS; + } + + if (outcome === 'REJECTED') { + return CALL_HISTORY_REJECTED_CALLER_DISPOSITIONS; + } + + return CALL_HISTORY_MISSED_CALLER_DISPOSITIONS; +}; + +const getExpectedDispositionForOutcome = ( + outcome: CallJourneyLeg['outcome'], + callSide: 'origin' | 'target' +): HistoryDebugRecord['expectedDisposition'] => { + if (outcome === 'ANSWERED') { + return 'ANSWERED'; + } + + if (callSide === 'origin') { + return 'CANCELED'; + } + + return outcome; +}; + +const executeCallJourneyLeg = async ( + leg: CallJourneyLeg, + testInfo: TestInfo +): Promise => { + await Promise.all([ + rememberCurrentHistoryRecords(leg.originPage, leg.originSeenHistoryKeys), + rememberCurrentHistoryRecords(leg.targetPage, leg.targetSeenHistoryKeys), + ]); + + const startedAt = new Date(Date.now() - CALL_HISTORY_TIME_LOOKBACK_MS); + + if (leg.outcome === 'ANSWERED') { + await establishCall(leg.originPage, leg.targetPage, leg.targetNumber); + await leg.originPage.waitForTimeout(2000); + await endCall(leg.originPage); + } else { + await makeCall(leg.originPage, leg.targetNumber); + await leg.targetPage.bringToFront().catch(() => {}); + await waitForIncomingCall(leg.targetPage); + + if (leg.outcome === 'REJECTED') { + await rejectCall(leg.targetPage); + await endCallerIfStillActive(leg.originPage); + } else { + await leg.originPage.waitForTimeout(5000); + await endCall(leg.originPage); + } + } + + await Promise.all([waitForCallDisconnect(leg.originPage), waitForCallDisconnect(leg.targetPage)]); + const endedAt = new Date(); + + const originRecord = await waitForNewHistoryRecord( + leg.originPage, + leg.originSeenHistoryKeys, + { + counterpartNumber: leg.targetNumber, + direction: 'OUTGOING', + startedAt, + dispositions: getOutgoingDispositionsForOutcome(leg.outcome), + }, + `${leg.label} ${leg.originLabel} outgoing ${leg.outcome.toLowerCase()} call` + ); + const targetRecord = await waitForNewHistoryRecord( + leg.targetPage, + leg.targetSeenHistoryKeys, + { + counterpartNumber: leg.originNumber, + direction: 'INCOMING', + startedAt, + dispositions: getIncomingDispositionsForOutcome(leg.outcome), + }, + `${leg.label} ${leg.targetLabel} incoming ${leg.outcome.toLowerCase()} call` + ); + + expectHistoryTiming(originRecord, {notBefore: startedAt, notAfter: endedAt}); + expectHistoryTiming(targetRecord, {notBefore: startedAt, notAfter: endedAt}); + + const records: HistoryDebugRecord[] = [ + { + user: leg.originLabel, + expectedDisposition: getExpectedDispositionForOutcome(leg.outcome, 'origin'), + record: originRecord, + }, + { + user: leg.targetLabel, + expectedDisposition: getExpectedDispositionForOutcome(leg.outcome, 'target'), + record: targetRecord, + }, + ]; + + await attachCallHistorySummary(testInfo, leg.label, records); + + return records; +}; + +export const runBidirectionalHistoryJourney = async ( + options: BidirectionalHistoryJourneyOptions, + testInfo: TestInfo +): Promise => { + const user1SeenHistoryKeys = new Set(); + const user2SeenHistoryKeys = new Set(); + const user1JourneyRecords: CallHistoryRecord[] = []; + const user2JourneyRecords: CallHistoryRecord[] = []; + const journeyDebugRecords: HistoryDebugRecord[] = []; + + const addJourneyRecords = (records: HistoryDebugRecord[]) => { + records.forEach(({user, record}) => { + if (user === 'user1') { + user1JourneyRecords.push(record); + } + + if (user === 'user2') { + user2JourneyRecords.push(record); + } + }); + }; + + const callJourneyLegs: CallJourneyLeg[] = [ + { + label: 'user1-to-user2-answered', + originLabel: 'user1', + originPage: options.user1Page, + originNumber: options.user1Number, + targetLabel: 'user2', + targetPage: options.user2Page, + targetNumber: options.user2Number, + outcome: 'ANSWERED', + originSeenHistoryKeys: user1SeenHistoryKeys, + targetSeenHistoryKeys: user2SeenHistoryKeys, + }, + { + label: 'user1-to-user2-rejected', + originLabel: 'user1', + originPage: options.user1Page, + originNumber: options.user1Number, + targetLabel: 'user2', + targetPage: options.user2Page, + targetNumber: options.user2Number, + outcome: 'REJECTED', + originSeenHistoryKeys: user1SeenHistoryKeys, + targetSeenHistoryKeys: user2SeenHistoryKeys, + }, + { + label: 'user1-to-user2-missed', + originLabel: 'user1', + originPage: options.user1Page, + originNumber: options.user1Number, + targetLabel: 'user2', + targetPage: options.user2Page, + targetNumber: options.user2Number, + outcome: 'MISSED', + originSeenHistoryKeys: user1SeenHistoryKeys, + targetSeenHistoryKeys: user2SeenHistoryKeys, + }, + { + label: 'user2-to-user1-answered', + originLabel: 'user2', + originPage: options.user2Page, + originNumber: options.user2Number, + targetLabel: 'user1', + targetPage: options.user1Page, + targetNumber: options.user1Number, + outcome: 'ANSWERED', + originSeenHistoryKeys: user2SeenHistoryKeys, + targetSeenHistoryKeys: user1SeenHistoryKeys, + }, + { + label: 'user2-to-user1-rejected', + originLabel: 'user2', + originPage: options.user2Page, + originNumber: options.user2Number, + targetLabel: 'user1', + targetPage: options.user1Page, + targetNumber: options.user1Number, + outcome: 'REJECTED', + originSeenHistoryKeys: user2SeenHistoryKeys, + targetSeenHistoryKeys: user1SeenHistoryKeys, + }, + { + label: 'user2-to-user1-missed', + originLabel: 'user2', + originPage: options.user2Page, + originNumber: options.user2Number, + targetLabel: 'user1', + targetPage: options.user1Page, + targetNumber: options.user1Number, + outcome: 'MISSED', + originSeenHistoryKeys: user2SeenHistoryKeys, + targetSeenHistoryKeys: user1SeenHistoryKeys, + }, + ]; + + /* eslint-disable no-await-in-loop */ + for (const leg of callJourneyLegs) { + const records = await executeCallJourneyLeg(leg, testInfo); + addJourneyRecords(records); + journeyDebugRecords.push(...records); + await Promise.all([ + expectUiShowsHistoryRecords( + options.user1Page, + getDisplayHistoryRecords(journeyDebugRecords, 'user1') + ), + expectUiShowsHistoryRecords( + options.user2Page, + getDisplayHistoryRecords(journeyDebugRecords, 'user2') + ), + ]); + await Promise.all([ + cleanupActiveCalls(options.user1Page), + cleanupActiveCalls(options.user2Page), + ]); + await options.user1Page.waitForTimeout(2000); + } + /* eslint-enable no-await-in-loop */ + + expect(user1JourneyRecords).toHaveLength(callJourneyLegs.length); + expect(user2JourneyRecords).toHaveLength(callJourneyLegs.length); + + await attachCallHistorySummary(testInfo, 'two-user-call-history-journey', journeyDebugRecords); + + return { + user1Records: user1JourneyRecords, + user2Records: user2JourneyRecords, + debugRecords: journeyDebugRecords, + }; +}; diff --git a/packages/calling/playwright/utils/call-history-types.ts b/packages/calling/playwright/utils/call-history-types.ts new file mode 100644 index 00000000000..f0334e16301 --- /dev/null +++ b/packages/calling/playwright/utils/call-history-types.ts @@ -0,0 +1,90 @@ +import type {Page} from '@playwright/test'; + +export type CallHistoryQuery = { + days?: number; + limit?: number; + sort?: 'ASC' | 'DESC'; + sortBy?: 'startTime' | 'endTime'; +}; + +export type CallHistoryWaitOptions = CallHistoryQuery & { + timeout?: number; +}; + +export type HistoryTimeBounds = { + notBefore?: Date; + notAfter?: Date; +}; + +export type CallHistoryRecord = { + sessionId?: string; + direction?: string; + disposition?: string; + startTime?: string; + endTime?: string; + durationSeconds?: number; + durationSecs?: number; + sessionType?: string; + other?: { + name?: string; + callbackAddress?: string; + phoneNumber?: string; + primaryDisplayString?: string; + secondaryDisplayString?: string; + }; + links?: { + callbackAddress?: string; + }; +}; + +export type CallHistoryRow = { + id: string; + name: string; + direction: string; + disposition: string; + startTime: string; + endTime: string; + sessionType: string; + callbackAddress: string; + redirectionReason: string; + forwardedBy: string; +}; + +export type HistoryMatcherOptions = { + counterpartNumber: string; + direction: 'INCOMING' | 'OUTGOING'; + startedAt: Date; + dispositions?: string[]; +}; + +export type HistoryDebugRecord = { + user: string; + expectedDisposition: 'ANSWERED' | 'REJECTED' | 'MISSED' | 'CANCELED'; + record: CallHistoryRecord; +}; + +export type CallJourneyLeg = { + label: string; + originLabel: string; + originPage: Page; + originNumber: string; + targetLabel: string; + targetPage: Page; + targetNumber: string; + outcome: 'ANSWERED' | 'REJECTED' | 'MISSED'; + originSeenHistoryKeys: Set; + targetSeenHistoryKeys: Set; +}; + +export type BidirectionalHistoryJourneyOptions = { + user1Page: Page; + user1Number: string; + user2Page: Page; + user2Number: string; +}; + +export type BidirectionalHistoryJourneyResult = { + user1Records: CallHistoryRecord[]; + user2Records: CallHistoryRecord[]; + debugRecords: HistoryDebugRecord[]; +}; diff --git a/packages/calling/playwright/utils/call-history.ts b/packages/calling/playwright/utils/call-history.ts new file mode 100644 index 00000000000..a4cfb30e3dc --- /dev/null +++ b/packages/calling/playwright/utils/call-history.ts @@ -0,0 +1,397 @@ +import {expect} from '@playwright/test'; +import type {Page, Route, TestInfo} from '@playwright/test'; +import { + AWAIT_TIMEOUT, + CALLING_SELECTORS, + CALL_HISTORY_COUNTERPART_MATCH_MIN_DIGITS, + CALL_HISTORY_DURATION_TOLERANCE_SECONDS, + CALL_HISTORY_EVENTUAL_CONSISTENCY_TIMEOUT, + CALL_HISTORY_POLL_INTERVALS, + CALL_HISTORY_RECENT_RECORD_TOLERANCE_MS, + CALL_HISTORY_TIMING_TOLERANCE_MS, + CALL_HISTORY_URL_PATTERN, +} from '../constants'; +import type { + CallHistoryQuery, + CallHistoryRecord, + CallHistoryRow, + CallHistoryWaitOptions, + HistoryDebugRecord, + HistoryMatcherOptions, + HistoryTimeBounds, +} from './call-history-types'; + +export type {CallHistoryRecord, CallHistoryRow} from './call-history-types'; + +const DEFAULT_HISTORY_QUERY: Required = { + days: 1, + limit: 20, + sort: 'DESC', + sortBy: 'endTime', +}; + +const normalizePhoneNumber = (value?: string): string => (value ?? '').replace(/\D/g, ''); + +/** + * Compares a record's available counterpart fields with the expected phone number. + */ +export const phoneMatchesRecord = ( + record: CallHistoryRecord, + phoneNumber: string, + minDigits = 7 +): boolean => { + const expectedDigits = normalizePhoneNumber(phoneNumber); + const expectedTail = expectedDigits.slice(-minDigits); + + if (!expectedTail) { + return false; + } + + const candidates = [ + record.other?.phoneNumber, + record.other?.callbackAddress, + record.other?.primaryDisplayString, + record.other?.secondaryDisplayString, + record.other?.name, + record.links?.callbackAddress, + ]; + + return candidates.some((candidate) => normalizePhoneNumber(candidate).endsWith(expectedTail)); +}; + +/** + * Reads the duration from the API record, or derives it from start and end timestamps. + */ +export const getCallHistoryDurationSeconds = (record: CallHistoryRecord): number | undefined => { + const apiDuration = record.durationSeconds ?? record.durationSecs; + + if (typeof apiDuration === 'number') { + return apiDuration; + } + + const start = Date.parse(record.startTime ?? ''); + const end = Date.parse(record.endTime ?? ''); + + if (Number.isNaN(start) || Number.isNaN(end)) { + return undefined; + } + + return Math.max(0, Math.round((end - start) / 1000)); +}; + +const isRecentRecord = (record: CallHistoryRecord, startedAt: Date): boolean => { + const startTime = Date.parse(record.startTime ?? ''); + + return ( + !Number.isNaN(startTime) && + startTime >= startedAt.getTime() - CALL_HISTORY_RECENT_RECORD_TOLERANCE_MS + ); +}; + +/** + * Checks whether a history record belongs to a specific call attempt. + */ +export const recordMatchesCallCase = ( + record: CallHistoryRecord, + options: HistoryMatcherOptions +): boolean => { + const disposition = (record.disposition ?? '').toUpperCase(); + + return ( + phoneMatchesRecord( + record, + options.counterpartNumber, + CALL_HISTORY_COUNTERPART_MATCH_MIN_DIGITS + ) && + (record.direction ?? '').toUpperCase() === options.direction && + isRecentRecord(record, options.startedAt) && + (!options.dispositions || options.dispositions.includes(disposition)) + ); +}; + +/** + * Fetches call-history records from the sample app's initialized Calling SDK instance. + */ +export const getCallHistoryRecords = async ( + page: Page, + options: CallHistoryQuery = {} +): Promise => { + const query = {...DEFAULT_HISTORY_QUERY, ...options}; + + return page.evaluate(async ({days, limit, sort, sortBy}) => { + const callHistory = (window as any).callHistory; + + if (!callHistory) { + throw new Error('window.callHistory is not available'); + } + + const response = await callHistory.getCallHistoryData(days, limit, sort, sortBy); + + return JSON.parse(JSON.stringify(response?.data?.userSessions ?? [])); + }, query); +}; + +/** + * Polls the SDK until the expected call-history record is eventually available. + */ +export const waitForCallHistoryRecord = async ( + page: Page, + matcher: (record: CallHistoryRecord) => boolean, + description: string, + options: CallHistoryWaitOptions = {} +): Promise => { + const {timeout = CALL_HISTORY_EVENTUAL_CONSISTENCY_TIMEOUT, ...queryOptions} = options; + let matchingRecord: CallHistoryRecord | undefined; + + await expect + .poll( + async () => { + const records = await getCallHistoryRecords(page, queryOptions); + matchingRecord = records.find(matcher); + + return Boolean(matchingRecord); + }, + { + timeout, + intervals: CALL_HISTORY_POLL_INTERVALS, + message: `Expected call history record: ${description}`, + } + ) + .toBe(true); + + return matchingRecord as CallHistoryRecord; +}; + +/** + * Waits for a call-history record matching one user side of a call. + */ +export const waitForCallHistoryCase = async ( + page: Page, + options: HistoryMatcherOptions, + description: string +): Promise => + waitForCallHistoryRecord(page, (record) => recordMatchesCallCase(record, options), description); + +/** + * Attaches compact call-history details to the Playwright report. + */ +export const attachCallHistorySummary = async ( + testInfo: TestInfo, + label: string, + records: HistoryDebugRecord[] +): Promise => { + const summary = records.map(({user, expectedDisposition, record}) => ({ + user, + expectedDisposition, + direction: record.direction, + rawDisposition: record.disposition, + displayDisposition: expectedDisposition, + startTime: record.startTime, + endTime: record.endTime, + durationSeconds: getCallHistoryDurationSeconds(record), + sessionType: record.sessionType, + counterpart: + record.other?.phoneNumber ?? + record.other?.callbackAddress ?? + record.links?.callbackAddress ?? + record.other?.name, + })); + const oneLineSummary = summary + .map( + (record) => + `${record.user}:${record.expectedDisposition}:${record.direction}/${record.rawDisposition}:${record.durationSeconds}s` + ) + .join('; '); + + testInfo.annotations.push({type: 'call-history', description: `${label}: ${oneLineSummary}`}); + await testInfo.attach(`${label}-call-history-summary.json`, { + body: JSON.stringify(summary, null, 2), + contentType: 'application/json', + }); +}; + +/** + * Returns records for one logical user from journey debug records. + */ +export const getDisplayHistoryRecords = ( + records: HistoryDebugRecord[], + user: string +): CallHistoryRecord[] => + records.filter((debugRecord) => debugRecord.user === user).map(({record}) => record); + +const clearCallHistoryTable = async (page: Page): Promise => { + await page.evaluate( + ({buttonSelector, headerSelector, bodySelector}) => { + const button = document.querySelector(buttonSelector); + const header = document.querySelector(headerSelector); + const body = document.querySelector(bodySelector); + + if (button) { + button.disabled = false; + } + if (header) { + header.innerHTML = ''; + } + if (body) { + body.innerHTML = ''; + } + }, + { + buttonSelector: CALLING_SELECTORS.CALL_HISTORY_BTN, + headerSelector: CALLING_SELECTORS.CALL_HISTORY_HEADER, + bodySelector: CALLING_SELECTORS.CALL_HISTORY_TABLE_BODY, + } + ); +}; + +const readCallHistoryRowsFromUi = async (page: Page): Promise => { + const rows = await page + .locator(`${CALLING_SELECTORS.CALL_HISTORY_TABLE_BODY} tr`) + .evaluateAll((rowElements) => + rowElements.map((row) => + Array.from(row.querySelectorAll('td')).map((cell) => cell.textContent?.trim() ?? '') + ) + ); + + return rows.map((cells) => ({ + id: cells[0] ?? '', + name: cells[1] ?? '', + direction: cells[2] ?? '', + disposition: cells[3] ?? '', + startTime: cells[4] ?? '', + endTime: cells[5] ?? '', + sessionType: cells[6] ?? '', + callbackAddress: cells[7] ?? '', + redirectionReason: cells[8] ?? '', + forwardedBy: cells[9] ?? '', + })); +}; + +export const openCallHistoryList = async (page: Page): Promise => { + await clearCallHistoryTable(page); + await page.locator(CALLING_SELECTORS.CALL_HISTORY_BTN).click({timeout: AWAIT_TIMEOUT}); + await expect(page.locator(`${CALLING_SELECTORS.CALL_HISTORY_HEADER} th`)).toHaveCount(10, { + timeout: AWAIT_TIMEOUT, + }); + await expect(page.locator(`${CALLING_SELECTORS.CALL_HISTORY_TABLE_BODY} tr`).first()).toBeVisible( + {timeout: CALL_HISTORY_EVENTUAL_CONSISTENCY_TIMEOUT} + ); + + return readCallHistoryRowsFromUi(page); +}; + +const openCallHistoryListWithRecords = async ( + page: Page, + records: CallHistoryRecord[] +): Promise => { + const routeHandler = async (route: Route): Promise => { + await route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({ + statusCode: 200, + userSessions: records, + }), + }); + }; + + await clearCallHistoryTable(page); + await page.route(CALL_HISTORY_URL_PATTERN, routeHandler); + + try { + await page.locator(CALLING_SELECTORS.CALL_HISTORY_BTN).click({timeout: AWAIT_TIMEOUT}); + await expect(page.locator(`${CALLING_SELECTORS.CALL_HISTORY_HEADER} th`)).toHaveCount(10, { + timeout: AWAIT_TIMEOUT, + }); + await expect(page.locator(`${CALLING_SELECTORS.CALL_HISTORY_TABLE_BODY} tr`)).toHaveCount( + records.length, + {timeout: AWAIT_TIMEOUT} + ); + + return await readCallHistoryRowsFromUi(page); + } finally { + await page.unroute(CALL_HISTORY_URL_PATTERN, routeHandler).catch(() => {}); + } +}; + +export const expectUiShowsHistoryRecord = async ( + page: Page, + record: CallHistoryRecord +): Promise => { + const rows = await openCallHistoryListWithRecords(page, [record]); + const matchingRow = rows.find( + (row) => + row.startTime === record.startTime && + row.endTime === record.endTime && + row.direction === record.direction && + row.disposition === record.disposition + ); + + expect( + matchingRow, + `Expected Call History UI to show ${record.direction} ${record.disposition} record from ${record.startTime}` + ).toBeTruthy(); + + const row = matchingRow as CallHistoryRow; + + if (record.sessionType) { + expect(row.sessionType).toBe(record.sessionType); + } + + return row; +}; + +const rowMatchesHistoryRecord = (row: CallHistoryRow, record: CallHistoryRecord): boolean => + row.startTime === record.startTime && + row.endTime === record.endTime && + row.direction === record.direction && + row.disposition === record.disposition; + +export const expectUiShowsHistoryRecords = async ( + page: Page, + records: CallHistoryRecord[] +): Promise => { + const rows = await openCallHistoryListWithRecords(page, records); + + records.forEach((record) => { + expect( + rows.some((row) => rowMatchesHistoryRecord(row, record)), + `Expected Call History UI to show ${record.direction} ${record.disposition} record from ${record.startTime}` + ).toBe(true); + }); + + return rows; +}; + +export const expectHistoryTiming = ( + record: CallHistoryRecord, + bounds: HistoryTimeBounds = {} +): void => { + const start = Date.parse(record.startTime ?? ''); + const end = Date.parse(record.endTime ?? ''); + + expect(Number.isNaN(start), 'Call history startTime should be a valid ISO date').toBe(false); + expect(Number.isNaN(end), 'Call history endTime should be a valid ISO date').toBe(false); + expect(end, 'Call history endTime should not precede startTime').toBeGreaterThanOrEqual(start); + + if (bounds.notBefore) { + expect(start).toBeGreaterThanOrEqual( + bounds.notBefore.getTime() - CALL_HISTORY_TIMING_TOLERANCE_MS + ); + } + + if (bounds.notAfter) { + expect(end).toBeLessThanOrEqual(bounds.notAfter.getTime() + CALL_HISTORY_TIMING_TOLERANCE_MS); + } + + const duration = getCallHistoryDurationSeconds(record); + + if (typeof duration === 'number') { + const elapsedSeconds = Math.max(0, Math.round((end - start) / 1000)); + + expect(duration).toBeGreaterThanOrEqual(0); + expect(Math.abs(duration - elapsedSeconds)).toBeLessThanOrEqual( + CALL_HISTORY_DURATION_TOLERANCE_SECONDS + ); + } +}; diff --git a/packages/calling/playwright/utils/call.ts b/packages/calling/playwright/utils/call.ts index 9bffaca1941..bf16420b871 100644 --- a/packages/calling/playwright/utils/call.ts +++ b/packages/calling/playwright/utils/call.ts @@ -95,6 +95,15 @@ export const endCall = async (page: Page): Promise => { ); }; +export const endCallerIfStillActive = async (page: Page): Promise => { + const endButton = page.locator(CALLING_SELECTORS.END_CALL_BTN); + const canEndCall = await endButton.isEnabled().catch(() => false); + + if (canEndCall) { + await endCall(page); + } +}; + export const endIncomingCall = async (page: Page): Promise => { await page.locator(CALLING_SELECTORS.END_BTN).click({timeout: AWAIT_TIMEOUT}); await page.waitForFunction(