From dc009dd494f9751e7fa1210e6d16c98e03b908f0 Mon Sep 17 00:00:00 2001 From: Stephen Hand Date: Wed, 15 Jul 2026 14:37:20 +0100 Subject: [PATCH 01/40] WIP add config and basic SMS test methods --- e2e-tests/config.ts | 40 ++++++++++++++++++++++++++ e2e-tests/twilio/channels.ts | 9 ++++-- e2e-tests/twilio/sms.ts | 55 ++++++++++++++++++++++++++++++++++++ e2e-tests/twilio/worker.ts | 7 ----- 4 files changed, 101 insertions(+), 10 deletions(-) create mode 100644 e2e-tests/twilio/sms.ts diff --git a/e2e-tests/config.ts b/e2e-tests/config.ts index 6831eb2cee..d7ac704d4a 100644 --- a/e2e-tests/config.ts +++ b/e2e-tests/config.ts @@ -41,6 +41,8 @@ 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 clientHelplineShortCode = process.env.HL?.toLocaleLowerCase() || helplineShortCode; const helplineEnv = process.env.HL_ENV?.toLocaleLowerCase() || 'local'; const shouldLoadFromSsm = process.env.LOAD_SSM_CONFIG && process.env.LOAD_SSM_CONFIG !== 'false'; @@ -135,6 +137,20 @@ 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: 'TWILIO_ACCOUNT_SID', + ssmPath: `/${localOverrideEnv}/twilio/${clientHelplineShortCode.toUpperCase()}/account_sid`, + default: () => getConfigValue('twilioAccountSid'), + }, + clientTwilioAuthToken: { + envKey: 'TWILIO_AUTH_TOKEN', + // Order is important here. We use a function so that we can reference the clientTwilioAccountSid config value above. + ssmPath: () => + `/${localOverrideEnv}/twilio/${getConfigValue('clientTwilioAccountSid')}/auth_token`, + default: () => getConfigValue('twilioAuthToken'), + }, + // Turn on debug mode. Possibly unused. debug: { envKey: 'DEBUG', @@ -180,6 +196,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: '', + }, + + // This should match the number set up on the clientTwilioAccountSid that can make outgoing calls + clientVoicePhoneNumber: { + envKey: 'CLIENT_VOICE_PHONE_NUMBER', + default: '', + }, + + // 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', diff --git a/e2e-tests/twilio/channels.ts b/e2e-tests/twilio/channels.ts index 005290357b..211a118155 100644 --- a/e2e-tests/twilio/channels.ts +++ b/e2e-tests/twilio/channels.ts @@ -39,11 +39,11 @@ export const deleteChatChannels = async (): Promise => { const client = twilio(accountSid, authToken); // List all chat services - const services = await client.chat.v2.services.list(); + const services = await client.conversations.v1.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(); + const users = await client.conversations.v1.services(service.sid).users.list(); console.log(`Found ${users.length} users in service ${service.sid}`); const matchingUser = users.find((user) => user.identity === encodedEmail); @@ -65,7 +65,10 @@ export const deleteChatChannels = async (): Promise => { 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(); + await client.conversations.v1.services + .get(service.sid) + .conversations.get(userChannel.channelSid) + .remove(); } } }; diff --git a/e2e-tests/twilio/sms.ts b/e2e-tests/twilio/sms.ts new file mode 100644 index 0000000000..175922f361 --- /dev/null +++ b/e2e-tests/twilio/sms.ts @@ -0,0 +1,55 @@ +import { getConfigValue } from '../config'; +import twilio from 'twilio'; +import type { ConversationInstance } from 'twilio/lib/rest/conversations/v1/conversation'; +import { AssertionError } from 'node:assert'; + +let clientConversation: ConversationInstance; + +export const sendSmsToService = async (messageText: string) => { + const accountSid = getConfigValue('clientTwilioAccountSid') as string; + const authToken = getConfigValue('clientTwilioAuthToken') as string; + const from = getConfigValue('clientSmsPhoneNumber') as string; + const to = getConfigValue('smsPhoneNumber') as string; + + const client = twilio(accountSid, authToken); + if (!clientConversation) { + clientConversation = await client.conversations.v1.conversations.create({ + friendlyName: 'E2E test client conversation', + + uniqueName: `sms/${from}/${Date.now()}`, + }); + await clientConversation.participants().create({ + identity: from, + }); + } + await client.messages.create({ from, to, body: messageText }); + console.debug(`Sent SMS message to service: '${messageText}'`); +}; +export const sendSmsFromService = async (messageText: string) => { + const accountSid = getConfigValue('twilioAccountSid') as string; + const authToken = getConfigValue('twilioAuthToken') as string; + const from = getConfigValue('smsPhoneNumber') as string; + const to = getConfigValue('clientSmsPhoneNumber') as string; + + const client = twilio(accountSid, authToken); + await client.messages.create({ from, to, body: messageText }); + console.debug(`Sent SMS message from service: '${messageText}'`); +}; + +const MAX_CHECKS = 10; + +export const checkForMessageOnClient = async (messageText: string): Promise => { + if (!clientConversation) { + throw new AssertionError({ + message: + "You cannot verify incoming messages until you've sent one and created a client side conversation", + }); + } + for (let i = 0; i < MAX_CHECKS; i++) { + const messages = await clientConversation.messages().list(); + if (messages.find((m) => m.body === messageText)) { + return true; + } + } + return false; +}; 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; - }); -}; From 396ff369f029f4fc9567b62d3c30730b99e46e76 Mon Sep 17 00:00:00 2001 From: Stephen Hand Date: Wed, 15 Jul 2026 14:44:25 +0100 Subject: [PATCH 02/40] Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- e2e-tests/config.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/e2e-tests/config.ts b/e2e-tests/config.ts index d7ac704d4a..71b263e858 100644 --- a/e2e-tests/config.ts +++ b/e2e-tests/config.ts @@ -42,7 +42,7 @@ 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 clientHelplineShortCode = process.env.HL?.toLocaleLowerCase() || helplineShortCode; +const clientHelplineShortCode = process.env.CLIENT_HL?.toLocaleLowerCase() || helplineShortCode; const helplineEnv = process.env.HL_ENV?.toLocaleLowerCase() || 'local'; const shouldLoadFromSsm = process.env.LOAD_SSM_CONFIG && process.env.LOAD_SSM_CONFIG !== 'false'; From 88a9075414a39235f5f5df2151d38b4586a35ecd Mon Sep 17 00:00:00 2001 From: Stephen Hand Date: Wed, 15 Jul 2026 14:44:44 +0100 Subject: [PATCH 03/40] Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- e2e-tests/twilio/sms.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/e2e-tests/twilio/sms.ts b/e2e-tests/twilio/sms.ts index 175922f361..bb2682d160 100644 --- a/e2e-tests/twilio/sms.ts +++ b/e2e-tests/twilio/sms.ts @@ -50,6 +50,7 @@ export const checkForMessageOnClient = async (messageText: string): Promise m.body === messageText)) { return true; } + await new Promise((resolve) => setTimeout(resolve, 1000)); } return false; }; From 30f7c09ece53823a8026f15b7ec59365f1f80063 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 15 Jul 2026 13:51:25 +0000 Subject: [PATCH 04/40] fix: use chat api for e2e channel cleanup lookup Co-authored-by: stephenhand <1694716+stephenhand@users.noreply.github.com> --- e2e-tests/twilio/channels.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/e2e-tests/twilio/channels.ts b/e2e-tests/twilio/channels.ts index 211a118155..f1ef09fe1c 100644 --- a/e2e-tests/twilio/channels.ts +++ b/e2e-tests/twilio/channels.ts @@ -39,11 +39,11 @@ export const deleteChatChannels = async (): Promise => { const client = twilio(accountSid, authToken); // List all chat services - const services = await client.conversations.v1.services.list(); + const services = await client.chat.v2.services.list(); for (const service of services) { // List all users in this chat service - const users = await client.conversations.v1.services(service.sid).users.list(); + 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); From 859eca5e9fd430ed3ce54f8d79860a8c9636382b Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 15 Jul 2026 15:08:21 +0000 Subject: [PATCH 05/40] Initial plan From a3f67305f560071dfb59d62eec3c979a718d3e89 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 15 Jul 2026 15:24:57 +0000 Subject: [PATCH 06/40] feat: add SMS E2E test mirroring webchat test with shared ChatStatement/AsyncIterable pattern Co-authored-by: stephenhand <1694716+stephenhand@users.noreply.github.com> --- e2e-tests/chatScripts.ts | 62 ++++++++++++++++++ e2e-tests/tests/sms.spec.ts | 127 ++++++++++++++++++++++++++++++++++++ e2e-tests/twilio/sms.ts | 83 ++++++++++++++++++----- 3 files changed, 255 insertions(+), 17 deletions(-) create mode 100644 e2e-tests/tests/sms.spec.ts 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/tests/sms.spec.ts b/e2e-tests/tests/sms.spec.ts new file mode 100644 index 0000000000..2488ee8a8f --- /dev/null +++ b/e2e-tests/tests/sms.spec.ts @@ -0,0 +1,127 @@ +/** + * 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 { Categories, contactForm, ContactFormTab } 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'; + +test.describe.serial('SMS 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('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.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, + }, + ]); + + console.info('Saving form'); + await form.save(); + }); +}); diff --git a/e2e-tests/twilio/sms.ts b/e2e-tests/twilio/sms.ts index bb2682d160..5e52833f20 100644 --- a/e2e-tests/twilio/sms.ts +++ b/e2e-tests/twilio/sms.ts @@ -1,30 +1,26 @@ import { getConfigValue } from '../config'; +// eslint-disable-next-line import/no-extraneous-dependencies import twilio from 'twilio'; -import type { ConversationInstance } from 'twilio/lib/rest/conversations/v1/conversation'; import { AssertionError } from 'node:assert'; +import { ChatStatement, ChatStatementOrigin } from '../chatModel'; -let clientConversation: ConversationInstance; +// Tracks the start of the current SMS test session so we only check messages received after this time +let sessionStartTime: Date | undefined; export const sendSmsToService = async (messageText: string) => { + if (!sessionStartTime) { + sessionStartTime = new Date(); + } const accountSid = getConfigValue('clientTwilioAccountSid') as string; const authToken = getConfigValue('clientTwilioAuthToken') as string; const from = getConfigValue('clientSmsPhoneNumber') as string; const to = getConfigValue('smsPhoneNumber') as string; const client = twilio(accountSid, authToken); - if (!clientConversation) { - clientConversation = await client.conversations.v1.conversations.create({ - friendlyName: 'E2E test client conversation', - - uniqueName: `sms/${from}/${Date.now()}`, - }); - await clientConversation.participants().create({ - identity: from, - }); - } await client.messages.create({ from, to, body: messageText }); console.debug(`Sent SMS message to service: '${messageText}'`); }; + export const sendSmsFromService = async (messageText: string) => { const accountSid = getConfigValue('twilioAccountSid') as string; const authToken = getConfigValue('twilioAuthToken') as string; @@ -38,19 +34,72 @@ export const sendSmsFromService = async (messageText: string) => { 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 (!clientConversation) { + if (!sessionStartTime) { throw new AssertionError({ - message: - "You cannot verify incoming messages until you've sent one and created a client side conversation", + message: "You cannot verify incoming messages until you've sent one and started a session", }); } + const accountSid = getConfigValue('twilioAccountSid') as string; + const authToken = getConfigValue('twilioAuthToken') 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 clientConversation.messages().list(); + const messages = await client.messages.list({ to, dateSentAfter: sessionStartTime }); if (messages.find((m) => m.body === messageText)) { return true; } - await new Promise((resolve) => setTimeout(resolve, 1000)); + 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); + } + } +} From 5b6c9190ab36a5d7c7f6b439c78d62b1e413972b Mon Sep 17 00:00:00 2001 From: Stephen Hand Date: Wed, 15 Jul 2026 17:28:52 +0100 Subject: [PATCH 07/40] Licence headers --- e2e-tests/twilio/sms.ts | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/e2e-tests/twilio/sms.ts b/e2e-tests/twilio/sms.ts index bb2682d160..8db0095a31 100644 --- a/e2e-tests/twilio/sms.ts +++ b/e2e-tests/twilio/sms.ts @@ -1,3 +1,19 @@ +/** + * 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'; import twilio from 'twilio'; import type { ConversationInstance } from 'twilio/lib/rest/conversations/v1/conversation'; From 64dd61e433b292adcd8ab38e9e924808b5f81c05 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 17 Jul 2026 18:03:15 +0000 Subject: [PATCH 08/40] feat: add SMS channel for E2E development environment - Add SMS channel to twilio-iac/helplines/e2e/development.hcl using the messaging-lex-v3-blocking-lambda.tftpl template (same as aselo_webchat) and an empty contact_identity (conversations address managed separately) - Create twilio-iac/helplines/e2e/files/additional.configure.tf that uses a Twilio data source to look up the only phone number attached to the account at apply time and creates the SMS conversations address linked to the SMS studio flow - Guard twilio_conversations_configuration_addresses_v1 in channels/v1/main.tf so channels with an empty contact_identity skip automatic address creation (allowing helpline-specific additional.tf to manage it instead) Co-authored-by: stephenhand <1694716+stephenhand@users.noreply.github.com> --- twilio-iac/helplines/e2e/development.hcl | 11 ++++++++++ .../e2e/files/additional.configure.tf | 20 +++++++++++++++++++ .../terraform-modules/channels/v1/main.tf | 4 +++- 3 files changed, 34 insertions(+), 1 deletion(-) create mode 100644 twilio-iac/helplines/e2e/files/additional.configure.tf diff --git a/twilio-iac/helplines/e2e/development.hcl b/twilio-iac/helplines/e2e/development.hcl index eafb13d33c..f90e2140d9 100644 --- a/twilio-iac/helplines/e2e/development.hcl +++ b/twilio-iac/helplines/e2e/development.hcl @@ -35,6 +35,17 @@ locals { channel_flow_vars = {} chatbot_unique_names = [] } + sms : { + channel_type = "sms" + messaging_mode = "conversations" + # contact_identity is intentionally empty here; the conversations address is created + # via additional.configure.tf using a data source that resolves the only phone number + # attached to this Twilio account at apply time. + contact_identity = "" + templatefile = "/app/twilio-iac/helplines/templates/studio-flows/messaging-lex-v3-blocking-lambda.tftpl" + channel_flow_vars = {} + chatbot_unique_names = [] + } } get_profile_flags_for_identifier_base_url = "https://hrm-development.tl.techmatters.org/lambda/twilio/account-scoped" #System Down Configuration diff --git a/twilio-iac/helplines/e2e/files/additional.configure.tf b/twilio-iac/helplines/e2e/files/additional.configure.tf new file mode 100644 index 0000000000..0de4d3fba3 --- /dev/null +++ b/twilio-iac/helplines/e2e/files/additional.configure.tf @@ -0,0 +1,20 @@ +# Dynamically resolve the single phone number attached to this Twilio account and +# configure it as the SMS conversations address, linked to the SMS studio flow created +# by the channels module. +# +# Using a data source rather than hardcoding the number keeps the config portable: +# the E2E Twilio account has exactly one phone number, so one() is intentionally +# strict here – if the account ever has zero or more than one number the apply will +# fail with a clear error. + +data "twilio_api_accounts_incoming_phone_numbers_v2010" "all" {} + +resource "twilio_conversations_configuration_addresses_v1" "sms" { + type = "sms" + address = one(data.twilio_api_accounts_incoming_phone_numbers_v2010.all.incoming_phone_numbers).phone_number + friendly_name = "Sms Conversation Address" + auto_creation_enabled = true + auto_creation_type = "studio" + auto_creation_conversation_service_sid = local.services_flex_chat_service_sid + auto_creation_studio_flow_sid = module.channel.channel_studio_flows_sids["sms"].flow_sid +} diff --git a/twilio-iac/terraform-modules/channels/v1/main.tf b/twilio-iac/terraform-modules/channels/v1/main.tf index 21e6d961bd..20af2664b1 100644 --- a/twilio-iac/terraform-modules/channels/v1/main.tf +++ b/twilio-iac/terraform-modules/channels/v1/main.tf @@ -107,7 +107,9 @@ resource "twilio_conversations_configuration_addresses_v1" "conversations_addres # Must be created manually in Twilio Console for now channel.channel_type != "chat" && channel.channel_type != "custom" && - channel.messaging_mode == "conversations" + channel.messaging_mode == "conversations" && + # Channels with no contact_identity manage their own conversations address (e.g. via additional.tf) + channel.contact_identity != "" ) } type = each.value.channel_type From e036e2cb87a5432ebb12772b9de35d7c7f0ac0fe Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 17 Jul 2026 18:03:57 +0000 Subject: [PATCH 09/40] fix: use consistent SMS capitalization in friendly_name Co-authored-by: stephenhand <1694716+stephenhand@users.noreply.github.com> --- twilio-iac/helplines/e2e/files/additional.configure.tf | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/twilio-iac/helplines/e2e/files/additional.configure.tf b/twilio-iac/helplines/e2e/files/additional.configure.tf index 0de4d3fba3..e8c9d036d9 100644 --- a/twilio-iac/helplines/e2e/files/additional.configure.tf +++ b/twilio-iac/helplines/e2e/files/additional.configure.tf @@ -12,7 +12,7 @@ data "twilio_api_accounts_incoming_phone_numbers_v2010" "all" {} resource "twilio_conversations_configuration_addresses_v1" "sms" { type = "sms" address = one(data.twilio_api_accounts_incoming_phone_numbers_v2010.all.incoming_phone_numbers).phone_number - friendly_name = "Sms Conversation Address" + friendly_name = "SMS Conversation Address" auto_creation_enabled = true auto_creation_type = "studio" auto_creation_conversation_service_sid = local.services_flex_chat_service_sid From 2d3f8053d273c71670c7f36503eb942e006df222 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 20 Jul 2026 14:19:00 +0000 Subject: [PATCH 10/40] refactor: hardcode E2E SMS channel phone number +12607821891 - Replace dynamic phone number lookup with hardcoded +12607821891 in both development.hcl and additional.configure.tf - Remove data source that previously scraped the phone number from the Twilio account - Set contact_identity to the hardcoded phone number in the SMS channel configuration Co-authored-by: stephenhand <1694716+stephenhand@users.noreply.github.com> --- twilio-iac/helplines/e2e/development.hcl | 5 +---- .../helplines/e2e/files/additional.configure.tf | 13 ++----------- 2 files changed, 3 insertions(+), 15 deletions(-) diff --git a/twilio-iac/helplines/e2e/development.hcl b/twilio-iac/helplines/e2e/development.hcl index f90e2140d9..ff1a8358d8 100644 --- a/twilio-iac/helplines/e2e/development.hcl +++ b/twilio-iac/helplines/e2e/development.hcl @@ -38,10 +38,7 @@ locals { sms : { channel_type = "sms" messaging_mode = "conversations" - # contact_identity is intentionally empty here; the conversations address is created - # via additional.configure.tf using a data source that resolves the only phone number - # attached to this Twilio account at apply time. - contact_identity = "" + contact_identity = "+12607821891" templatefile = "/app/twilio-iac/helplines/templates/studio-flows/messaging-lex-v3-blocking-lambda.tftpl" channel_flow_vars = {} chatbot_unique_names = [] diff --git a/twilio-iac/helplines/e2e/files/additional.configure.tf b/twilio-iac/helplines/e2e/files/additional.configure.tf index e8c9d036d9..944ff076bd 100644 --- a/twilio-iac/helplines/e2e/files/additional.configure.tf +++ b/twilio-iac/helplines/e2e/files/additional.configure.tf @@ -1,17 +1,8 @@ -# Dynamically resolve the single phone number attached to this Twilio account and -# configure it as the SMS conversations address, linked to the SMS studio flow created -# by the channels module. -# -# Using a data source rather than hardcoding the number keeps the config portable: -# the E2E Twilio account has exactly one phone number, so one() is intentionally -# strict here – if the account ever has zero or more than one number the apply will -# fail with a clear error. - -data "twilio_api_accounts_incoming_phone_numbers_v2010" "all" {} +# Configure the SMS conversations address with the hardcoded phone number +12607821891. resource "twilio_conversations_configuration_addresses_v1" "sms" { type = "sms" - address = one(data.twilio_api_accounts_incoming_phone_numbers_v2010.all.incoming_phone_numbers).phone_number + address = "+12607821891" friendly_name = "SMS Conversation Address" auto_creation_enabled = true auto_creation_type = "studio" From 0d150f9bbf2b624b5ba5cc4ce1d83e874dbeb29d Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 20 Jul 2026 14:26:26 +0000 Subject: [PATCH 11/40] refactor: remove redundant additional.configure.tf for SMS address The twilio-iac/terraform-modules/channels/v1/main.tf module now automatically creates the SMS conversations address since contact_identity is no longer empty. The separate additional.configure.tf file is no longer needed. Co-authored-by: stephenhand <1694716+stephenhand@users.noreply.github.com> --- .../helplines/e2e/files/additional.configure.tf | 11 ----------- 1 file changed, 11 deletions(-) delete mode 100644 twilio-iac/helplines/e2e/files/additional.configure.tf diff --git a/twilio-iac/helplines/e2e/files/additional.configure.tf b/twilio-iac/helplines/e2e/files/additional.configure.tf deleted file mode 100644 index 944ff076bd..0000000000 --- a/twilio-iac/helplines/e2e/files/additional.configure.tf +++ /dev/null @@ -1,11 +0,0 @@ -# Configure the SMS conversations address with the hardcoded phone number +12607821891. - -resource "twilio_conversations_configuration_addresses_v1" "sms" { - type = "sms" - address = "+12607821891" - friendly_name = "SMS Conversation Address" - auto_creation_enabled = true - auto_creation_type = "studio" - auto_creation_conversation_service_sid = local.services_flex_chat_service_sid - auto_creation_studio_flow_sid = module.channel.channel_studio_flows_sids["sms"].flow_sid -} From 77c4c7e1426e97ff76d02fc8214951dc4da02852 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 22 Jul 2026 11:49:27 +0000 Subject: [PATCH 12/40] feat: add voice channel for E2E development environment - Add voice channel using voice-no-chatbot-operating-hours-blocking-lambda template - Use the same phone number (+12607821891) as the SMS channel - Include voice_ivr_greeting_message, voice_ivr_blocked_message, and voice_ivr_language - Follows established patterns used in other helplines for voice configurations Co-authored-by: stephenhand <1694716+stephenhand@users.noreply.github.com> --- twilio-iac/helplines/e2e/development.hcl | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/twilio-iac/helplines/e2e/development.hcl b/twilio-iac/helplines/e2e/development.hcl index ff1a8358d8..4cbd493785 100644 --- a/twilio-iac/helplines/e2e/development.hcl +++ b/twilio-iac/helplines/e2e/development.hcl @@ -43,6 +43,17 @@ locals { 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 From cc25f5d7873727394368e2bd0a59c317036a44ca Mon Sep 17 00:00:00 2001 From: Stephen Hand Date: Wed, 22 Jul 2026 12:52:26 +0100 Subject: [PATCH 13/40] WIP voice testing --- e2e-tests/config.ts | 19 ++- ...ChatChannels.ts => deleteConversations.ts} | 5 +- e2e-tests/package.json | 6 +- e2e-tests/tests/sms.spec.ts | 2 + e2e-tests/tests/voice.spec.ts | 111 ++++++++++++++++ e2e-tests/twilio/channels.ts | 122 ++++++++++++------ e2e-tests/twilio/sms.ts | 46 ++++--- e2e-tests/twilio/voice.ts | 27 ++++ 8 files changed, 268 insertions(+), 70 deletions(-) rename e2e-tests/{deleteChatChannels.ts => deleteConversations.ts} (88%) create mode 100644 e2e-tests/tests/voice.spec.ts create mode 100644 e2e-tests/twilio/voice.ts diff --git a/e2e-tests/config.ts b/e2e-tests/config.ts index 71b263e858..26cef92eb1 100644 --- a/e2e-tests/config.ts +++ b/e2e-tests/config.ts @@ -42,8 +42,9 @@ 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 clientHelplineShortCode = process.env.CLIENT_HL?.toLocaleLowerCase() || helplineShortCode; 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 @@ -139,16 +140,14 @@ const configOptions: ConfigOptions = { // The twilio account sid and auth token are used to target a flex account clientTwilioAccountSid: { - envKey: 'TWILIO_ACCOUNT_SID', - ssmPath: `/${localOverrideEnv}/twilio/${clientHelplineShortCode.toUpperCase()}/account_sid`, - default: () => getConfigValue('twilioAccountSid'), + envKey: 'CLIENT_TWILIO_ACCOUNT_SID', + ssmPath: `/${clientHelplineEnv}/twilio/${clientHelplineShortCode.toUpperCase()}/account_sid`, }, clientTwilioAuthToken: { - envKey: 'TWILIO_AUTH_TOKEN', + envKey: 'CLIENT_TWILIO_AUTH_TOKEN', // Order is important here. We use a function so that we can reference the clientTwilioAccountSid config value above. ssmPath: () => - `/${localOverrideEnv}/twilio/${getConfigValue('clientTwilioAccountSid')}/auth_token`, - default: () => getConfigValue('twilioAuthToken'), + `/${clientHelplineEnv}/twilio/${getConfigValue('clientTwilioAccountSid')}/auth_token`, }, // Turn on debug mode. Possibly unused. @@ -199,13 +198,13 @@ const configOptions: ConfigOptions = { // This should match the number set up for the Voice studio flow on the helpline under test voicePhoneNumber: { envKey: 'VOICE_PHONE_NUMBER', - default: '', + default: '+12607821891', }, // This should match the number set up on the clientTwilioAccountSid that can make outgoing calls clientVoicePhoneNumber: { envKey: 'CLIENT_VOICE_PHONE_NUMBER', - default: '', + default: '+12064083885', }, // This should match the number set up for the SMS studio flow on the helpline under test @@ -296,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/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/package.json b/e2e-tests/package.json index 14da237ed9..9323999c69 100644 --- a/e2e-tests/package.json +++ b/e2e-tests/package.json @@ -5,12 +5,12 @@ "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 voice", "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 voice", "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", diff --git a/e2e-tests/tests/sms.spec.ts b/e2e-tests/tests/sms.spec.ts index 2488ee8a8f..5d16c47fc5 100644 --- a/e2e-tests/tests/sms.spec.ts +++ b/e2e-tests/tests/sms.spec.ts @@ -31,6 +31,7 @@ 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(); @@ -39,6 +40,7 @@ test.describe.serial('SMS caller', () => { test.beforeAll(async ({ browser }) => { test.setTimeout(180000); + await deleteSmsConversations(); ({ page: pluginPage } = await setupContextAndPage(browser)); await clearOfflineTask( diff --git a/e2e-tests/tests/voice.spec.ts b/e2e-tests/tests/voice.spec.ts new file mode 100644 index 0000000000..b9b8c616bc --- /dev/null +++ b/e2e-tests/tests/voice.spec.ts @@ -0,0 +1,111 @@ +/** + * 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 { Categories, contactForm, ContactFormTab } 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 { makeCallToService } from '../twilio/voice'; + +test.describe.serial('SMS 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('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. + await makeCallToService(); + + 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.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, + }, + ]); + + console.info('Saving form'); + await form.save(); + }); +}); diff --git a/e2e-tests/twilio/channels.ts b/e2e-tests/twilio/channels.ts index f1ef09fe1c..1aeb62b168 100644 --- a/e2e-tests/twilio/channels.ts +++ b/e2e-tests/twilio/channels.ts @@ -15,7 +15,7 @@ */ // eslint-disable-next-line import/no-extraneous-dependencies -import twilio from 'twilio'; +import twilio, { Twilio } from 'twilio'; import { getConfigValue } from '../config'; const encodeEmailToUnicode = (email: string) => { @@ -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,45 +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); - - if (!matchingUser) { - continue; - } - - console.log(`Found user ${email} in service ${service.sid}`); - - // List all channels the matching user is a part of - const userChannels = await client.chat.v2 - .services(service.sid) - .users(matchingUser.sid) - .userChannels.list(); - - console.log( - `Found ${userChannels.length} chat channels for user ${email} in service ${service.sid}`, - ); - - for (const userChannel of userChannels) { - console.log(`Removing chat channel ${userChannel.channelSid} from service ${service.sid}`); - await client.conversations.v1.services - .get(service.sid) - .conversations.get(userChannel.channelSid) - .remove(); - } + // 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) { + return; + } + + console.info(`Found user ${email} in conversations`); + + // List all channels the matching user is a part of + const userConversations = await client.conversations.v1.users + .get(matchingUser.sid) + .userConversations.list(); + + console.debug(`Found ${userConversations.length} chat channels for user ${email}`); + + 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 index e4af5df8ad..e4a01853e9 100644 --- a/e2e-tests/twilio/sms.ts +++ b/e2e-tests/twilio/sms.ts @@ -23,31 +23,36 @@ 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 accountSid = getConfigValue('clientTwilioAccountSid') as string; + 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(accountSid, authToken); - await client.messages.create({ from, to, body: messageText }); + 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}'`); }; -export const sendSmsFromService = async (messageText: string) => { - const accountSid = getConfigValue('twilioAccountSid') as string; - const authToken = getConfigValue('twilioAuthToken') as string; - const from = getConfigValue('smsPhoneNumber') as string; - const to = getConfigValue('clientSmsPhoneNumber') as string; - - const client = twilio(accountSid, authToken); - await client.messages.create({ from, to, body: messageText }); - console.debug(`Sent SMS message from service: '${messageText}'`); -}; - const MAX_CHECKS = 10; /** @@ -56,20 +61,23 @@ const MAX_CHECKS = 10; * Uses the service Twilio account to list outbound messages to the client number. */ export const checkForMessageOnClient = async (messageText: string): Promise => { - if (!sessionStartTime) { + if (!clientConversationSid) { throw new AssertionError({ message: "You cannot verify incoming messages until you've sent one and started a session", }); } - const accountSid = getConfigValue('twilioAccountSid') as string; - const authToken = getConfigValue('twilioAuthToken') as string; + 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.messages.list({ to, dateSentAfter: sessionStartTime }); - if (messages.find((m) => m.body === messageText)) { + 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); diff --git a/e2e-tests/twilio/voice.ts b/e2e-tests/twilio/voice.ts new file mode 100644 index 0000000000..eb3798dedc --- /dev/null +++ b/e2e-tests/twilio/voice.ts @@ -0,0 +1,27 @@ +import { getConfigValue } from '../config'; +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("Hello, I'm and end to end test"); + + const client = twilio(clientAccountSid, authToken); + //const call = + await client.calls.create({ + method: 'GET', + twiml: response, + from, + to, + }); + //callerCallSid = call.sid; +}; From 9a0cbe83fbc47a27a82ff6a526ab06c8396bdd2a Mon Sep 17 00:00:00 2001 From: Stephen Hand Date: Wed, 22 Jul 2026 19:26:14 +0100 Subject: [PATCH 14/40] First passing voice E2E test --- e2e-tests/contactForm.ts | 27 ++++++++++- e2e-tests/formContentsByHelpline.ts | 19 +++++++- e2e-tests/package.json | 8 ++-- e2e-tests/tests/aseloWebchat.spec.ts | 21 +-------- e2e-tests/tests/offlineContact.spec.ts | 39 +++------------ e2e-tests/tests/sms.spec.ts | 21 +-------- e2e-tests/tests/voice.spec.ts | 47 ++++--------------- e2e-tests/twilio/voice.ts | 2 +- twilio-iac/helplines/defaults.hcl | 4 ++ twilio-iac/helplines/e2e/common.hcl | 4 ++ twilio-iac/helplines/e2e/development.hcl | 2 +- .../templates/workflows/master.tftpl | 10 ++++ 12 files changed, 85 insertions(+), 119 deletions(-) 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/formContentsByHelpline.ts b/e2e-tests/formContentsByHelpline.ts index b1d0caf6a3..1d5b8114ba 100644 --- a/e2e-tests/formContentsByHelpline.ts +++ b/e2e-tests/formContentsByHelpline.ts @@ -14,6 +14,7 @@ * along with this program. If not, see https://www.gnu.org/licenses/. */ + export const formContentsByHelpline = { e2e: { childInformation: { @@ -27,7 +28,7 @@ export const formContentsByHelpline = { Accessibility: ['Education'], }, caseInformation: { - callSummary: 'E2E TEST CALL', + callSummary: 'E2E TEST PREPOPULATED FORM', }, }, ca: { @@ -54,3 +55,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', + }, + }, +}; \ No newline at end of file diff --git a/e2e-tests/package.json b/e2e-tests/package.json index 9323999c69..b55dbf52be 100644 --- a/e2e-tests/package.json +++ b/e2e-tests/package.json @@ -8,16 +8,16 @@ "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 -- --retries 0 voice", + "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 voice", + "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/tests/aseloWebchat.spec.ts b/e2e-tests/tests/aseloWebchat.spec.ts index ecda6bd8ca..2b3fd446a1 100644 --- a/e2e-tests/tests/aseloWebchat.spec.ts +++ b/e2e-tests/tests/aseloWebchat.spec.ts @@ -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..4af76fa29f 100644 --- a/e2e-tests/tests/offlineContact.spec.ts +++ b/e2e-tests/tests/offlineContact.spec.ts @@ -23,6 +23,8 @@ 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} from "../formContentsByHelpline"; test.describe.serial('Offline Contact (with Case)', () => { skipTestIfNotTargeted(); @@ -56,6 +58,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 = formContentsByHelpline[helpline]; const form = contactForm(pluginPage); await form.selectChildCallType(); @@ -69,39 +73,8 @@ test.describe.serial('Offline Contact (with Case)', () => { channel: 'web', 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 index 5d16c47fc5..a82d81e9f1 100644 --- a/e2e-tests/tests/sms.spec.ts +++ b/e2e-tests/tests/sms.spec.ts @@ -102,26 +102,7 @@ test.describe.serial('SMS 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/voice.spec.ts b/e2e-tests/tests/voice.spec.ts index b9b8c616bc..84ce54d432 100644 --- a/e2e-tests/tests/voice.spec.ts +++ b/e2e-tests/tests/voice.spec.ts @@ -16,11 +16,7 @@ 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 { Categories, contactForm, ContactFormTab } from '../contactForm'; import { deleteAllTasksInQueue } from '../twilio/tasks'; import { notificationBar } from '../notificationBar'; @@ -28,12 +24,12 @@ 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 {formContentsByHelpline, formContentsByHelplineForEmptyForm} from '../formContentsByHelpline'; import { getConfigValue } from '../config'; -import { smsChat } from '../twilio/sms'; import { makeCallToService } from '../twilio/voice'; +import { tasks } from '../tasks'; -test.describe.serial('SMS caller', () => { +test.describe.serial('Voice caller', () => { skipTestIfNotTargeted(); let pluginPage: Page; @@ -65,45 +61,22 @@ test.describe.serial('SMS caller', () => { await deleteAllTasksInQueue(); }); - test('Chat', async () => { + test('Call', 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. 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 = formContentsByHelpline[helpline]; + const formContent = formContentsByHelplineForEmptyForm[helpline]; if (!formContent) { 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.selectChildCallType(); + await form.fillWithContent(formContent); console.info('Saving form'); await form.save(); diff --git a/e2e-tests/twilio/voice.ts b/e2e-tests/twilio/voice.ts index eb3798dedc..f34cfb194d 100644 --- a/e2e-tests/twilio/voice.ts +++ b/e2e-tests/twilio/voice.ts @@ -13,7 +13,7 @@ export const makeCallToService = async () => { const to = getConfigValue('voicePhoneNumber') as string; const response = new VoiceResponse(); - response.say("Hello, I'm and end to end test"); + response.say({ loop: 100 }, "Hello, I'm an end to end test"); const client = twilio(clientAccountSid, authToken); //const call = diff --git a/twilio-iac/helplines/defaults.hcl b/twilio-iac/helplines/defaults.hcl index d4fdc6d476..5ac2d1293a 100644 --- a/twilio-iac/helplines/defaults.hcl +++ b/twilio-iac/helplines/defaults.hcl @@ -60,6 +60,10 @@ locals { e2e_test : { "target_workers" = "email=='aselo-alerts+production@techmatters.org'", "friendly_name" = "E2E Test Queue" + }, + e2e_test_voice : { + "target_workers" = "email=='aselo-alerts+production@techmatters.org'", + "friendly_name" = "E2E Test Queue (Voice)" } // survey : { // friendly_name = "Survey" diff --git a/twilio-iac/helplines/e2e/common.hcl b/twilio-iac/helplines/e2e/common.hcl index 2b20711115..c2376b0a43 100644 --- a/twilio-iac/helplines/e2e/common.hcl +++ b/twilio-iac/helplines/e2e/common.hcl @@ -52,6 +52,10 @@ locals { e2e_test : { "target_workers" = "email=='aselo-alerts+production@techmatters.org'", "friendly_name" = "E2E Test Queue" + }, + e2e_test_voice : { + "target_workers" = "email=='aselo-alerts+production@techmatters.org'", + "friendly_name" = "E2E Test Queue (Voice)" } } diff --git a/twilio-iac/helplines/e2e/development.hcl b/twilio-iac/helplines/e2e/development.hcl index 4cbd493785..67cde25a69 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" diff --git a/twilio-iac/helplines/templates/workflows/master.tftpl b/twilio-iac/helplines/templates/workflows/master.tftpl index 5082bdd9a1..291d9f6b2f 100644 --- a/twilio-iac/helplines/templates/workflows/master.tftpl +++ b/twilio-iac/helplines/templates/workflows/master.tftpl @@ -42,6 +42,16 @@ "queue": "${task_queues.e2e_test}" } ] + }, + { + "filter_friendly_name": "Voice E2E Test", + "expression": "channelType=='voice' AND name=='+12064083885'", + "targets": [ + { + "expression": "(worker.waitingOfflineContact != true AND ((task.channelType == 'voice' AND worker.channel.chat.assigned_tasks == 0) OR (task.channelType != 'voice' AND worker.channel.voice.assigned_tasks == 0)) AND ((task.transferTargetType == 'worker' AND task.targetSid == worker.sid) OR (task.transferTargetType != 'worker' AND worker.sid != task.ignoreAgent))) OR (worker.waitingOfflineContact == true AND task.targetSid == worker.sid AND task.isContactlessTask == true)", + "queue": "${task_queues.e2e_test_voice}" + } + ] } ] } From 9c75bccc90fe4f4360b910519ddee95c9144a38f Mon Sep 17 00:00:00 2001 From: Stephen Hand Date: Wed, 22 Jul 2026 19:28:27 +0100 Subject: [PATCH 15/40] Licence --- e2e-tests/twilio/voice.ts | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/e2e-tests/twilio/voice.ts b/e2e-tests/twilio/voice.ts index f34cfb194d..4126c13de5 100644 --- a/e2e-tests/twilio/voice.ts +++ b/e2e-tests/twilio/voice.ts @@ -1,3 +1,19 @@ +/** + * 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'; import twilio from 'twilio'; import VoiceResponse = twilio.twiml.VoiceResponse; From cfdb3b214623c0229ea6b06abe514d1e39ce9bf3 Mon Sep 17 00:00:00 2001 From: Stephen Hand Date: Wed, 15 Jul 2026 14:37:20 +0100 Subject: [PATCH 16/40] WIP add config and basic SMS test methods --- e2e-tests/config.ts | 40 ++++++++++++++++++++++++++ e2e-tests/twilio/channels.ts | 9 ++++-- e2e-tests/twilio/sms.ts | 55 ++++++++++++++++++++++++++++++++++++ e2e-tests/twilio/worker.ts | 7 ----- 4 files changed, 101 insertions(+), 10 deletions(-) create mode 100644 e2e-tests/twilio/sms.ts diff --git a/e2e-tests/config.ts b/e2e-tests/config.ts index 6831eb2cee..d7ac704d4a 100644 --- a/e2e-tests/config.ts +++ b/e2e-tests/config.ts @@ -41,6 +41,8 @@ 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 clientHelplineShortCode = process.env.HL?.toLocaleLowerCase() || helplineShortCode; const helplineEnv = process.env.HL_ENV?.toLocaleLowerCase() || 'local'; const shouldLoadFromSsm = process.env.LOAD_SSM_CONFIG && process.env.LOAD_SSM_CONFIG !== 'false'; @@ -135,6 +137,20 @@ 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: 'TWILIO_ACCOUNT_SID', + ssmPath: `/${localOverrideEnv}/twilio/${clientHelplineShortCode.toUpperCase()}/account_sid`, + default: () => getConfigValue('twilioAccountSid'), + }, + clientTwilioAuthToken: { + envKey: 'TWILIO_AUTH_TOKEN', + // Order is important here. We use a function so that we can reference the clientTwilioAccountSid config value above. + ssmPath: () => + `/${localOverrideEnv}/twilio/${getConfigValue('clientTwilioAccountSid')}/auth_token`, + default: () => getConfigValue('twilioAuthToken'), + }, + // Turn on debug mode. Possibly unused. debug: { envKey: 'DEBUG', @@ -180,6 +196,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: '', + }, + + // This should match the number set up on the clientTwilioAccountSid that can make outgoing calls + clientVoicePhoneNumber: { + envKey: 'CLIENT_VOICE_PHONE_NUMBER', + default: '', + }, + + // 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', diff --git a/e2e-tests/twilio/channels.ts b/e2e-tests/twilio/channels.ts index 005290357b..211a118155 100644 --- a/e2e-tests/twilio/channels.ts +++ b/e2e-tests/twilio/channels.ts @@ -39,11 +39,11 @@ export const deleteChatChannels = async (): Promise => { const client = twilio(accountSid, authToken); // List all chat services - const services = await client.chat.v2.services.list(); + const services = await client.conversations.v1.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(); + const users = await client.conversations.v1.services(service.sid).users.list(); console.log(`Found ${users.length} users in service ${service.sid}`); const matchingUser = users.find((user) => user.identity === encodedEmail); @@ -65,7 +65,10 @@ export const deleteChatChannels = async (): Promise => { 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(); + await client.conversations.v1.services + .get(service.sid) + .conversations.get(userChannel.channelSid) + .remove(); } } }; diff --git a/e2e-tests/twilio/sms.ts b/e2e-tests/twilio/sms.ts new file mode 100644 index 0000000000..175922f361 --- /dev/null +++ b/e2e-tests/twilio/sms.ts @@ -0,0 +1,55 @@ +import { getConfigValue } from '../config'; +import twilio from 'twilio'; +import type { ConversationInstance } from 'twilio/lib/rest/conversations/v1/conversation'; +import { AssertionError } from 'node:assert'; + +let clientConversation: ConversationInstance; + +export const sendSmsToService = async (messageText: string) => { + const accountSid = getConfigValue('clientTwilioAccountSid') as string; + const authToken = getConfigValue('clientTwilioAuthToken') as string; + const from = getConfigValue('clientSmsPhoneNumber') as string; + const to = getConfigValue('smsPhoneNumber') as string; + + const client = twilio(accountSid, authToken); + if (!clientConversation) { + clientConversation = await client.conversations.v1.conversations.create({ + friendlyName: 'E2E test client conversation', + + uniqueName: `sms/${from}/${Date.now()}`, + }); + await clientConversation.participants().create({ + identity: from, + }); + } + await client.messages.create({ from, to, body: messageText }); + console.debug(`Sent SMS message to service: '${messageText}'`); +}; +export const sendSmsFromService = async (messageText: string) => { + const accountSid = getConfigValue('twilioAccountSid') as string; + const authToken = getConfigValue('twilioAuthToken') as string; + const from = getConfigValue('smsPhoneNumber') as string; + const to = getConfigValue('clientSmsPhoneNumber') as string; + + const client = twilio(accountSid, authToken); + await client.messages.create({ from, to, body: messageText }); + console.debug(`Sent SMS message from service: '${messageText}'`); +}; + +const MAX_CHECKS = 10; + +export const checkForMessageOnClient = async (messageText: string): Promise => { + if (!clientConversation) { + throw new AssertionError({ + message: + "You cannot verify incoming messages until you've sent one and created a client side conversation", + }); + } + for (let i = 0; i < MAX_CHECKS; i++) { + const messages = await clientConversation.messages().list(); + if (messages.find((m) => m.body === messageText)) { + return true; + } + } + return false; +}; 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; - }); -}; From c7e8ebd401f7391f5ebaeb4e40b1e13fdff598df Mon Sep 17 00:00:00 2001 From: Stephen Hand Date: Wed, 15 Jul 2026 14:44:25 +0100 Subject: [PATCH 17/40] Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- e2e-tests/config.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/e2e-tests/config.ts b/e2e-tests/config.ts index d7ac704d4a..71b263e858 100644 --- a/e2e-tests/config.ts +++ b/e2e-tests/config.ts @@ -42,7 +42,7 @@ 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 clientHelplineShortCode = process.env.HL?.toLocaleLowerCase() || helplineShortCode; +const clientHelplineShortCode = process.env.CLIENT_HL?.toLocaleLowerCase() || helplineShortCode; const helplineEnv = process.env.HL_ENV?.toLocaleLowerCase() || 'local'; const shouldLoadFromSsm = process.env.LOAD_SSM_CONFIG && process.env.LOAD_SSM_CONFIG !== 'false'; From 78fe83e6fe06895d8f45dafcc368b2ef104a4946 Mon Sep 17 00:00:00 2001 From: Stephen Hand Date: Wed, 15 Jul 2026 14:44:44 +0100 Subject: [PATCH 18/40] Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- e2e-tests/twilio/sms.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/e2e-tests/twilio/sms.ts b/e2e-tests/twilio/sms.ts index 175922f361..bb2682d160 100644 --- a/e2e-tests/twilio/sms.ts +++ b/e2e-tests/twilio/sms.ts @@ -50,6 +50,7 @@ export const checkForMessageOnClient = async (messageText: string): Promise m.body === messageText)) { return true; } + await new Promise((resolve) => setTimeout(resolve, 1000)); } return false; }; From aacc4b9dd3e1d7ad1583b9e4126286bec8889bda Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 15 Jul 2026 13:51:25 +0000 Subject: [PATCH 19/40] fix: use chat api for e2e channel cleanup lookup Co-authored-by: stephenhand <1694716+stephenhand@users.noreply.github.com> --- e2e-tests/twilio/channels.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/e2e-tests/twilio/channels.ts b/e2e-tests/twilio/channels.ts index 211a118155..f1ef09fe1c 100644 --- a/e2e-tests/twilio/channels.ts +++ b/e2e-tests/twilio/channels.ts @@ -39,11 +39,11 @@ export const deleteChatChannels = async (): Promise => { const client = twilio(accountSid, authToken); // List all chat services - const services = await client.conversations.v1.services.list(); + const services = await client.chat.v2.services.list(); for (const service of services) { // List all users in this chat service - const users = await client.conversations.v1.services(service.sid).users.list(); + 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); From 62bc1fea229e820d63778fdb0a2af266b36b661f Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 15 Jul 2026 15:08:21 +0000 Subject: [PATCH 20/40] Initial plan From dcba9771d578557e1369520c21efc897a92f84d9 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 15 Jul 2026 15:24:57 +0000 Subject: [PATCH 21/40] feat: add SMS E2E test mirroring webchat test with shared ChatStatement/AsyncIterable pattern Co-authored-by: stephenhand <1694716+stephenhand@users.noreply.github.com> --- e2e-tests/chatScripts.ts | 62 ++++++++++++++++++ e2e-tests/tests/sms.spec.ts | 127 ++++++++++++++++++++++++++++++++++++ e2e-tests/twilio/sms.ts | 83 ++++++++++++++++++----- 3 files changed, 255 insertions(+), 17 deletions(-) create mode 100644 e2e-tests/tests/sms.spec.ts 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/tests/sms.spec.ts b/e2e-tests/tests/sms.spec.ts new file mode 100644 index 0000000000..2488ee8a8f --- /dev/null +++ b/e2e-tests/tests/sms.spec.ts @@ -0,0 +1,127 @@ +/** + * 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 { Categories, contactForm, ContactFormTab } 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'; + +test.describe.serial('SMS 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('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.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, + }, + ]); + + console.info('Saving form'); + await form.save(); + }); +}); diff --git a/e2e-tests/twilio/sms.ts b/e2e-tests/twilio/sms.ts index bb2682d160..5e52833f20 100644 --- a/e2e-tests/twilio/sms.ts +++ b/e2e-tests/twilio/sms.ts @@ -1,30 +1,26 @@ import { getConfigValue } from '../config'; +// eslint-disable-next-line import/no-extraneous-dependencies import twilio from 'twilio'; -import type { ConversationInstance } from 'twilio/lib/rest/conversations/v1/conversation'; import { AssertionError } from 'node:assert'; +import { ChatStatement, ChatStatementOrigin } from '../chatModel'; -let clientConversation: ConversationInstance; +// Tracks the start of the current SMS test session so we only check messages received after this time +let sessionStartTime: Date | undefined; export const sendSmsToService = async (messageText: string) => { + if (!sessionStartTime) { + sessionStartTime = new Date(); + } const accountSid = getConfigValue('clientTwilioAccountSid') as string; const authToken = getConfigValue('clientTwilioAuthToken') as string; const from = getConfigValue('clientSmsPhoneNumber') as string; const to = getConfigValue('smsPhoneNumber') as string; const client = twilio(accountSid, authToken); - if (!clientConversation) { - clientConversation = await client.conversations.v1.conversations.create({ - friendlyName: 'E2E test client conversation', - - uniqueName: `sms/${from}/${Date.now()}`, - }); - await clientConversation.participants().create({ - identity: from, - }); - } await client.messages.create({ from, to, body: messageText }); console.debug(`Sent SMS message to service: '${messageText}'`); }; + export const sendSmsFromService = async (messageText: string) => { const accountSid = getConfigValue('twilioAccountSid') as string; const authToken = getConfigValue('twilioAuthToken') as string; @@ -38,19 +34,72 @@ export const sendSmsFromService = async (messageText: string) => { 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 (!clientConversation) { + if (!sessionStartTime) { throw new AssertionError({ - message: - "You cannot verify incoming messages until you've sent one and created a client side conversation", + message: "You cannot verify incoming messages until you've sent one and started a session", }); } + const accountSid = getConfigValue('twilioAccountSid') as string; + const authToken = getConfigValue('twilioAuthToken') 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 clientConversation.messages().list(); + const messages = await client.messages.list({ to, dateSentAfter: sessionStartTime }); if (messages.find((m) => m.body === messageText)) { return true; } - await new Promise((resolve) => setTimeout(resolve, 1000)); + 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); + } + } +} From dece3bcee8ccc4820d01ba5c7763bb76a94b65ea Mon Sep 17 00:00:00 2001 From: Stephen Hand Date: Wed, 15 Jul 2026 17:28:52 +0100 Subject: [PATCH 22/40] Licence headers --- e2e-tests/twilio/sms.ts | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/e2e-tests/twilio/sms.ts b/e2e-tests/twilio/sms.ts index 5e52833f20..e4af5df8ad 100644 --- a/e2e-tests/twilio/sms.ts +++ b/e2e-tests/twilio/sms.ts @@ -1,3 +1,19 @@ +/** + * 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'; From fbbc441de0c6b509c52082a8f8eaf65948a95508 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 17 Jul 2026 18:03:15 +0000 Subject: [PATCH 23/40] feat: add SMS channel for E2E development environment - Add SMS channel to twilio-iac/helplines/e2e/development.hcl using the messaging-lex-v3-blocking-lambda.tftpl template (same as aselo_webchat) and an empty contact_identity (conversations address managed separately) - Create twilio-iac/helplines/e2e/files/additional.configure.tf that uses a Twilio data source to look up the only phone number attached to the account at apply time and creates the SMS conversations address linked to the SMS studio flow - Guard twilio_conversations_configuration_addresses_v1 in channels/v1/main.tf so channels with an empty contact_identity skip automatic address creation (allowing helpline-specific additional.tf to manage it instead) Co-authored-by: stephenhand <1694716+stephenhand@users.noreply.github.com> --- twilio-iac/helplines/e2e/development.hcl | 11 ++++++++++ .../e2e/files/additional.configure.tf | 20 +++++++++++++++++++ .../terraform-modules/channels/v1/main.tf | 4 +++- 3 files changed, 34 insertions(+), 1 deletion(-) create mode 100644 twilio-iac/helplines/e2e/files/additional.configure.tf diff --git a/twilio-iac/helplines/e2e/development.hcl b/twilio-iac/helplines/e2e/development.hcl index eafb13d33c..f90e2140d9 100644 --- a/twilio-iac/helplines/e2e/development.hcl +++ b/twilio-iac/helplines/e2e/development.hcl @@ -35,6 +35,17 @@ locals { channel_flow_vars = {} chatbot_unique_names = [] } + sms : { + channel_type = "sms" + messaging_mode = "conversations" + # contact_identity is intentionally empty here; the conversations address is created + # via additional.configure.tf using a data source that resolves the only phone number + # attached to this Twilio account at apply time. + contact_identity = "" + templatefile = "/app/twilio-iac/helplines/templates/studio-flows/messaging-lex-v3-blocking-lambda.tftpl" + channel_flow_vars = {} + chatbot_unique_names = [] + } } get_profile_flags_for_identifier_base_url = "https://hrm-development.tl.techmatters.org/lambda/twilio/account-scoped" #System Down Configuration diff --git a/twilio-iac/helplines/e2e/files/additional.configure.tf b/twilio-iac/helplines/e2e/files/additional.configure.tf new file mode 100644 index 0000000000..0de4d3fba3 --- /dev/null +++ b/twilio-iac/helplines/e2e/files/additional.configure.tf @@ -0,0 +1,20 @@ +# Dynamically resolve the single phone number attached to this Twilio account and +# configure it as the SMS conversations address, linked to the SMS studio flow created +# by the channels module. +# +# Using a data source rather than hardcoding the number keeps the config portable: +# the E2E Twilio account has exactly one phone number, so one() is intentionally +# strict here – if the account ever has zero or more than one number the apply will +# fail with a clear error. + +data "twilio_api_accounts_incoming_phone_numbers_v2010" "all" {} + +resource "twilio_conversations_configuration_addresses_v1" "sms" { + type = "sms" + address = one(data.twilio_api_accounts_incoming_phone_numbers_v2010.all.incoming_phone_numbers).phone_number + friendly_name = "Sms Conversation Address" + auto_creation_enabled = true + auto_creation_type = "studio" + auto_creation_conversation_service_sid = local.services_flex_chat_service_sid + auto_creation_studio_flow_sid = module.channel.channel_studio_flows_sids["sms"].flow_sid +} diff --git a/twilio-iac/terraform-modules/channels/v1/main.tf b/twilio-iac/terraform-modules/channels/v1/main.tf index 21e6d961bd..20af2664b1 100644 --- a/twilio-iac/terraform-modules/channels/v1/main.tf +++ b/twilio-iac/terraform-modules/channels/v1/main.tf @@ -107,7 +107,9 @@ resource "twilio_conversations_configuration_addresses_v1" "conversations_addres # Must be created manually in Twilio Console for now channel.channel_type != "chat" && channel.channel_type != "custom" && - channel.messaging_mode == "conversations" + channel.messaging_mode == "conversations" && + # Channels with no contact_identity manage their own conversations address (e.g. via additional.tf) + channel.contact_identity != "" ) } type = each.value.channel_type From 49b86ec47f69a089bb33a7f295a4f1f13b337872 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 17 Jul 2026 18:03:57 +0000 Subject: [PATCH 24/40] fix: use consistent SMS capitalization in friendly_name Co-authored-by: stephenhand <1694716+stephenhand@users.noreply.github.com> --- twilio-iac/helplines/e2e/files/additional.configure.tf | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/twilio-iac/helplines/e2e/files/additional.configure.tf b/twilio-iac/helplines/e2e/files/additional.configure.tf index 0de4d3fba3..e8c9d036d9 100644 --- a/twilio-iac/helplines/e2e/files/additional.configure.tf +++ b/twilio-iac/helplines/e2e/files/additional.configure.tf @@ -12,7 +12,7 @@ data "twilio_api_accounts_incoming_phone_numbers_v2010" "all" {} resource "twilio_conversations_configuration_addresses_v1" "sms" { type = "sms" address = one(data.twilio_api_accounts_incoming_phone_numbers_v2010.all.incoming_phone_numbers).phone_number - friendly_name = "Sms Conversation Address" + friendly_name = "SMS Conversation Address" auto_creation_enabled = true auto_creation_type = "studio" auto_creation_conversation_service_sid = local.services_flex_chat_service_sid From d65a26d1dd0f32b08cceb3ee30adea3e53f1142c Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 20 Jul 2026 14:19:00 +0000 Subject: [PATCH 25/40] refactor: hardcode E2E SMS channel phone number +12607821891 - Replace dynamic phone number lookup with hardcoded +12607821891 in both development.hcl and additional.configure.tf - Remove data source that previously scraped the phone number from the Twilio account - Set contact_identity to the hardcoded phone number in the SMS channel configuration Co-authored-by: stephenhand <1694716+stephenhand@users.noreply.github.com> --- twilio-iac/helplines/e2e/development.hcl | 5 +---- .../helplines/e2e/files/additional.configure.tf | 13 ++----------- 2 files changed, 3 insertions(+), 15 deletions(-) diff --git a/twilio-iac/helplines/e2e/development.hcl b/twilio-iac/helplines/e2e/development.hcl index f90e2140d9..ff1a8358d8 100644 --- a/twilio-iac/helplines/e2e/development.hcl +++ b/twilio-iac/helplines/e2e/development.hcl @@ -38,10 +38,7 @@ locals { sms : { channel_type = "sms" messaging_mode = "conversations" - # contact_identity is intentionally empty here; the conversations address is created - # via additional.configure.tf using a data source that resolves the only phone number - # attached to this Twilio account at apply time. - contact_identity = "" + contact_identity = "+12607821891" templatefile = "/app/twilio-iac/helplines/templates/studio-flows/messaging-lex-v3-blocking-lambda.tftpl" channel_flow_vars = {} chatbot_unique_names = [] diff --git a/twilio-iac/helplines/e2e/files/additional.configure.tf b/twilio-iac/helplines/e2e/files/additional.configure.tf index e8c9d036d9..944ff076bd 100644 --- a/twilio-iac/helplines/e2e/files/additional.configure.tf +++ b/twilio-iac/helplines/e2e/files/additional.configure.tf @@ -1,17 +1,8 @@ -# Dynamically resolve the single phone number attached to this Twilio account and -# configure it as the SMS conversations address, linked to the SMS studio flow created -# by the channels module. -# -# Using a data source rather than hardcoding the number keeps the config portable: -# the E2E Twilio account has exactly one phone number, so one() is intentionally -# strict here – if the account ever has zero or more than one number the apply will -# fail with a clear error. - -data "twilio_api_accounts_incoming_phone_numbers_v2010" "all" {} +# Configure the SMS conversations address with the hardcoded phone number +12607821891. resource "twilio_conversations_configuration_addresses_v1" "sms" { type = "sms" - address = one(data.twilio_api_accounts_incoming_phone_numbers_v2010.all.incoming_phone_numbers).phone_number + address = "+12607821891" friendly_name = "SMS Conversation Address" auto_creation_enabled = true auto_creation_type = "studio" From 5e704584ecaf4e44b71b0dd0bd2470539becfa2b Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 20 Jul 2026 14:26:26 +0000 Subject: [PATCH 26/40] refactor: remove redundant additional.configure.tf for SMS address The twilio-iac/terraform-modules/channels/v1/main.tf module now automatically creates the SMS conversations address since contact_identity is no longer empty. The separate additional.configure.tf file is no longer needed. Co-authored-by: stephenhand <1694716+stephenhand@users.noreply.github.com> --- .../helplines/e2e/files/additional.configure.tf | 11 ----------- 1 file changed, 11 deletions(-) delete mode 100644 twilio-iac/helplines/e2e/files/additional.configure.tf diff --git a/twilio-iac/helplines/e2e/files/additional.configure.tf b/twilio-iac/helplines/e2e/files/additional.configure.tf deleted file mode 100644 index 944ff076bd..0000000000 --- a/twilio-iac/helplines/e2e/files/additional.configure.tf +++ /dev/null @@ -1,11 +0,0 @@ -# Configure the SMS conversations address with the hardcoded phone number +12607821891. - -resource "twilio_conversations_configuration_addresses_v1" "sms" { - type = "sms" - address = "+12607821891" - friendly_name = "SMS Conversation Address" - auto_creation_enabled = true - auto_creation_type = "studio" - auto_creation_conversation_service_sid = local.services_flex_chat_service_sid - auto_creation_studio_flow_sid = module.channel.channel_studio_flows_sids["sms"].flow_sid -} From fc9a0b41877e14b46b85e1899150ab68d1b921ea Mon Sep 17 00:00:00 2001 From: Stephen Hand Date: Wed, 22 Jul 2026 12:52:26 +0100 Subject: [PATCH 27/40] WIP voice testing --- e2e-tests/config.ts | 19 ++- ...ChatChannels.ts => deleteConversations.ts} | 5 +- e2e-tests/package.json | 6 +- e2e-tests/tests/sms.spec.ts | 2 + e2e-tests/tests/voice.spec.ts | 111 ++++++++++++++++ e2e-tests/twilio/channels.ts | 122 ++++++++++++------ e2e-tests/twilio/sms.ts | 46 ++++--- e2e-tests/twilio/voice.ts | 27 ++++ 8 files changed, 268 insertions(+), 70 deletions(-) rename e2e-tests/{deleteChatChannels.ts => deleteConversations.ts} (88%) create mode 100644 e2e-tests/tests/voice.spec.ts create mode 100644 e2e-tests/twilio/voice.ts diff --git a/e2e-tests/config.ts b/e2e-tests/config.ts index 71b263e858..26cef92eb1 100644 --- a/e2e-tests/config.ts +++ b/e2e-tests/config.ts @@ -42,8 +42,9 @@ 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 clientHelplineShortCode = process.env.CLIENT_HL?.toLocaleLowerCase() || helplineShortCode; 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 @@ -139,16 +140,14 @@ const configOptions: ConfigOptions = { // The twilio account sid and auth token are used to target a flex account clientTwilioAccountSid: { - envKey: 'TWILIO_ACCOUNT_SID', - ssmPath: `/${localOverrideEnv}/twilio/${clientHelplineShortCode.toUpperCase()}/account_sid`, - default: () => getConfigValue('twilioAccountSid'), + envKey: 'CLIENT_TWILIO_ACCOUNT_SID', + ssmPath: `/${clientHelplineEnv}/twilio/${clientHelplineShortCode.toUpperCase()}/account_sid`, }, clientTwilioAuthToken: { - envKey: 'TWILIO_AUTH_TOKEN', + envKey: 'CLIENT_TWILIO_AUTH_TOKEN', // Order is important here. We use a function so that we can reference the clientTwilioAccountSid config value above. ssmPath: () => - `/${localOverrideEnv}/twilio/${getConfigValue('clientTwilioAccountSid')}/auth_token`, - default: () => getConfigValue('twilioAuthToken'), + `/${clientHelplineEnv}/twilio/${getConfigValue('clientTwilioAccountSid')}/auth_token`, }, // Turn on debug mode. Possibly unused. @@ -199,13 +198,13 @@ const configOptions: ConfigOptions = { // This should match the number set up for the Voice studio flow on the helpline under test voicePhoneNumber: { envKey: 'VOICE_PHONE_NUMBER', - default: '', + default: '+12607821891', }, // This should match the number set up on the clientTwilioAccountSid that can make outgoing calls clientVoicePhoneNumber: { envKey: 'CLIENT_VOICE_PHONE_NUMBER', - default: '', + default: '+12064083885', }, // This should match the number set up for the SMS studio flow on the helpline under test @@ -296,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/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/package.json b/e2e-tests/package.json index 14da237ed9..9323999c69 100644 --- a/e2e-tests/package.json +++ b/e2e-tests/package.json @@ -5,12 +5,12 @@ "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 voice", "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 voice", "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", diff --git a/e2e-tests/tests/sms.spec.ts b/e2e-tests/tests/sms.spec.ts index 2488ee8a8f..5d16c47fc5 100644 --- a/e2e-tests/tests/sms.spec.ts +++ b/e2e-tests/tests/sms.spec.ts @@ -31,6 +31,7 @@ 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(); @@ -39,6 +40,7 @@ test.describe.serial('SMS caller', () => { test.beforeAll(async ({ browser }) => { test.setTimeout(180000); + await deleteSmsConversations(); ({ page: pluginPage } = await setupContextAndPage(browser)); await clearOfflineTask( diff --git a/e2e-tests/tests/voice.spec.ts b/e2e-tests/tests/voice.spec.ts new file mode 100644 index 0000000000..b9b8c616bc --- /dev/null +++ b/e2e-tests/tests/voice.spec.ts @@ -0,0 +1,111 @@ +/** + * 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 { Categories, contactForm, ContactFormTab } 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 { makeCallToService } from '../twilio/voice'; + +test.describe.serial('SMS 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('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. + await makeCallToService(); + + 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.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, + }, + ]); + + console.info('Saving form'); + await form.save(); + }); +}); diff --git a/e2e-tests/twilio/channels.ts b/e2e-tests/twilio/channels.ts index f1ef09fe1c..1aeb62b168 100644 --- a/e2e-tests/twilio/channels.ts +++ b/e2e-tests/twilio/channels.ts @@ -15,7 +15,7 @@ */ // eslint-disable-next-line import/no-extraneous-dependencies -import twilio from 'twilio'; +import twilio, { Twilio } from 'twilio'; import { getConfigValue } from '../config'; const encodeEmailToUnicode = (email: string) => { @@ -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,45 +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); - - if (!matchingUser) { - continue; - } - - console.log(`Found user ${email} in service ${service.sid}`); - - // List all channels the matching user is a part of - const userChannels = await client.chat.v2 - .services(service.sid) - .users(matchingUser.sid) - .userChannels.list(); - - console.log( - `Found ${userChannels.length} chat channels for user ${email} in service ${service.sid}`, - ); - - for (const userChannel of userChannels) { - console.log(`Removing chat channel ${userChannel.channelSid} from service ${service.sid}`); - await client.conversations.v1.services - .get(service.sid) - .conversations.get(userChannel.channelSid) - .remove(); - } + // 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) { + return; + } + + console.info(`Found user ${email} in conversations`); + + // List all channels the matching user is a part of + const userConversations = await client.conversations.v1.users + .get(matchingUser.sid) + .userConversations.list(); + + console.debug(`Found ${userConversations.length} chat channels for user ${email}`); + + 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 index e4af5df8ad..e4a01853e9 100644 --- a/e2e-tests/twilio/sms.ts +++ b/e2e-tests/twilio/sms.ts @@ -23,31 +23,36 @@ 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 accountSid = getConfigValue('clientTwilioAccountSid') as string; + 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(accountSid, authToken); - await client.messages.create({ from, to, body: messageText }); + 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}'`); }; -export const sendSmsFromService = async (messageText: string) => { - const accountSid = getConfigValue('twilioAccountSid') as string; - const authToken = getConfigValue('twilioAuthToken') as string; - const from = getConfigValue('smsPhoneNumber') as string; - const to = getConfigValue('clientSmsPhoneNumber') as string; - - const client = twilio(accountSid, authToken); - await client.messages.create({ from, to, body: messageText }); - console.debug(`Sent SMS message from service: '${messageText}'`); -}; - const MAX_CHECKS = 10; /** @@ -56,20 +61,23 @@ const MAX_CHECKS = 10; * Uses the service Twilio account to list outbound messages to the client number. */ export const checkForMessageOnClient = async (messageText: string): Promise => { - if (!sessionStartTime) { + if (!clientConversationSid) { throw new AssertionError({ message: "You cannot verify incoming messages until you've sent one and started a session", }); } - const accountSid = getConfigValue('twilioAccountSid') as string; - const authToken = getConfigValue('twilioAuthToken') as string; + 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.messages.list({ to, dateSentAfter: sessionStartTime }); - if (messages.find((m) => m.body === messageText)) { + 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); diff --git a/e2e-tests/twilio/voice.ts b/e2e-tests/twilio/voice.ts new file mode 100644 index 0000000000..eb3798dedc --- /dev/null +++ b/e2e-tests/twilio/voice.ts @@ -0,0 +1,27 @@ +import { getConfigValue } from '../config'; +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("Hello, I'm and end to end test"); + + const client = twilio(clientAccountSid, authToken); + //const call = + await client.calls.create({ + method: 'GET', + twiml: response, + from, + to, + }); + //callerCallSid = call.sid; +}; From 188b5d67aea8c24239fed38969bd0d5032b805e2 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 22 Jul 2026 11:49:27 +0000 Subject: [PATCH 28/40] feat: add voice channel for E2E development environment - Add voice channel using voice-no-chatbot-operating-hours-blocking-lambda template - Use the same phone number (+12607821891) as the SMS channel - Include voice_ivr_greeting_message, voice_ivr_blocked_message, and voice_ivr_language - Follows established patterns used in other helplines for voice configurations Co-authored-by: stephenhand <1694716+stephenhand@users.noreply.github.com> --- twilio-iac/helplines/e2e/development.hcl | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/twilio-iac/helplines/e2e/development.hcl b/twilio-iac/helplines/e2e/development.hcl index ff1a8358d8..4cbd493785 100644 --- a/twilio-iac/helplines/e2e/development.hcl +++ b/twilio-iac/helplines/e2e/development.hcl @@ -43,6 +43,17 @@ locals { 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 From f1144a11b4ba32f31f9e982a5e52bc78c5819ffe Mon Sep 17 00:00:00 2001 From: Stephen Hand Date: Wed, 22 Jul 2026 19:26:14 +0100 Subject: [PATCH 29/40] First passing voice E2E test --- e2e-tests/contactForm.ts | 27 ++++++++++- e2e-tests/formContentsByHelpline.ts | 19 +++++++- e2e-tests/package.json | 8 ++-- e2e-tests/tests/aseloWebchat.spec.ts | 21 +-------- e2e-tests/tests/offlineContact.spec.ts | 39 +++------------ e2e-tests/tests/sms.spec.ts | 21 +-------- e2e-tests/tests/voice.spec.ts | 47 ++++--------------- e2e-tests/twilio/voice.ts | 2 +- twilio-iac/helplines/defaults.hcl | 4 ++ twilio-iac/helplines/e2e/common.hcl | 4 ++ twilio-iac/helplines/e2e/development.hcl | 2 +- .../templates/workflows/master.tftpl | 10 ++++ 12 files changed, 85 insertions(+), 119 deletions(-) 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/formContentsByHelpline.ts b/e2e-tests/formContentsByHelpline.ts index b1d0caf6a3..1d5b8114ba 100644 --- a/e2e-tests/formContentsByHelpline.ts +++ b/e2e-tests/formContentsByHelpline.ts @@ -14,6 +14,7 @@ * along with this program. If not, see https://www.gnu.org/licenses/. */ + export const formContentsByHelpline = { e2e: { childInformation: { @@ -27,7 +28,7 @@ export const formContentsByHelpline = { Accessibility: ['Education'], }, caseInformation: { - callSummary: 'E2E TEST CALL', + callSummary: 'E2E TEST PREPOPULATED FORM', }, }, ca: { @@ -54,3 +55,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', + }, + }, +}; \ No newline at end of file diff --git a/e2e-tests/package.json b/e2e-tests/package.json index 9323999c69..b55dbf52be 100644 --- a/e2e-tests/package.json +++ b/e2e-tests/package.json @@ -8,16 +8,16 @@ "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 -- --retries 0 voice", + "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 voice", + "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/tests/aseloWebchat.spec.ts b/e2e-tests/tests/aseloWebchat.spec.ts index ecda6bd8ca..2b3fd446a1 100644 --- a/e2e-tests/tests/aseloWebchat.spec.ts +++ b/e2e-tests/tests/aseloWebchat.spec.ts @@ -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..4af76fa29f 100644 --- a/e2e-tests/tests/offlineContact.spec.ts +++ b/e2e-tests/tests/offlineContact.spec.ts @@ -23,6 +23,8 @@ 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} from "../formContentsByHelpline"; test.describe.serial('Offline Contact (with Case)', () => { skipTestIfNotTargeted(); @@ -56,6 +58,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 = formContentsByHelpline[helpline]; const form = contactForm(pluginPage); await form.selectChildCallType(); @@ -69,39 +73,8 @@ test.describe.serial('Offline Contact (with Case)', () => { channel: 'web', 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 index 5d16c47fc5..a82d81e9f1 100644 --- a/e2e-tests/tests/sms.spec.ts +++ b/e2e-tests/tests/sms.spec.ts @@ -102,26 +102,7 @@ test.describe.serial('SMS 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/voice.spec.ts b/e2e-tests/tests/voice.spec.ts index b9b8c616bc..84ce54d432 100644 --- a/e2e-tests/tests/voice.spec.ts +++ b/e2e-tests/tests/voice.spec.ts @@ -16,11 +16,7 @@ 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 { Categories, contactForm, ContactFormTab } from '../contactForm'; import { deleteAllTasksInQueue } from '../twilio/tasks'; import { notificationBar } from '../notificationBar'; @@ -28,12 +24,12 @@ 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 {formContentsByHelpline, formContentsByHelplineForEmptyForm} from '../formContentsByHelpline'; import { getConfigValue } from '../config'; -import { smsChat } from '../twilio/sms'; import { makeCallToService } from '../twilio/voice'; +import { tasks } from '../tasks'; -test.describe.serial('SMS caller', () => { +test.describe.serial('Voice caller', () => { skipTestIfNotTargeted(); let pluginPage: Page; @@ -65,45 +61,22 @@ test.describe.serial('SMS caller', () => { await deleteAllTasksInQueue(); }); - test('Chat', async () => { + test('Call', 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. 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 = formContentsByHelpline[helpline]; + const formContent = formContentsByHelplineForEmptyForm[helpline]; if (!formContent) { 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.selectChildCallType(); + await form.fillWithContent(formContent); console.info('Saving form'); await form.save(); diff --git a/e2e-tests/twilio/voice.ts b/e2e-tests/twilio/voice.ts index eb3798dedc..f34cfb194d 100644 --- a/e2e-tests/twilio/voice.ts +++ b/e2e-tests/twilio/voice.ts @@ -13,7 +13,7 @@ export const makeCallToService = async () => { const to = getConfigValue('voicePhoneNumber') as string; const response = new VoiceResponse(); - response.say("Hello, I'm and end to end test"); + response.say({ loop: 100 }, "Hello, I'm an end to end test"); const client = twilio(clientAccountSid, authToken); //const call = diff --git a/twilio-iac/helplines/defaults.hcl b/twilio-iac/helplines/defaults.hcl index d4fdc6d476..5ac2d1293a 100644 --- a/twilio-iac/helplines/defaults.hcl +++ b/twilio-iac/helplines/defaults.hcl @@ -60,6 +60,10 @@ locals { e2e_test : { "target_workers" = "email=='aselo-alerts+production@techmatters.org'", "friendly_name" = "E2E Test Queue" + }, + e2e_test_voice : { + "target_workers" = "email=='aselo-alerts+production@techmatters.org'", + "friendly_name" = "E2E Test Queue (Voice)" } // survey : { // friendly_name = "Survey" diff --git a/twilio-iac/helplines/e2e/common.hcl b/twilio-iac/helplines/e2e/common.hcl index 2b20711115..c2376b0a43 100644 --- a/twilio-iac/helplines/e2e/common.hcl +++ b/twilio-iac/helplines/e2e/common.hcl @@ -52,6 +52,10 @@ locals { e2e_test : { "target_workers" = "email=='aselo-alerts+production@techmatters.org'", "friendly_name" = "E2E Test Queue" + }, + e2e_test_voice : { + "target_workers" = "email=='aselo-alerts+production@techmatters.org'", + "friendly_name" = "E2E Test Queue (Voice)" } } diff --git a/twilio-iac/helplines/e2e/development.hcl b/twilio-iac/helplines/e2e/development.hcl index 4cbd493785..67cde25a69 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" diff --git a/twilio-iac/helplines/templates/workflows/master.tftpl b/twilio-iac/helplines/templates/workflows/master.tftpl index 5082bdd9a1..291d9f6b2f 100644 --- a/twilio-iac/helplines/templates/workflows/master.tftpl +++ b/twilio-iac/helplines/templates/workflows/master.tftpl @@ -42,6 +42,16 @@ "queue": "${task_queues.e2e_test}" } ] + }, + { + "filter_friendly_name": "Voice E2E Test", + "expression": "channelType=='voice' AND name=='+12064083885'", + "targets": [ + { + "expression": "(worker.waitingOfflineContact != true AND ((task.channelType == 'voice' AND worker.channel.chat.assigned_tasks == 0) OR (task.channelType != 'voice' AND worker.channel.voice.assigned_tasks == 0)) AND ((task.transferTargetType == 'worker' AND task.targetSid == worker.sid) OR (task.transferTargetType != 'worker' AND worker.sid != task.ignoreAgent))) OR (worker.waitingOfflineContact == true AND task.targetSid == worker.sid AND task.isContactlessTask == true)", + "queue": "${task_queues.e2e_test_voice}" + } + ] } ] } From 64730def90e6cc4c863a6bb33c2fd75dcd8f7dd3 Mon Sep 17 00:00:00 2001 From: Stephen Hand Date: Wed, 22 Jul 2026 19:28:27 +0100 Subject: [PATCH 30/40] Licence --- e2e-tests/twilio/voice.ts | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/e2e-tests/twilio/voice.ts b/e2e-tests/twilio/voice.ts index f34cfb194d..4126c13de5 100644 --- a/e2e-tests/twilio/voice.ts +++ b/e2e-tests/twilio/voice.ts @@ -1,3 +1,19 @@ +/** + * 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'; import twilio from 'twilio'; import VoiceResponse = twilio.twiml.VoiceResponse; From 4547284e9b4e46d4401ce384e1bc58d05c40181b Mon Sep 17 00:00:00 2001 From: Stephen Hand Date: Wed, 22 Jul 2026 23:52:37 +0100 Subject: [PATCH 31/40] Fix offlie contact e2e test --- e2e-tests/tests/offlineContact.spec.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/e2e-tests/tests/offlineContact.spec.ts b/e2e-tests/tests/offlineContact.spec.ts index 4af76fa29f..2bbfaeab23 100644 --- a/e2e-tests/tests/offlineContact.spec.ts +++ b/e2e-tests/tests/offlineContact.spec.ts @@ -24,7 +24,7 @@ import { closePage, setupContextAndPage } from '../browser'; import { apiHrmRequest } from '../hrm/hrmRequest'; import { clearOfflineTask } from '../hrm/clearOfflineTask'; import {getConfigValue} from "../config"; -import {formContentsByHelpline} from "../formContentsByHelpline"; +import {formContentsByHelpline, formContentsByHelplineForEmptyForm} from "../formContentsByHelpline"; test.describe.serial('Offline Contact (with Case)', () => { skipTestIfNotTargeted(); @@ -59,7 +59,7 @@ test.describe.serial('Offline Contact (with Case)', () => { console.log('Starting filling form'); const helpline = getConfigValue('helplineShortCode') as keyof typeof formContentsByHelpline; - const formContent = formContentsByHelpline[helpline]; + const formContent = formContentsByHelplineForEmptyForm[helpline]; const form = contactForm(pluginPage); await form.selectChildCallType(); From 465730d058e79dae6a003b55fd242d9584980556 Mon Sep 17 00:00:00 2001 From: Stephen Hand Date: Wed, 22 Jul 2026 23:57:08 +0100 Subject: [PATCH 32/40] Fix offlie contact e2e test --- e2e-tests/tests/offlineContact.spec.ts | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/e2e-tests/tests/offlineContact.spec.ts b/e2e-tests/tests/offlineContact.spec.ts index 2bbfaeab23..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,8 +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"; +import { getConfigValue } from '../config'; +import { + formContentsByHelpline, + formContentsByHelplineForEmptyForm, +} from '../formContentsByHelpline'; test.describe.serial('Offline Contact (with Case)', () => { skipTestIfNotTargeted(); @@ -73,7 +76,8 @@ test.describe.serial('Offline Contact (with Case)', () => { channel: 'web', helpline: 'Childline', }, - }]); + }, + ]); await form.fillWithContent(formContent); const beforeDate = new Date(); // Capture date here since we'll create case inmediately after saving contact From cf32d57165e6f7112728386bd05deceee64b128d Mon Sep 17 00:00:00 2001 From: Stephen Hand Date: Thu, 23 Jul 2026 00:21:56 +0100 Subject: [PATCH 33/40] Linter --- e2e-tests/formContentsByHelpline.ts | 3 +-- e2e-tests/tests/aseloWebchat.spec.ts | 2 +- e2e-tests/tests/sms.spec.ts | 2 +- e2e-tests/tests/voice.spec.ts | 7 +++++-- e2e-tests/twilio/channels.ts | 2 +- e2e-tests/twilio/voice.ts | 1 + 6 files changed, 10 insertions(+), 7 deletions(-) diff --git a/e2e-tests/formContentsByHelpline.ts b/e2e-tests/formContentsByHelpline.ts index 1d5b8114ba..3fbb37a484 100644 --- a/e2e-tests/formContentsByHelpline.ts +++ b/e2e-tests/formContentsByHelpline.ts @@ -14,7 +14,6 @@ * along with this program. If not, see https://www.gnu.org/licenses/. */ - export const formContentsByHelpline = { e2e: { childInformation: { @@ -70,4 +69,4 @@ export const formContentsByHelplineForEmptyForm = { callSummary: 'E2E TEST EMPTY FORM', }, }, -}; \ No newline at end of file +}; diff --git a/e2e-tests/tests/aseloWebchat.spec.ts b/e2e-tests/tests/aseloWebchat.spec.ts index 2b3fd446a1..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'; diff --git a/e2e-tests/tests/sms.spec.ts b/e2e-tests/tests/sms.spec.ts index a82d81e9f1..510e017a4a 100644 --- a/e2e-tests/tests/sms.spec.ts +++ b/e2e-tests/tests/sms.spec.ts @@ -21,7 +21,7 @@ import { getSmsScript } 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'; diff --git a/e2e-tests/tests/voice.spec.ts b/e2e-tests/tests/voice.spec.ts index 84ce54d432..8e88a64e0d 100644 --- a/e2e-tests/tests/voice.spec.ts +++ b/e2e-tests/tests/voice.spec.ts @@ -17,14 +17,17 @@ import { Page, request, test } from '@playwright/test'; import { statusIndicator } from '../workerStatus'; import { skipTestIfNotTargeted } from '../skipTest'; -import { Categories, contactForm, ContactFormTab } from '../contactForm'; +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 { + formContentsByHelpline, + formContentsByHelplineForEmptyForm, +} from '../formContentsByHelpline'; import { getConfigValue } from '../config'; import { makeCallToService } from '../twilio/voice'; import { tasks } from '../tasks'; diff --git a/e2e-tests/twilio/channels.ts b/e2e-tests/twilio/channels.ts index 1aeb62b168..da543ce7ab 100644 --- a/e2e-tests/twilio/channels.ts +++ b/e2e-tests/twilio/channels.ts @@ -15,7 +15,7 @@ */ // eslint-disable-next-line import/no-extraneous-dependencies -import twilio, { Twilio } from 'twilio'; +import twilio from 'twilio'; import { getConfigValue } from '../config'; const encodeEmailToUnicode = (email: string) => { diff --git a/e2e-tests/twilio/voice.ts b/e2e-tests/twilio/voice.ts index 4126c13de5..ec016f6c85 100644 --- a/e2e-tests/twilio/voice.ts +++ b/e2e-tests/twilio/voice.ts @@ -15,6 +15,7 @@ */ import { getConfigValue } from '../config'; +// eslint-disable-next-line import/no-extraneous-dependencies import twilio from 'twilio'; import VoiceResponse = twilio.twiml.VoiceResponse; From d4c6192187fd04e527c2bd5007a0343cba0a7d49 Mon Sep 17 00:00:00 2001 From: Stephen Hand Date: Thu, 23 Jul 2026 08:24:48 +0100 Subject: [PATCH 34/40] Extra assertion in E2E tests --- e2e-tests/workerStatus.ts | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/e2e-tests/workerStatus.ts b/e2e-tests/workerStatus.ts index f04030f167..d8fd7d068f 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('|'))); }, }; } From 8cc9d5e21562dce4469676e13b47b09fca3741d1 Mon Sep 17 00:00:00 2001 From: Stephen Hand Date: Thu, 23 Jul 2026 08:39:58 +0100 Subject: [PATCH 35/40] Fake sound input for all E2E browsers, not just those running in a lambda --- e2e-tests/package.json | 2 +- e2e-tests/playwright.config.ts | 9 ++++++--- 2 files changed, 7 insertions(+), 4 deletions(-) diff --git a/e2e-tests/package.json b/e2e-tests/package.json index b55dbf52be..5bde88e89e 100644 --- a/e2e-tests/package.json +++ b/e2e-tests/package.json @@ -10,7 +10,7 @@ "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 -- --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", + "test:local:debug": "cross-env DEBUG=pw:api LOAD_SSM_CONFIG=true npm run test -- --headed --debug --retries 0 voice", "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", 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, From e37b2a80d39db47966d93a0ee9a2ad230ba29bb9 Mon Sep 17 00:00:00 2001 From: Stephen Hand Date: Thu, 23 Jul 2026 08:40:33 +0100 Subject: [PATCH 36/40] Revert local change --- e2e-tests/package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/e2e-tests/package.json b/e2e-tests/package.json index 5bde88e89e..b55dbf52be 100644 --- a/e2e-tests/package.json +++ b/e2e-tests/package.json @@ -10,7 +10,7 @@ "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 -- --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 voice", + "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", From 42daf154844aa4e16e34243ac613fc9b6438ed34 Mon Sep 17 00:00:00 2001 From: Stephen Hand Date: Thu, 23 Jul 2026 08:53:24 +0100 Subject: [PATCH 37/40] Linter --- e2e-tests/workerStatus.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/e2e-tests/workerStatus.ts b/e2e-tests/workerStatus.ts index d8fd7d068f..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 {expect, Locator, Page} from '@playwright/test'; +import { expect, Locator, Page } from '@playwright/test'; export const WORKER_STATUS = { AVAILABLE: ['Available', 'Ready'], From 5fb2a6f1248f41aa09d0baf81e00af119d0ad451 Mon Sep 17 00:00:00 2001 From: Stephen Hand Date: Thu, 23 Jul 2026 16:23:45 +0100 Subject: [PATCH 38/40] Apply suggestions from code review Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- twilio-iac/helplines/e2e/development.hcl | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/twilio-iac/helplines/e2e/development.hcl b/twilio-iac/helplines/e2e/development.hcl index 67cde25a69..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' OR channelType = 'voice' OR channelType = 'sms') 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" From 02ebb61ad9d6c26b24111a11421ba8ff041270cf Mon Sep 17 00:00:00 2001 From: Stephen Hand Date: Thu, 23 Jul 2026 16:28:58 +0100 Subject: [PATCH 39/40] Remove E2E voice queue --- twilio-iac/helplines/defaults.hcl | 4 ---- twilio-iac/helplines/e2e/common.hcl | 4 ---- twilio-iac/helplines/templates/workflows/master.tftpl | 10 ---------- 3 files changed, 18 deletions(-) diff --git a/twilio-iac/helplines/defaults.hcl b/twilio-iac/helplines/defaults.hcl index 5ac2d1293a..d4fdc6d476 100644 --- a/twilio-iac/helplines/defaults.hcl +++ b/twilio-iac/helplines/defaults.hcl @@ -60,10 +60,6 @@ locals { e2e_test : { "target_workers" = "email=='aselo-alerts+production@techmatters.org'", "friendly_name" = "E2E Test Queue" - }, - e2e_test_voice : { - "target_workers" = "email=='aselo-alerts+production@techmatters.org'", - "friendly_name" = "E2E Test Queue (Voice)" } // survey : { // friendly_name = "Survey" diff --git a/twilio-iac/helplines/e2e/common.hcl b/twilio-iac/helplines/e2e/common.hcl index c2376b0a43..2b20711115 100644 --- a/twilio-iac/helplines/e2e/common.hcl +++ b/twilio-iac/helplines/e2e/common.hcl @@ -52,10 +52,6 @@ locals { e2e_test : { "target_workers" = "email=='aselo-alerts+production@techmatters.org'", "friendly_name" = "E2E Test Queue" - }, - e2e_test_voice : { - "target_workers" = "email=='aselo-alerts+production@techmatters.org'", - "friendly_name" = "E2E Test Queue (Voice)" } } diff --git a/twilio-iac/helplines/templates/workflows/master.tftpl b/twilio-iac/helplines/templates/workflows/master.tftpl index 291d9f6b2f..5082bdd9a1 100644 --- a/twilio-iac/helplines/templates/workflows/master.tftpl +++ b/twilio-iac/helplines/templates/workflows/master.tftpl @@ -42,16 +42,6 @@ "queue": "${task_queues.e2e_test}" } ] - }, - { - "filter_friendly_name": "Voice E2E Test", - "expression": "channelType=='voice' AND name=='+12064083885'", - "targets": [ - { - "expression": "(worker.waitingOfflineContact != true AND ((task.channelType == 'voice' AND worker.channel.chat.assigned_tasks == 0) OR (task.channelType != 'voice' AND worker.channel.voice.assigned_tasks == 0)) AND ((task.transferTargetType == 'worker' AND task.targetSid == worker.sid) OR (task.transferTargetType != 'worker' AND worker.sid != task.ignoreAgent))) OR (worker.waitingOfflineContact == true AND task.targetSid == worker.sid AND task.isContactlessTask == true)", - "queue": "${task_queues.e2e_test_voice}" - } - ] } ] } From 8924abace85d235c9688f4b94edf028be3a8c3c4 Mon Sep 17 00:00:00 2001 From: Stephen Hand Date: Thu, 23 Jul 2026 16:33:54 +0100 Subject: [PATCH 40/40] Revert unused update --- twilio-iac/terraform-modules/channels/v1/main.tf | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/twilio-iac/terraform-modules/channels/v1/main.tf b/twilio-iac/terraform-modules/channels/v1/main.tf index 20af2664b1..21e6d961bd 100644 --- a/twilio-iac/terraform-modules/channels/v1/main.tf +++ b/twilio-iac/terraform-modules/channels/v1/main.tf @@ -107,9 +107,7 @@ resource "twilio_conversations_configuration_addresses_v1" "conversations_addres # Must be created manually in Twilio Console for now channel.channel_type != "chat" && channel.channel_type != "custom" && - channel.messaging_mode == "conversations" && - # Channels with no contact_identity manage their own conversations address (e.g. via additional.tf) - channel.contact_identity != "" + channel.messaging_mode == "conversations" ) } type = each.value.channel_type