diff --git a/e2e-tests/chatScripts.ts b/e2e-tests/chatScripts.ts index d069bff2c5..4b5cff13e3 100644 --- a/e2e-tests/chatScripts.ts +++ b/e2e-tests/chatScripts.ts @@ -80,3 +80,65 @@ export const getWebchatScript = (): ChatStatement[] => { return defaultScript; }; + +/** + * SMS scripts are structurally the same as webchat scripts (using the same ChatStatement format), + * but must start with a CALLER statement since the client must initiate an SMS conversation. + * BOT messages are expected to arrive after the client sends its first message. + */ +export const defaultSmsScript: ChatStatement[] = [ + callerStatement('hi'), + botStatement('Welcome to the helpline. Please answer the following questions.'), + callerStatement('yes'), + botStatement('How old are you?'), + callerStatement('10'), + botStatement('What is your gender?'), + callerStatement('girl'), + botStatement('We will transfer you now. Please hold for a counsellor.'), + counselorAutoStatement('Hi, this is the counsellor. How can I help you?'), + callerStatement('CALLER TEST SMS MESSAGE'), + counselorStatement('COUNSELLOR TEST SMS MESSAGE'), +]; + +export const smsCommonScripts: Record = { + ca: [ + callerStatement('CALLER TEST SMS MESSAGE'), + counselorAutoStatement("Hi, you've reached a counsellor. What would you like to talk about?"), + counselorStatement('COUNSELLOR TEST SMS MESSAGE'), + ], +}; + +export const smsEnvScripts: Record> = { + development: { + as: [ + callerStatement('hi'), + botStatement("Sorry, I didn't understand that. Please try again."), + callerStatement('hi'), + botStatement('Are you calling about yourself? Please answer Yes or No.'), + callerStatement('yes'), + botStatement('How old are you?'), + callerStatement('10'), + botStatement('What is your gender?'), + callerStatement('girl'), + botStatement("We'll transfer you now. Please hold for a counsellor."), + counselorAutoStatement('Hi, this is the counsellor. How can I help you?'), + callerStatement('CALLER TEST SMS MESSAGE'), + counselorStatement('COUNSELLOR TEST SMS MESSAGE'), + ], + }, +}; + +export const getSmsScript = (): ChatStatement[] => { + const helplineShortCode = getConfigValue('helplineShortCode') as string; + const helplineEnv = getConfigValue('helplineEnv') as string; + + if (smsEnvScripts[helplineEnv]?.[helplineShortCode]) { + return smsEnvScripts[helplineEnv][helplineShortCode]; + } + + if (smsCommonScripts[helplineShortCode]) { + return smsCommonScripts[helplineShortCode]; + } + + return defaultSmsScript; +}; diff --git a/e2e-tests/config.ts b/e2e-tests/config.ts index 6831eb2cee..26cef92eb1 100644 --- a/e2e-tests/config.ts +++ b/e2e-tests/config.ts @@ -41,7 +41,10 @@ export type Config = { }; const helplineShortCode = process.env.HL?.toLocaleLowerCase() || 'e2e'; +// Account to initiate calls into the helpline under test from, for testing voice & SMS const helplineEnv = process.env.HL_ENV?.toLocaleLowerCase() || 'local'; +const clientHelplineShortCode = process.env.CLIENT_HL?.toLocaleLowerCase() || 'as'; +const clientHelplineEnv = process.env.CLIENT_HL_ENV?.toLocaleLowerCase() || 'development'; const shouldLoadFromSsm = process.env.LOAD_SSM_CONFIG && process.env.LOAD_SSM_CONFIG !== 'false'; // These are environments where we want to avoid tests or steps that update HRM data @@ -135,6 +138,18 @@ const configOptions: ConfigOptions = { ssmPath: () => `/${localOverrideEnv}/twilio/${getConfigValue('twilioAccountSid')}/auth_token`, }, + // The twilio account sid and auth token are used to target a flex account + clientTwilioAccountSid: { + envKey: 'CLIENT_TWILIO_ACCOUNT_SID', + ssmPath: `/${clientHelplineEnv}/twilio/${clientHelplineShortCode.toUpperCase()}/account_sid`, + }, + clientTwilioAuthToken: { + envKey: 'CLIENT_TWILIO_AUTH_TOKEN', + // Order is important here. We use a function so that we can reference the clientTwilioAccountSid config value above. + ssmPath: () => + `/${clientHelplineEnv}/twilio/${getConfigValue('clientTwilioAccountSid')}/auth_token`, + }, + // Turn on debug mode. Possibly unused. debug: { envKey: 'DEBUG', @@ -180,6 +195,30 @@ const configOptions: ConfigOptions = { default: `https://assets-${localOverrideEnv}.tl.techmatters.org/aselo-webchat-react-app/${helplineShortCode}/?e2eTestMode=true`, }, + // This should match the number set up for the Voice studio flow on the helpline under test + voicePhoneNumber: { + envKey: 'VOICE_PHONE_NUMBER', + default: '+12607821891', + }, + + // This should match the number set up on the clientTwilioAccountSid that can make outgoing calls + clientVoicePhoneNumber: { + envKey: 'CLIENT_VOICE_PHONE_NUMBER', + default: '+12064083885', + }, + + // This should match the number set up for the SMS studio flow on the helpline under test + smsPhoneNumber: { + envKey: 'SMS_PHONE_NUMBER', + default: () => getConfigValue('voicePhoneNumber'), + }, + + // This should match the number set up on the clientTwilioAccountSid that can send outgoing SMS messages + clientSmsPhoneNumber: { + envKey: 'CLIENT_SMS_PHONE_NUMBER', + default: () => getConfigValue('clientVoicePhoneNumber'), + }, + // inLambda is used to determine if we are running in a lambda or not and set other config values accordingly inLambda: { envKey: 'TEST_IN_LAMBDA', @@ -256,7 +295,7 @@ const setConfigValueFromSsm = async (key: string) => { throw err; } - console.log(`Failed to load config value from SSM at ${option.ssmPath}. Using default value`); + console.warn(`Failed to load config value from SSM at ${option.ssmPath}. Using default value`); setConfigValue(key, typeof option.default === 'function' ? option.default() : option.default); } diff --git a/e2e-tests/contactForm.ts b/e2e-tests/contactForm.ts index e7e171575b..7e9aecf959 100644 --- a/e2e-tests/contactForm.ts +++ b/e2e-tests/contactForm.ts @@ -85,7 +85,7 @@ export function contactForm(page: Page) { } } - return { + const formApi = { selectChildCallType: async () => { const childCallTypeButton = selectors.childCallTypeButton(); const responsePromise = page.waitForResponse('**/contacts/**'); @@ -100,6 +100,28 @@ export function contactForm(page: Page) { await tab.fill(tab); } }, + fillWithContent: async (formContent: any) => { + await formApi.fill([ + { + id: 'childInformation', + label: 'TabbedForms-AddChildInfoTab', + fill: formApi.fillStandardTab, + items: formContent.childInformation, + }, + >{ + id: 'categories', + label: 'TabbedForms-CategoriesTab', + fill: formApi.fillCategoriesTab, + items: formContent.categories, + }, + { + id: 'caseInformation', + label: 'TabbedForms-AddCaseInfoTab', + fill: formApi.fillStandardTab, + items: formContent.caseInformation, + }, + ]); + }, save: async ({ saveAndAddToCase }: { saveAndAddToCase?: boolean } = {}) => { const tab = { id: 'caseInformation', @@ -124,5 +146,6 @@ export function contactForm(page: Page) { }, fillCategoriesTab, fillStandardTab, - }; + } as const; + return formApi; } diff --git a/e2e-tests/deleteChatChannels.ts b/e2e-tests/deleteConversations.ts similarity index 88% rename from e2e-tests/deleteChatChannels.ts rename to e2e-tests/deleteConversations.ts index 3c15d958c0..59b33ed447 100644 --- a/e2e-tests/deleteChatChannels.ts +++ b/e2e-tests/deleteConversations.ts @@ -23,12 +23,13 @@ * send new messages from the e2e test user. */ -import { deleteChatChannels } from './twilio/channels'; +import { deleteChatConversations, deleteSmsConversations } from './twilio/channels'; import { initConfig } from './config'; const main = async () => { await initConfig(); - await deleteChatChannels(); + await deleteChatConversations(); + await deleteSmsConversations(); }; main(); diff --git a/e2e-tests/formContentsByHelpline.ts b/e2e-tests/formContentsByHelpline.ts index b1d0caf6a3..3fbb37a484 100644 --- a/e2e-tests/formContentsByHelpline.ts +++ b/e2e-tests/formContentsByHelpline.ts @@ -27,7 +27,7 @@ export const formContentsByHelpline = { Accessibility: ['Education'], }, caseInformation: { - callSummary: 'E2E TEST CALL', + callSummary: 'E2E TEST PREPOPULATED FORM', }, }, ca: { @@ -54,3 +54,19 @@ export const formContentsByHelpline = { }, }, }; + +export const formContentsByHelplineForEmptyForm = { + ...formContentsByHelpline, + e2e: { + ...formContentsByHelpline.e2e, + childInformation: { + ...formContentsByHelpline.e2e.childInformation, + + gender: 'Unknown', + age: 'Unknown', + }, + caseInformation: { + callSummary: 'E2E TEST EMPTY FORM', + }, + }, +}; diff --git a/e2e-tests/package.json b/e2e-tests/package.json index 14da237ed9..b55dbf52be 100644 --- a/e2e-tests/package.json +++ b/e2e-tests/package.json @@ -5,19 +5,19 @@ "main": "index.js", "scripts": { "postinstall": "npx playwright install chromium", - "deleteChatChannels": "tsx deleteChatChannels.ts", + "deleteChatChannels": "tsx deleteConversations.ts", "test": "npx playwright test --workers 1 ", "test:ui": "npx playwright test --workers 1 --config ui-tests/playwright.ui-test.config.ts", - "test:local": "cross-env DEBUG=pw:api LOAD_SSM_CONFIG=true npm run test", + "test:local": "cross-env DEBUG=pw:api LOAD_SSM_CONFIG=true npm run test -- --retries 0", "test:local-aselo-webchat": "cross-env DEBUG=pw:api LOAD_SSM_CONFIG=true ASELO_WEBCHAT_URL=http://localhost:3001 npm run test -- --headed --debug --retries 0 aseloWebchat", - "test:local:debug": "cross-env DEBUG=pw:api LOAD_SSM_CONFIG=true npm run test -- --headed --debug --retries 0 offline", + "test:local:debug": "cross-env DEBUG=pw:api LOAD_SSM_CONFIG=true npm run test -- --headed --debug --retries 0", "test:development:as": "cross-env DEBUG=pw:api LOAD_SSM_CONFIG=true HL_ENV=development HL=as SKIP_DATA_UPDATE=true npm run test", "test:development:as:debug": "cross-env DEBUG=pw:api LOAD_SSM_CONFIG=true HL_ENV=development HL=as SKIP_DATA_UPDATE=true npm run test -- --headed --retries 0", "test:development:e2e": "cross-env DEBUG=pw:api LOAD_SSM_CONFIG=true HL_ENV=development HL=e2e npm run test", "test:development:e2e:local": "cross-env LOAD_SSM_CONFIG=true HL_ENV=development HL=e2e npm run test -- --headed", - "test:development:e2e:debug": "cross-env DEBUG=pw:api LOAD_SSM_CONFIG=true HL_ENV=development HL=e2e npm run test -- --headed --debug --retries 0 login", + "test:development:e2e:debug": "cross-env DEBUG=pw:api LOAD_SSM_CONFIG=true HL_ENV=development HL=e2e npm run test -- --headed --debug --retries 0", "test:staging:ca": "cross-env DEBUG=pw:api LOAD_SSM_CONFIG=true HL_ENV=staging HL=ca npm run test", - "test:staging:ca:headed": "cross-env LOAD_SSM_CONFIG=true HL_ENV=staging HL=ca npm test -- --headed --debug --retries 0 aseloWebchat", + "test:staging:ca:headed": "cross-env LOAD_SSM_CONFIG=true HL_ENV=staging HL=ca npm test -- --headed --debug --retries 0", "test:production:ca": "cross-env DEBUG=pw:api LOAD_SSM_CONFIG=true HL_ENV=production HL=ca npm run test", "lint": "eslint --ext ts .", "lint:fix": "npm run lint -- --fix .", diff --git a/e2e-tests/playwright.config.ts b/e2e-tests/playwright.config.ts index d4363de572..ddb8924a2b 100644 --- a/e2e-tests/playwright.config.ts +++ b/e2e-tests/playwright.config.ts @@ -21,6 +21,8 @@ import { getConfigValue } from './config'; const inLambda = getConfigValue('inLambda') as boolean; +const browserArgs = ['--use-fake-ui-for-media-stream', '--use-fake-device-for-media-stream']; + const playwrightConfig: PlaywrightTestConfig = { globalSetup: require.resolve('./global-setup'), use: { @@ -40,6 +42,7 @@ const playwrightConfig: PlaywrightTestConfig = { * of chromium/Playwright. We use the `TEST_NAME` environment variable to set * a unique target for each test that runs in lambdas to avoid this issue. */ + ...browserArgs, '--single-process', '--autoplay-policy=user-gesture-required', '--disable-background-networking', @@ -81,11 +84,11 @@ const playwrightConfig: PlaywrightTestConfig = { '--disable-gpu', '--use-gl=swiftshader', '--autoplay-policy=no-user-gesture-required', - '--use-fake-ui-for-media-stream', - '--use-fake-device-for-media-stream', ], } - : {}, + : { + args: browserArgs, + }, }, testDir: './tests', retries: inLambda ? 0 : 1, diff --git a/e2e-tests/tests/aseloWebchat.spec.ts b/e2e-tests/tests/aseloWebchat.spec.ts index ecda6bd8ca..78b2b6d400 100644 --- a/e2e-tests/tests/aseloWebchat.spec.ts +++ b/e2e-tests/tests/aseloWebchat.spec.ts @@ -23,7 +23,7 @@ import { getWebchatScript } from '../chatScripts'; import { flexChat } from '../flexChat'; import { skipTestIfNotTargeted } from '../skipTest'; import { tasks } from '../tasks'; -import { Categories, contactForm, ContactFormTab } from '../contactForm'; +import { contactForm } from '../contactForm'; import { deleteAllTasksInQueue } from '../twilio/tasks'; import { notificationBar } from '../notificationBar'; import { clickThroughTwilioPasteModals } from '../agent-desktop'; @@ -102,26 +102,7 @@ test.describe.serial('Aselo web chat caller', () => { throw new Error(`No form contents configured for helplineShortCode="${String(helpline)}"`); } const form = contactForm(pluginPage); - await form.fill([ - { - id: 'childInformation', - label: 'TabbedForms-AddChildInfoTab', - fill: form.fillStandardTab, - items: formContent.childInformation, - }, - >{ - id: 'categories', - label: 'TabbedForms-CategoriesTab', - fill: form.fillCategoriesTab, - items: formContent.categories, - }, - { - id: 'caseInformation', - label: 'TabbedForms-AddCaseInfoTab', - fill: form.fillStandardTab, - items: formContent.caseInformation, - }, - ]); + await form.fillWithContent(formContent); console.info('Saving form'); await form.save(); diff --git a/e2e-tests/tests/offlineContact.spec.ts b/e2e-tests/tests/offlineContact.spec.ts index 1af1d559ea..fd27dead94 100644 --- a/e2e-tests/tests/offlineContact.spec.ts +++ b/e2e-tests/tests/offlineContact.spec.ts @@ -15,7 +15,7 @@ */ import { expect, Page, request, test } from '@playwright/test'; -import { Categories, contactForm, ContactFormTab } from '../contactForm'; +import { contactForm, ContactFormTab } from '../contactForm'; import { caseHome } from '../case'; import { agentDesktop, navigateToAgentDesktop } from '../agent-desktop'; import { skipTestIfDataUpdateDisabled, skipTestIfNotTargeted } from '../skipTest'; @@ -23,6 +23,11 @@ import { notificationBar } from '../notificationBar'; import { closePage, setupContextAndPage } from '../browser'; import { apiHrmRequest } from '../hrm/hrmRequest'; import { clearOfflineTask } from '../hrm/clearOfflineTask'; +import { getConfigValue } from '../config'; +import { + formContentsByHelpline, + formContentsByHelplineForEmptyForm, +} from '../formContentsByHelpline'; test.describe.serial('Offline Contact (with Case)', () => { skipTestIfNotTargeted(); @@ -56,6 +61,8 @@ test.describe.serial('Offline Contact (with Case)', () => { await agentDesktopPage.addOfflineContact(); console.log('Starting filling form'); + const helpline = getConfigValue('helplineShortCode') as keyof typeof formContentsByHelpline; + const formContent = formContentsByHelplineForEmptyForm[helpline]; const form = contactForm(pluginPage); await form.selectChildCallType(); @@ -70,38 +77,8 @@ test.describe.serial('Offline Contact (with Case)', () => { helpline: 'Childline', }, }, - { - id: 'childInformation', - label: 'TabbedForms-AddChildInfoTab', - fill: form.fillStandardTab, - items: { - firstName: 'E2E', - lastName: 'OFFLINE CONTACT', - gender: 'Unknown', - age: 'Unknown', - phone1: '1234512345', - province: 'Northern', - district: 'District A', - }, - }, - >{ - id: 'categories', - label: 'TabbedForms-CategoriesTab', - fill: form.fillCategoriesTab, - items: { - Accessibility: ['Education'], - }, - }, - { - id: 'caseInformation', - label: 'TabbedForms-AddCaseInfoTab', - fill: form.fillStandardTab, - items: { - callSummary: 'E2E OFFLINE CONTACT', - }, - }, ]); - + await form.fillWithContent(formContent); const beforeDate = new Date(); // Capture date here since we'll create case inmediately after saving contact // if (getConfigValue('skipDataUpdate') as boolean) { diff --git a/e2e-tests/tests/sms.spec.ts b/e2e-tests/tests/sms.spec.ts new file mode 100644 index 0000000000..510e017a4a --- /dev/null +++ b/e2e-tests/tests/sms.spec.ts @@ -0,0 +1,110 @@ +/** + * Copyright (C) 2021-2023 Technology Matters + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published + * by the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see https://www.gnu.org/licenses/. + */ + +import { Page, request, test } from '@playwright/test'; +import { statusIndicator } from '../workerStatus'; +import { ChatStatement, ChatStatementOrigin } from '../chatModel'; +import { getSmsScript } from '../chatScripts'; +import { flexChat } from '../flexChat'; +import { skipTestIfNotTargeted } from '../skipTest'; +import { tasks } from '../tasks'; +import { contactForm } from '../contactForm'; +import { deleteAllTasksInQueue } from '../twilio/tasks'; +import { notificationBar } from '../notificationBar'; +import { clickThroughTwilioPasteModals } from '../agent-desktop'; +import { setupContextAndPage, closePage } from '../browser'; +import { clearOfflineTask } from '../hrm/clearOfflineTask'; +import { apiHrmRequest } from '../hrm/hrmRequest'; +import { formContentsByHelpline } from '../formContentsByHelpline'; +import { getConfigValue } from '../config'; +import { smsChat } from '../twilio/sms'; +import { deleteSmsConversations } from '../twilio/channels'; + +test.describe.serial('SMS caller', () => { + skipTestIfNotTargeted(); + + let pluginPage: Page; + + test.beforeAll(async ({ browser }) => { + test.setTimeout(180000); + await deleteSmsConversations(); + ({ page: pluginPage } = await setupContextAndPage(browser)); + + await clearOfflineTask( + apiHrmRequest(await request.newContext(), process.env.FLEX_TOKEN!), + process.env.LOGGED_IN_WORKER_SID!, + ); + console.info('SMS E2E test - plugin page launched.'); + + await clickThroughTwilioPasteModals(pluginPage); + console.info('Plugin page visited.'); + }); + + test.afterAll(async () => { + await statusIndicator(pluginPage)?.setStatus('OFFLINE'); + if (pluginPage) { + await notificationBar(pluginPage).dismissAllNotifications(); + } + await closePage(pluginPage); + await deleteAllTasksInQueue(); + }); + + test.afterEach(async () => { + await deleteAllTasksInQueue(); + }); + + test('Chat', async () => { + test.setTimeout(180000); + + const chatScript = getSmsScript(); + + // smsChat handles the client (caller) side via the Twilio Messages API. + // flexChat handles the counselor side via the Flex browser UI. + // Both iterate the same shared script, yielding control when they hit a + // statement the other side needs to handle — the same pattern used by the + // Aselo webchat test. + const smsChatProgress = smsChat(chatScript); + const flexChatProgress: AsyncIterator = flexChat(pluginPage).chat(chatScript); + + for await (const expectedCounselorStatement of smsChatProgress) { + console.info('Statement for flex chat to process', expectedCounselorStatement); + if (expectedCounselorStatement) { + switch (expectedCounselorStatement.origin) { + case ChatStatementOrigin.COUNSELOR_AUTO: + await statusIndicator(pluginPage).setStatus('AVAILABLE'); + await tasks(pluginPage).acceptNextTask(); + await flexChatProgress.next(); + break; + default: + await flexChatProgress.next(); + break; + } + } + } + + console.info('Starting filling form'); + const helpline = getConfigValue('helplineShortCode') as keyof typeof formContentsByHelpline; + const formContent = formContentsByHelpline[helpline]; + if (!formContent) { + throw new Error(`No form contents configured for helplineShortCode="${String(helpline)}"`); + } + const form = contactForm(pluginPage); + await form.fillWithContent(formContent); + + console.info('Saving form'); + await form.save(); + }); +}); diff --git a/e2e-tests/tests/voice.spec.ts b/e2e-tests/tests/voice.spec.ts new file mode 100644 index 0000000000..8e88a64e0d --- /dev/null +++ b/e2e-tests/tests/voice.spec.ts @@ -0,0 +1,87 @@ +/** + * Copyright (C) 2021-2023 Technology Matters + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published + * by the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see https://www.gnu.org/licenses/. + */ + +import { Page, request, test } from '@playwright/test'; +import { statusIndicator } from '../workerStatus'; +import { skipTestIfNotTargeted } from '../skipTest'; +import { contactForm } from '../contactForm'; +import { deleteAllTasksInQueue } from '../twilio/tasks'; +import { notificationBar } from '../notificationBar'; +import { clickThroughTwilioPasteModals } from '../agent-desktop'; +import { setupContextAndPage, closePage } from '../browser'; +import { clearOfflineTask } from '../hrm/clearOfflineTask'; +import { apiHrmRequest } from '../hrm/hrmRequest'; +import { + formContentsByHelpline, + formContentsByHelplineForEmptyForm, +} from '../formContentsByHelpline'; +import { getConfigValue } from '../config'; +import { makeCallToService } from '../twilio/voice'; +import { tasks } from '../tasks'; + +test.describe.serial('Voice caller', () => { + skipTestIfNotTargeted(); + + let pluginPage: Page; + + test.beforeAll(async ({ browser }) => { + test.setTimeout(180000); + ({ page: pluginPage } = await setupContextAndPage(browser)); + + await clearOfflineTask( + apiHrmRequest(await request.newContext(), process.env.FLEX_TOKEN!), + process.env.LOGGED_IN_WORKER_SID!, + ); + console.info('Voice E2E test - plugin page launched.'); + + await clickThroughTwilioPasteModals(pluginPage); + console.info('Plugin page visited.'); + }); + + test.afterAll(async () => { + await statusIndicator(pluginPage)?.setStatus('OFFLINE'); + if (pluginPage) { + await notificationBar(pluginPage).dismissAllNotifications(); + } + await closePage(pluginPage); + await deleteAllTasksInQueue(); + }); + + test.afterEach(async () => { + await deleteAllTasksInQueue(); + }); + + test('Call', async () => { + test.setTimeout(180000); + await makeCallToService(); + await statusIndicator(pluginPage).setStatus('AVAILABLE'); + await tasks(pluginPage).acceptNextTask(); + + console.info('Starting filling form'); + const helpline = getConfigValue('helplineShortCode') as keyof typeof formContentsByHelpline; + const formContent = formContentsByHelplineForEmptyForm[helpline]; + if (!formContent) { + throw new Error(`No form contents configured for helplineShortCode="${String(helpline)}"`); + } + const form = contactForm(pluginPage); + + await form.selectChildCallType(); + await form.fillWithContent(formContent); + + console.info('Saving form'); + await form.save(); + }); +}); diff --git a/e2e-tests/twilio/channels.ts b/e2e-tests/twilio/channels.ts index 005290357b..da543ce7ab 100644 --- a/e2e-tests/twilio/channels.ts +++ b/e2e-tests/twilio/channels.ts @@ -30,7 +30,66 @@ const encodeEmailToUnicode = (email: string) => { .join(''); }; -export const deleteChatChannels = async (): Promise => { +const deleteSmsConversationFromOneEnd = async ( + accountSid: string, + authToken: string, + fromNumber: string, + toNumber: string, +) => { + const client = twilio(accountSid, authToken); + const activeConversations = await client.conversations.v1.conversations.list({ + state: 'active', + }); + console.info(`${activeConversations.length} active conversations found.`); + await Promise.all( + activeConversations.map(async (conversation) => { + const participants = await conversation.participants().list(); + + if ( + // eslint-disable-next-line @typescript-eslint/no-loop-func + participants.some((participant) => { + return ( + participant.messagingBinding?.address === fromNumber && + participant.messagingBinding?.proxy_address === toNumber + ); + }) + ) { + console.info( + `Found a participant with the from SMS number address (${fromNumber}) and to SMS number proxy address (${toNumber}), attempting to close conversation ${conversation.sid} from ${accountSid}`, + ); + await conversation.update({ state: 'closed' }); + } + }), + ); +}; + +export const deleteSmsConversations = async (): Promise => { + const serviceAccountSid = getConfigValue('twilioAccountSid') as string; + const serviceAuthToken = getConfigValue('twilioAuthToken') as string; + const serviceSmsNumber = getConfigValue('smsPhoneNumber') as string; + + const senderAccountSid = getConfigValue('clientTwilioAccountSid') as string; + const senderAuthToken = getConfigValue('clientTwilioAuthToken') as string; + const senderSmsNumber = getConfigValue('clientSmsPhoneNumber') as string; + + // Delete conversations from service Twilio account + await deleteSmsConversationFromOneEnd( + serviceAccountSid, + serviceAuthToken, + senderSmsNumber, + serviceSmsNumber, + ); + + // Delete conversations from sender Twilio account + await deleteSmsConversationFromOneEnd( + senderAccountSid, + senderAuthToken, + serviceSmsNumber, + senderSmsNumber, + ); +}; + +export const deleteChatConversations = async (): Promise => { const accountSid = getConfigValue('twilioAccountSid') as string; const authToken = getConfigValue('twilioAuthToken') as string; const email = getConfigValue('oktaUsername') as string; @@ -38,42 +97,36 @@ export const deleteChatChannels = async (): Promise => { const client = twilio(accountSid, authToken); - // List all chat services - const services = await client.chat.v2.services.list(); - - for (const service of services) { - // List all users in this chat service - const users = await client.chat.v2.services(service.sid).users.list(); - console.log(`Found ${users.length} users in service ${service.sid}`); - const matchingUser = users.find((user) => user.identity === encodedEmail); + // List all users in this chat service + const users = await client.conversations.v1.users.list(); + console.debug(`Found ${users.length} users in conversations`); + const matchingUser = users.find((user) => user.identity === encodedEmail); - if (!matchingUser) { - continue; - } + if (!matchingUser) { + return; + } - console.log(`Found user ${email} in service ${service.sid}`); + console.info(`Found user ${email} in conversations`); - // List all channels the matching user is a part of - const userChannels = await client.chat.v2 - .services(service.sid) - .users(matchingUser.sid) - .userChannels.list(); + // List all channels the matching user is a part of + const userConversations = await client.conversations.v1.users + .get(matchingUser.sid) + .userConversations.list(); - console.log( - `Found ${userChannels.length} chat channels for user ${email} in service ${service.sid}`, - ); + console.debug(`Found ${userConversations.length} chat channels for user ${email}`); - for (const userChannel of userChannels) { - console.log(`Removing chat channel ${userChannel.channelSid} from service ${service.sid}`); - await client.chat.v2.services(service.sid).channels(userChannel.channelSid).remove(); - } + for (const { conversationSid } of userConversations) { + console.debug(`Removing chat channel ${conversationSid}`); + await client.conversations.v1.conversations.get(conversationSid).remove(); } }; // Handle exit signals process.on('SIGINT', () => { - deleteChatChannels().catch((err) => console.error(err)); + deleteChatConversations().catch((err) => console.error(err)); + deleteSmsConversations().catch((err) => console.error(err)); }); process.on('SIGTERM', () => { - deleteChatChannels().catch((err) => console.error(err)); + deleteChatConversations().catch((err) => console.error(err)); + deleteSmsConversations().catch((err) => console.error(err)); }); diff --git a/e2e-tests/twilio/sms.ts b/e2e-tests/twilio/sms.ts new file mode 100644 index 0000000000..e4a01853e9 --- /dev/null +++ b/e2e-tests/twilio/sms.ts @@ -0,0 +1,129 @@ +/** + * Copyright (C) 2021-2023 Technology Matters + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published + * by the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see https://www.gnu.org/licenses/. + */ + +import { getConfigValue } from '../config'; +// eslint-disable-next-line import/no-extraneous-dependencies +import twilio from 'twilio'; +import { AssertionError } from 'node:assert'; +import { ChatStatement, ChatStatementOrigin } from '../chatModel'; + +// Tracks the start of the current SMS test session so we only check messages received after this time +let sessionStartTime: Date | undefined; + +let clientConversationSid: string; + +export const sendSmsToService = async (messageText: string) => { + if (!sessionStartTime) { + sessionStartTime = new Date(); + } + const clientAccountSid = getConfigValue('clientTwilioAccountSid') as string; + const authToken = getConfigValue('clientTwilioAuthToken') as string; + const from = getConfigValue('clientSmsPhoneNumber') as string; + const serviceAccountSid = getConfigValue('twilioAccountSid') as string; + const to = getConfigValue('smsPhoneNumber') as string; + + const client = twilio(clientAccountSid, authToken); + if (!clientConversationSid) { + const clientConversation = await client.conversations.v1.conversations.create({ + friendlyName: `E2E test conversation with ${serviceAccountSid}, ${new Date().toISOString()}`, + }); + await clientConversation.participants().create({ + 'messagingBinding.address': to, + 'messagingBinding.proxyAddress': from, + 'messagingBinding.type': 'sms', + } as any); + clientConversationSid = clientConversation.sid; + } + await client.conversations.v1.conversations + .get(clientConversationSid) + .messages.create({ author: from, body: messageText }); + console.debug(`Sent SMS message to service: '${messageText}'`); +}; + +const MAX_CHECKS = 10; + +/** + * Checks whether the given message text was received by the SMS client (i.e., sent from the + * service to the client phone number) at any point since the current session started. + * Uses the service Twilio account to list outbound messages to the client number. + */ +export const checkForMessageOnClient = async (messageText: string): Promise => { + if (!clientConversationSid) { + throw new AssertionError({ + message: "You cannot verify incoming messages until you've sent one and started a session", + }); + } + const accountSid = getConfigValue('clientTwilioAccountSid') as string; + const authToken = getConfigValue('clientTwilioAuthToken') as string; + const to = getConfigValue('clientSmsPhoneNumber') as string; + + const client = twilio(accountSid, authToken); + const delay = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)); + for (let i = 0; i < MAX_CHECKS; i++) { + const messages = await client.conversations.v1.conversations + .get(clientConversationSid) + .messages.list(); + //client.conversations.v1.roles.l + if (messages.find((m) => m.body === messageText && m.author !== to)) { + return true; + } + await delay(1000); + } + return false; +}; + +/** + * Asserts that the given message text was received by the SMS client within the polling window. + * Throws if the message is not found. + */ +const assertMessageReceivedOnClient = async (messageText: string): Promise => { + const received = await checkForMessageOnClient(messageText); + if (!received) { + throw new AssertionError({ + message: `SMS message not received on client: '${messageText}'`, + }); + } +}; + +/** + * Runs the 'client side' of an SMS conversation using the Twilio Messages API. + * It loops through a list of chat statements, sending caller SMS messages via the API and + * polling for expected bot/counselor messages on the client number. + * As soon as it hits a counselor statement (COUNSELOR or COUNSELOR_AUTO), it yields execution + * back to the calling code so it can action those statements in Flex. + * + * A similar function exists in flexChat.ts to handle the counselor side of the conversation. + * Both iterate the same shared ChatStatement list, yielding control when they hit a statement + * the other side needs to handle. + * @param statements - a unified list of all the chat statements in a conversation + */ +export async function* smsChat(statements: ChatStatement[]): AsyncGenerator { + for (const statementItem of statements) { + const { text, origin } = statementItem; + switch (origin) { + case ChatStatementOrigin.CALLER: + await sendSmsToService(text); + break; + case ChatStatementOrigin.BOT: { + await assertMessageReceivedOnClient(text); + break; + } + default: + yield statementItem; + await assertMessageReceivedOnClient(text); + } + } +} diff --git a/e2e-tests/twilio/voice.ts b/e2e-tests/twilio/voice.ts new file mode 100644 index 0000000000..ec016f6c85 --- /dev/null +++ b/e2e-tests/twilio/voice.ts @@ -0,0 +1,44 @@ +/** + * Copyright (C) 2021-2026 Technology Matters + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published + * by the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see https://www.gnu.org/licenses/. + */ + +import { getConfigValue } from '../config'; +// eslint-disable-next-line import/no-extraneous-dependencies +import twilio from 'twilio'; +import VoiceResponse = twilio.twiml.VoiceResponse; + +// The callSid on the caller's side +// let callerCallSid: string; + +export const makeCallToService = async () => { + const clientAccountSid = getConfigValue('clientTwilioAccountSid') as string; + const authToken = getConfigValue('clientTwilioAuthToken') as string; + const from = getConfigValue('clientSmsPhoneNumber') as string; + // const serviceAccountSid = getConfigValue('twilioAccountSid') as string; + const to = getConfigValue('voicePhoneNumber') as string; + + const response = new VoiceResponse(); + response.say({ loop: 100 }, "Hello, I'm an end to end test"); + + const client = twilio(clientAccountSid, authToken); + //const call = + await client.calls.create({ + method: 'GET', + twiml: response, + from, + to, + }); + //callerCallSid = call.sid; +}; diff --git a/e2e-tests/twilio/worker.ts b/e2e-tests/twilio/worker.ts index f93f22931a..bef984e6dd 100644 --- a/e2e-tests/twilio/worker.ts +++ b/e2e-tests/twilio/worker.ts @@ -36,10 +36,3 @@ export const getSidForWorker = async (friendlyName: string): Promise => { - return page.evaluate(() => { - const manager = (window as any).Twilio.Flex.Manager.getInstance(); - return manager.workerClient.sid; - }); -}; diff --git a/e2e-tests/workerStatus.ts b/e2e-tests/workerStatus.ts index f04030f167..b18a3f62b9 100644 --- a/e2e-tests/workerStatus.ts +++ b/e2e-tests/workerStatus.ts @@ -15,7 +15,7 @@ */ // eslint-disable-next-line import/no-extraneous-dependencies -import { Locator, Page } from '@playwright/test'; +import { expect, Locator, Page } from '@playwright/test'; export const WORKER_STATUS = { AVAILABLE: ['Available', 'Ready'], @@ -45,12 +45,13 @@ export function statusIndicator(page: Page) { return { setStatus: async function (status: WorkerStatus) { await selectors.userActivityDropdownButton.click(); - console.log('Worker status dropdown should be open'); + console.debug('Worker status dropdown should be open'); await selectors.activityMenu.waitFor({ state: 'visible' }); const statusSelector = await getFirstMatchingStatus(page, WORKER_STATUS[status]); - console.log('Worker status option spotted'); + console.debug('Worker status option spotted'); await statusSelector.click(); - console.log('Worker status option clicked'); + console.debug('Worker status option clicked'); + await expect(statusSelector).toContainText(new RegExp(WORKER_STATUS[status].join('|'))); }, }; } diff --git a/twilio-iac/helplines/e2e/development.hcl b/twilio-iac/helplines/e2e/development.hcl index eafb13d33c..dce9b14ab3 100644 --- a/twilio-iac/helplines/e2e/development.hcl +++ b/twilio-iac/helplines/e2e/development.hcl @@ -7,7 +7,7 @@ locals { local_config = { enable_external_recordings = true permission_config = "e2e" - custom_task_routing_filter_expression = "*(helpline IN ['Childline', ''] OR channelType =='web') AND isContactlessTask != true" + custom_task_routing_filter_expression = "*(helpline IN ['Childline', ''] OR channelType =='web' OR channelType == 'voice' OR channelType == 'sms') AND isContactlessTask != true" flow_vars = { service_sid = "ZS43ea9fdb2e1901c2fc23b4654b285202" environment_sid = "ZE0241494e654e208f715b4d9612171dc0" @@ -35,6 +35,25 @@ locals { channel_flow_vars = {} chatbot_unique_names = [] } + sms : { + channel_type = "sms" + messaging_mode = "conversations" + contact_identity = "+12607821891" + templatefile = "/app/twilio-iac/helplines/templates/studio-flows/messaging-lex-v3-blocking-lambda.tftpl" + channel_flow_vars = {} + chatbot_unique_names = [] + } + voice : { + channel_type = "voice" + contact_identity = "+12607821891" + templatefile = "/app/twilio-iac/helplines/templates/studio-flows/voice-no-chatbot-operating-hours-blocking-lambda.tftpl" + channel_flow_vars = { + voice_ivr_greeting_message = "Thank you for contacting E2E. One of our counselors will be with you shortly." + voice_ivr_blocked_message = "You have been blocked from contacting this service." + voice_ivr_language = "en-US" + } + chatbot_unique_names = [] + } } get_profile_flags_for_identifier_base_url = "https://hrm-development.tl.techmatters.org/lambda/twilio/account-scoped" #System Down Configuration