diff --git a/.eslintrc.js b/.eslintrc.js
index 942882731..783f1c99d 100644
--- a/.eslintrc.js
+++ b/.eslintrc.js
@@ -6,9 +6,10 @@ module.exports = {
env: {
node: true
},
- extends: ['eslint:recommended', 'plugin:vue/recommended', 'airbnb-base'],
+ extends: ['eslint:recommended', 'plugin:vue/vue3-recommended', 'airbnb-base', 'plugin:vue-pug/vue3-recommended'],
rules: {
indent: ['warn', 4],
- quotes: ['warn', 'single']
+ quotes: ['warn', 'single'],
+ 'vue/no-deprecated-slot-attribute': 'off'
}
};
diff --git a/.gitignore b/.gitignore
index 31c651134..e87aa889b 100644
--- a/.gitignore
+++ b/.gitignore
@@ -97,8 +97,11 @@ coverage.xml
*.py,cover
.hypothesis/
.pytest_cache/
+**/.venv-test/
docs/excel_import/~$sample.xlsx
+.nightswatch/functional/files/~$import-pass.xlsx
+.nightswatch/functional/files/~$import-fail.xlsx
# pipenv lock files
Pipfile
diff --git a/.nightswatch/deployment/taskcat.yml b/.nightswatch/deployment/taskcat.yml
new file mode 100644
index 000000000..f398a217d
--- /dev/null
+++ b/.nightswatch/deployment/taskcat.yml
@@ -0,0 +1,216 @@
+######################################################################################################################
+# Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. #
+# #
+# Licensed under the Apache License, Version 2.0 (the "License"). You may not use this file except in compliance #
+# with the License. A copy of the License is located at #
+# #
+# http://www.apache.org/licenses/LICENSE-2.0 #
+# #
+# or in the 'license' file accompanying this file. This file is distributed on an 'AS IS' BASIS, WITHOUT WARRANTIES #
+# OR CONDITIONS OF ANY KIND, express or implied. See the License for the specific language governing permissions #
+# and limitations under the License. #
+######################################################################################################################
+
+general:
+ auth:
+ default: 'QnABot_Profile' # Your aws profile should be defined inside scripts/env.sh file.
+project:
+ name: qnabot
+ shorten_stack_name: true
+ template: qnabot-on-aws-main.template
+tests:
+ qnabot-t1:
+ parameters:
+ DefaultKendraIndexId: '650682ba-baee-4bb9-9d04-322e4834f894'
+ ElasticSearchNodeCount: '4'
+ Email: 'test@example.com'
+ Encryption: 'ENCRYPTED'
+ KibanaDashboardRetentionMinutes: '43200'
+ LexBotVersion: 'LexV1 and LexV2'
+ LexV2BotLocaleIds: 'en_US,es_US,fr_CA'
+ PublicOrPrivate: 'PRIVATE'
+ Username: 'Admin'
+ XraySetting: 'FALSE'
+ FulfillmentConcurrency: '0'
+ EmbeddingsApi: SAGEMAKER
+ SagemakerInitialInstanceCount: 1
+ LLMSagemakerInitialInstanceCount: 1
+ LLMApi: SAGEMAKER
+ LLMSagemakerInstanceType: ml.g5.12xlarge
+ regions:
+ - us-east-1
+ qnabot-t2:
+ parameters:
+ DefaultKendraIndexId: '95fab795-bf1c-47b7-a561-31c3cb60ceb0'
+ ElasticSearchNodeCount: '4'
+ Email: 'test@example.com'
+ Encryption: 'ENCRYPTED'
+ KibanaDashboardRetentionMinutes: '43200'
+ LexBotVersion: 'LexV1 and LexV2'
+ LexV2BotLocaleIds: 'en_US,es_US,fr_CA'
+ PublicOrPrivate: 'PRIVATE'
+ Username: 'Admin'
+ XraySetting: 'FALSE'
+ FulfillmentConcurrency: '0'
+ EmbeddingsApi: SAGEMAKER
+ SagemakerInitialInstanceCount: 1
+ LLMSagemakerInitialInstanceCount: 1
+ LLMApi: SAGEMAKER
+ LLMSagemakerInstanceType: ml.g5.12xlarge
+ regions:
+ - us-west-2
+ qnabot-t3:
+ parameters:
+ DefaultKendraIndexId: '18c53754-0a24-42eb-85d9-ad65aa24f891'
+ ElasticSearchNodeCount: '4'
+ Email: 'test@example.com'
+ Encryption: 'ENCRYPTED'
+ KibanaDashboardRetentionMinutes: '43200'
+ LexBotVersion: 'LexV1 and LexV2'
+ LexV2BotLocaleIds: 'en_US,es_US,fr_CA'
+ PublicOrPrivate: 'PRIVATE'
+ Username: 'Admin'
+ XraySetting: 'FALSE'
+ FulfillmentConcurrency: '0'
+ EmbeddingsApi: SAGEMAKER
+ SagemakerInitialInstanceCount: 1
+ regions:
+ - ap-southeast-1
+ qnabot-t4:
+ parameters:
+ DefaultKendraIndexId: '50849777-a352-4708-aa0d-f40046b1bdc7'
+ ElasticSearchNodeCount: '4'
+ Email: 'test@example.com'
+ Encryption: 'ENCRYPTED'
+ KibanaDashboardRetentionMinutes: '43200'
+ LexBotVersion: 'LexV1 and LexV2'
+ LexV2BotLocaleIds: 'en_US,es_US,fr_CA'
+ PublicOrPrivate: 'PRIVATE'
+ Username: 'Admin'
+ XraySetting: 'FALSE'
+ FulfillmentConcurrency: '0'
+ EmbeddingsApi: SAGEMAKER
+ SagemakerInitialInstanceCount: 1
+ LLMSagemakerInitialInstanceCount: 1
+ LLMApi: SAGEMAKER
+ LLMSagemakerInstanceType: ml.g5.12xlarge
+ regions:
+ - ap-southeast-2
+ qnabot-t5:
+ parameters:
+ DefaultKendraIndexId: '72be0d99-fe41-4dda-b89c-acba05ea1282'
+ ElasticSearchNodeCount: '4'
+ Email: 'test@example.com'
+ Encryption: 'ENCRYPTED'
+ KibanaDashboardRetentionMinutes: '43200'
+ LexBotVersion: 'LexV2 Only'
+ LexV2BotLocaleIds: 'en_US,es_US,fr_CA'
+ PublicOrPrivate: 'PRIVATE'
+ Username: 'Admin'
+ XraySetting: 'FALSE'
+ FulfillmentConcurrency: '0'
+ EmbeddingsApi: SAGEMAKER
+ SagemakerInitialInstanceCount: 1
+ LLMSagemakerInitialInstanceCount: 1
+ LLMApi: SAGEMAKER
+ LLMSagemakerInstanceType: ml.g5.12xlarge
+ regions:
+ - ca-central-1
+ qnabot-t6:
+ parameters:
+ DefaultKendraIndexId: 'e647d178-347c-48cb-9386-4dcb2f73ebae'
+ ElasticSearchNodeCount: '4'
+ Email: 'test@example.com'
+ Encryption: 'ENCRYPTED'
+ KibanaDashboardRetentionMinutes: '43200'
+ LexBotVersion: 'LexV1 and LexV2'
+ LexV2BotLocaleIds: 'en_US,es_US,fr_CA'
+ PublicOrPrivate: 'PRIVATE'
+ Username: 'Admin'
+ XraySetting: 'FALSE'
+ FulfillmentConcurrency: '0'
+ EmbeddingsApi: SAGEMAKER
+ SagemakerInitialInstanceCount: 1
+ LLMSagemakerInitialInstanceCount: 1
+ LLMApi: SAGEMAKER
+ LLMSagemakerInstanceType: ml.g5.12xlarge
+ regions:
+ - eu-west-1
+ qnabot-t7:
+ parameters:
+ DefaultKendraIndexId: ''
+ ElasticSearchNodeCount: '4'
+ Email: 'test@example.com'
+ Encryption: 'ENCRYPTED'
+ KibanaDashboardRetentionMinutes: '43200'
+ LexBotVersion: 'LexV1 and LexV2'
+ LexV2BotLocaleIds: 'en_US,es_US,fr_CA'
+ PublicOrPrivate: 'PRIVATE'
+ Username: 'Admin'
+ XraySetting: 'FALSE'
+ FulfillmentConcurrency: '0'
+ EmbeddingsApi: SAGEMAKER
+ SagemakerInitialInstanceCount: 1
+ regions:
+ - ap-northeast-1
+ qnabot-t8:
+ parameters:
+ DefaultKendraIndexId: ''
+ ElasticSearchNodeCount: '4'
+ Email: 'test@example.com'
+ Encryption: 'ENCRYPTED'
+ KibanaDashboardRetentionMinutes: '43200'
+ LexBotVersion: 'LexV1 and LexV2'
+ LexV2BotLocaleIds: 'en_US,es_US,fr_CA'
+ PublicOrPrivate: 'PRIVATE'
+ Username: 'Admin'
+ XraySetting: 'FALSE'
+ FulfillmentConcurrency: '0'
+ EmbeddingsApi: SAGEMAKER
+ SagemakerInitialInstanceCount: 1
+ LLMSagemakerInitialInstanceCount: 1
+ LLMApi: SAGEMAKER
+ LLMSagemakerInstanceType: ml.g5.12xlarge
+ regions:
+ - eu-central-1
+ qnabot-t9:
+ parameters:
+ DefaultKendraIndexId: ''
+ ElasticSearchNodeCount: '4'
+ Email: 'test@example.com'
+ Encryption: 'ENCRYPTED'
+ KibanaDashboardRetentionMinutes: '43200'
+ LexBotVersion: 'LexV1 and LexV2'
+ LexV2BotLocaleIds: 'en_US,es_US,fr_CA'
+ PublicOrPrivate: 'PRIVATE'
+ Username: 'Admin'
+ XraySetting: 'FALSE'
+ FulfillmentConcurrency: '0'
+ EmbeddingsApi: SAGEMAKER
+ SagemakerInitialInstanceCount: 1
+ LLMSagemakerInitialInstanceCount: 1
+ LLMApi: SAGEMAKER
+ LLMSagemakerInstanceType: ml.g5.12xlarge
+ regions:
+ - eu-west-2
+ qnabot-t10:
+ parameters:
+ DefaultKendraIndexId: ''
+ ElasticSearchNodeCount: '4'
+ Email: 'test@example.com'
+ Encryption: 'ENCRYPTED'
+ KibanaDashboardRetentionMinutes: '43200'
+ LexBotVersion: 'LexV2 Only'
+ LexV2BotLocaleIds: 'en_US,es_US,fr_CA'
+ PublicOrPrivate: 'PRIVATE'
+ Username: 'Admin'
+ XraySetting: 'FALSE'
+ FulfillmentConcurrency: '0'
+ EmbeddingsApi: SAGEMAKER
+ SagemakerInitialInstanceCount: 1
+ LLMSagemakerInitialInstanceCount: 1
+ LLMApi: SAGEMAKER
+ LLMSagemakerInstanceType: ml.g5.12xlarge
+ regions:
+ - ap-northeast-2
+##ca-central-1 only supports Lexv2 not Lexv1
\ No newline at end of file
diff --git a/.nightswatch/functional/conftest.py b/.nightswatch/functional/conftest.py
new file mode 100644
index 000000000..bf80f1b12
--- /dev/null
+++ b/.nightswatch/functional/conftest.py
@@ -0,0 +1,186 @@
+######################################################################################################################
+# Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. #
+# #
+# Licensed under the Apache License, Version 2.0 (the "License"). You may not use this file except in compliance #
+# with the License. A copy of the License is located at #
+# #
+# http://www.apache.org/licenses/LICENSE-2.0 #
+# #
+# or in the 'license' file accompanying this file. This file is distributed on an 'AS IS' BASIS, WITHOUT WARRANTIES #
+# OR CONDITIONS OF ANY KIND, express or implied. See the License for the specific language governing permissions #
+# and limitations under the License. #
+######################################################################################################################
+
+import pytest
+import os
+import logging
+import string
+import secrets
+from packaging import version
+log = logging.getLogger(__name__)
+
+from helpers.cfn_parameter_fetcher import ParameterFetcher
+from helpers.kendra_client import KendraClient
+from helpers.lex_client import LexClient
+from helpers.iam_client import IamClient
+from helpers.translate_client import TranslateClient
+from helpers.cloud_watch_client import CloudWatchClient
+from helpers.website_model.dom_operator import DomOperator
+from helpers.website_model.login_page import LoginPage
+
+def get_password() -> str:
+ cognito_special_characters = '^$*.[]{}()?-"!@#%&,><:;|_~+='
+
+ def is_special(c):
+ for s in cognito_special_characters:
+ if s == c:
+ return True
+ return False
+
+ alphabet = string.ascii_letters + string.digits + cognito_special_characters
+ while True:
+ password = ''.join(secrets.choice(alphabet) for i in range(10))
+ if (any(c.islower() for c in password)
+ and any(c.isupper() for c in password)
+ and sum(c.isdigit() for c in password) >= 3
+ and any(is_special(c) for c in password)):
+ return password
+
+temp_pass = get_password()
+new_pass = get_password()
+
+
+@pytest.fixture
+def region() -> str:
+ return os.environ.get('CURRENT_STACK_REGION')
+
+@pytest.fixture
+def stack_name() -> str:
+ return os.environ.get('CURRENT_STACK_NAME')
+
+@pytest.fixture
+def username() -> str:
+ if os.environ.get('USER'):
+ return os.environ.get('USER')
+ return 'QnaAdmin'
+
+@pytest.fixture
+def email() -> str:
+ email = os.environ.get("EMAIL", "")
+ return email
+
+@pytest.fixture
+def temporary_password() -> str:
+ return temp_pass
+
+@pytest.fixture
+def password() -> str:
+ if os.environ.get('PASSWORD'):
+ return os.environ.get('PASSWORD')
+ return new_pass
+
+@pytest.fixture
+def languages() -> list['str']:
+ return ['fr', 'es']
+
+@pytest.fixture
+def param_fetcher(region: str, stack_name: str) -> ParameterFetcher:
+ return ParameterFetcher(region, stack_name)
+
+@pytest.fixture
+def kendra_client(region: str, param_fetcher: ParameterFetcher) -> KendraClient:
+ return KendraClient(region, param_fetcher.get_kendra_index())
+
+@pytest.fixture
+def lex_client(region: str) -> LexClient:
+ return LexClient(region)
+
+@pytest.fixture
+def translate_client(region: str) -> TranslateClient:
+ return TranslateClient(region)
+
+@pytest.fixture
+def iam_client(region: str) -> IamClient:
+ return IamClient(region)
+
+@pytest.fixture
+def app_version(param_fetcher: ParameterFetcher) -> str:
+ app_version = param_fetcher.get_deployment_version()
+ return app_version
+
+@pytest.fixture(autouse=True)
+def skip_if_version_less_than(request, app_version):
+ if request.node.get_closest_marker('skipif_version_less_than'):
+ marker = request.node.get_closest_marker('skipif_version_less_than')
+ expected_version = marker.args[0]
+ if version.parse(app_version) < version.parse(expected_version):
+ pytest.skip(f'App Version {app_version} is less than expected version {expected_version}. Skipping...')
+
+@pytest.fixture
+def cw_client(region: str, param_fetcher: ParameterFetcher) -> CloudWatchClient:
+ fulfillment_lambda_name = param_fetcher.get_fulfillment_lambda_name()
+ return CloudWatchClient(region, fulfillment_lambda_name)
+
+@pytest.fixture(autouse=True)
+def dom_operator():
+ dom_operator = DomOperator()
+ yield dom_operator
+ dom_operator.end_session()
+
+@pytest.fixture
+def designer_login(dom_operator: DomOperator, param_fetcher: ParameterFetcher, username: str, password: str):
+ designer_url = param_fetcher.get_designer_url()
+ login_page = LoginPage(dom_operator, designer_url)
+ return login_page.login(username, password)
+
+@pytest.fixture
+def client_login(dom_operator: DomOperator, param_fetcher: ParameterFetcher, username: str, password: str):
+ client_url = param_fetcher.get_client_url()
+ login_page = LoginPage(dom_operator, client_url)
+ return login_page.login(username, password)
+
+@pytest.fixture
+def lambda_hook_example_arn(dom_operator: DomOperator, param_fetcher: ParameterFetcher, username: str, password: str) -> str:
+ return param_fetcher.get_lambda_hook_example_arn().split(':')[-1]
+
+test_time_flag = os.environ.get('TIMESTAMPS')
+if test_time_flag:
+ @pytest.fixture(autouse=True, scope='function')
+ def log_timestamps(request):
+ log.info(f"{request.node.cls} {request.node.name} start.")
+ yield
+ log.info(f"{request.node.cls} {request.node.name} end.")
+
+@pytest.fixture
+def kendra_is_enabled(param_fetcher: ParameterFetcher):
+ return param_fetcher.kendra_is_enabled()
+
+@pytest.fixture(autouse=True)
+def skip_kendra(request, kendra_is_enabled):
+ if request.node.get_closest_marker('skipif_kendra_not_enabled'):
+ # if True:
+ if not kendra_is_enabled:
+ pytest.skip('Kendra is not configured for this environment. Skipping...')
+
+@pytest.fixture
+def llm_is_enabled(param_fetcher: ParameterFetcher):
+ return param_fetcher.llm_is_enabled()
+
+@pytest.fixture(autouse=True)
+def skip_llm(request, llm_is_enabled):
+ if request.node.get_closest_marker('skipif_llm_not_enabled'):
+ # if True:
+ if not llm_is_enabled:
+ pytest.skip('An LLM is not configured for this environment. Skipping...')
+
+@pytest.fixture
+def embeddings_is_enabled(param_fetcher: ParameterFetcher):
+ return param_fetcher.embeddings_is_enabled()
+
+@pytest.fixture(autouse=True)
+def skip_embeddings(request, embeddings_is_enabled):
+ if request.node.get_closest_marker('skipif_embeddings_not_enabled'):
+ # if True:
+ if not embeddings_is_enabled:
+ pytest.skip('Embeddings is not configured for this environment. Skipping...')
+
diff --git a/.nightswatch/functional/files/EPCTerminology.csv b/.nightswatch/functional/files/EPCTerminology.csv
new file mode 100644
index 000000000..56ae0e319
--- /dev/null
+++ b/.nightswatch/functional/files/EPCTerminology.csv
@@ -0,0 +1,2 @@
+en,es
+without incurring any fees, sin incurrir ningĂșn cargo
\ No newline at end of file
diff --git a/.nightswatch/functional/files/import-fail.xlsx b/.nightswatch/functional/files/import-fail.xlsx
new file mode 100644
index 000000000..b119362f2
Binary files /dev/null and b/.nightswatch/functional/files/import-fail.xlsx differ
diff --git a/.nightswatch/functional/files/import-pass-expected.json b/.nightswatch/functional/files/import-pass-expected.json
new file mode 100644
index 000000000..f1a3a0416
--- /dev/null
+++ b/.nightswatch/functional/files/import-pass-expected.json
@@ -0,0 +1,57 @@
+{
+ "qna": [
+ {
+ "a": "From the import page.",
+ "r": {
+ "buttons": [
+ {
+ "text": "Tell me about the Alexa Show.",
+ "value": "The Echo Show"
+ },
+ {
+ "text": "Tell me about the Echo Dot",
+ "value": "The Echo Dot"
+ }
+ ],
+ "imageUrl": "https://images-na.ssl-images-amazon.com/images/I/61bze1WJhfL._AC_SL1024_.jpg",
+ "title": "Alexa"
+ },
+ "t": "import",
+ "elicitResponse": {
+ "responsebot_hook": "QnAYesNoBot"
+ },
+ "alt": {
+ "markdown": "*From the import page.*",
+ "ssml": "From the import page."
+ },
+ "type": "qna",
+ "qid": "Import.002",
+ "sa": [
+ {
+ "enableTranslate": false,
+ "text": "TestName",
+ "value": "TestValue"
+ },
+ {
+ "enableTranslate": true,
+ "text": "TestName2",
+ "value": "TestValue2"
+ }
+ ],
+ "clientFilterValues": "Test",
+ "q": [
+ "How do I import questions in content designer?",
+ "How do I import questions using QnA Bot?"
+ ]
+ },
+ {
+ "a": "Of course!",
+ "type": "qna",
+ "qid": "Import.003",
+ "q": [
+ "Can I import multiple answers when I import with excel?",
+ "Can I import multiple answers when I import with excel using QnA Bot?"
+ ]
+ }
+ ]
+}
\ No newline at end of file
diff --git a/.nightswatch/functional/files/import-pass.xlsx b/.nightswatch/functional/files/import-pass.xlsx
new file mode 100644
index 000000000..c8c2bbd6c
Binary files /dev/null and b/.nightswatch/functional/files/import-pass.xlsx differ
diff --git a/.nightswatch/functional/files/terms.csv b/.nightswatch/functional/files/terms.csv
new file mode 100644
index 000000000..84c00987b
--- /dev/null
+++ b/.nightswatch/functional/files/terms.csv
@@ -0,0 +1,2 @@
+en,fr,es
+custom terminology,custom terminology,custom terminology
\ No newline at end of file
diff --git a/website/js/components/hooks/code.py b/.nightswatch/functional/helpers/__init__.py
similarity index 93%
rename from website/js/components/hooks/code.py
rename to .nightswatch/functional/helpers/__init__.py
index 237633841..d08267fc3 100644
--- a/website/js/components/hooks/code.py
+++ b/.nightswatch/functional/helpers/__init__.py
@@ -10,9 +10,3 @@
# OR CONDITIONS OF ANY KIND, express or implied. See the License for the specific language governing permissions #
# and limitations under the License. #
######################################################################################################################
-
-import json
-
-def lambda_handler(event,context):
- print(json.dumps(event,indent=4))
- return event
diff --git a/.nightswatch/functional/helpers/bot_intents/get_attribute.json b/.nightswatch/functional/helpers/bot_intents/get_attribute.json
new file mode 100644
index 000000000..af783f2b4
--- /dev/null
+++ b/.nightswatch/functional/helpers/bot_intents/get_attribute.json
@@ -0,0 +1,58 @@
+{
+ "intentName": "GetAttribute",
+ "localeId": "en_US",
+ "sampleUtterances": [
+ {
+ "utterance": "Do I have an attribute?"
+ }
+ ],
+ "initialResponseSetting": {
+ "conditional": {
+ "active": true,
+ "conditionalBranches": [
+ {
+ "condition": {
+ "expressionString": "[myAttribute] = \"test\""
+ },
+ "name": "hasAttribute",
+ "nextStep": {
+ "dialogAction": {
+ "type": "EndConversation"
+ }
+ },
+ "response": {
+ "allowInterrupt": true,
+ "messageGroups": [
+ {
+ "message": {
+ "plainTextMessage": {
+ "value": "TRUE - YOUR ATTRIBUTE IS CONFIGURED CORRECTLY"
+ }
+ }
+ }
+ ]
+ }
+ }
+ ],
+ "defaultBranch": {
+ "nextStep": {
+ "dialogAction": {
+ "type": "EndConversation"
+ }
+ },
+ "response": {
+ "allowInterrupt": true,
+ "messageGroups": [
+ {
+ "message": {
+ "plainTextMessage": {
+ "value": "FALSE - YOUR ATTRIBUTE IS NOT CONFIGURED CORRECTLY"
+ }
+ }
+ }
+ ]
+ }
+ }
+ }
+ }
+}
diff --git a/.nightswatch/functional/helpers/bot_intents/greetings.json b/.nightswatch/functional/helpers/bot_intents/greetings.json
new file mode 100644
index 000000000..de9e2c3ad
--- /dev/null
+++ b/.nightswatch/functional/helpers/bot_intents/greetings.json
@@ -0,0 +1,45 @@
+{
+ "intentName": "sayHello",
+ "localeId": "en_US",
+ "sampleUtterances": [
+ {
+ "utterance": "Hello"
+ },
+ {
+ "utterance": "Hi"
+ },
+ {
+ "utterance": "Greetings"
+ }
+ ],
+ "fulfillmentCodeHook": {
+ "active": true,
+ "enabled": false,
+ "postFulfillmentStatusSpecification": {
+ "failureResponse": {
+ "allowInterrupt": true,
+ "messageGroups": [
+ {
+ "message": {
+ "plainTextMessage": {
+ "value": "I BROKE"
+ }
+ }
+ }
+ ]
+ },
+ "successResponse": {
+ "allowInterrupt": true,
+ "messageGroups": [
+ {
+ "message": {
+ "plainTextMessage": {
+ "value": "GREETINGS, I AM TEST BOT"
+ }
+ }
+ }
+ ]
+ }
+ }
+ }
+}
diff --git a/.nightswatch/functional/helpers/bot_intents/set_attribute.json b/.nightswatch/functional/helpers/bot_intents/set_attribute.json
new file mode 100644
index 000000000..2ac832844
--- /dev/null
+++ b/.nightswatch/functional/helpers/bot_intents/set_attribute.json
@@ -0,0 +1,30 @@
+{
+ "intentName": "SetAttribute",
+ "localeId": "en_US",
+ "sampleUtterances": [
+ {
+ "utterance": "Give me an attribute"
+ }
+ ],
+ "initialResponseSetting": {
+ "initialResponse": {
+ "messageGroups": [
+ {
+ "message": {
+ "plainTextMessage": {
+ "value": "HERE IS A SESSION ATTRIBUTE"
+ }
+ }
+ }
+ ]
+ },
+ "nextStep": {
+ "dialogAction": {
+ "type": "EndConversation"
+ },
+ "sessionAttributes": {
+ "botAttribute" : "test"
+ }
+ }
+ }
+}
\ No newline at end of file
diff --git a/.nightswatch/functional/helpers/cfn_parameter_fetcher.py b/.nightswatch/functional/helpers/cfn_parameter_fetcher.py
new file mode 100644
index 000000000..651f53b87
--- /dev/null
+++ b/.nightswatch/functional/helpers/cfn_parameter_fetcher.py
@@ -0,0 +1,240 @@
+######################################################################################################################
+# Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. #
+# #
+# Licensed under the Apache License, Version 2.0 (the "License"). You may not use this file except in compliance #
+# with the License. A copy of the License is located at #
+# #
+# http://www.apache.org/licenses/LICENSE-2.0 #
+# #
+# or in the 'license' file accompanying this file. This file is distributed on an 'AS IS' BASIS, WITHOUT WARRANTIES #
+# OR CONDITIONS OF ANY KIND, express or implied. See the License for the specific language governing permissions #
+# and limitations under the License. #
+######################################################################################################################
+
+from typing import Optional
+import boto3
+import re
+
+class ParameterFetcher:
+ """
+ A Python class to interact with AWS CloudFormation using Boto3.
+ This class provides various methods to fetch details related to the specified stack.
+ """
+
+ def __init__(self, region: str, stack_name: str) -> None:
+ """
+ Constructs all the necessary attributes for the ParameterFetcher object.
+
+ Parameters:
+ ----------
+ region : str
+ AWS region name where the CloudFormation stack exists.
+ stack_name : str
+ The name of the CloudFormation stack.
+ """
+ self.region = region
+ self.stack_name = stack_name
+ self.cloudformation_client = boto3.client('cloudformation', region_name=region)
+
+ def get_user_pool_id(self) -> Optional[str]:
+ """
+ Retrieves the User Pool ID from the stack resources.
+
+ Returns:
+ -------
+ The User Pool ID if found, otherwise None.
+ """
+ response = self.cloudformation_client.list_stack_resources(
+ StackName=self.stack_name
+ )
+
+ while True:
+ for StackResourceSummary in response['StackResourceSummaries']:
+ if StackResourceSummary['LogicalResourceId'] == 'UserPool':
+ user_pool_id = StackResourceSummary['PhysicalResourceId']
+ return user_pool_id
+
+ if 'NextToken' in response:
+ response = self.cloudformation_client.list_stack_resources(
+ StackName=self.stack_name,
+ NextToken=response['NextToken']
+ )
+ else:
+ raise RuntimeError('User Pool ID not found.')
+
+
+ def __get_cfn_param(self, key: str) -> Optional[str]:
+ """
+ Retrieves the key value from the stack parameters.
+
+ Returns:
+ -------
+ The value if found, otherwise None.
+ """
+ response = self.cloudformation_client.describe_stacks(
+ StackName=self.stack_name
+ )
+ stacks = response['Stacks']
+ for stack in stacks:
+ parameters = stack['Parameters']
+ for parameter in parameters:
+ if parameter['ParameterKey'] == key:
+ parameter_value = parameter['ParameterValue']
+ return parameter_value
+
+ def __get_stack_description(self) -> Optional[str]:
+ """
+ Retrieves the stack description.
+
+ Returns:
+ -------
+ The stack description.
+ """
+ response = self.cloudformation_client.describe_stacks(
+ StackName=self.stack_name
+ )
+ return response['Stacks'][0]['Description']
+
+ def __get_resource_name_from_logical_id(self, logical_id: str) -> Optional[str]:
+ """
+ Retrieves the resource name from the stack resources.
+
+ Returns:
+ -------
+ The resource name if found.
+ """
+ response = self.cloudformation_client.describe_stack_resource(
+ StackName=self.stack_name,
+ LogicalResourceId=logical_id
+ )
+ return response['StackResourceDetail']['PhysicalResourceId']
+
+ def __get_stack_outputs(self, key: str) -> Optional[str]:
+ """
+ Retrieves the stack outputs using the key provided.
+
+ Returns:
+ -------
+ The stack outputs.
+ """
+ describe_stack = self.cloudformation_client.describe_stacks(StackName=self.stack_name)
+ stack_outputs = describe_stack['Stacks'][0]['Outputs']
+ for output in stack_outputs:
+ if output['OutputKey'] == key:
+ return output['OutputValue']
+
+ def get_kendra_index(self) -> Optional[str]:
+ """
+ Retrieves the Kendra Index from the stack parameters.
+
+ Returns:
+ -------
+ The Kendra Index if found, otherwise None.
+ """
+
+ kendra_index_id = self.__get_cfn_param('DefaultKendraIndexId')
+ return kendra_index_id
+
+ def get_designer_client_id(self) -> Optional[str]:
+ """
+ Retrieves the Designer Client ID from the stack resources.
+
+ Returns:
+ -------
+ The Designer Client ID if found, otherwise None.
+ """
+ response = self.cloudformation_client.list_stack_resources(
+ StackName=self.stack_name
+ )
+ for StackResourceSummary in response['StackResourceSummaries']:
+ if StackResourceSummary['LogicalResourceId'] == 'ClientDesigner':
+ designer_client_id = StackResourceSummary['PhysicalResourceId']
+ return designer_client_id
+
+ def get_designer_url(self) -> Optional[str]:
+ """
+ Retrieves the Content Designer URL from the stack outputs.
+
+ Returns:
+ -------
+ The Content Designer URL if found, otherwise None.
+ """
+ return self.__get_stack_outputs('ContentDesignerURL')
+
+ def get_client_url(self) -> Optional[str]:
+ """
+ Retrieves the Client URL from the stack outputs.
+
+ Returns:
+ -------
+ The Client URL if found, otherwise None.
+ """
+ return self.__get_stack_outputs('ClientURL')
+
+ def kendra_is_enabled(self) -> bool:
+ """
+ Identifies if the Kendra is configured for the deployment.
+
+ Returns:
+ -------
+ True if the kendra index is set.
+ """
+ kendra_index_id = self.get_kendra_index()
+ return kendra_index_id != None and kendra_index_id != ''
+
+ def llm_is_enabled(self) -> bool:
+ """
+ Identifies if an LLM is deployed.
+
+ Returns:
+ -------
+ True if an LLM is configured.
+ """
+ llm_api_param = self.__get_cfn_param('LLMApi')
+ return llm_api_param != None and llm_api_param != 'DISABLED'
+
+ def embeddings_is_enabled(self) -> bool:
+ """
+ Identifies if embeddings is deployed.
+
+ Returns:
+ -------
+ True if embeddings is configured.
+ """
+ embeddings_api_param = self.__get_cfn_param('EmbeddingsApi')
+ return embeddings_api_param != None and embeddings_api_param != 'DISABLED'
+
+ def get_fulfillment_lambda_name(self) -> str:
+ """
+ Retrieves the name of the fulfillment lambda.
+
+ Returns:
+ -------
+ The name of the fulfillment lambda.
+ """
+ return self.__get_resource_name_from_logical_id('FulfillmentLambda')
+
+ def get_deployment_version(self) -> str:
+ """
+ Retrieves the deployment version from the stack description.
+
+ Returns:
+ -------
+ The deployment version.
+ """
+ description = self.__get_stack_description()
+ version = re.search(r'Version v\s*([\d.]+)', description).group(1)
+ return version
+
+ def get_lambda_hook_example_arn(self) -> str:
+ """
+ Retrieves the ARN of the lambda hook example.
+
+ Returns:
+ -------
+ The ARN of the lambda hook example.
+ """
+ examples_stack_name = self.__get_resource_name_from_logical_id('ExamplesStack')
+ examples_stack_param_fetcher = ParameterFetcher(self.region, examples_stack_name)
+ return examples_stack_param_fetcher.__get_stack_outputs('EXTCustomJSHook')
+
diff --git a/.nightswatch/functional/helpers/cloud_watch_client.py b/.nightswatch/functional/helpers/cloud_watch_client.py
new file mode 100644
index 000000000..86877f3f8
--- /dev/null
+++ b/.nightswatch/functional/helpers/cloud_watch_client.py
@@ -0,0 +1,72 @@
+######################################################################################################################
+# Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. #
+# #
+# Licensed under the Apache License, Version 2.0 (the "License"). You may not use this file except in compliance #
+# with the License. A copy of the License is located at #
+# #
+# http://www.apache.org/licenses/LICENSE-2.0 #
+# #
+# or in the 'license' file accompanying this file. This file is distributed on an 'AS IS' BASIS, WITHOUT WARRANTIES #
+# OR CONDITIONS OF ANY KIND, express or implied. See the License for the specific language governing permissions #
+# and limitations under the License. #
+######################################################################################################################
+
+# A client that connects with AWS CloudWatch and returns logs from log groups from a specific time period
+import boto3
+import time
+import datetime
+
+class CloudWatchClient:
+ """
+ Interacts with CloudWatch using Boto3.
+ This class provides methods for pulling logs from log groups based on matches.
+ """
+ def __init__(self, region: str, fulfillment_lambda_name: str):
+ """
+ Initializes the CloudWatchClient.
+ :param region: The AWS region to connect to.
+ :type region: st
+ """
+ self.client = boto3.client('logs', region_name=region)
+ self.region = region
+ self.fulfillment_lambda_log_group = f'/aws/lambda/{fulfillment_lambda_name}'
+ self.start_time = int(time.time() * 1000)
+
+ def __get_logs(self, log_group_name: str, start_time: int, filter_pattern: str) -> dict:
+ """
+ Gets logs from a log group.
+ :param log_group_name: The name of the log group.
+ :param start_time: The start time of the logs.
+ :param end_time: The end time of the logs.
+ :return: The logs.
+ :rtype: dict
+ """
+
+ response = self.client.filter_log_events(
+ logGroupName=log_group_name,
+ startTime=start_time,
+ filterPattern=filter_pattern,
+ limit=20
+ )
+ return response
+
+ def print_logs(self, log_group_name: str, filter_pattern: str='') -> dict:
+ """
+ Prints logs from a given log group from the time of the start of the test to current.
+ :param log_group_name: Log group name.
+ :param filter_pattern: CloudWatch filter pattern.
+ """
+ # Wait for CloudWatch logs to be available
+ time.sleep(10)
+
+ print(f'----- Printing log group {log_group_name} from: {datetime.datetime.utcfromtimestamp(self.start_time/1000).strftime("%c")} to: {datetime.datetime.utcfromtimestamp(time.time()).strftime("%c")} -----')
+ response = self.__get_logs(log_group_name, self.start_time, filter_pattern)
+ for event in response['events']:
+ print(event['message'])
+
+ def print_fulfillment_lambda_logs(self, filter_pattern: str='?TypeError ?InvokeError ?"Invoke Error"') -> dict:
+ """
+ Prints logs from the fulfillment lambda function.
+ :param filter_pattern: CloudWatch filter pattern.
+ """
+ self.print_logs(self.fulfillment_lambda_log_group, filter_pattern)
\ No newline at end of file
diff --git a/.nightswatch/functional/helpers/cognito_client.py b/.nightswatch/functional/helpers/cognito_client.py
new file mode 100644
index 000000000..ecdb980ae
--- /dev/null
+++ b/.nightswatch/functional/helpers/cognito_client.py
@@ -0,0 +1,142 @@
+######################################################################################################################
+# Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. #
+# #
+# Licensed under the Apache License, Version 2.0 (the "License"). You may not use this file except in compliance #
+# with the License. A copy of the License is located at #
+# #
+# http://www.apache.org/licenses/LICENSE-2.0 #
+# #
+# or in the 'license' file accompanying this file. This file is distributed on an 'AS IS' BASIS, WITHOUT WARRANTIES #
+# OR CONDITIONS OF ANY KIND, express or implied. See the License for the specific language governing permissions #
+# and limitations under the License. #
+######################################################################################################################
+
+import boto3
+from botocore.exceptions import ClientError
+import time
+
+class CognitoClient:
+ """
+ A Python class to interact with AWS Cognito using Boto3.
+ This class provides various methods to manage users in a Cognito User Pool.
+ """
+
+ def __init__(self, region: str, user_pool_id: str, client_id: str) -> None:
+ """
+ Constructs all the necessary attributes for the CognitoClient object.
+
+ Parameters:
+ ----------
+ region : str
+ AWS region name where the Cognito User Pool exists.
+ user_pool_id : str
+ The ID of the Cognito User Pool.
+ client_id : str
+ The ID of the Cognito User Pool Client.
+ """
+ self.user_pool_id = user_pool_id
+ self.client_id = client_id
+ self.cognito_idp_client = boto3.client('cognito-idp', region_name=region)
+
+ def create_admin_user(self, username: str, temporary_password: str, email: str) -> None:
+ """
+ Creates a new admin user if the user doesn't already exist in the Cognito User Pool.
+
+ Parameters:
+ ----------
+ username : str
+ The username for the new user.
+ temporary_password : str
+ The temporary password for the new user.
+ email : str
+ The email of the new user.
+ """
+
+ self.cognito_idp_client.admin_create_user(
+ UserPoolId=self.user_pool_id,
+ Username=username,
+ UserAttributes=[
+ {
+ 'Name': 'email',
+ 'Value': email
+ },
+ {
+ 'Name': 'email_verified',
+ 'Value': 'True'
+ }
+ ],
+ TemporaryPassword=temporary_password,
+ ForceAliasCreation=True
+ )
+ self.cognito_idp_client.admin_add_user_to_group(
+ UserPoolId=self.user_pool_id,
+ Username=username,
+ GroupName='Admins'
+ )
+
+ def delete_admin_user(self, username: str) -> None:
+ """
+ Deletes an admin user from the Cognito User Pool.
+
+ Parameters:
+ ----------
+ username : str
+ The username of the user to delete.
+ """
+
+ self.cognito_idp_client.admin_delete_user(
+ UserPoolId=self.user_pool_id,
+ Username=username
+ )
+
+ def create_admin_and_set_password(self, username: str, temporary_password: str, new_password: str, email: str) -> int:
+ """
+ Creates a new admin user with the given username if the user doesn't already exist.
+
+ Parameters:
+ ----------
+ username : str
+ The username of the user.
+ temporary_password : str
+ The temporary password of the user.
+ new_password : str
+ The new password for the user.
+ email : str
+ The email of the user.
+
+ Returns:
+ -------
+ The HTTP status code of the response to the AdminRespondToAuthChallenge request.
+ """
+
+ try:
+ self.create_admin_user(username, temporary_password, email)
+ response_admin_initiate_auth = self.cognito_idp_client.admin_initiate_auth(
+ UserPoolId=self.user_pool_id,
+ ClientId=self.client_id,
+ AuthFlow='ADMIN_NO_SRP_AUTH',
+ AuthParameters={
+ 'USERNAME': username,
+ 'PASSWORD': temporary_password
+ }
+ )
+ session = response_admin_initiate_auth['Session']
+ response_admin_respond_to_auth_challenge = self.cognito_idp_client.admin_respond_to_auth_challenge(
+ UserPoolId=self.user_pool_id,
+ ClientId=self.client_id,
+ ChallengeName='NEW_PASSWORD_REQUIRED',
+ ChallengeResponses={
+ 'USERNAME': username,
+ 'NEW_PASSWORD': new_password
+ },
+ Session=session
+ )
+ return response_admin_respond_to_auth_challenge['ResponseMetadata']['HTTPStatusCode']
+ except ClientError as e:
+ if e.response['Error']['Code'] == 'UsernameExistsException':
+ print('User already exists')
+ self.delete_admin_user(username)
+ time.sleep(5) # Wait for 5 seconds before trying to create the user again.
+ return self.create_admin_and_set_password(username, temporary_password, new_password, email)
+ else:
+ raise e
diff --git a/.nightswatch/functional/helpers/iam_client.py b/.nightswatch/functional/helpers/iam_client.py
new file mode 100644
index 000000000..ea46d8acf
--- /dev/null
+++ b/.nightswatch/functional/helpers/iam_client.py
@@ -0,0 +1,123 @@
+######################################################################################################################
+# Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. #
+# #
+# Licensed under the Apache License, Version 2.0 (the "License"). You may not use this file except in compliance #
+# with the License. A copy of the License is located at #
+# #
+# http://www.apache.org/licenses/LICENSE-2.0 #
+# #
+# or in the 'license' file accompanying this file. This file is distributed on an 'AS IS' BASIS, WITHOUT WARRANTIES #
+# OR CONDITIONS OF ANY KIND, express or implied. See the License for the specific language governing permissions #
+# and limitations under the License. #
+######################################################################################################################
+
+import boto3
+import json
+
+class IamClient:
+ """
+ A Python class to interact with Amazon IAM using Boto3.
+ This class provides various methods to perform operations on IAM.
+ """
+
+ def __init__(self, region: str) -> None:
+ """
+ Initializes the IamClient class.
+
+ Args:
+ region (str): The AWS region to connect to.
+ Returns:
+ None.
+ Raises:
+ None.
+ """
+
+ self.iam_client = boto3.client('iam', region_name=region)
+
+ def create_role(self, role_name: str, trust_relationship: dict) -> dict:
+ """
+ Creates an IAM role.
+
+ Args:
+ role_name (str): The name of the role to create.
+ trust_relationship (dict): The trust relationship for the role.
+ Returns:
+ dict: The response from the create_role API call.
+ Raises:
+ None.
+ """
+
+ return self.iam_client.create_role(
+ RoleName=role_name,
+ AssumeRolePolicyDocument=trust_relationship
+ )
+
+ def attach_policy(self, policy_arn: str, role_name: str) -> dict:
+ """
+ Attaches an IAM policy to a role.
+
+ Args:
+ policy_arn (str): The ARN of the policy to attach.
+ role_name (str): The name of the role to attach the policy to.
+ Returns:
+ dict: The response from the attach_policy API call.
+ Raises:
+ None.
+ """
+
+ return self.iam_client.attach_role_policy(
+ PolicyArn=policy_arn,
+ RoleName=role_name
+ )
+
+ def create_lexv2_role(self, bot_name) -> str:
+ """
+ Creates an Amazon Lex V2 role.
+
+ Args:
+ bot_name: The name of the bot.
+ Returns:
+ str: The name of the role.
+ Raises:
+ None.
+ """
+ role_name = f'lex_bot_role_{bot_name}'
+ trust_relationship = {
+ "Version": "2012-10-17",
+ "Statement": [
+ {
+ "Effect": "Allow",
+ "Principal": {
+ "Service": "lexv2.amazonaws.com"
+ },
+ "Action": "sts:AssumeRole"
+ }
+ ]
+ }
+ # policy_arn = 'arn:aws:iam::aws:policy/aws-service-role/AmazonLexV2BotPolicy'
+
+ self.delete_role_if_exists(role_name)
+
+ response = self.create_role(role_name, json.dumps(trust_relationship))
+ role_arn = response['Role']['Arn']
+
+ # self.attach_policy(policy_arn, role_name)
+
+ return role_arn
+
+ def delete_role_if_exists(self, role_name: str) -> None:
+ """
+ Deletes an IAM role if it exists.
+
+ Args:
+ role_name (str): The name of the role to delete.
+ Returns:
+ None.
+ Raises:
+ None.
+ """
+
+ try:
+ self.iam_client.delete_role(RoleName=role_name)
+ except:
+ pass
\ No newline at end of file
diff --git a/.nightswatch/functional/helpers/kendra_client.py b/.nightswatch/functional/helpers/kendra_client.py
new file mode 100644
index 000000000..06b4d0127
--- /dev/null
+++ b/.nightswatch/functional/helpers/kendra_client.py
@@ -0,0 +1,79 @@
+######################################################################################################################
+# Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. #
+# #
+# Licensed under the Apache License, Version 2.0 (the "License"). You may not use this file except in compliance #
+# with the License. A copy of the License is located at #
+# #
+# http://www.apache.org/licenses/LICENSE-2.0 #
+# #
+# or in the 'license' file accompanying this file. This file is distributed on an 'AS IS' BASIS, WITHOUT WARRANTIES #
+# OR CONDITIONS OF ANY KIND, express or implied. See the License for the specific language governing permissions #
+# and limitations under the License. #
+######################################################################################################################
+
+import boto3
+
+class KendraClient:
+ """
+ A Python class to interact with Amazon Kendra using Boto3.
+ This class provides various methods to perform operations on Kendra.
+ """
+
+ def __init__(self, region: str, index: str) -> None:
+ """
+ Constructs all the necessary attributes for the KendraClient object.
+
+ Parameters:
+ ----------
+ region : str
+ AWS region name where the Kendra instance exists.
+ index : str
+ The index ID of Amazon Kendra.
+ """
+ self.index = index
+ self.kendra_client = boto3.client('kendra', region_name=region)
+
+ def list_faqs(self) -> dict:
+ """
+ Lists all FAQs for the given Amazon Kendra index.
+
+ Returns:
+ -------
+ A dict containing the response from the ListFaqs operation.
+ """
+ return self.kendra_client.list_faqs(IndexId=self.index)
+
+ def delete_faq_by_id(self, id: str) -> dict:
+ """
+ Deletes a specific FAQ based on its ID.
+
+ Parameters:
+ ----------
+ id : str
+ The ID of the FAQ to delete.
+
+ Returns:
+ -------
+ A dict containing the response from the DeleteFaq operation.
+ """
+ return self.kendra_client.delete_faq(Id=id, IndexId=self.index)
+
+ def list_data_sources(self) -> dict:
+ """
+ Lists all data sources for the given Amazon Kendra index.
+
+ Returns:
+ -------
+ A dict containing the response from the ListDataSources operation.
+ """
+ return self.kendra_client.list_data_sources(IndexId=self.index)
+
+ def query(self, query: str) -> dict:
+ """
+ Searches an index given an input query.
+
+ Returns:
+ -------
+ A dict containing the response from the Query operation.
+ """
+ return self.kendra_client.query(IndexId=self.index, QueryText=query)
diff --git a/.nightswatch/functional/helpers/lex_client.py b/.nightswatch/functional/helpers/lex_client.py
new file mode 100644
index 000000000..fd7d2e64c
--- /dev/null
+++ b/.nightswatch/functional/helpers/lex_client.py
@@ -0,0 +1,343 @@
+######################################################################################################################
+# Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. #
+# #
+# Licensed under the Apache License, Version 2.0 (the "License"). You may not use this file except in compliance #
+# with the License. A copy of the License is located at #
+# #
+# http://www.apache.org/licenses/LICENSE-2.0 #
+# #
+# or in the 'license' file accompanying this file. This file is distributed on an 'AS IS' BASIS, WITHOUT WARRANTIES #
+# OR CONDITIONS OF ANY KIND, express or implied. See the License for the specific language governing permissions #
+# and limitations under the License. #
+######################################################################################################################
+
+import boto3
+import json
+import time
+
+from botocore.exceptions import ClientError
+
+class LexClient:
+ """
+ Class representing a Lex Bot Client.
+
+ This class provides methods to interact with an AWS Lex bot, such as retrieving its properties
+ and verifying the presence or absence of slot types.
+
+ Attributes:
+ lex_client (botocore.client.LexModelBuildingService): An instance of Boto3 Lex client.
+ """
+
+ def __init__(self, region: str) -> None:
+ """
+ Initializes the LexClient with a specific AWS region.
+
+ Args:
+ region (str): The AWS region to associate the Lex bot client.
+ """
+
+ self.lex_client = boto3.client('lexv2-models', region_name=region)
+
+ def __get_bot_id_version(self, bot_name) -> tuple:
+ """
+ Private method to get the bot ID and latest version for a specific bot.
+
+ Args:
+ bot_name (str): The name of the bot.
+
+ Returns:
+ tuple: The bot ID and latest version of the bot.
+ """
+
+ response = self.lex_client.list_bots(
+ filters=[
+ {
+ 'name': 'BotName',
+ 'values': [
+ bot_name,
+ ],
+ 'operator': 'EQ'
+ },
+ ],
+ )
+ while True:
+ for bot_summary in response['botSummaries']:
+ if bot_summary['botName'] == bot_name:
+ return bot_summary['botId'], bot_summary['latestBotVersion']
+
+ if 'nextToken' in response:
+ response = self.lex_client.list_bots(
+ nextToken=response['nextToken'],
+ filters=[
+ {
+ 'name': 'BotName',
+ 'values': [
+ bot_name,
+ ],
+ 'operator': 'EQ'
+ },
+ ],
+ )
+ else:
+ raise RuntimeError(f'Bot with name "{bot_name}" not found.')
+
+ def __list_slot_type_names(self, id: str, version: str, locale: str) -> list[str]:
+ """
+ Private method to list the slot type names for a specific bot.
+
+ Args:
+ id (str): The ID of the bot.
+ version (str): The version of the bot.
+ locale (str): The locale for the bot.
+
+ Returns:
+ list[str]: The list of slot type names.
+ """
+
+ response = self.lex_client.list_slot_types(
+ botId=id,
+ botVersion=version,
+ localeId=locale
+ )
+
+ return [slot_type['slotTypeName'] for slot_type in response['slotTypeSummaries']]
+
+ def bot_slot_type_names_exist_for_all_locales(self, bot_name: str, slot_type_names: list[str], locales: list[str]=['en_US']) -> bool:
+ """
+ Checks if the specified slot type names exist for all the locales of a specific bot.
+
+ Args:
+ bot_name (str): The name of the bot.
+ slot_type_names (list[str]): The list of slot type names to check.
+ locales (list[str], optional): The list of locales to check. Defaults to ['en_US'].
+
+ Returns:
+ bool: True if all the slot type names exist for all locales, False otherwise.
+ """
+
+ bot_id, version = self.__get_bot_id_version(bot_name)
+
+ return all([set(slot_type_names) <= set(self.__list_slot_type_names(bot_id, version, locale)) for locale in locales])
+
+ def bot_slot_type_names_do_not_exist_for_all_locales(self, bot_name: str, slot_type_names: list[str], locales: list[str]=['en_US']) -> bool:
+ """
+ Checks if the specified slot type names do not exist for all the locales of a specific bot.
+
+ Args:
+ bot_name (str): The name of the bot.
+ slot_type_names (list[str]): The list of slot type names to check.
+ locales (list[str], optional): The list of locales to check. Defaults to ['en_US'].
+
+ Returns:
+ bool: True if none of the slot type names exist for all locales, False otherwise.
+ """
+
+ bot_id, version = self.__get_bot_id_version(bot_name)
+
+ for locale in locales:
+ if any(slot in slot_type_names for slot in self.__list_slot_type_names(bot_id, version, locale)):
+ return False
+
+ return True
+
+ def create_test_bot(self, bot_name: str, role_arn: str, intent_files: list[str], locales: list[str]=['en_US']) -> str:
+ """
+ Creates a new bot with the specified slot type names for all the locales.
+
+ Args:
+ bot_name (str): The name of the bot.
+ role_arn (str): The ARN of the IAM role that Amazon Lex uses to access the bot.
+ locales (list[str], optional): The list of locales to create the bot for. Defaults to ['en_US'].
+ intent_files (list[str]): The list of intent files to create the bot for.
+
+ Raises:
+ ClientError: If the create bot request fails.
+
+ Returns:
+ str: The bot id.
+ """
+
+ bot_id = self.create_bot(bot_name, role_arn)
+ bot_version = 'DRAFT'
+
+ self.create_bot_locales(bot_id, bot_version, locales)
+
+ for intent_file in intent_files:
+ intent = json.loads(open(intent_file).read())
+ self.create_intent(bot_id, bot_version, intent=intent)
+
+ self.build_bot_locales(bot_id, bot_version, locales)
+
+
+ def create_bot(self, bot_name: str, role_arn: str) -> str:
+ """
+ Creates a new bot.
+
+ Args:
+ bot_name (str): The name of the bot.
+ role_arn (str): The ARN of the IAM role that Amazon Lex uses to access the bot.
+
+ Raises:
+ ClientError: If the create bot request fails.
+
+ Returns:
+ str: The bot id.
+ """
+
+ self.delete_bot_if_exists(bot_name)
+
+ try:
+ resp = self.lex_client.create_bot(
+ botName=bot_name,
+ description='Bot for testing bot routing functionality',
+ roleArn=role_arn,
+ dataPrivacy={
+ 'childDirected': False
+ },
+ idleSessionTTLInSeconds=300
+ )
+
+ self.lex_client.get_waiter('bot_available').wait(
+ botId=resp['botId']
+ )
+ return resp['botId']
+
+ except ClientError as e:
+ raise e
+
+ def create_bot_locales(self, bot_id: str, bot_version: str, locales: list[str]) -> None:
+ """
+ Creates the specified bot locales.
+
+ Args:
+ bot_id (str): The ID of the bot.
+ bot_version (str): The version of the bot.
+ locale (list[str]): The locales to create.
+ """
+
+ for locale in locales:
+ self.lex_client.create_bot_locale(
+ botId=bot_id,
+ botVersion=bot_version,
+ localeId=locale,
+ nluIntentConfidenceThreshold=0.5,
+ )
+
+ for locale in locales:
+ self.lex_client.get_waiter('bot_locale_created').wait(
+ botId=bot_id,
+ botVersion=bot_version,
+ localeId=locale
+ )
+
+ def build_bot_locales(self, bot_id: str, bot_version: str, locales: list[str]) -> None:
+ """
+ Builds the specified bot locale.
+
+ Args:
+ bot_id (str): The ID of the bot.
+ bot_version (str): The version of the bot.
+ locale (list[str]): The locales to build the bot for.
+ """
+
+ for locale in locales:
+ self.lex_client.build_bot_locale(
+ botId=bot_id,
+ botVersion=bot_version,
+ localeId=locale
+ )
+
+ for locale in locales:
+ self.lex_client.get_waiter('bot_locale_built').wait(
+ botId=bot_id,
+ botVersion=bot_version,
+ localeId=locale
+ )
+
+ def delete_bot_if_exists(self, bot_name: str) -> None:
+ """
+ Deletes a specific bot if it exists.
+
+ Args:
+ bot_name (str): The name of the bot.
+ """
+
+ bot_id = self.find_bot_id_from_bot_name(bot_name)
+
+ if bot_id:
+ try:
+ self.lex_client.delete_bot(botId=bot_id)
+ # wait for bot to delete
+ seconds_to_wait = 10
+ elapsed_time = 0
+ while self.find_bot_id_from_bot_name(bot_name) != '' and elapsed_time < seconds_to_wait:
+ elapsed_time += 1
+ if elapsed_time == seconds_to_wait:
+ raise RuntimeError('Bot did not delete in time')
+ else:
+ time.sleep(1)
+
+ except ClientError:
+ pass
+
+ def create_intent(self, bot_id: str, bot_version: str, intent: dict) -> str:
+ """
+ Creates a new intent.
+
+ Args:
+ bot_id (str): The ID of the bot.
+ intent_name (str): The name of the intent.
+ locale (str): The locale of the intent.
+ utterances (list[str]): The list of utterances for the intent.
+ intent (dict): The intent object.
+
+ """
+
+ intent['botId'] = bot_id
+ intent['botVersion'] = bot_version
+
+ self.lex_client.create_intent(**intent)
+
+
+ def find_bot_id_from_bot_name(self, bot_name: str) -> str:
+ """
+ Finds the bot id from the bot name.
+
+ Args:
+ bot_name (str): The name of the bot.
+
+ Returns:
+ str: The bot id.
+ """
+
+ bots = self.lex_client.list_bots(
+ filters=[
+ {
+ 'name': 'BotName',
+ 'values': [
+ bot_name,
+ ],
+ 'operator': 'EQ'
+ },
+ ],
+ # needs to be a large number to ensure all bots are returned - filter appears to filter the returned list not the full list
+ maxResults=200,
+ )['botSummaries']
+
+ try:
+ return bots[0]['botId']
+ except IndexError:
+ return ''
+
+ def check_bot_exists(self, bot_name: str) -> bool:
+ """
+ Checks if the specified bot exists.
+
+ Args:
+ bot_name (str): The name of the bot.
+
+ Returns:
+ bool: True if the bot exists, False otherwise.
+ """
+
+ return self.find_bot_id_from_bot_name(bot_name) != ''
\ No newline at end of file
diff --git a/.nightswatch/functional/helpers/translate_client.py b/.nightswatch/functional/helpers/translate_client.py
new file mode 100644
index 000000000..9c97763ea
--- /dev/null
+++ b/.nightswatch/functional/helpers/translate_client.py
@@ -0,0 +1,119 @@
+######################################################################################################################
+# Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. #
+# #
+# Licensed under the Apache License, Version 2.0 (the "License"). You may not use this file except in compliance #
+# with the License. A copy of the License is located at #
+# #
+# http://www.apache.org/licenses/LICENSE-2.0 #
+# #
+# or in the 'license' file accompanying this file. This file is distributed on an 'AS IS' BASIS, WITHOUT WARRANTIES #
+# OR CONDITIONS OF ANY KIND, express or implied. See the License for the specific language governing permissions #
+# and limitations under the License. #
+######################################################################################################################
+
+import boto3
+import string
+
+from botocore.exceptions import ClientError
+
+class TranslateClient:
+ """
+ TranslateClient is a wrapper around the AWS Translate API.
+ """
+ def __init__(self, region: str) -> None:
+ """
+ Initializes the TranslateClient.
+
+ :param region: The AWS region to use.
+ """
+
+ self.client = boto3.client('translate', region_name=region)
+
+ def __remove_non_ascii(self, a_str: str) -> str:
+ """
+ Removed non-printable characters from string. Needed since QnABot performs this function as well.
+
+ :param a_str: string to be cleaned
+ :return: cleaned string
+
+ """
+ ascii_chars = set('\xa0')
+
+ def replace_unprintable_chars_with_whitespace(char):
+ if char in ascii_chars:
+ return ' '
+ return char
+
+ return ''.join(
+ map(replace_unprintable_chars_with_whitespace, a_str)
+ )
+
+ def list_terminologies(self) -> list[str]:
+ """
+ Lists all the terminologies.
+
+ :return: A list of all the terminologies.
+ """
+ # will not return all terminologies for more than 100 results but this is more than enough for now
+ response = self.client.list_terminologies(
+ MaxResults=100
+ )
+
+ return [terminology['Name'] for terminology in response['TerminologyPropertiesList']]
+
+ def has_terminology(self, name: str) -> bool:
+ """
+ Returns turns true if the terminology exists.
+
+ :param name: The name of the terminology.
+ :return: True if the terminology exists.
+ """
+ terminologies = self.list_terminologies()
+ print(name)
+ print(terminologies)
+
+ return name in terminologies
+
+ def delete_terminology(self, name: str):
+ """
+ Deletes provided terminology
+
+ :param name: The terminology to delete.
+ :return: None.
+ """
+ self.client.delete_terminology(
+ Name=name
+ )
+
+ def delete_all_terminologies(self):
+ """
+ Deletes all the terminologies.
+
+ :return: None.
+ """
+
+ terminologies = self.list_terminologies()
+ for terminology in terminologies:
+ self.delete_terminology(terminology)
+
+ def translate(self, text: str, target_language: str) -> str:
+ """
+ :param text: The text to translate.
+ :param target_language: The target language.
+ :return: The translated text.
+ """
+
+ source_language = 'en'
+
+ terminologies = self.list_terminologies()
+
+ response = self.client.translate_text(
+ Text=text,
+ TerminologyNames=terminologies,
+ SourceLanguageCode=source_language,
+ TargetLanguageCode=target_language,
+ )
+
+ return self.__remove_non_ascii(response['TranslatedText'])
+
+
diff --git a/.nightswatch/functional/helpers/utils/__init__.py b/.nightswatch/functional/helpers/utils/__init__.py
new file mode 100644
index 000000000..d08267fc3
--- /dev/null
+++ b/.nightswatch/functional/helpers/utils/__init__.py
@@ -0,0 +1,12 @@
+######################################################################################################################
+# Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. #
+# #
+# Licensed under the Apache License, Version 2.0 (the "License"). You may not use this file except in compliance #
+# with the License. A copy of the License is located at #
+# #
+# http://www.apache.org/licenses/LICENSE-2.0 #
+# #
+# or in the 'license' file accompanying this file. This file is distributed on an 'AS IS' BASIS, WITHOUT WARRANTIES #
+# OR CONDITIONS OF ANY KIND, express or implied. See the License for the specific language governing permissions #
+# and limitations under the License. #
+######################################################################################################################
diff --git a/.nightswatch/functional/helpers/utils/textbox.py b/.nightswatch/functional/helpers/utils/textbox.py
new file mode 100644
index 000000000..bf52a0aff
--- /dev/null
+++ b/.nightswatch/functional/helpers/utils/textbox.py
@@ -0,0 +1,72 @@
+######################################################################################################################
+# Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. #
+# #
+# Licensed under the Apache License, Version 2.0 (the "License"). You may not use this file except in compliance #
+# with the License. A copy of the License is located at #
+# #
+# http://www.apache.org/licenses/LICENSE-2.0 #
+# #
+# or in the 'license' file accompanying this file. This file is distributed on an 'AS IS' BASIS, WITHOUT WARRANTIES #
+# OR CONDITIONS OF ANY KIND, express or implied. See the License for the specific language governing permissions #
+# and limitations under the License. #
+######################################################################################################################
+
+import time
+from selenium.webdriver.common.keys import Keys
+
+class Textbox:
+ """
+ Class representing a WebElement textbox.
+
+ This class provides methods to interact with a WebElement textbox, such as getting its value and setting a new value.
+
+ Attributes:
+ element (selenium.webdriver.remote.webelement.WebElement): The WebElement representing the textbox.
+ """
+
+ def __init__(self, element) -> None:
+ """
+ Initializes the Textbox with a specific WebElement.
+
+ Args:
+ element (selenium.webdriver.remote.webelement.WebElement): The WebElement representing the textbox.
+ """
+
+ self.element = element
+
+ def __send_keys(self, keys):
+ """
+ Private method to send specific keys to the textbox.
+
+ Args:
+ keys: The keys to send to the textbox.
+ """
+
+ self.element.send_keys(keys)
+
+ def get_value(self) -> str:
+ """
+ Gets the current value of the textbox.
+
+ Returns:
+ str: The current value of the textbox.
+ """
+
+ return self.element.get_attribute("value")
+
+ def set_value(self, value):
+ """
+ Sets a new value for the textbox.
+
+ This method first deletes the current value of the textbox, then types the new value.
+
+ Args:
+ value: The new value to set for the textbox.
+ """
+
+ current_value = self.get_value()
+
+ if current_value != value:
+ length = len(current_value)
+ self.__send_keys(length * Keys.BACKSPACE)
+ self.__send_keys(value)
\ No newline at end of file
diff --git a/.nightswatch/functional/helpers/website_model/__init__.py b/.nightswatch/functional/helpers/website_model/__init__.py
new file mode 100644
index 000000000..d08267fc3
--- /dev/null
+++ b/.nightswatch/functional/helpers/website_model/__init__.py
@@ -0,0 +1,12 @@
+######################################################################################################################
+# Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. #
+# #
+# Licensed under the Apache License, Version 2.0 (the "License"). You may not use this file except in compliance #
+# with the License. A copy of the License is located at #
+# #
+# http://www.apache.org/licenses/LICENSE-2.0 #
+# #
+# or in the 'license' file accompanying this file. This file is distributed on an 'AS IS' BASIS, WITHOUT WARRANTIES #
+# OR CONDITIONS OF ANY KIND, express or implied. See the License for the specific language governing permissions #
+# and limitations under the License. #
+######################################################################################################################
diff --git a/.nightswatch/functional/helpers/website_model/chat_page.py b/.nightswatch/functional/helpers/website_model/chat_page.py
new file mode 100644
index 000000000..f1e6985f4
--- /dev/null
+++ b/.nightswatch/functional/helpers/website_model/chat_page.py
@@ -0,0 +1,138 @@
+######################################################################################################################
+# Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. #
+# #
+# Licensed under the Apache License, Version 2.0 (the "License"). You may not use this file except in compliance #
+# with the License. A copy of the License is located at #
+# #
+# http://www.apache.org/licenses/LICENSE-2.0 #
+# #
+# or in the 'license' file accompanying this file. This file is distributed on an 'AS IS' BASIS, WITHOUT WARRANTIES #
+# OR CONDITIONS OF ANY KIND, express or implied. See the License for the specific language governing permissions #
+# and limitations under the License. #
+######################################################################################################################
+
+import time
+from selenium.webdriver.common.keys import Keys
+from helpers.website_model.dom_operator import DomOperator
+
+TEXT_INPUT_NAME = 'text-input'
+MESSAGE_LIST_CSS = '.message-list'
+PAGE_READINESS_ELEMENT_XPATH = '//div[@class="message-text"]'
+MENU_XPATH = '//button[@aria-label="menu options"]'
+LAST_MESSAGE_XPATH = '(//div[@class="message-bubble focusable message-bubble-row-bot"])[last()]'
+
+SIGNIN_XPATH = '//form//button'
+
+class ChatPage:
+ """
+ Class to represent a chat page on a website.
+
+ This class uses a DomOperator object to interact with the webpage.
+ It includes functionality to wait for the page to load, send and receive chat messages,
+ check for the presence of elements, and select a language locale from a menu.
+
+ :param operator: The DomOperator object to operate on the webpage.
+ """
+
+ def __init__(self, operator: DomOperator) -> None:
+ """
+ Initialize ChatPage with a DomOperator object and signs in as current user if bot is configured to be private.
+
+ :param operator: The DomOperator object to operate on the webpage.
+ """
+
+ self.operator = operator
+
+ if self.operator.get_title() != 'QnABot Client':
+ self.operator.select_xpath(SIGNIN_XPATH, wait=5, click=True)
+ self.__wait_to_load()
+
+ def __wait_to_load(self):
+ """
+ Private method to wait for the page to load by waiting for the presence of a specific element.
+ """
+
+ self.operator.wait_for_element_by_xpath(PAGE_READINESS_ELEMENT_XPATH)
+
+ def __wait_for_message_response(self, message):
+ """
+ Private method to wait for a response to the given message.
+
+ :param message: The message to wait for a response to.
+ """
+ self.operator.wait_for_element_by_xpath(f'(//div[normalize-space(text()) = "{message}"]/ancestor::div[contains(concat(" ", @class, " "), " message-human ")][1])[last()]/following-sibling::div[@class="v-row message message-bot"]', delay=30)
+ def select_text_input(self):
+ """
+ Select the text input element on the chat page.
+
+ :return: The selected text input element.
+ """
+
+ return self.operator.select_name(TEXT_INPUT_NAME)
+
+ def send_message(self, message):
+ """
+ Send a message through the text input on the chat page and wait for a response.
+
+ :param message: The message to send.
+ """
+
+ text_input = self.select_text_input()
+ text_input.send_keys(message)
+ text_input.send_keys(Keys.ENTER)
+ self.__wait_for_message_response(message)
+
+ def get_messages(self) -> str:
+ """
+ Get the text of all messages in the chat.
+
+ :return: The text of all messages in the chat.
+ """
+
+ full_page = self.operator.select_css(MESSAGE_LIST_CSS)
+ return full_page.text
+
+ def get_last_message_element(self):
+ """
+ Get the last message element in the chat.
+
+ :return: last message element.
+ """
+ return self.operator.select_xpath(LAST_MESSAGE_XPATH)
+
+ def get_last_message_text(self) -> str:
+ """
+ Get the last message text in the chat.
+
+ :return: the text content in the last message element.
+ """
+ return self.get_last_message_element().text
+
+ def has_element_with_xpath(self, xpath) -> str:
+ """
+ Check if an element with the given XPath exists in the chat page.
+
+ :param xpath: The XPath of the element.
+ :return: True if the element exists, False otherwise.
+ """
+
+ return self.operator.element_exists_by_xpath(xpath)
+
+ def select_locale(self, locale: str):
+ """
+ Select the given locale from the menu and wait for the locale info element to update.
+
+ :param locale: The locale to select.
+ """
+
+ self.operator.select_xpath(MENU_XPATH, click=True)
+ self.operator.wait_for_element_by_xpath(f'//div[@class="v-list-item-title" and normalize-space(text()) = "{locale}"]')
+ self.operator.select_xpath(f'//div[@class="v-list-item-title" and normalize-space(text()) = "{locale}"]', click=True)
+ self.operator.wait_for_element_by_xpath(f'//span[@class="localeInfo" and contains(text(), "{locale}")]')
+
+ def send_positive_feedback(self):
+ """
+ Send positive feedback through the chat page.
+ """
+ self.operator.select_xpath('//i[contains(@class, "feedback-icons-positive")]', click=True)
+ self.__wait_for_message_response('Thumbs up')
diff --git a/.nightswatch/functional/helpers/website_model/custom_terminology_page.py b/.nightswatch/functional/helpers/website_model/custom_terminology_page.py
new file mode 100644
index 000000000..ead9a5baa
--- /dev/null
+++ b/.nightswatch/functional/helpers/website_model/custom_terminology_page.py
@@ -0,0 +1,44 @@
+######################################################################################################################
+# Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. #
+# #
+# Licensed under the Apache License, Version 2.0 (the "License"). You may not use this file except in compliance #
+# with the License. A copy of the License is located at #
+# #
+# http://www.apache.org/licenses/LICENSE-2.0 #
+# #
+# or in the 'license' file accompanying this file. This file is distributed on an 'AS IS' BASIS, WITHOUT WARRANTIES #
+# OR CONDITIONS OF ANY KIND, express or implied. See the License for the specific language governing permissions #
+# and limitations under the License. #
+######################################################################################################################
+
+from helpers.website_model.dom_operator import DomOperator
+
+UPLOAD_FILE_ID = 'upload-file'
+
+class CustomTerminologyPage:
+ """
+ Class to represent a Custom Terminology page on a website.
+
+ This class uses a DomOperator object to interact with the webpage.
+ It includes functionality to upload a file which contains custom terminology that should not be translated into different languages.
+
+ :param operator: The DomOperator object to operate on the webpage.
+ """
+
+ def __init__(self, operator: DomOperator) -> None:
+ """
+ Initialize CustomTerminologyPage with a DomOperator object.
+
+ :param operator: The DomOperator object to operate on the webpage.
+ """
+
+ self.operator = operator
+
+ def upload_file(self, file):
+ """
+ Upload a file to the Custom Terminology page.
+
+ :param file: The path to the file to be uploaded.
+ """
+
+ self.operator.select_id(UPLOAD_FILE_ID).send_keys(file)
\ No newline at end of file
diff --git a/.nightswatch/functional/helpers/website_model/dom_operator.py b/.nightswatch/functional/helpers/website_model/dom_operator.py
new file mode 100644
index 000000000..4b56d24ad
--- /dev/null
+++ b/.nightswatch/functional/helpers/website_model/dom_operator.py
@@ -0,0 +1,304 @@
+######################################################################################################################
+# Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. #
+# #
+# Licensed under the Apache License, Version 2.0 (the "License"). You may not use this file except in compliance #
+# with the License. A copy of the License is located at #
+# #
+# http://www.apache.org/licenses/LICENSE-2.0 #
+# #
+# or in the 'license' file accompanying this file. This file is distributed on an 'AS IS' BASIS, WITHOUT WARRANTIES #
+# OR CONDITIONS OF ANY KIND, express or implied. See the License for the specific language governing permissions #
+# and limitations under the License. #
+######################################################################################################################
+
+import os
+
+from selenium import webdriver
+from selenium.webdriver.firefox.options import Options
+from selenium.webdriver.firefox.service import Service
+from selenium.webdriver.support.ui import WebDriverWait
+from selenium.webdriver.support import expected_conditions as EC
+from selenium.webdriver.common.by import By
+from selenium.common.exceptions import TimeoutException, NoSuchElementException
+
+DOWNLOAD_DIR = f'../../{os.path.realpath(os.path.dirname(__file__))}/files'
+HEADLESS_MODE_ENABLED = os.environ.get('HEADLESS_BROWSER') != 'false'
+
+class DomOperator():
+ """
+ A singleton class to interact with the Document Object Model (DOM) of a webpage using Selenium.
+
+ This class provides various methods for interacting with webpage elements,
+ including selecting, checking existence, waiting for elements, and manipulating browser behavior.
+ """
+
+ def __new__(cls):
+ """
+ Implement the singleton pattern. If an instance of this class exists, it's returned.
+ Otherwise, a new instance is created and returned.
+ """
+ if not hasattr(cls, 'instance'):
+ cls.instance = super(DomOperator, cls).__new__(cls)
+ return cls.instance
+
+ def __init__(self) -> None:
+ """
+ Initialize the class with a Selenium webdriver with specific download preferences.
+ """
+
+ options = Options()
+
+ if HEADLESS_MODE_ENABLED:
+ options.add_argument("-headless")
+
+ options.set_preference("browser.download.folderList", 2)
+ options.set_preference("browser.download.manager.showWhenStarting", False)
+ options.set_preference("browser.download.dir", DOWNLOAD_DIR)
+ options.set_preference("browser.helperApps.neverAsk.saveToDisk", "application/x-gzip")
+ options.set_preference("devtools.console.stdout.content", True)
+
+ service = Service(log_output="../test_browser_console.log")
+ self.driver = webdriver.Firefox(options=options, service=service)
+ self.driver.implicitly_wait(5)
+
+ def get_url(self, url):
+ """
+ Navigate the webdriver to the provided URL.
+
+ :param url: The URL to navigate to.
+ """
+
+ self.driver.get(url)
+
+ def get_current_url(self) -> str:
+ """
+ Get the current URL that the webdriver is on.
+
+ :return: The current URL as a string.
+ """
+
+ return self.driver.current_url
+
+ def set_window_size(self, x, y):
+ """
+ Set the size of the browser window.
+
+ :param x: The width of the window.
+ :param y: The height of the window.
+ """
+
+ self.driver.set_window_size(x, y)
+
+ def get_title(self) -> str:
+ """
+ Get the title of the current webpage.
+
+ :return: The title of the current webpage as a string.
+ """
+
+ return self.driver.title
+
+ def refresh_browser(self) -> None:
+ """
+ Refresh the current webpage.
+ """
+ self.driver.refresh()
+
+ def element_exists_by_id(self, id, wait:int=5) -> bool:
+ """
+ Check if an element with the given ID exists in the webpage.
+
+ :param id: The ID of the element.
+ :return: True if the element exists, False otherwise.
+ """
+
+ try:
+ WebDriverWait(self.driver, wait).until(EC.presence_of_element_located((By.ID, id)))
+ return True
+ except TimeoutException:
+ print(f"Timeout exeception waiting for element with id {id} to appear")
+ return False
+ except NoSuchElementException:
+ return False
+
+ def element_exists_by_xpath(self, xpath, wait:int=3) -> bool:
+ """
+ Check if an element with the given XPath exists in the webpage.
+
+ :param xpath: The XPath of the element.
+ :return: True if the element exists, False otherwise.
+ """
+
+ try:
+ WebDriverWait(self.driver, wait).until(EC.presence_of_element_located((By.XPATH, xpath)))
+ return True
+ except TimeoutException:
+ print(f"Timeout exeception waiting for element with xpath {xpath} to appear")
+ return False
+ except NoSuchElementException:
+ return False
+
+ def select_id(self, id: str, wait:int=3, click:bool=False):
+ """
+ Select the element with the given ID and optionally click on it.
+
+ :param id: The ID of the element.
+ :param wait: The time in seconds to implicitly wait for the element.
+ :param click: Whether to click the element or not.
+ :return: The selected element.
+ """
+ try:
+ element = self.driver.find_element(By.ID, id)
+ if click:
+ WebDriverWait(self.driver, wait).until(EC.element_to_be_clickable((By.ID, id)))
+ self.driver.execute_script("arguments[0].click();", element)
+ return element
+ except NoSuchElementException as e:
+ raise RuntimeError(f'Element with ID {id} not found.')
+
+ def click_element_by_id(self, id: str, wait:int=0):
+ """
+ Click on the element with the given ID
+
+ :param id: The ID of the element.
+ :param wait: The time in seconds to implicitly wait for the element.
+ """
+ try:
+ element = self.driver.find_element(By.ID, id)
+ self.driver.execute_script("arguments[0].click();", element)
+ except NoSuchElementException as e:
+ raise RuntimeError(f'Element with ID {id} not found.')
+
+ def select_css(self, css_selector: str, wait:int=0, click:bool=False):
+ """
+ Select the element with the given CSS selector and optionally click on it.
+
+ :param css_selector: The CSS selector of the element.
+ :param wait: The time to implicitly wait for the element.
+ :param click: Whether to click the element or not.
+ :return: The selected element.
+ """
+
+ try:
+ element = self.driver.find_element(By.CSS_SELECTOR, (css_selector))
+ if click:
+ element.click()
+ return element
+ except NoSuchElementException as e:
+ raise RuntimeError(f'Element with CSS selector {css_selector} not found.')
+
+ def select_name(self, name: str, wait:int=0, click:bool=False):
+ """
+ Select the element with the given name and optionally click on it.
+
+ :param name: The name of the element.
+ :param wait: The time to implicitly wait for the element.
+ :param click: Whether to click the element or not.
+ :return: The selected element.
+ """
+ try:
+ element = self.driver.find_element(By.NAME, (name))
+ if click:
+ self.driver.execute_script("arguments[0].click();", element)
+ return element
+ except NoSuchElementException as e:
+ raise RuntimeError(f'Element with name {name} not found.')
+
+ def select_xpath(self, xpath: str, wait:int=0, click:bool=False):
+ """
+ Select the element with the given XPath and optionally click on it.
+
+ :param xpath: The XPath of the element.
+ :param wait: The time to implicitly wait for the element.
+ :param click: Whether to click the element or not.
+ :return: The selected element.
+ """
+ try:
+ element = self.driver.find_element(By.XPATH, xpath)
+ if click:
+ self.driver.execute_script("arguments[0].click();", element)
+ return element
+ except NoSuchElementException as e:
+ raise RuntimeError(f'Element with XPath {xpath} not found.')
+
+ def wait_for_element_by_id(self, id: str, delay: int = 10):
+ """
+ Wait for the element with the given ID to be present in the webpage.
+
+ :param id: The ID of the element.
+ :param delay: The maximum time in seconds to wait for the element.
+ :return: The element if it appears within the wait time, None otherwise.
+ """
+
+ try:
+ return WebDriverWait(self.driver, delay).until(EC.presence_of_element_located((By.ID, id)))
+ except TimeoutException:
+ print(f'TimeoutException: element id: "{id}" waited {delay}s to load.')
+
+ def wait_for_element_by_xpath(self, xpath: str, delay: int = 10):
+ """
+ Wait for the element with the given XPath to be present in the webpage.
+
+ :param xpath: The XPath of the element.
+ :param delay: The maximum time to wait for the element.
+ :return: The element if it appears within the wait time, None otherwise.
+ """
+
+ try:
+ return WebDriverWait(self.driver, delay).until(EC.presence_of_element_located((By.XPATH, xpath)))
+ except TimeoutException:
+ print(f'TimeoutException: element xpath: "{xpath}" waited {delay}s to load.')
+
+ def wait_for_element_by_id_text(self, id: str, text: str, delay: int = 10):
+ """
+ Wait for the element with the given ID and text to be present in the webpage.
+
+ :param id: The ID of the element.
+ :param text: The text expected to be present in the element.
+ :param delay: The maximum time to wait for the element.
+ :return: True if the element with the expected text appears within the wait time, False otherwise.
+ """
+
+ try:
+ return WebDriverWait(self.driver, delay).until(EC.text_to_be_present_in_element((By.ID, id), text))
+ except TimeoutException:
+ print(f'TimeoutException: element id "{id}" with text: "{text}" waited {delay}s to load.')
+
+ def wait_for_element_by_xpath_text(self, xpath: str, text: str, delay: int = 10):
+ """
+ Wait for the element with the given xpath and text to be present in the webpage.
+
+ :param xpath: The xpath of the element.
+ :param text: The text expected to be present in the element.
+ :param delay: The maximum time to wait for the element.
+ :return: True if the element with the expected text appears within the wait time, False otherwise.
+ """
+
+ try:
+ return WebDriverWait(self.driver, delay).until(EC.text_to_be_present_in_element((By.XPATH, xpath), text))
+ except TimeoutException:
+ print(f'TimeoutException: element id "{xpath}" with text: "{text}" waited {delay}s to load.')
+
+ def switch_windows(self):
+ """
+ Switch to the next window handle if there is more than one window handle present.
+ """
+
+ if len(self.driver.window_handles) == 1:
+ return
+
+ original_window = self.driver.current_window_handle
+
+ for window_handle in self.driver.window_handles:
+ if window_handle != original_window:
+ self.driver.switch_to.window(window_handle)
+ break
+
+ def end_session(self):
+ """
+ End the webdriver session.
+
+ :return: The result of the webdriver's quit function.
+ """
+
+ return self.driver.quit()
\ No newline at end of file
diff --git a/.nightswatch/functional/helpers/website_model/edit_page.py b/.nightswatch/functional/helpers/website_model/edit_page.py
new file mode 100644
index 000000000..5f6b75406
--- /dev/null
+++ b/.nightswatch/functional/helpers/website_model/edit_page.py
@@ -0,0 +1,821 @@
+######################################################################################################################
+# Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. #
+# #
+# Licensed under the Apache License, Version 2.0 (the "License"). You may not use this file except in compliance #
+# with the License. A copy of the License is located at #
+# #
+# http://www.apache.org/licenses/LICENSE-2.0 #
+# #
+# or in the 'license' file accompanying this file. This file is distributed on an 'AS IS' BASIS, WITHOUT WARRANTIES #
+# OR CONDITIONS OF ANY KIND, express or implied. See the License for the specific language governing permissions #
+# and limitations under the License. #
+######################################################################################################################
+
+
+import time
+import logging
+
+from helpers.website_model.dom_operator import DomOperator
+from helpers.utils.textbox import Textbox
+
+MODAL_XPATH = '//div[@id="add-question-form"]'
+EDIT_MODAL_XPATH = '//div[@class="dialog dialog--active"]'
+
+SUB_MENU_ID = 'edit-sub-menu'
+
+ADD_QUESTION_ID = 'add-question-btn'
+REFRESH_BUTTON_XPATH = '//button//span[contains(string(), "Refresh")]'
+
+ITEM_ID = 'qid'
+QUESTION_ID = 'qna-q'
+ANSWER_ID = 'qna-a'
+DESCRIPTION_ID = 'slottype-descr'
+MARKDOWN_ANSWER_ID = 'qna-alt-markdown'
+QNA_RADIO_XPATH = '//input[@type="radio" and @value="qna"]'
+QUESTION_ADVANCED_MENU_XPATH = '//button[@class="v-expansion-panel-title" and contains(string(), "Advanced")]'
+QUESTION_TOPIC_ID = 't'
+QUESTION_CARD_TITLE_ID = 'r-title'
+QUESTION_CARD_SUBTITLE_ID = 'r-subTitle'
+QUESTION_CARD_URL_ID = 'r-imageUrl'
+QUESTION_CARD_ADD_LEX_BUTTON_ID = 'qna.r.buttons-add'
+ADD_SA_BUTTON_ID = 'qna.sa-add'
+LAMBDA_HOOK_ID = 'l'
+LAMBDA_HOOK_ARGS_ID_QNA = 'qna-args'
+LAMBDA_HOOK_ARGS_ID = 'args'
+CLIENT_FILTER_ID = 'clientFilterValues'
+REF_MARKDOWN_ID = 'text-refMarkdown'
+TAGS_ID = 'tags'
+RP_ID = 'rp'
+NEXT_ID = 'next'
+BOT_ROUTING_ID = 'qna-botRouting-specialty_bot'
+BOT_ROUTING_NAME_ID = 'qna-botRouting-specialty_bot_name'
+BOT_ROUTING_ATTRIBUTE_ID = 'qna-botRouting-specialty_bot_session_attributes_to_merge'
+KENDRA_REDIRECT_QUERY_ID = 'kendraRedirectQueryText'
+KENDRA_REDIRECT_QUERY_ARGS_ID = 'kendraRedirectQueryArgs'
+KENDRA_CONFIDENCE_ID = 'kendraRedirectQueryConfidenceThreshold'
+CHAINING_ID = 'conditionalChaining'
+RESPONSE_HOOK_ID = 'qna-elicitResponse-responsebot_hook'
+RESPONSE_ATTRIBUTE_ID = 'qna-elicitResponse-response_sessionattr_namespace'
+QUESTION_SUBMIT_ID = 'add-question-submit'
+QUESTION_CANCEL_ID = 'add-question-cancel'
+ADD_UTTERANCE_ID = 'qna.q-add'
+ADD_QUESTION_SUCCESS_ID = 'add-success'
+ADD_QUESTION_CLOSE_ID = 'add-close'
+
+# EDIT_QUESTION_SUBMIT_ID = 'edit-submit'
+# Cannot use ID due to hidden modals with buttons using same ID
+EDIT_QUESTION_SUBMIT_XPATH = EDIT_MODAL_XPATH + '//button[@id="edit-submit"]'
+EDIT_QUESTION_SUBMIT_ID = "edit-submit"
+EDIT_QUESTION_ADVANCED_MENU_XPATH = '//button[@class="v-expansion-panel-title" and contains(string(), "Advanced")]'
+EDIT_QUESTION_CANCEL_ID = 'edit-cancel'
+EDIT_QUESTION_SUCCESS_ID = 'edit-success'
+EDIT_QUESTION_CLOSE_ID = 'edit-close'
+
+QUIZ_RADIO_XPATH = '//input[@type="radio" and @value="quiz"]'
+QUIZ_QUESTION_ID = 'quiz-question'
+QUIZ_CORRECT_ANSWER_ID = 'quiz-correctAnswers'
+QUIZ_INCORRECT_ANSWER_ID = 'quiz-incorrectAnswers'
+QUIZ_ADD_CORRECT_ANSWER_BUTTON_ID = 'quiz.correctAnswers-add'
+QUIZ_ADD_INCORRECT_ANSWER_BUTTON_ID = 'quiz.incorrectAnswers-add'
+
+SLOT_RADIO_XPATH = '//input[@type="radio" and @value="slottype"]'
+SLOT_TYPE_DESCRIPTION_XPATH = MODAL_XPATH + '//input[@id="slottype-descr"]'
+SLOT_TYPE_RESTRICT_VALUES_XPATH = MODAL_XPATH + '//div[@data-path="slottype.resolutionStrategyRestrict"]//input'
+SLOT_TYPE_ADD_BUTTON_XPATH = MODAL_XPATH + '//button[@id="slottype.slotTypeValues-add"]'
+SLOT_DEDICATED_BOT_CHECKBOX_XPATH = MODAL_XPATH + '//div[@data-path="qna.enableQidIntent"]//input'
+SLOT_ADD_SLOT_BUTTON_ID = MODAL_XPATH + '//button[@id="qna.slots-add"]'
+
+TEXT_RADIO_XPATH = '//input[@type="radio" and @value="text"]'
+PASSAGE_ID = 'text-passage'
+
+# CONFIRM_DELETE_ID = 'confirm-delete'
+# Cannot use ID due to hidden delete buttons sharing same id
+CONFIRM_DELETE_XPATH = '//div[@class="v-card-actions"]//button[@id="confirm-delete"]'
+CONFIRM_DELETE_CLOSE_XPATH = '//div[@class="v-card-actions"]//button//span[contains(string(), "close")]'
+CONFIRM_DELETE_SUCCESS_ID = 'delete-success'
+
+REBUILD_LEX_ID = 'lex-rebuild'
+REBUILD_LEX_LOADING_ID = 'lex-loading'
+REBUILD_LEX_SUCCESS_ID = 'lex-success'
+REBUILD_LEX_CLOSE_ID = 'lex-close'
+
+SYNC_KENDRA_FAQ_ID = 'kendra-sync'
+SYNC_KENDRA_STATUS_ID = 'kendra-syncing'
+SYNC_KENDRA_SUCCESS_ID = 'success'
+SYNC_KENDRA_CLOSE_ID = 'kendra-close'
+
+TEST_TAB_XPATH = '//button[@id="test-tab"]'
+TEST_ALL_TAB_XPATH = '//button[@id="testAll-tab"]'
+TEST_TAB_QUERY_ID = 'query'
+TEST_TAB_QUERY_BUTTON_ID = 'query-test'
+TEST_ALL_BUTTON_ID = 'testAll'
+TEST_ALL_JOBS_ID = 'test-jobs'
+
+# arbitrary element to wait for; one of the last elements to always load
+# need to wait for table to fully load, otherwise auth error thrown on page exit
+# if there are no elements in the table then we wait for the timeout, which will throw a warning
+PAGE_READINESS_ELEMENT_XPATH = '//table//td//div'
+
+class EditPage:
+ """
+ A class representing the administrative page that is used for managing questions and answers for Q&A Bot.
+
+ This page provides functionality to create, update, and delete 3 types of questions: QnA, SlotType, and Quiz.
+ Additionally, it has a submenu that triggers the AWS Lex chatbot to rebuild with any updates to the questions
+ and provides functionality to sync AWS Kendra FAQ based on the questions.
+
+ Attributes
+ ----------
+ operator: DomOperator
+ A DomOperator object used to interact with the web page.
+ """
+
+ def __init__(self, operator: DomOperator) -> None:
+ """
+ Initializes EditPage with the provided DomOperator object.
+
+ Parameters
+ ----------
+ operator : DomOperator
+ A DomOperator object used to interact with the web page.
+ """
+
+ self.operator = operator
+ self.__wait_to_load()
+
+ def __wait_to_load(self):
+ """
+ A private method to wait for a page to load. Waits for a specific element
+ identified by its ID.
+ """
+
+ self.operator.wait_for_element_by_xpath(PAGE_READINESS_ELEMENT_XPATH)
+ time.sleep(1)
+
+ def refresh_questions(self):
+ """
+ Refreshes the question table.
+ """
+ time.sleep(3)
+ self.operator.wait_for_element_by_xpath(REFRESH_BUTTON_XPATH)
+ self.operator.select_xpath(REFRESH_BUTTON_XPATH, click=True)
+ time.sleep(3)
+
+ def add_question(self, qid: str, type: str, q: list[str]=[], a: str='', descr: str='', _id: str='', l: str='', args: str='', elicitResponse: dict={}, slots: list[dict]=[], r: dict={}, t: str='', question: str='', questions: list[str]=[], correctAnswers: list[str]=[], incorrectAnswers: list[str]=[], slotTypeValues: list[dict]=[], resolutionStrategyRestrict: bool=False, enableQidIntent: bool=False, kendraRedirectQueryText: str='', kendraRedirectQueryConfidenceThreshold: str='', conditionalChaining: str='', sa: list[dict]=[], botRouting: dict={}, passage: str='', alt: dict={}):
+ """
+ Adds a new question to the bot. The method behavior depends on the type
+ of question being added ('quiz', 'slottype', or "qna"). After adding the question,
+ it waits for confirmation of the successful addition.
+ """
+
+ self.operator.click_element_by_id(ADD_QUESTION_ID, wait=10)
+ self.operator.wait_for_element_by_xpath(QUESTION_ADVANCED_MENU_XPATH)
+ self.operator.select_xpath(QUESTION_ADVANCED_MENU_XPATH, click=True)
+
+ if type == 'quiz':
+ self.__add_quiz_question(question, correctAnswers, incorrectAnswers)
+ elif type == 'slottype':
+ self.__add_slot_question(descr, slotTypeValues, resolutionStrategyRestrict)
+ elif type == 'text':
+ self.__add_text_question(passage)
+ else:
+ self.__add_qna_question(q, a, l, args, slots, r, t, elicitResponse, kendraRedirectQueryText, kendraRedirectQueryConfidenceThreshold, conditionalChaining, sa, botRouting, alt)
+
+ qid_textbox = Textbox(self.operator.select_id(f"{type}-{ITEM_ID}"))
+ qid_textbox.set_value(qid)
+
+ self.operator.click_element_by_id(QUESTION_SUBMIT_ID)
+ self.operator.wait_for_element_by_id(ADD_QUESTION_SUCCESS_ID, delay=30)
+ self.operator.select_id(ADD_QUESTION_CLOSE_ID, wait= 5, click=True)
+ self.operator.wait_for_element_by_id(ADD_QUESTION_ID)
+
+ def __add_qna_lambda_hook(self, l, l_args):
+ """
+ A private method that sets a lambda hook for a QnA question. This lambda hook
+ can be used to provide dynamic responses.
+ """
+
+ l_textbox = Textbox(self.operator.select_id(f"qna-{LAMBDA_HOOK_ID}"))
+ l_textbox.set_value(l)
+
+ la_textbox = Textbox(self.operator.select_id(f"{LAMBDA_HOOK_ARGS_ID_QNA}-0"))
+ la_textbox.set_value(l_args)
+
+ def __add_qna_slot(self, index, slot):
+ """
+ A private method that adds a slot to a QnA question. Slots can be used to capture
+ and utilize user inputs within the conversation.
+ """
+
+ if 'slotRequired' in slot:
+ self.operator.select_xpath(f'{MODAL_XPATH}//div[@data-path="qna.slots[{index}].slotRequired"]//input', click=slot['slotRequired'])
+ if 'slotCached' in slot:
+ self.operator.select_xpath(f'{MODAL_XPATH}//div[@data-path="qna.slots[{index}].slotValueCached"]//i', click=slot['slotCached'])
+
+ name_textbox = Textbox(self.operator.select_xpath(f'{MODAL_XPATH}//div[@data-path="qna.slots[{index}].slotName"]//input'))
+ name_textbox.set_value(slot['slotName'])
+
+ type_textbox = Textbox(self.operator.select_xpath(f'{MODAL_XPATH}//div[@data-path="qna.slots[{index}].slotType"]//input'))
+ type_textbox.set_value(slot['slotType'])
+
+ prompt_textbox = Textbox(self.operator.select_xpath(f'{MODAL_XPATH}//div[@data-path="qna.slots[{index}].slotPrompt"]//input'))
+ prompt_textbox.set_value(slot['slotPrompt'])
+
+ if 'slotSampleUtterances' in slot:
+ utterances_textbox = Textbox(self.operator.select_xpath(f'{MODAL_XPATH}//div[@data-path="qna.slots[{index}].slotSampleUtterances"]//input'))
+ utterances_textbox.set_value(slot['slotSampleUtterances'])
+
+ def __add_qna_card(self, r, type: str='qna'):
+ """
+ A private method that sets up a response card for a QnA question. Response cards
+ provide additional visual elements for the bot.
+ """
+
+ title_textbox = Textbox(self.operator.select_id(f'{type}-{QUESTION_CARD_TITLE_ID}'))
+ title_textbox.set_value(r['title'])
+
+ subtitle_textbox = Textbox(self.operator.select_id(f'{type}-{QUESTION_CARD_SUBTITLE_ID}'))
+ subtitle_textbox.set_value(r['subTitle'])
+
+ url_textbox = Textbox(self.operator.select_id(f'{type}-{QUESTION_CARD_URL_ID}'))
+ url_textbox.set_value(r['imageUrl'])
+
+ for index, button in enumerate(r['buttons']):
+ if index > 0:
+ self.operator.select_id(QUESTION_CARD_ADD_LEX_BUTTON_ID, click=True)
+
+ button_display_textbox = Textbox(self.operator.select_xpath(f'//div[@data-path="qna.r.buttons[{index}].text"]//input'))
+ button_display_textbox.set_value(button['text'])
+
+ button_value_textbox = Textbox(self.operator.select_xpath(f'//div[@data-path="qna.r.buttons[{index}].value"]//input'))
+ button_value_textbox.set_value(button['value'])
+
+ def __add_qna_question(self, q: list[str], a: str, l: str='', args: str='', slots: list[dict]=[], r: dict={}, t: str='', elicitResponse: dict={}, kendraRedirectQueryText: str='', kendraRedirectQueryConfidenceThreshold: str='', conditionalChaining: str='', sa: list[dict]=[], botRouting: dict={}, alt: dict={}, mode: str= 'add'):
+ """
+ A private method that adds a QnA question to the bot.
+ """
+ type = "qna"
+ if mode == 'add':
+ self.operator.select_xpath(QNA_RADIO_XPATH, click=True)
+
+ for index, utterance in enumerate(q):
+ utterance_id = f'{QUESTION_ID}-{index}'
+
+ # if not self.operator.element_exists_by_id(utterance_id):
+ if index > 0:
+ self.operator.click_element_by_id(ADD_UTTERANCE_ID)
+
+ utterance_textbox = Textbox(self.operator.select_id(utterance_id))
+ utterance_textbox.set_value(utterance)
+
+ a_textbox = Textbox(self.operator.select_id(ANSWER_ID))
+ a_textbox.set_value(a)
+
+ if alt:
+ alt_textbox = Textbox(self.operator.select_id(MARKDOWN_ANSWER_ID))
+ alt_textbox.set_value(alt['markdown'])
+
+ if l:
+ self.__add_qna_lambda_hook(l, args)
+
+ if slots:
+ self.operator.select_xpath(SLOT_DEDICATED_BOT_CHECKBOX_XPATH, click=True)
+
+ for index, slot in enumerate(slots):
+ if index > 0:
+ self.operator.select_xpath(SLOT_ADD_SLOT_BUTTON_ID, click=True)
+ self.__add_qna_slot(index, slot)
+
+ if r:
+ self.__add_qna_card(r, 'qna')
+
+ if t:
+ topic_textbox = Textbox(self.operator.select_id(f'{type}-{QUESTION_TOPIC_ID}'))
+ topic_textbox.set_value(t)
+
+ if elicitResponse:
+ hook_textbox = Textbox(self.operator.select_id(RESPONSE_HOOK_ID))
+ hook_textbox.set_value(elicitResponse['responsebot_hook'])
+
+ hook_textbox = Textbox(self.operator.select_id(RESPONSE_ATTRIBUTE_ID))
+ hook_textbox.set_value(elicitResponse['response_sessionattr_namespace'])
+
+ if kendraRedirectQueryText:
+ kendra_query_textbox = Textbox(self.operator.select_id(f'{type}-{KENDRA_REDIRECT_QUERY_ID}'))
+ kendra_query_textbox.set_value(kendraRedirectQueryText)
+
+ kendra_confidence_textbox = Textbox(self.operator.select_id(f'{type}-{KENDRA_CONFIDENCE_ID}'))
+ kendra_confidence_textbox.set_value(kendraRedirectQueryConfidenceThreshold)
+
+ if conditionalChaining:
+ chaining_textbox = Textbox(self.operator.select_id(f'{type}-{CHAINING_ID}'))
+ chaining_textbox.set_value(conditionalChaining)
+
+ for index, attribute in enumerate(sa):
+ if index > 0:
+ self.operator.select_xpath(ADD_SA_BUTTON_ID, click=True)
+ sa_name_textbox = Textbox(self.operator.select_xpath(f'//div[@data-path="{type}.sa[{index}].text"]//input'))
+ sa_name_textbox.set_value(attribute['text'])
+
+ sa_value_textbox = Textbox(self.operator.select_xpath(f'//div[@data-path="{type}.sa[{index}].value"]//textarea'))
+ sa_value_textbox.set_value(attribute['value'])
+
+ if botRouting:
+ for key, value in botRouting.items():
+ if self.operator.element_exists_by_id(f"{type}-botRouting-{key}"):
+ bot_routing_textbox = Textbox(self.operator.select_id(f"{type}-botRouting-{key}"))
+ bot_routing_textbox.set_value(value)
+
+ def __add_quiz_question(self, question: str, correctAnswers: list[str]=[], incorrectAnswers: list[str]=[]):
+ """
+ A private method that adds a quiz question to the bot. Quiz questions provide multiple
+ answers and the bot verifies if the user's answer is correct.
+ """
+
+ self.operator.select_xpath(QUIZ_RADIO_XPATH, click=True)
+
+ q_textbox = Textbox(self.operator.select_id(QUIZ_QUESTION_ID))
+ q_textbox.set_value(question)
+
+ for index, answer in enumerate(correctAnswers):
+ answer_id = QUIZ_CORRECT_ANSWER_ID
+ answer_id = answer_id + f'-{index}'
+ self.operator.select_id(QUIZ_ADD_CORRECT_ANSWER_BUTTON_ID, wait=5, click=True)
+
+ a_textbox = Textbox(self.operator.select_id(answer_id))
+ a_textbox.set_value(answer)
+
+ for index, answer in enumerate(incorrectAnswers):
+ answer_id = QUIZ_INCORRECT_ANSWER_ID
+ answer_id = answer_id + f'-{index}'
+ self.operator.select_id(QUIZ_ADD_INCORRECT_ANSWER_BUTTON_ID, click=True)
+
+ a_textbox = Textbox(self.operator.select_id(answer_id))
+ a_textbox.set_value(answer)
+
+ def __add_slot_question(self, descr: str='', slotTypeValues: list[dict]=[], resolutionStrategyRestrict: bool=False):
+ """
+ A private method that adds a slot type question to the bot. Slot type questions can
+ capture user inputs as slot values for use in the conversation.
+ """
+
+ self.operator.select_xpath(SLOT_RADIO_XPATH, click=True)
+
+ description_textbox = Textbox(self.operator.select_xpath(SLOT_TYPE_DESCRIPTION_XPATH))
+ description_textbox.set_value(descr)
+
+ self.operator.select_xpath(SLOT_TYPE_RESTRICT_VALUES_XPATH, click=resolutionStrategyRestrict)
+
+ for index, values in enumerate(slotTypeValues):
+ if index > 0:
+ self.operator.select_xpath(SLOT_TYPE_ADD_BUTTON_XPATH, click=True)
+
+ value_textbox = Textbox(self.operator.select_xpath(f'//div[@data-path="slottype.slotTypeValues[{index}].samplevalue"]//input', wait=5))
+ value_textbox.set_value(values['samplevalue'])
+ synonym_textbox = Textbox(self.operator.select_xpath(f'//div[@data-path="slottype.slotTypeValues[{index}].synonyms"]//input', wait=5))
+ synonym_textbox.set_value(values['synonyms'])
+
+ def __add_text_question(self, passage):
+ """
+ A private method that adds a text type question to the bot. Text type questions capture passages used for LLM inference.
+ """
+
+ self.operator.select_xpath(TEXT_RADIO_XPATH, click=True)
+
+ passage_textbox = Textbox(self.operator.select_id(PASSAGE_ID))
+ passage_textbox.set_value(passage)
+
+ def check_question_exists_by_qid(self, qid: str) -> bool:
+ """
+ Checks if a question exists by its QID. Returns True if the question exists,
+ False otherwise.
+ """
+
+ return self.operator.element_exists_by_id(f'qa-{qid}')
+
+ def edit_question_by_qid(self, qid: str, type: str, q: list[str]=[], a: str='', descr: str='', _id: str='', l: str='', args: str='', elicitResponse: dict={}, slots: list[dict]=[], r: dict={}, t: str='', question: str='', questions: list[str]=[], correctAnswers: list[str]=[], incorrectAnswers: list[str]=[], slotTypeValues: list[dict]=[], resolutionStrategyRestrict: bool=False, enableQidIntent: bool=False, kendraRedirectQueryText: str='', kendraRedirectQueryConfidenceThreshold: str='', conditionalChaining: str='', passage: str='', alt: dict={}):
+ """
+ Edits an existing question identified by its QID. The type of edit performed
+ depends on the type of question ('quiz', 'slottype', or "qna").
+ """
+ logging.info(f"Editing question : {qid}, type : {type}")
+ self.operator.select_xpath(f'//span[@id="qa-{qid}-edit"]//descendant::button', wait=1, click=True)
+ self.operator.wait_for_element_by_xpath(EDIT_QUESTION_ADVANCED_MENU_XPATH)
+ self.operator.select_xpath(EDIT_QUESTION_ADVANCED_MENU_XPATH, click=True)
+
+ if type == 'quiz':
+ self.__add_quiz_question(question, correctAnswers, incorrectAnswers)
+ elif type == 'slottype':
+ self.__add_slot_question(descr, slotTypeValues, resolutionStrategyRestrict)
+ elif type == 'text':
+ self.__add_text_question(passage)
+ else:
+ self.__add_qna_question(q, a, l, args, slots, r, t, elicitResponse, kendraRedirectQueryText, kendraRedirectQueryConfidenceThreshold, conditionalChaining, alt, mode='edit')
+
+ self.operator.wait_for_element_by_id(EDIT_QUESTION_SUBMIT_ID)
+ self.operator.click_element_by_id(EDIT_QUESTION_SUBMIT_ID)
+ self.operator.wait_for_element_by_id(EDIT_QUESTION_SUCCESS_ID)
+ self.operator.click_element_by_id(EDIT_QUESTION_CLOSE_ID, wait= 10)
+ self.operator.wait_for_element_by_id(ADD_QUESTION_ID)
+
+
+ def match_question_field_values(
+ self,
+ qid: str,
+ type: str='',
+ q: list[str]=[],
+ a: str='',
+ descr: str='',
+ _id: str='',
+ l: str='',
+ args: str='',
+ elicitResponse: dict={},
+ slots: list[dict]=[],
+ r: dict={},
+ t: str='',
+ slotTypeValues: list[dict]=[],
+ resolutionStrategyRestrict: bool=False,
+ enableQidIntent: bool=False,
+ kendraRedirectQueryText: str='',
+ kendraRedirectQueryConfidenceThreshold: str='',
+ conditionalChaining: str='',
+ sa: list[dict]=[],
+ botRouting: dict={},
+ passage: str='',
+ alt: dict={},
+ clientFilterValues: str='',
+ refMarkdown: str='',
+ tags: str='',
+ kendraRedirectQueryArgs: list[str]=[],
+ rp: str='',
+ next: str='',
+ ) -> bool:
+
+ self.operator.wait_for_element_by_xpath(f'//span[@id="qa-{qid}-edit"]//descendant::button')
+ self.operator.select_xpath(f'//span[@id="qa-{qid}-edit"]//descendant::button', click=True)
+ self.operator.wait_for_element_by_xpath(EDIT_QUESTION_ADVANCED_MENU_XPATH)
+ self.operator.select_xpath(EDIT_QUESTION_ADVANCED_MENU_XPATH, click=True)
+
+ for index, utterance in enumerate(q):
+ utterance_id = f'{QUESTION_ID}-{index}'
+
+ utterance_textbox = Textbox(self.operator.select_id(utterance_id))
+ if utterance_textbox.get_value() != utterance:
+ print(f'Value "{utterance_textbox.get_value()}" does not match expected field value "{utterance=}"')
+ self.operator.select_id(EDIT_QUESTION_CANCEL_ID, click=True)
+ return False
+
+ if a:
+ a_textbox = Textbox(self.operator.select_id(ANSWER_ID))
+ if a_textbox.get_value() != a:
+ print(f'Value "{a_textbox.get_value()}" does not match expected field value "{a=}"')
+ self.operator.select_id(EDIT_QUESTION_CANCEL_ID, click=True)
+ return False
+
+ if alt:
+ alt_textbox = Textbox(self.operator.select_id(MARKDOWN_ANSWER_ID))
+ if alt_textbox.get_value() != alt['markdown']:
+ print(f'Value "{alt_textbox.get_value()}" does not match expected field value "{alt=}"')
+ self.operator.select_id(EDIT_QUESTION_CANCEL_ID, click=True)
+ return False
+
+ if descr:
+ descr_textbox = Textbox(self.operator.select_id(DESCRIPTION_ID))
+ if descr_textbox.get_value() != descr:
+ print(f'Value "{descr_textbox.get_value()}" does not match expected field value "{descr=}"')
+ self.operator.select_id(EDIT_QUESTION_CANCEL_ID, click=True)
+ return False
+
+ if l:
+ l_textbox = Textbox(self.operator.select_id(f"{type}-{LAMBDA_HOOK_ID}"))
+ if l_textbox.get_value() != l:
+ print(f'Value "{l_textbox.get_value()}" does not match expected field value "{l=}"')
+ self.operator.select_id(EDIT_QUESTION_CANCEL_ID, click=True)
+ return False
+
+ for index, l_arg in enumerate(args):
+ l_arg_id = f'{type}-{LAMBDA_HOOK_ARGS_ID}-{index}'
+ arg_textbox = Textbox(self.operator.select_id(l_arg_id))
+
+ if arg_textbox.get_value() != l_arg:
+ print(f'Value "{arg_textbox.get_value()}" does not match expected field value "{l_arg=}"')
+ self.operator.select_id(EDIT_QUESTION_CANCEL_ID, click=True)
+ return False
+
+ if r:
+ title_textbox = Textbox(self.operator.select_id(f'{type}-{QUESTION_CARD_TITLE_ID}'))
+ if title_textbox.get_value() != r['title']:
+ print(f'Value "{title_textbox.get_value()}" does not match expected field value "{r=}"')
+ self.operator.select_id(EDIT_QUESTION_CANCEL_ID, click=True)
+ return False
+
+ if 'subTitle' in r:
+ subtitle_textbox = Textbox(self.operator.select_id(f'{type}-{QUESTION_CARD_SUBTITLE_ID}'))
+ if subtitle_textbox.get_value() != r['subTitle']:
+ print(f'Value "{subtitle_textbox.get_value()}" does not match expected field value "{r=}"')
+ self.operator.select_id(EDIT_QUESTION_CANCEL_ID, click=True)
+ return False
+
+ url_textbox = Textbox(self.operator.select_id(f'{type}-{QUESTION_CARD_URL_ID}'))
+ if url_textbox.get_value() != r['imageUrl']:
+ print(f'Value "{url_textbox.get_value()}" does not match expected field value "{r=}"')
+ self.operator.select_id(EDIT_QUESTION_CANCEL_ID, click=True)
+ return False
+
+ for index, button in enumerate(r['buttons']):
+
+ button_display_textbox = Textbox(self.operator.select_xpath(f'//div[@data-path="{type}.r.buttons[{index}].text"]//input[@id="{type}-r-buttons-{index}-text"]'))
+ if button_display_textbox.get_value() != button['text']:
+ print(f'Value "{button_display_textbox.get_value()}" does not match expected field value "{button=}"')
+ self.operator.select_id(EDIT_QUESTION_CANCEL_ID, click=True)
+ return False
+
+ button_value_textbox = Textbox(self.operator.select_xpath(f'//div[@data-path="{type}.r.buttons[{index}].value"]//input[@id="{type}-r-buttons-{index}-value"]'))
+ if button_value_textbox.get_value() != button['value']:
+ print(f'Value "{button_value_textbox.get_value()}" does not match expected field value "{button=}"')
+ self.operator.select_id(EDIT_QUESTION_CANCEL_ID, click=True)
+ return False
+
+ if t:
+ topic_textbox = Textbox(self.operator.select_id(f'{type}-{QUESTION_TOPIC_ID}'))
+ if topic_textbox.get_value() != t:
+ print(f'Value "{topic_textbox.get_value()}" does not match expected field value "{t=}"')
+ self.operator.select_id(EDIT_QUESTION_CANCEL_ID, click=True)
+ return False
+
+ if elicitResponse:
+ hook_textbox = Textbox(self.operator.select_id(RESPONSE_HOOK_ID))
+ if hook_textbox.get_value() != elicitResponse['responsebot_hook']:
+ print(f'Value "{hook_textbox.get_value()}" does not match expected field value "{elicitResponse=}"')
+ self.operator.select_id(EDIT_QUESTION_CANCEL_ID, click=True)
+ return False
+
+ if 'response_sessionattr_name' in elicitResponse:
+ hook_textbox = Textbox(self.operator.select_id(RESPONSE_ATTRIBUTE_ID))
+ if hook_textbox.get_value() != elicitResponse['response_sessionattr_name']:
+ print(f'Value "{hook_textbox.get_value()}" does not match expected field value "{elicitResponse=}"')
+ self.operator.select_id(EDIT_QUESTION_CANCEL_ID, click=True)
+ return False
+
+ if kendraRedirectQueryText:
+ kendra_query_textbox = Textbox(self.operator.select_id(f'{type}-{KENDRA_REDIRECT_QUERY_ID}'))
+ if kendra_query_textbox.get_value() != kendraRedirectQueryText:
+ print(f'Value "{kendra_query_textbox.get_value()}" does not match expected field value "{kendraRedirectQueryText=}"')
+ self.operator.select_id(EDIT_QUESTION_CANCEL_ID, click=True)
+ return False
+
+ kendra_confidence_textbox = Textbox(self.operator.select_id(f'{type}-{KENDRA_CONFIDENCE_ID}'))
+ if kendra_confidence_textbox.get_value() != kendraRedirectQueryConfidenceThreshold:
+ print(f'Value "{kendra_confidence_textbox.get_value()}" does not match expected field value "{kendraRedirectQueryConfidenceThreshold=}"')
+ self.operator.select_id(EDIT_QUESTION_CANCEL_ID, click=True)
+ return False
+
+ if conditionalChaining:
+ chaining_textbox = Textbox(self.operator.select_id(f'{type}-{CHAINING_ID}'))
+ if chaining_textbox.get_value() != conditionalChaining:
+ print(f'Value "{chaining_textbox.get_value()}" does not match expected field value "{conditionalChaining=}"')
+ self.operator.select_id(EDIT_QUESTION_CANCEL_ID, click=True)
+ return False
+
+ for index, attribute in enumerate(sa):
+ sa_name_textbox = Textbox(self.operator.select_xpath(f'//div[@data-path="{type}.sa[{index}].text"]//input'))
+ if sa_name_textbox.get_value() != attribute['text']:
+ print(f'Value "{sa_name_textbox.get_value()}" does not match expected field value "{attribute=}"')
+ self.operator.select_id(EDIT_QUESTION_CANCEL_ID, click=True)
+ return False
+
+ sa_value_textbox = Textbox(self.operator.select_xpath(f'//div[@data-path="{type}.sa[{index}].value"]//textarea'))
+ if sa_value_textbox.get_value() != attribute['value']:
+ print(f'Value "{sa_value_textbox.get_value()}" does not match expected field value "{attribute=}"')
+ self.operator.select_id(EDIT_QUESTION_CANCEL_ID, click=True)
+ return False
+
+ if botRouting:
+ bot_routing_textbox = Textbox(self.operator.select_id(BOT_ROUTING_ID))
+ if bot_routing_textbox.get_value() != botRouting['specialty_bot']:
+ print(f'Value "{bot_routing_textbox.get_value()}" does not match expected field value "{botRouting=}"')
+ self.operator.select_id(EDIT_QUESTION_CANCEL_ID, click=True)
+ return False
+
+ bot_routing_textbox = Textbox(self.operator.select_id(BOT_ROUTING_NAME_ID))
+ if bot_routing_textbox.get_value() != botRouting['specialty_bot_name']:
+ print(f'Value "{bot_routing_textbox.get_value()}" does not match expected field value "{botRouting=}"')
+ self.operator.select_id(EDIT_QUESTION_CANCEL_ID, click=True)
+ return False
+
+ bot_routing_textbox = Textbox(self.operator.select_id(BOT_ROUTING_ATTRIBUTE_ID))
+ if bot_routing_textbox.get_value() != botRouting['specialty_bot_session_attributes_to_merge']:
+ print(f'Value "{bot_routing_textbox.get_value()}" does not match expected field value "{botRouting=}"')
+ self.operator.select_id(EDIT_QUESTION_CANCEL_ID, click=True)
+ return False
+
+ if slots:
+ for index, slot in enumerate(slots):
+ name_textbox = Textbox(self.operator.select_xpath(f'//div[@data-path="qna.slots[{index}].slotName"]//input'))
+ if name_textbox.get_value() != slot['slotName']:
+ print(f'Value "{name_textbox.get_value()}" does not match expected field value "{slot=}"')
+ self.operator.select_id(EDIT_QUESTION_CANCEL_ID, click=True)
+ return False
+
+ type_textbox = Textbox(self.operator.select_xpath(f'//div[@data-path="qna.slots[{index}].slotType"]//input'))
+ if type_textbox.get_value() != slot['slotType']:
+ print(f'Value "{type_textbox.get_value()}" does not match expected field value "{slot=}"')
+ self.operator.select_id(EDIT_QUESTION_CANCEL_ID, click=True)
+ return False
+
+ prompt_textbox = Textbox(self.operator.select_xpath(f'//div[@data-path="qna.slots[{index}].slotPrompt"]//input'))
+ if prompt_textbox.get_value() != slot['slotPrompt']:
+ print(f'Value "{prompt_textbox.get_value()}" does not match expected field value "{slot=}"')
+ self.operator.select_id(EDIT_QUESTION_CANCEL_ID, click=True)
+ return False
+
+ if 'slotSampleUtterances' in slot:
+ utterances_textbox = Textbox(self.operator.select_xpath(f'//div[@data-path="qna.slots[{index}].slotSampleUtterances"]//input'))
+ if utterances_textbox.get_value() != slot['slotSampleUtterances']:
+ print(f'Value "{utterances_textbox.get_value()}" does not match expected field value "{slot=}"')
+ self.operator.select_id(EDIT_QUESTION_CANCEL_ID, click=True)
+ return False
+
+ if slotTypeValues:
+ for index, values in enumerate(slotTypeValues):
+ sample_value_textbox = Textbox(self.operator.select_xpath(f'//div[@data-path="slottype.slotTypeValues[{index}].samplevalue"]//input'))
+ if sample_value_textbox.get_value() != values['samplevalue']:
+ print(f'Value "{sample_value_textbox.get_value()}" does not match expected field value "{values=}"')
+ self.operator.select_id(EDIT_QUESTION_CANCEL_ID, click=True)
+ return False
+ synonym_textbox = Textbox(self.operator.select_xpath(f'//div[@data-path="slottype.slotTypeValues[{index}].synonyms"]//input'))
+ if synonym_textbox.get_value() != values['synonyms']:
+ print(f'Value "{synonym_textbox.get_value()}" does not match expected field value "{values=}"')
+ self.operator.select_id(EDIT_QUESTION_CANCEL_ID, click=True)
+ return False
+
+ if passage:
+ passage_textbox = Textbox(self.operator.select_id(PASSAGE_ID))
+ if passage_textbox.get_value() != passage:
+ print(f'Value "{passage_textbox.get_value()}" does not match expected field value "{passage=}"')
+ self.operator.select_id(EDIT_QUESTION_CANCEL_ID, click=True)
+ return False
+
+ if clientFilterValues:
+ client_filter_textbox = Textbox(self.operator.select_id(f'{type}-{CLIENT_FILTER_ID}'))
+ if client_filter_textbox.get_value() != clientFilterValues:
+ print(f'Value "{client_filter_textbox.get_value()}" does not match expected field value "{clientFilterValues=}"')
+ self.operator.select_id(EDIT_QUESTION_CANCEL_ID, click=True)
+ return False
+
+ if refMarkdown:
+ ref_markdown_textbox = Textbox(self.operator.select_id(REF_MARKDOWN_ID))
+ if ref_markdown_textbox.get_value() != refMarkdown:
+ print(f'Value "{ref_markdown_textbox.get_value()}" does not match expected field value "{refMarkdown=}"')
+ self.operator.select_id(EDIT_QUESTION_CANCEL_ID, click=True)
+ return False
+
+ if tags:
+ tags_textbox = Textbox(self.operator.select_id(f'{type}-{TAGS_ID}'))
+ if tags_textbox.get_value() != tags:
+ print(f'Value "{tags_textbox.get_value()}" does not match expected field value "{tags=}"')
+ self.operator.select_id(EDIT_QUESTION_CANCEL_ID, click=True)
+ return False
+
+ if kendraRedirectQueryArgs:
+ for index, query_arg in enumerate(kendraRedirectQueryArgs):
+ query_arg_id = f'{type}-{KENDRA_REDIRECT_QUERY_ARGS_ID}-{index}'
+ query_arg_textbox = Textbox(self.operator.select_id(query_arg_id))
+
+ if query_arg_textbox.get_value() != query_arg:
+ print(f'Value "{query_arg_textbox.get_value()}" does not match expected field value "{query_arg=}"')
+ self.operator.select_id(EDIT_QUESTION_CANCEL_ID, click=True)
+ return False
+
+ if rp:
+ rp_textbox = Textbox(self.operator.select_id(f'{type}-{RP_ID}'))
+ if rp_textbox.get_value() != rp:
+ print(f'Value "{rp_textbox.get_value()}" does not match expected field value "{rp=}"')
+ self.operator.select_id(EDIT_QUESTION_CANCEL_ID, click=True)
+ return False
+
+ if next:
+ next_textbox = Textbox(self.operator.select_id(f'{type}-{NEXT_ID}'))
+ if next_textbox.get_value() != next:
+ print(f'Value "{next_textbox.get_value()}" does not match expected field value "{next=}"')
+ self.operator.select_id(EDIT_QUESTION_CANCEL_ID, click=True)
+ return False
+ self.operator.select_id(EDIT_QUESTION_CANCEL_ID, click=True)
+ return True
+
+ def delete_question_by_qid(self, qid: str):
+ """
+ Deletes a question identified by its QID. Waits for confirmation of successful
+ deletion.
+ """
+
+ self.operator.select_xpath(f'//span[@id="qa-{qid}-delete"]//descendant::button', wait=1, click=True)
+
+ self.operator.wait_for_element_by_xpath(CONFIRM_DELETE_XPATH)
+ self.operator.select_xpath(CONFIRM_DELETE_XPATH, click=True)
+
+ self.operator.wait_for_element_by_id(CONFIRM_DELETE_SUCCESS_ID, delay=20)
+
+ # Delete success modal sometimes does not appear - check to ensure it exists before interacting with button
+ if self.operator.element_exists_by_xpath(CONFIRM_DELETE_CLOSE_XPATH):
+ self.operator.select_xpath(CONFIRM_DELETE_CLOSE_XPATH, wait=1, click=True)
+
+ # After deleting multiple questions the XPATH points to the wrong QID in the table, so the table is refreshed
+ self.refresh_questions()
+
+ def select_question_by_qid(self, qid: str, column: int):
+ """
+ Selects a question by its QID and column number. Returns the selected question.
+ """
+
+ xpath = f'//*[@id="qa-{qid}"]/td[{column}]'
+
+ for i in range(5):
+ time.sleep(2 ** i)
+ if self.operator.element_exists_by_xpath(xpath):
+ break
+ print(f'Element {xpath} not ready. Waiting {2 ** (i + 1)}s.')
+ self.refresh_questions()
+
+ return self.operator.select_xpath(xpath)
+
+ def select_question_by_row_and_column(self, row: int, column: int):
+ """
+ Selects a question by its row and column number. Returns the selected question.
+ """
+ # Even rows are qa/answer content but are hidden, so we need to skip them
+ skip_row = row * 2 - 1
+
+ xpath = f'//table//tbody//tr[{skip_row}]/td[{column}]'
+
+ for i in range(5):
+ time.sleep(2 ** i)
+ if self.operator.element_exists_by_xpath(xpath):
+ break
+ print(f'Element {xpath} not ready. Waiting {2 ** (i + 1)}s.')
+
+ return self.operator.select_xpath(xpath)
+
+ def select_sub_menu(self) -> None:
+ """
+ Selects the sub-menu on the current page.
+ """
+
+ self.operator.select_id(SUB_MENU_ID, click=True)
+
+ def rebuild_lex(self) -> str:
+ """
+ Rebuilds the bot and returns the status of the operation.
+ """
+
+ self.select_sub_menu()
+ self.operator.select_id(REBUILD_LEX_ID, click=True)
+
+ success_status = self.operator.wait_for_element_by_id(REBUILD_LEX_SUCCESS_ID, delay=360).text
+ self.operator.select_id(REBUILD_LEX_CLOSE_ID, click=True)
+ time.sleep(1)
+ return success_status
+
+ def sync_kendra_faq(self):
+ """
+ Synchronizes the Kendra FAQ and returns the status of the operation.
+ """
+
+ self.select_sub_menu()
+ time.sleep(10)
+ self.operator.select_id(SYNC_KENDRA_FAQ_ID, click=True, wait=30)
+
+ success_status = self.operator.wait_for_element_by_id(SYNC_KENDRA_SUCCESS_ID, delay=180).text
+
+ self.operator.select_id(SYNC_KENDRA_CLOSE_ID, click=True)
+ return success_status
+
+ def select_test_tab(self):
+ """
+ Selects the test tab on the edit page
+ """
+ self.operator.select_xpath(TEST_TAB_XPATH, click=True)
+ self.operator.wait_for_element_by_id(TEST_TAB_QUERY_BUTTON_ID)
+
+ def select_test_all_tab(self):
+ """
+ Selects the test all tab on the edit page
+ """
+ self.operator.select_xpath(TEST_ALL_TAB_XPATH, click=True)
+ self.operator.wait_for_element_by_id(TEST_ALL_JOBS_ID)
+
+ def execute_test_query(self, query: str) -> None:
+ """
+ Executes a test query on the test tab
+ """
+ query_textbox = Textbox(self.operator.select_id(TEST_TAB_QUERY_ID))
+ query_textbox.set_value(query)
+ self.operator.select_id(TEST_TAB_QUERY_BUTTON_ID, click=True)
+
+ def generate_test_report(self) -> str:
+ """
+ Generates a test report and returns the text content of the job
+ """
+ self.operator.select_id(TEST_ALL_BUTTON_ID, click=True)
+ self.operator.wait_for_element_by_id_text(TEST_ALL_JOBS_ID, 'Completed', delay=300)
+ return self.operator.select_id(TEST_ALL_JOBS_ID).text
diff --git a/.nightswatch/functional/helpers/website_model/export_page.py b/.nightswatch/functional/helpers/website_model/export_page.py
new file mode 100644
index 000000000..cfd584ba6
--- /dev/null
+++ b/.nightswatch/functional/helpers/website_model/export_page.py
@@ -0,0 +1,78 @@
+######################################################################################################################
+# Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. #
+# #
+# Licensed under the Apache License, Version 2.0 (the "License"). You may not use this file except in compliance #
+# with the License. A copy of the License is located at #
+# #
+# http://www.apache.org/licenses/LICENSE-2.0 #
+# #
+# or in the 'license' file accompanying this file. This file is distributed on an 'AS IS' BASIS, WITHOUT WARRANTIES #
+# OR CONDITIONS OF ANY KIND, express or implied. See the License for the specific language governing permissions #
+# and limitations under the License. #
+######################################################################################################################
+
+import time
+from helpers.website_model.dom_operator import DomOperator
+from helpers.utils.textbox import Textbox
+
+FILENAME_ID = 'filename'
+FILTER_ID = 'filter'
+EXPORT_BUTTON_ID = 'export'
+
+class ExportPage:
+ """Class representing an ExportPage that allows the admin user to export questions.
+
+ This class provides methods to set filename, filter and generate export for the questions.
+
+ Attributes:
+ operator (DomOperator): An instance of DomOperator to manipulate and interact with the DOM.
+ """
+
+ def __init__(self, operator: DomOperator) -> None:
+ """
+ Initializes ExportPage with a DomOperator instance.
+
+ Args:
+ operator (DomOperator): An instance of DomOperator to manipulate and interact with the DOM.
+ """
+
+ self.operator = operator
+
+ def __set_filename(self, filename: str):
+ """
+ Private method to set the filename of the export file.
+
+ Args:
+ filename (str): The name of the export file.
+ """
+
+ filename_textbox = Textbox(self.operator.select_id(FILENAME_ID, click=True))
+ filename_textbox.set_value(filename)
+
+ def __set_filter(self, filter: str):
+ """
+ Private method to set the filter for the export operation.
+
+ Args:
+ filter (str): The filter for the export operation.
+ """
+
+ filter_textbox = Textbox(self.operator.select_id(FILTER_ID, click=False))
+ filter_textbox.set_value(filter)
+
+ def generate_export(self, filename: str, filter: str):
+ """
+ Generate an export file with the given filename and filter.
+
+ Args:
+ filename (str): The name of the export file.
+ filter (str): The filter for the export operation.
+ """
+
+ self.__set_filename(filename)
+ self.__set_filter(filter)
+ self.operator.select_id(EXPORT_BUTTON_ID, click=True)
+
+ self.operator.wait_for_element_by_xpath(f'//div[@id="export-job-{filename}" and @data-status="Started"]//i[contains( text( ),"file_download")]//ancestor::button')
+ self.operator.wait_for_element_by_xpath(f'//div[@id="export-job-{filename}" and @data-status="Completed"]//i[contains( text( ),"file_download")]//ancestor::button')
+ self.operator.select_xpath(f'//div[@id="export-job-{filename}" and @data-status="Completed"]//i[contains( text( ),"file_download")]//ancestor::button', click=True)
diff --git a/.nightswatch/functional/helpers/website_model/import_page.py b/.nightswatch/functional/helpers/website_model/import_page.py
new file mode 100644
index 000000000..7d148c44c
--- /dev/null
+++ b/.nightswatch/functional/helpers/website_model/import_page.py
@@ -0,0 +1,135 @@
+######################################################################################################################
+# Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. #
+# #
+# Licensed under the Apache License, Version 2.0 (the "License"). You may not use this file except in compliance #
+# with the License. A copy of the License is located at #
+# #
+# http://www.apache.org/licenses/LICENSE-2.0 #
+# #
+# or in the 'license' file accompanying this file. This file is distributed on an 'AS IS' BASIS, WITHOUT WARRANTIES #
+# OR CONDITIONS OF ANY KIND, express or implied. See the License for the specific language governing permissions #
+# and limitations under the License. #
+######################################################################################################################
+
+from helpers.website_model.dom_operator import DomOperator
+
+IMPORT_EXAMPLES_MENU_XPATH = '//button[@class="v-expansion-panel-title"]'
+IMPORT_EXAMPLES_BLOG_EXAMPLE_ID = 'example-blog-samples-final'
+IMPORT_LANGUAGE_ID = 'example-Language'
+IMPORT_GREETING_HOOK_ID = 'example-GreetingHook'
+EXPANSION_MENU_XPATH = '//button[@class="v-expansion-panel-title v-expansion-panel-title--active"]'
+UPLOAD_FILE_ID = 'upload-file'
+ERROR_MODAL_ID = 'error-modal'
+
+class ImportPage:
+ """Class representing an ImportPage that allows the admin user to import questions.
+
+ This class provides methods to expand examples, select examples, and import blog examples, language, and greeting hook.
+
+ Attributes:
+ operator (DomOperator): An instance of DomOperator to manipulate and interact with the DOM.
+ """
+
+ def __init__(self, operator: DomOperator) -> None:
+ """
+ Initializes ImportPage with a DomOperator instance.
+
+ Args:
+ operator (DomOperator): An instance of DomOperator to manipulate and interact with the DOM.
+ """
+
+ self.operator = operator
+ self.__wait_to_load()
+
+ def __wait_to_load(self):
+ """
+ A private method to wait for a page to load. Waits for a specific element
+ identified by its ID.
+ """
+
+ self.operator.wait_for_element_by_id(UPLOAD_FILE_ID)
+
+ def __delete_existing_import_file_if_exists(self, file_name: str) -> None:
+ """
+ A private method to delete the existing import file if it exists.
+
+ Args:
+ file_name (str): The name of the file to be deleted.
+ """
+ file_element = f'//div[@id="import-job-{file_name}"]//button'
+
+ if self.operator.element_exists_by_xpath(file_element):
+ print(f'Deleting existing import file {file_name}')
+ self.operator.select_xpath(file_element, click=True)
+
+ def expand_examples(self) -> None:
+ """
+ Expands the examples section on the import page.
+ """
+
+ self.operator.select_xpath(IMPORT_EXAMPLES_MENU_XPATH, wait=10, click=True)
+ self.operator.wait_for_element_by_xpath(EXPANSION_MENU_XPATH)
+
+ def select_example(self, item: str) -> None:
+ """
+ Selects an example from the examples section on the import page.
+
+ Args:
+ item (str): The CSS selector of the example to be selected.
+ """
+
+ self.operator.select_css(item, wait=10, click=True)
+
+ def import_file(self, file: str) -> None:
+ """
+ Imports a file on the import page.
+
+ :param file: The path to the file to be uploaded.
+ """
+
+ file_name = file.split('/')[-1]
+ file_element = f'//div[@id="import-job-{file_name}" and @data-status="Complete"]'
+
+ self.__delete_existing_import_file_if_exists(file_name)
+ self.operator.wait_for_element_by_id(UPLOAD_FILE_ID)
+ self.operator.select_id(UPLOAD_FILE_ID).send_keys(file)
+ try:
+ self.operator.wait_for_element_by_xpath(file_element, delay=240)
+ except Exception as e:
+ print(f'Exception while waiting for import file element: {e}')
+ self.operator.driver.switch_to.alert.accept()
+ self.operator.wait_for_element_by_xpath(file_element, delay=240)
+
+ def get_import_file_error(self) -> None:
+ """
+ Gets the error message from the error modal on the import page.
+ """
+ self.operator.wait_for_element_by_xpath(f'//div[@id="{ERROR_MODAL_ID}"]//li')
+ return self.operator.select_id(ERROR_MODAL_ID).text
+
+ def import_blog_examples(self) -> None:
+ """
+ Imports blog examples from the examples section on the import page.
+ """
+
+ self.expand_examples()
+ self.operator.wait_for_element_by_id(IMPORT_EXAMPLES_BLOG_EXAMPLE_ID)
+ self.operator.click_element_by_id(IMPORT_EXAMPLES_BLOG_EXAMPLE_ID)
+
+ def import_language(self) -> None:
+ """
+ Imports language examples from the examples section on the import page.
+ """
+
+ self.expand_examples()
+ self.operator.wait_for_element_by_id(IMPORT_LANGUAGE_ID)
+ self.operator.click_element_by_id(IMPORT_LANGUAGE_ID)
+
+ def import_greeting_hook(self) -> None:
+ """
+ Imports greeting hook examples from the examples section on the import page.
+ """
+
+ self.expand_examples()
+ self.operator.wait_for_element_by_id(IMPORT_GREETING_HOOK_ID)
+ self.operator.click_element_by_id(IMPORT_GREETING_HOOK_ID)
\ No newline at end of file
diff --git a/.nightswatch/functional/helpers/website_model/kendra_page.py b/.nightswatch/functional/helpers/website_model/kendra_page.py
new file mode 100644
index 000000000..174142acc
--- /dev/null
+++ b/.nightswatch/functional/helpers/website_model/kendra_page.py
@@ -0,0 +1,76 @@
+######################################################################################################################
+# Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. #
+# #
+# Licensed under the Apache License, Version 2.0 (the "License"). You may not use this file except in compliance #
+# with the License. A copy of the License is located at #
+# #
+# http://www.apache.org/licenses/LICENSE-2.0 #
+# #
+# or in the 'license' file accompanying this file. This file is distributed on an 'AS IS' BASIS, WITHOUT WARRANTIES #
+# OR CONDITIONS OF ANY KIND, express or implied. See the License for the specific language governing permissions #
+# and limitations under the License. #
+######################################################################################################################
+
+import time
+from helpers.website_model.dom_operator import DomOperator
+
+KENDRA_INDEXING_BUTTON_XPATH = '//*[@id="btnKendraStartIndex"]'
+
+KENDRA_IMPORT_XPATH = '//div[@id="page-import"]//p'
+
+SYNCING_TEXT = 'Current Status: SYNCING'
+
+PAGE_READINESS_ELEMENT_XPATH = KENDRA_INDEXING_BUTTON_XPATH
+
+class KendraPage:
+ """Class representing a KendraPage that allows the admin user to trigger a re-index with Kendra.
+
+ This class provides a method to initiate the re-indexing process and return its status.
+
+ Attributes:
+ operator (DomOperator): An instance of DomOperator to manipulate and interact with the DOM.
+ """
+
+ def __init__(self, operator: DomOperator) -> None:
+ """
+ Initializes KendraPage with a DomOperator instance.
+
+ Args:
+ operator (DomOperator): An instance of DomOperator to manipulate and interact with the DOM.
+ """
+
+ self.operator = operator
+ self.__wait_to_load()
+
+ def __wait_to_load(self):
+ """
+ A private method to wait for a page to load. Waits for a specific element
+ identified by its ID.
+ """
+
+ self.operator.wait_for_element_by_xpath(PAGE_READINESS_ELEMENT_XPATH)
+
+ def index(self) -> None:
+ """
+ Triggers the re-indexing process with Kendra and returns the status of the operation.
+
+ Returns:
+ str: The status text of the re-indexing process.
+ """
+
+ # self.operator.wait_for_element_by_xpath(KENDRA_INDEXING_BUTTON_XPATH)
+ self.operator.select_xpath(KENDRA_INDEXING_BUTTON_XPATH, wait=30, click=True)
+ self.operator.wait_for_element_by_xpath_text(KENDRA_IMPORT_XPATH, SYNCING_TEXT, delay=360)
+ status = self.operator.select_xpath(KENDRA_IMPORT_XPATH).text
+ return status
+
+ def get_crawling_status(self) -> None:
+ """
+ Returns current Kendra crawling status
+
+ Returns:
+ str: Current Kendra crawling status
+ """
+
+ # self.operator.wait_for_element_by_xpath(KENDRA_INDEXING_BUTTON_XPATH)
+ return self.operator.select_xpath(KENDRA_IMPORT_XPATH).text
diff --git a/.nightswatch/functional/helpers/website_model/login_page.py b/.nightswatch/functional/helpers/website_model/login_page.py
new file mode 100644
index 000000000..07a5e3cff
--- /dev/null
+++ b/.nightswatch/functional/helpers/website_model/login_page.py
@@ -0,0 +1,93 @@
+######################################################################################################################
+# Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. #
+# #
+# Licensed under the Apache License, Version 2.0 (the "License"). You may not use this file except in compliance #
+# with the License. A copy of the License is located at #
+# #
+# http://www.apache.org/licenses/LICENSE-2.0 #
+# #
+# or in the 'license' file accompanying this file. This file is distributed on an 'AS IS' BASIS, WITHOUT WARRANTIES #
+# OR CONDITIONS OF ANY KIND, express or implied. See the License for the specific language governing permissions #
+# and limitations under the License. #
+######################################################################################################################
+
+import time
+from helpers.website_model.dom_operator import DomOperator
+from helpers.website_model.edit_page import EditPage
+from helpers.website_model.chat_page import ChatPage
+
+LOGOUT_ID = 'div-logout'
+USERNAME_ID = 'signInFormUsername'
+PASSWORD_ID = 'signInFormPassword'
+SUBMIT_BUTTON_NAME = "signInSubmitButton"
+
+class LoginPage:
+ """Class representing a LoginPage that provides a way to log into the AWS cognito interface.
+
+ This class offers a method to log in to the AWS cognito interface using credentials.
+ It can handle login for two URLs - Question Designer and the Client Chat Bot.
+
+ Attributes:
+ operator (DomOperator): An instance of DomOperator to manipulate and interact with the DOM.
+ url (str): The URL of the destination page.
+ """
+
+ def __init__(self, operator: DomOperator, url) -> None:
+ """
+ Initializes LoginPage with a DomOperator instance and a URL.
+
+ Args:
+ operator (DomOperator): An instance of DomOperator to manipulate and interact with the DOM.
+ url (str): The URL of the destination page.
+ """
+
+ self.operator = operator
+ self.url = url
+
+ def __is_client(self):
+ """
+ Private method to determine if the current URL represents the client Chat Bot page.
+
+ Returns:
+ bool: True if the url is for the client, False otherwise.
+ """
+
+ return 'client.html' in self.url
+
+ def login(self, username, password) -> str:
+ """
+ Performs the login operation with the given credentials and returns the title of the loaded page.
+
+ Args:
+ username (str): The username to log in.
+ password (str): The password for the given username.
+
+ Returns:
+ str: The title of the page after successful login.
+ """
+
+ self.operator.get_url(self.url)
+ self.operator.wait_for_element_by_id(USERNAME_ID, delay=5)
+ self.operator.set_window_size(800, 800)
+ designer_page = self.operator.select_xpath('/html/body')
+
+ if self.operator.get_title() == 'QnABot Client' or self.operator.get_title() == 'QnABot Designer':
+ return self.operator.get_title()
+
+ if 'Sign In as' in designer_page.text:
+ self.operator.select_id(LOGOUT_ID, click=True)
+
+ self.operator.select_id(USERNAME_ID).send_keys(username)
+ self.operator.select_id(PASSWORD_ID).send_keys(password)
+ self.operator.select_name(SUBMIT_BUTTON_NAME, click=True)
+
+ # Instantiating pages to wait for page readiness before exiting function
+ if self.__is_client():
+ ChatPage(self.operator)
+ else:
+ EditPage(self.operator)
+
+ if self.operator.element_exists_by_id('loginErrorMessage'):
+ raise RuntimeError(self.operator.select_id('loginErrorMessage').text)
+
+ return self.operator.get_title()
\ No newline at end of file
diff --git a/.nightswatch/functional/helpers/website_model/menu_nav.py b/.nightswatch/functional/helpers/website_model/menu_nav.py
new file mode 100644
index 000000000..09c92123f
--- /dev/null
+++ b/.nightswatch/functional/helpers/website_model/menu_nav.py
@@ -0,0 +1,127 @@
+######################################################################################################################
+# Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. #
+# #
+# Licensed under the Apache License, Version 2.0 (the "License"). You may not use this file except in compliance #
+# with the License. A copy of the License is located at #
+# #
+# http://www.apache.org/licenses/LICENSE-2.0 #
+# #
+# or in the 'license' file accompanying this file. This file is distributed on an 'AS IS' BASIS, WITHOUT WARRANTIES #
+# OR CONDITIONS OF ANY KIND, express or implied. See the License for the specific language governing permissions #
+# and limitations under the License. #
+######################################################################################################################
+
+import time
+
+from helpers.website_model.edit_page import EditPage
+from helpers.website_model.import_page import ImportPage
+from helpers.website_model.export_page import ExportPage
+from helpers.website_model.settings_page import SettingsPage
+from helpers.website_model.kendra_page import KendraPage
+from helpers.website_model.custom_terminology_page import CustomTerminologyPage
+from helpers.website_model.dom_operator import DomOperator
+from helpers.website_model.login_page import LoginPage
+from helpers.website_model.chat_page import ChatPage
+
+MENU_ID = 'nav-open'
+LOGOUT_ID = 'logout-button'
+EDIT_PAGE_ID = 'page-link-edit'
+EDIT_ID = 'page-link-edit'
+EXPORT_PAGE_ID = 'page-link-export'
+IMPORT_PAGE_ID = 'page-link-import'
+SETTINGS_ID = 'page-link-settings'
+KENDRA_ID = 'page-link-kendraIndexing'
+CUSTOM_TERM_ID = 'page-link-customTranslate'
+CHAT_ID = 'page-link-client'
+
+class MenuNav:
+ """Class representing a Menu Navigation Bar.
+
+ This class provides a way to navigate through different pages in the application by selecting menu options.
+ It is used across all administrative pages.
+
+ Attributes:
+ operator (DomOperator): An instance of DomOperator to manipulate and interact with the DOM.
+ """
+
+ def __init__(self, operator: DomOperator) -> None:
+ """
+ Initializes the MenuNav with a DomOperator instance.
+
+ Args:
+ operator (DomOperator): An instance of DomOperator to manipulate and interact with the DOM.
+ """
+
+ self.operator = operator
+
+ def select_menu(self) -> None:
+ """Selects the menu in the navigation bar."""
+
+ self.operator.click_element_by_id(MENU_ID, wait=10)
+
+ def __from_menu_select_item(self, item: str) -> None:
+ """
+ Private method to select an item from the menu.
+
+ Args:
+ item (str): The CSS selector of the menu item to be selected.
+ """
+
+ self.operator.select_css(item, wait=10, click=True)
+
+ def logout(self) -> LoginPage:
+ """Logs out the current user and returns the LoginPage instance."""
+ self.operator.select_id(LOGOUT_ID, wait=10, click=True)
+ return LoginPage(self.operator, self.operator.get_current_url())
+
+ def open_import_page(self) -> ImportPage:
+ """Opens the ImportPage through the menu and returns its instance."""
+
+ self.select_menu()
+ self.operator.select_id(IMPORT_PAGE_ID, wait=10, click=True)
+ return ImportPage(self.operator)
+
+ def open_export_page(self) -> ExportPage:
+ """Opens the ExportPage through the menu and returns its instance."""
+
+ self.select_menu()
+ self.operator.select_id(EXPORT_PAGE_ID, wait=10, click=True)
+ return ExportPage(self.operator)
+
+ def open_edit_page(self) -> EditPage:
+ """Opens the EditPage through the menu and returns its instance."""
+
+ self.select_menu()
+ self.operator.wait_for_element_by_id(EDIT_ID)
+ self.operator.click_element_by_id(EDIT_PAGE_ID, wait=10)
+ return EditPage(self.operator)
+
+ def open_settings_page(self) -> SettingsPage:
+ """Opens the SettingsPage through the menu and returns its instance."""
+
+ self.select_menu()
+ self.operator.select_id(SETTINGS_ID, wait=10, click=True)
+ return SettingsPage(self.operator)
+
+ def open_kendra_page(self) -> KendraPage:
+ """Opens the KendraPage through the menu and returns its instance."""
+
+ self.select_menu()
+ self.operator.select_id(KENDRA_ID, wait=10, click=True)
+ return KendraPage(self.operator)
+
+ def open_custom_terminology(self) -> CustomTerminologyPage:
+ """Opens the CustomTerminologyPage through the menu and returns its instance."""
+
+ self.select_menu()
+ self.operator.select_id(CUSTOM_TERM_ID, wait=10, click=True)
+ return CustomTerminologyPage(self.operator)
+
+ def open_chat_page(self) -> ChatPage:
+ """Opens the ChatPage through the menu, switches to its window and returns its instance."""
+
+ self.select_menu()
+ self.operator.click_element_by_id(CHAT_ID, wait=10)
+ time.sleep(5)
+ self.operator.switch_windows()
+ return ChatPage(self.operator)
\ No newline at end of file
diff --git a/.nightswatch/functional/helpers/website_model/settings_page.py b/.nightswatch/functional/helpers/website_model/settings_page.py
new file mode 100644
index 000000000..52a945226
--- /dev/null
+++ b/.nightswatch/functional/helpers/website_model/settings_page.py
@@ -0,0 +1,395 @@
+######################################################################################################################
+# Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. #
+# #
+# Licensed under the Apache License, Version 2.0 (the "License"). You may not use this file except in compliance #
+# with the License. A copy of the License is located at #
+# #
+# http://www.apache.org/licenses/LICENSE-2.0 #
+# #
+# or in the 'license' file accompanying this file. This file is distributed on an 'AS IS' BASIS, WITHOUT WARRANTIES #
+# OR CONDITIONS OF ANY KIND, express or implied. See the License for the specific language governing permissions #
+# and limitations under the License. #
+######################################################################################################################
+
+import time
+
+from helpers.utils.textbox import Textbox
+from helpers.website_model.dom_operator import DomOperator
+
+EMPTY_MESSAGE_LABEL = 'EMPTYMESSAGE'
+MULTI_LANGUAGE_SUPPORT_LABEL = 'ENABLE_MULTI_LANGUAGE_SUPPORT'
+ENABLE_KENDRA_LABEL = 'ENABLE_KENDRA_WEB_INDEXER'
+ENABLE_KENDRA_FALLBACK_LABEL = 'KENDRA_FAQ_ES_FALLBACK'
+KENDRA_INDEX_LABEL = 'KENDRA_INDEXER_URLS'
+ENABLE_EMBEDDINGS_LABEL = 'EMBEDDINGS_ENABLE'
+ENABLE_CUSTOM_TERMINOLOGY_LABEL = 'ENABLE_CUSTOM_TERMINOLOGY'
+ENABLE_FILTER_LABEL = 'ES_USE_KEYWORD_FILTERS'
+FILTER_CRITERIA_LABEL = 'ES_MINIMUM_SHOULD_MATCH'
+KENDRA_INDEXER_CRAWL_DEPTH_LABEL = 'KENDRA_INDEXER_CRAWL_DEPTH'
+KENDRA_INDEXER_MODE_LABEL = 'KENDRA_INDEXER_CRAWL_MODE'
+KENDRA_INDEXER_SCHEDULE_LABEL = 'KENDRA_INDEXER_SCHEDULE'
+KENDRA_MAX_DOCUMENT_COUNT = 'ALT_SEARCH_KENDRA_MAX_DOCUMENT_COUNT'
+
+ENABLE_DEBUG_RESPONSES_LABEL = 'ENABLE_DEBUG_RESPONSES'
+ES_SCORE_TEXT_ITEM_PASSAGES_LABEL = 'ES_SCORE_TEXT_ITEM_PASSAGES'
+LLM_GENERATE_QUERY_ENABLE_LABEL = 'LLM_GENERATE_QUERY_ENABLE'
+LLM_QA_ENABLE_LABEL = 'LLM_QA_ENABLE'
+LLM_QA_USE_KENDRA_RETRIEVAL_API_LABEL = 'LLM_QA_USE_KENDRA_RETRIEVAL_API'
+LLM_QA_SHOW_CONTEXT_TEXT_LABEL = 'LLM_QA_SHOW_CONTEXT_TEXT'
+LLM_QA_SHOW_SOURCE_LINKS_LABEL = 'LLM_QA_SHOW_SOURCE_LINKS'
+
+PRE_PROCESSING_LAMBDA_LABEL = 'LAMBDA_PREPROCESS_HOOK'
+POST_PROCESSING_LAMBDA_LABEL = 'LAMBDA_POSTPROCESS_HOOK'
+
+SAVE_XPATH = "//button[span='Save']"
+RESET_XPATH = "//button[span='Reset to defaults']"
+SAVE_STATUS_CSS = '#error-modal'
+SAVE_MODAL_CLOSE_XPATH = "//button[span='close']"
+
+class SettingsPage:
+ """
+ Class representing a Settings Page.
+
+ This class provides methods to interact with and manipulate the settings of Q&A bot.
+
+ Attributes:
+ operator (DomOperator): An instance of DomOperator to manipulate and interact with the DOM.
+ """
+
+ def __init__(self, operator: DomOperator) -> None:
+ """
+ Initializes the SettingsPage with a DomOperator instance.
+
+ Args:
+ operator (DomOperator): An instance of DomOperator to manipulate and interact with the DOM.
+ """
+
+ self.operator = operator
+
+ def save_settings(self) -> str:
+ """Saves the current settings and returns the status of the operation."""
+
+ self.operator.select_xpath(SAVE_XPATH, click=True)
+ self.operator.wait_for_element_by_xpath(SAVE_MODAL_CLOSE_XPATH)
+ time.sleep(1)
+
+ status = self.operator.select_css(SAVE_STATUS_CSS).text
+ self.operator.select_xpath(SAVE_MODAL_CLOSE_XPATH, click=True)
+
+ return status
+
+ def reset_settings(self) -> str:
+ """Resets the current settings."""
+
+ self.operator.select_xpath(RESET_XPATH, click=True)
+ time.sleep(1)
+
+ def select_setting_by_label(self, label: str):
+ """
+ Selects a setting by its label.
+
+ Args:
+ label (str): The label of the setting to select.
+
+ Returns:
+ The selected WebElement.
+ """
+
+ return self.operator.select_id(label, click=False)
+
+ def __set_element_value(self, element, value):
+ """
+ Private method to set the value of a WebElement.
+
+ Args:
+ element: The WebElement to update.
+ value: The value to set in the WebElement.
+ """
+
+ textbox = Textbox(element)
+ textbox.set_value(value)
+
+ def __get_element_value(self, element) -> str:
+ """
+ Private method to get the value of a WebElement.
+
+ Args:
+ element: The WebElement to update.
+ Returns:
+ The value of the WebElement.
+ """
+
+ textbox = Textbox(element)
+ return textbox.get_value()
+
+ def customize_empty_message(self, message) -> str:
+ """
+ Customizes the empty message setting and saves the changes.
+
+ Args:
+ message (str): The new empty message.
+
+ Returns:
+ The status of the save operation.
+ """
+
+ customize_empty_message = self.select_setting_by_label(EMPTY_MESSAGE_LABEL)
+ self.__set_element_value(customize_empty_message, message)
+ return self.save_settings()
+
+ def enable_multi_language_support(self) -> str:
+ """
+ Enables the multi-language support setting and saves the changes.
+
+ Returns:
+ The status of the save operation.
+ """
+
+ enable_multi_language_support = self.select_setting_by_label(MULTI_LANGUAGE_SUPPORT_LABEL)
+ self.__set_element_value(enable_multi_language_support, 'true')
+ return self.save_settings()
+
+ def enable_kendra(self, indexer_url: str, depth: int=2, mode: str='subdomains', schedule: str='rate(1 day)', doc_count: str='10') -> str:
+ """
+ Enables the Kendra setting and configures Kendra parameters.
+
+ Args:
+ indexer_url (str): The URL of the indexer.
+ depth (int, optional): The crawl depth. Defaults to 3.
+ mode (str, optional): The indexing mode. Defaults to 'subdomains'.
+ schedule (str, optional): The indexing schedule. Defaults to 'rate(1 day)'.
+ doc_count (str, optional): The number of docs returned by Kendra. Defaults to '10'.
+
+ Returns:
+ The status of the save operation.
+ """
+
+ enable_kendra_web_indexer = self.select_setting_by_label(ENABLE_KENDRA_LABEL)
+ self.__set_element_value(enable_kendra_web_indexer, 'true')
+
+ update_kendra_indexer_urls = self.select_setting_by_label(KENDRA_INDEX_LABEL)
+ self.__set_element_value(update_kendra_indexer_urls, indexer_url)
+
+ update_kendra_indexer_crawl_depth = self.select_setting_by_label(KENDRA_INDEXER_CRAWL_DEPTH_LABEL)
+ self.__set_element_value(update_kendra_indexer_crawl_depth, depth)
+
+ update_kendra_indexer_mode = self.select_setting_by_label(KENDRA_INDEXER_MODE_LABEL)
+ self.__set_element_value(update_kendra_indexer_mode, mode)
+
+ update_kendra_indexer_schedule = self.select_setting_by_label(KENDRA_INDEXER_SCHEDULE_LABEL)
+ self.__set_element_value(update_kendra_indexer_schedule, schedule)
+
+ update_kendra_doc_count = self.select_setting_by_label(KENDRA_MAX_DOCUMENT_COUNT)
+ self.__set_element_value(update_kendra_doc_count, doc_count)
+
+ return self.save_settings()
+
+ def enable_kendra_fallback(self) -> str:
+ """
+ Enables the Kendra fallback setting.
+
+ Returns:
+ The status of the save operation.
+ """
+
+ enable_kendra_web_indexer = self.select_setting_by_label(ENABLE_KENDRA_FALLBACK_LABEL)
+ self.__set_element_value(enable_kendra_web_indexer, 'true')
+
+ return self.save_settings()
+
+ def disable_kendra_fallback(self) -> str:
+ """
+ Disables the Kendra fallback setting.
+
+ Returns:
+ The status of the save operation.
+ """
+
+ enable_kendra_web_indexer = self.select_setting_by_label(ENABLE_KENDRA_FALLBACK_LABEL)
+ self.__set_element_value(enable_kendra_web_indexer, 'false')
+
+ return self.save_settings()
+
+ def enable_embeddings(self) -> str:
+ """
+ Enables the embeddings setting and saves the changes.
+
+ Returns:
+ The status of the save operation.
+ """
+
+ enable_embeddings = self.select_setting_by_label(ENABLE_EMBEDDINGS_LABEL)
+ self.__set_element_value(enable_embeddings, 'true')
+ return self.save_settings()
+
+ def disable_embeddings(self) -> str:
+ """
+ Disables the embeddings setting and saves the changes.
+
+ Returns:
+ The status of the save operation.
+ """
+
+ disable_embeddings = self.select_setting_by_label(ENABLE_EMBEDDINGS_LABEL)
+ self.__set_element_value(disable_embeddings, 'false')
+ return self.save_settings()
+
+ def enable_llm(self) -> str:
+ """
+ Enables LLM setting and saves the changes.
+
+ Returns:
+ The status of the save operation.
+ """
+
+ enable_debug = self.select_setting_by_label(ENABLE_DEBUG_RESPONSES_LABEL)
+ self.__set_element_value(enable_debug, 'true')
+
+ enable_item_passages = self.select_setting_by_label(ES_SCORE_TEXT_ITEM_PASSAGES_LABEL)
+ self.__set_element_value(enable_item_passages, 'true')
+
+ enable_generative_query = self.select_setting_by_label(LLM_GENERATE_QUERY_ENABLE_LABEL)
+ self.__set_element_value(enable_generative_query, 'true')
+
+ enable_llm_qa = self.select_setting_by_label(LLM_QA_ENABLE_LABEL)
+ self.__set_element_value(enable_llm_qa, 'true')
+
+ enable_llm_kendra = self.select_setting_by_label(LLM_QA_USE_KENDRA_RETRIEVAL_API_LABEL)
+ self.__set_element_value(enable_llm_kendra, 'true')
+
+ enable_show_context_text = self.select_setting_by_label(LLM_QA_SHOW_CONTEXT_TEXT_LABEL)
+ self.__set_element_value(enable_show_context_text, 'true')
+
+ enable_source_links = self.select_setting_by_label(LLM_QA_SHOW_SOURCE_LINKS_LABEL)
+ self.__set_element_value(enable_source_links, 'true')
+
+ return self.save_settings()
+
+ def disable_llm(self) -> str:
+ """
+ Disables LLM setting and saves the changes.
+
+ Returns:
+ The status of the save operation.
+ """
+
+ enable_item_passages = self.select_setting_by_label(ES_SCORE_TEXT_ITEM_PASSAGES_LABEL)
+ self.__set_element_value(enable_item_passages, 'false')
+
+ enable_generative_query = self.select_setting_by_label(LLM_GENERATE_QUERY_ENABLE_LABEL)
+ self.__set_element_value(enable_generative_query, 'false')
+
+ enable_llm_qa = self.select_setting_by_label(LLM_QA_ENABLE_LABEL)
+ self.__set_element_value(enable_llm_qa, 'false')
+
+ enable_llm_kendra = self.select_setting_by_label(LLM_QA_USE_KENDRA_RETRIEVAL_API_LABEL)
+ self.__set_element_value(enable_llm_kendra, 'false')
+
+ enable_show_context_text = self.select_setting_by_label(LLM_QA_SHOW_CONTEXT_TEXT_LABEL)
+ self.__set_element_value(enable_show_context_text, 'false')
+
+ enable_source_links = self.select_setting_by_label(LLM_QA_SHOW_SOURCE_LINKS_LABEL)
+ self.__set_element_value(enable_source_links, 'false')
+
+ return self.save_settings()
+
+ def disable_llm_disambiguation(self):
+ enable_generative_query = self.select_setting_by_label(LLM_GENERATE_QUERY_ENABLE_LABEL)
+ self.__set_element_value(enable_generative_query, 'false')
+
+ return self.save_settings()
+
+ def enable_custom_terminology(self) -> str:
+ """
+ Enables the custom terminology setting and saves the changes.
+
+ Returns:
+ The status of the save operation.
+ """
+
+ enable_custom_terminology = self.select_setting_by_label(ENABLE_CUSTOM_TERMINOLOGY_LABEL)
+ self.__set_element_value(enable_custom_terminology, 'true')
+ return self.save_settings()
+
+ def enable_filter(self) -> str:
+ """
+ Enables the filter setting and saves the changes.
+
+ Returns:
+ The status of the save operation.
+ """
+
+ enable_filter = self.select_setting_by_label(ENABLE_FILTER_LABEL)
+ self.__set_element_value(enable_filter, 'true')
+ return self.save_settings()
+
+ def disable_filter(self) -> str:
+ """
+ Disables the filter setting and saves the changes.
+
+ Returns:
+ The status of the save operation.
+ """
+
+ disable_filter = self.select_setting_by_label(ENABLE_FILTER_LABEL)
+ self.__set_element_value(disable_filter, 'false')
+ return self.save_settings()
+
+ def set_match_criteria(self, criteria: str) -> str:
+ """
+ Sets the match criteria setting and saves the changes.
+
+ Args:
+ criteria (str): The match criteria to set.
+
+ Returns:
+ The status of the save operation.
+ """
+
+ match_criteria = self.select_setting_by_label(FILTER_CRITERIA_LABEL)
+ self.__set_element_value(match_criteria, criteria)
+ return self.save_settings()
+
+ def get_no_hits_response(self) -> str:
+ """
+ Returns the no hits response from the settings page.
+
+ Returns:
+ The no hits response from the settings page.
+ """
+
+ ho_hits = self.select_setting_by_label(EMPTY_MESSAGE_LABEL)
+ return self.__get_element_value(ho_hits)
+
+ def set_pre_processing_lambda(self, l: str) -> str:
+ """
+ Sets the pre-processing lambda setting and saves the changes.
+
+ Args:
+ l (str): The lambda function to set.
+
+ Returns:
+ The status of the save operation.
+ """
+
+ pre_processing_lambda = self.select_setting_by_label(PRE_PROCESSING_LAMBDA_LABEL)
+ self.__set_element_value(pre_processing_lambda, l)
+ return self.save_settings()
+
+
+ def set_post_processing_lambda(self, l: str) -> str:
+ """
+ Sets the post-processing lambda setting and saves the changes.
+
+ Args:
+ l (str): The lambda function to set.
+
+ Returns:
+ The status of the save operation.
+ """
+
+ post_processing_lambda = self.select_setting_by_label(POST_PROCESSING_LAMBDA_LABEL)
+ self.__set_element_value(post_processing_lambda, l)
+ return self.save_settings()
+
+
diff --git a/.nightswatch/functional/pytest.ini b/.nightswatch/functional/pytest.ini
new file mode 100644
index 000000000..efabb7ac9
--- /dev/null
+++ b/.nightswatch/functional/pytest.ini
@@ -0,0 +1,13 @@
+[pytest]
+log_cli = true
+log_cli_date_format = %Y-%m-%d %H:%M:%S
+log_cli_format = %(asctime)s %(levelname)s %(message)s
+log_cli_level = INFO
+log_file = regression_tests_run.log
+log_file_date_format = %Y-%m-%d %H:%M:%S
+log_file_format = %(asctime)s %(levelname)s %(message)s
+markers =
+ skipif_kendra_not_enabled
+ skipif_llm_not_enabled
+ skipif_embeddings_not_enabled
+ skipif_version_less_than
\ No newline at end of file
diff --git a/.nightswatch/functional/question_bank/embeddings_questions.json b/.nightswatch/functional/question_bank/embeddings_questions.json
new file mode 100644
index 000000000..f064aca90
--- /dev/null
+++ b/.nightswatch/functional/question_bank/embeddings_questions.json
@@ -0,0 +1,12 @@
+{
+ "qna": [
+ {
+ "a": "1600 Pennsylvania Ave., NW, Washington, DC 20500",
+ "type": "qna",
+ "qid": "Embed.001",
+ "q": [
+ "What is the address of the White House?"
+ ]
+ }
+ ]
+}
diff --git a/.nightswatch/functional/question_bank/import_questions.json b/.nightswatch/functional/question_bank/import_questions.json
new file mode 100644
index 000000000..c0c37bc7d
--- /dev/null
+++ b/.nightswatch/functional/question_bank/import_questions.json
@@ -0,0 +1,11 @@
+{
+ "qna": [
+ {
+ "q": [
+ "Which file formats are supported by the QnA Bot question designer import?"
+ ],
+ "a": "JSON and xlsx.",
+ "qid": "Import.001"
+ }
+ ]
+}
\ No newline at end of file
diff --git a/.nightswatch/functional/question_bank/import_questions_qna.json b/.nightswatch/functional/question_bank/import_questions_qna.json
new file mode 100644
index 000000000..3d2c6c462
--- /dev/null
+++ b/.nightswatch/functional/question_bank/import_questions_qna.json
@@ -0,0 +1,86 @@
+{
+ "qna": [
+ {
+ "next": "NextQid",
+ "a": "Answer",
+ "enableQidIntent": true,
+ "kendraRedirectQueryText": "KendraRedirect",
+ "elicitResponse": {
+ "response_sessionattr_namespace": "Namespace",
+ "responsebot_hook": "QNAYesNo"
+ },
+ "alt": {
+ "markdown": "markdown",
+ "ssml": "SSML"
+ },
+ "conditionalChaining": "DocumentChaining",
+ "l": "LambdaHook",
+ "type": "qna",
+ "qid": "Import.004",
+ "sa": [
+ {
+ "enableTranslate": true,
+ "text": "AttributeName1",
+ "value": "AttributeValue1"
+ },
+ {
+ "text": "AttributeName2",
+ "value": "AttributeValue2"
+ }
+ ],
+ "kendraRedirectQueryConfidenceThreshold": "LOW",
+ "clientFilterValues": "ClientFilter",
+ "tags": "Tags",
+ "args": [
+ "HookArg1",
+ "HookArg2"
+ ],
+ "slots": [
+ {
+ "slotName": "SlotName1",
+ "slotPrompt": "SlotPrompt1",
+ "slotRequired": true,
+ "slotType": "SlotType1",
+ "slotValueCached": true,
+ "slotSampleUtterances": "SlotUtterances1"
+ },
+ {
+ "slotName": "SlotName2",
+ "slotPrompt": "SlotPrompt2",
+ "slotType": "SlotType2",
+ "slotSampleUtterances": "SlotUtterances2"
+ }
+ ],
+ "r": {
+ "buttons": [
+ {
+ "text": "ButtonText1",
+ "value": "ButtonValue1"
+ },
+ {
+ "text": "ButtonText2",
+ "value": "ButtonValue2"
+ }
+ ],
+ "subTitle": "CardSubtitle",
+ "imageUrl": "CardUrl",
+ "title": "CardTitle"
+ },
+ "t": "Topic",
+ "kendraRedirectQueryArgs": [
+ "KendraArg1",
+ "KendraArg2"
+ ],
+ "botRouting": {
+ "specialty_bot": "lexv2::7T1CYCPYVK",
+ "specialty_bot_name": "Testbot",
+ "specialty_bot_session_attributes_to_merge": "1,2,3"
+ },
+ "rp": "AlexaReprompt",
+ "q": [
+ "Question1",
+ "Question2"
+ ]
+ }
+ ]
+}
\ No newline at end of file
diff --git a/.nightswatch/functional/question_bank/import_questions_slot.json b/.nightswatch/functional/question_bank/import_questions_slot.json
new file mode 100644
index 000000000..f91dbb270
--- /dev/null
+++ b/.nightswatch/functional/question_bank/import_questions_slot.json
@@ -0,0 +1,20 @@
+{
+ "qna": [
+ {
+ "descr": "Description",
+ "resolutionStrategyRestrict": true,
+ "type": "slottype",
+ "qid": "Import.006",
+ "slotTypeValues": [
+ {
+ "synonyms": "Synonyms",
+ "samplevalue": "Value1"
+ },
+ {
+ "synonyms": "Synonyms2",
+ "samplevalue": "Value2"
+ }
+ ]
+ }
+ ]
+}
\ No newline at end of file
diff --git a/.nightswatch/functional/question_bank/import_questions_text.json b/.nightswatch/functional/question_bank/import_questions_text.json
new file mode 100644
index 000000000..ef9d5c771
--- /dev/null
+++ b/.nightswatch/functional/question_bank/import_questions_text.json
@@ -0,0 +1,52 @@
+{
+ "qna": [
+ {
+ "passage": "Passage",
+ "kendraRedirectQueryText": "KendraRedirect",
+ "refMarkdown": "ReferenceLinks",
+ "conditionalChaining": "ChainingRule",
+ "l": "LambdaHook",
+ "type": "text",
+ "qid": "Import.007",
+ "sa": [
+ {
+ "enableTranslate": true,
+ "text": "SessionAttributeName1",
+ "value": "SessionAttributeValue1"
+ },
+ {
+ "text": "SessionAttributeName2",
+ "value": "SessionAttributeValue2"
+ }
+ ],
+ "kendraRedirectQueryConfidenceThreshold": "LOW",
+ "clientFilterValues": "ClientFilter",
+ "tags": "Tags",
+ "args": [
+ "Arg1",
+ "Arg2"
+ ],
+ "r": {
+ "buttons": [
+ {
+ "text": "ButtonText1",
+ "value": "ButtonValue1"
+ },
+ {
+ "text": "ButtonText2",
+ "value": "ButtonValue2"
+ }
+ ],
+ "subTitle": "CardSubtitle",
+ "imageUrl": "CardUrl",
+ "title": "CardTitle"
+ },
+ "t": "Topic",
+ "kendraRedirectQueryArgs": [
+ "KendraQuery1",
+ "KendraQuery2"
+ ],
+ "rp": "AlexReprompt"
+ }
+ ]
+}
\ No newline at end of file
diff --git a/.nightswatch/functional/question_bank/kendra_questions.json b/.nightswatch/functional/question_bank/kendra_questions.json
new file mode 100644
index 000000000..942bd44d8
--- /dev/null
+++ b/.nightswatch/functional/question_bank/kendra_questions.json
@@ -0,0 +1,12 @@
+ï»ż{
+ "qna": [
+ {
+ "a": "Check out these links:",
+ "kendraRedirectQueryText": "alexa AND \"custom skill\"",
+ "type": "qna",
+ "qid": "Kendra.001",
+ "kendraRedirectQueryConfidenceThreshold": "LOW",
+ "q": ["Unrelated question about Kindle"]
+ }
+ ]
+}
diff --git a/.nightswatch/functional/question_bank/llm_questions.json b/.nightswatch/functional/question_bank/llm_questions.json
new file mode 100644
index 000000000..06c062474
--- /dev/null
+++ b/.nightswatch/functional/question_bank/llm_questions.json
@@ -0,0 +1,9 @@
+ï»ż{
+ "qna": [
+ {
+ "passage": "Humpty Dumpty sat on the wall, Humpty Dumpty had a great fall, All the king's horses and all the king's men, Couldn't put Humpty together again.",
+ "type": "text",
+ "qid": "LLM.001"
+ }
+ ]
+}
diff --git a/.nightswatch/functional/question_bank/question_designer_questions.json b/.nightswatch/functional/question_bank/question_designer_questions.json
new file mode 100644
index 000000000..a0cf2b884
--- /dev/null
+++ b/.nightswatch/functional/question_bank/question_designer_questions.json
@@ -0,0 +1,214 @@
+{
+ "qna": [
+ {
+ "qid": "Designer.001",
+ "type": "qna",
+ "q": ["What is delicious?"],
+ "a": "candy"
+ },
+ {
+ "qid": "Quiz.001",
+ "type": "quiz",
+ "question": "Which celestial object is a planet?",
+ "correctAnswers": ["Earth", "Mars"],
+ "incorrectAnswers": ["Pluto", "Moon"]
+ },
+ {
+ "qid": "Quiz.002",
+ "type": "qna",
+ "q": ["Quiz start"],
+ "a": "Let's start the quiz:",
+ "l": "QNA:ExampleJSLambdaQuiz",
+ "args": "Quiz.001"
+ },
+ {
+ "a": "{{#ifCond Slots.Confirmation '==' 'yes'}}\nOkay, I have confirmed your reservation. The reservation details are below:\n- **Name**: {{Slots.Name}}\n- **Departing City:** {{Slots.DepartureCity}}\n- **Destination**: {{Slots.ArrivalCity}}\n- **Date**: {{Slots.Date}}\n- **Time**: {{Slots.Time}}\n{{else}}\nOkay, I have cancelled your reservation in progress.\n{{/ifCond}}",
+ "slots": [
+ {
+ "slotName": "Name",
+ "slotPrompt": "What is the name of the passenger?",
+ "slotRequired": true,
+ "slotType": "AMAZON.FirstName"
+ },
+ {
+ "slotName": "Date",
+ "slotPrompt": "What date do you want to book the flight?",
+ "slotRequired": true,
+ "slotType": "AMAZON.Date"
+ },
+ {
+ "slotName": "DepartureCity",
+ "slotPrompt": "What city are you departing from?",
+ "slotRequired": true,
+ "slotType": "AMAZON.City"
+ },
+ {
+ "slotName": "ArrivalCity",
+ "slotPrompt": "What city are you traveling to?",
+ "slotRequired": true,
+ "slotType": "AMAZON.City"
+ },
+ {
+ "slotName": "Time",
+ "slotPrompt": "What time do you wish to depart? The available times are 0800, 1200, 1400.",
+ "slotRequired": true,
+ "slotType": "Slot.003"
+ },
+ {
+ "slotName": "Confirmation",
+ "slotPrompt": "Should I confirm the reservation?",
+ "slotRequired": true,
+ "slotType": "Slot.002"
+ }
+ ],
+ "enableQidIntent": true,
+ "type": "qna",
+ "qid": "Slot.001",
+ "q": ["I want to book a flight"]
+ },
+ {
+ "descr": "Confirmation phrases",
+ "resolutionStrategyRestrict": true,
+ "questions": [],
+ "type": "slottype",
+ "qid": "Slot.002",
+ "slotTypeValues": [
+ {
+ "synonyms": "yep,Y,yeah,please do,yes please,confirm,sure",
+ "samplevalue": "yes"
+ },
+ {
+ "synonyms": "nope,cancel,N",
+ "samplevalue": "no"
+ }
+ ],
+ "_id": "Slot.002"
+ },
+ {
+ "descr": "Available flight times",
+ "resolutionStrategyRestrict": true,
+ "questions": [],
+ "type": "slottype",
+ "qid": "Slot.003",
+ "slotTypeValues": [
+ {
+ "synonyms": "8:00,8am,8:00am",
+ "samplevalue": "0800"
+ },
+ {
+ "synonyms": "12:00,12pm,12:00pm",
+ "samplevalue": "1200"
+ },
+ {
+ "synonyms": "2:00,2pm,2:00pm",
+ "samplevalue": "1400"
+ }
+ ],
+ "_id": "Slot.003"
+ },
+ {
+ "a": "Echo Show brings you everything you love about Alexa, and now she can show you things. She is the perfect companion for Q and A Bot.",
+ "r": {
+ "buttons": [
+ {
+ "text": "Tell me more",
+ "value": "Tell me more"
+ },
+ {
+ "text": "Not interested",
+ "value": "Not interested"
+ }
+ ],
+ "subTitle": "Echo Show",
+ "imageUrl": "https://images-na.ssl-images-amazon.com/images/I/61OddH8ddDL._SL1000_.jpg",
+ "title": "Echo Show"
+ },
+ "type": "qna",
+ "qid": "Card.001",
+ "q": ["What is the Echo Show?"]
+ },
+ {
+ "a": "A household robot for home monitoring.",
+ "t": "Astro",
+ "type": "qna",
+ "qid": "Topic.001",
+ "q": ["What is Amazon Astro?"]
+ },
+ {
+ "a": "$1,599.99",
+ "t": "Astro",
+ "type": "qna",
+ "qid": "Topic.002",
+ "q": ["How much does it cost?"]
+ },
+ {
+ "a": "An automatic soap dispenser that counts down for you.",
+ "t": "Soap",
+ "type": "qna",
+ "qid": "Topic.003",
+ "q": ["What is Amazon Smart Soap Dispenser?"]
+ },
+ {
+ "a": "$34.99",
+ "t": "Soap",
+ "type": "qna",
+ "qid": "Topic.004",
+ "q": ["How much does it cost?"]
+ },
+ {
+ "a": "You have interacted with me {{UserInfo.InteractionCount}} times.",
+ "type": "qna",
+ "qid": "Handlebars.001",
+ "q": ["What is my interaction count?"]
+ },
+ {
+ "a": "It seems like you are asking about: {{getQuestion}}",
+ "type": "qna",
+ "qid": "Handlebars.002",
+ "q": ["How do I use handlebars to return a matched question?"]
+ },
+ {
+ "a": "Hello. Can you give me your First Name and Last Name please.",
+ "elicitResponse": {
+ "response_sessionattr_namespace": "name_of_user",
+ "responsebot_hook": "QNAName"
+ },
+ "type": "qna",
+ "qid": "Elicit.001",
+ "q": ["Ask my name"]
+ },
+ {
+ "a": "Hello {{SessionAttributes.name_of_user.FirstName}} â What is your age in years?",
+ "elicitResponse": {
+ "response_sessionattr_namespace": "age_of_user",
+ "responsebot_hook": "QNAAge"
+ },
+ "type": "qna",
+ "qid": "Elicit.002",
+ "q": ["Ask my age"]
+ },
+ {
+ "a": "You are young!",
+ "type": "qna",
+ "qid": "Elicit.003",
+ "q": ["Under 18"]
+ },
+ {
+ "a": "You are old!",
+ "type": "qna",
+ "qid": "Elicit.004",
+ "q": ["Over 18 answer"]
+ },
+ {
+ "a": "Don't use this answer.",
+ "alt": {
+ "markdown": "# Markdown\nYou can use the [Markdown Cheat Sheet](https://www.markdownguide.org/cheat-sheet/) to make your answers *dynamic*.\n\nHere are some examples:\n**bold text**\n\n> blockquote\n\n1. First item\n2. Second item\n3. Third item\n---\n- First item\n- Second item\n- Third item\n---\n`code`\n\n| Syntax | Description |\n| ----------- | ----------- |\n| Header | Title |\n| Paragraph | Text |\n\n```\n{\n \"firstName\": \"John\",\n \"lastName\": \"Smith\",\n \"age\": 25\n}\n```\n\n- [x] Write the press release\n- [ ] Update the website\n- [ ] Contact the media\n\n![West Coast Grocery](https://github.com/aws-solutions/qnabot-on-aws/blob/main/assets/examples/photos/west%20coast%20grocery.jpg?raw=true)\n"
+ },
+ "type": "qna",
+ "qid": "Markdown.001",
+ "q": [
+ "How do I use rich text in my answers?"
+ ]
+ }
+ ]
+}
diff --git a/.nightswatch/functional/question_bank/routing_questions.json b/.nightswatch/functional/question_bank/routing_questions.json
new file mode 100644
index 000000000..f64fc95d4
--- /dev/null
+++ b/.nightswatch/functional/question_bank/routing_questions.json
@@ -0,0 +1,54 @@
+{
+ "qna": [
+ {
+ "a": "One second. Let me get him for you...",
+ "type": "qna",
+ "qid": "Routing.001",
+ "botRouting": {
+ "specialty_bot": "lexv2::7T1CYCPYVK/TSTALIASID/en_US",
+ "specialty_bot_name": "test_bot",
+ "specialty_bot_session_attributes_to_merge": "myAttribute"
+ },
+ "sa": [
+ {
+ "text": "myAttribute",
+ "value": "test"
+ }
+ ],
+ "q": ["I want to talk to test bot"]
+ },
+ {
+ "a": "One second. Let me get test bot for you...",
+ "conditionalChaining": "(SessionAttributes.specialtyBot.botAttribute == \"test\") ? \"Specialty Bot Attribute\" : \"No Specialty Bot Attribute\"",
+ "type": "qna",
+ "qid": "Routing.002",
+ "botRouting": {
+ "specialty_bot": "lexv2::7T1CYCPYVK/TSTALIASID/en_US",
+ "specialty_bot_name": "test_bot",
+ "specialty_bot_session_attributes_to_merge": "myAttribute",
+ "specialty_bot_start_up_text": "${utterance}",
+ "specialty_bot_session_attributes_to_receive": "botAttribute",
+ "specialty_bot_session_attributes_to_receive_namespace": "specialtyBot"
+ },
+ "sa": [
+ {
+ "text": "myAttribute",
+ "value": "test"
+ }
+ ],
+ "q": ["Give me an attribute"]
+ },
+ {
+ "a": "You just received a session attribute from test bot.",
+ "type": "qna",
+ "qid": "Routing.003",
+ "q": ["Specialty Bot Attribute"]
+ },
+ {
+ "a": "Something went wrong.",
+ "type": "qna",
+ "qid": "Routing.004",
+ "q": ["No Specialty Bot Attribute"]
+ }
+ ]
+}
diff --git a/.nightswatch/functional/question_bank/session_attribute_questions.json b/.nightswatch/functional/question_bank/session_attribute_questions.json
new file mode 100644
index 000000000..50f077712
--- /dev/null
+++ b/.nightswatch/functional/question_bank/session_attribute_questions.json
@@ -0,0 +1,34 @@
+{
+ "qna": [
+ {
+ "a": "I have set \"Amazon\" as your session attribute.",
+ "type": "qna",
+ "qid": "Session.002",
+ "sa": [
+ {
+ "text": "myAttribute",
+ "value": "Amazon"
+ }
+ ],
+ "q": ["Set session attribute as Amazon"]
+ },
+ {
+ "a": "I have set a session attribute for \"AWS\".\n\n\"{{setSessionAttr 'myAttribute' 'AWS'}}\"",
+ "type": "qna",
+ "qid": "Session.003",
+ "q": ["Set session attribute as AWS"]
+ },
+ {
+ "a": "Here is your session attribute: \"{{getSessionAttr 'missingAttribute' 'default'}}\"",
+ "type": "qna",
+ "qid": "Session.001",
+ "q": ["Get empty session attribute"]
+ },
+ {
+ "a": "Here is your session attribute: \"{{getSessionAttr 'myAttribute' 'empty'}}\"",
+ "type": "qna",
+ "qid": "Session.004",
+ "q": ["Get session attribute"]
+ }
+ ]
+}
diff --git a/.nightswatch/functional/question_bank/settings_questions.json b/.nightswatch/functional/question_bank/settings_questions.json
new file mode 100644
index 000000000..692610e81
--- /dev/null
+++ b/.nightswatch/functional/question_bank/settings_questions.json
@@ -0,0 +1,12 @@
+{
+ "qna": [
+ {
+ "a": "You stumped me, I don't currently know the answer to that question.",
+ "type": "qna",
+ "qid": "CustomNoMatches",
+ "q": [
+ "no_hits"
+ ]
+ }
+ ]
+}
\ No newline at end of file
diff --git a/.nightswatch/functional/question_bank/translate_questions.json b/.nightswatch/functional/question_bank/translate_questions.json
new file mode 100644
index 000000000..7bde454bd
--- /dev/null
+++ b/.nightswatch/functional/question_bank/translate_questions.json
@@ -0,0 +1,22 @@
+{
+ "qna": [
+ {
+ "qid": "Translate.001",
+ "type": "qna",
+ "q": ["Can you tell me why I should use custom terminology?"],
+ "a": "Using custom terminology enables you to ensure your brand names aren't modified during translation."
+ },
+ {
+ "a": "{{#ifLang 'es'}}\n Significa mucho para mĂ\n{{/ifLang}}\n\n{{#defaultLang}}\n It means a lot to them\n{{/defaultLang}}",
+ "type": "qna",
+ "qid": "Translate.002",
+ "q": ["Why should you say mucho when talking to your Hispanic friends?"]
+ },
+ {
+ "qid": "Translate.003",
+ "type": "qna",
+ "q": ["Test fees translation"],
+ "a": "You can import this product without incurring any fees or custom duties."
+ }
+ ]
+}
diff --git a/.nightswatch/functional/test_1_login.py b/.nightswatch/functional/test_1_login.py
new file mode 100644
index 000000000..f31d5fe33
--- /dev/null
+++ b/.nightswatch/functional/test_1_login.py
@@ -0,0 +1,62 @@
+######################################################################################################################
+# Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. #
+# #
+# Licensed under the Apache License, Version 2.0 (the "License"). You may not use this file except in compliance #
+# with the License. A copy of the License is located at #
+# #
+# http://www.apache.org/licenses/LICENSE-2.0 #
+# #
+# or in the 'license' file accompanying this file. This file is distributed on an 'AS IS' BASIS, WITHOUT WARRANTIES #
+# OR CONDITIONS OF ANY KIND, express or implied. See the License for the specific language governing permissions #
+# and limitations under the License. #
+######################################################################################################################
+
+import pytest
+import time
+import os
+
+from helpers.cognito_client import CognitoClient
+from helpers.website_model.menu_nav import MenuNav
+from helpers.cfn_parameter_fetcher import ParameterFetcher
+from helpers.cognito_client import CognitoClient
+from helpers.website_model.dom_operator import DomOperator
+
+class TestLogin:
+
+ @pytest.mark.skipif(os.getenv("USER") != None and os.getenv("PASSWORD") != None, reason="Skipping user creation; user provided.")
+ def test_admin_user_creation(self, region: str, param_fetcher: ParameterFetcher, username: str, temporary_password: str, password: str, email: str):
+ """
+ Test creates a user and updates the password
+ """
+
+ cognito_client = CognitoClient(region, param_fetcher.get_user_pool_id(), param_fetcher.get_designer_client_id())
+ admin_user_code_create_auth = cognito_client.create_admin_and_set_password(username, temporary_password, password, email)
+
+ assert admin_user_code_create_auth == 200
+
+ def test_designer_login(self, designer_login):
+ """
+ Test login to designer
+ """
+ title = designer_login
+ assert title == 'QnABot Designer'
+
+ def test_designer_logout(self, designer_login, dom_operator: DomOperator):
+ """
+ Test logout from designer
+ """
+ menu = MenuNav(dom_operator)
+ time.sleep(10)
+ menu.logout()
+ time.sleep(3)
+ title = dom_operator.get_title()
+
+ assert title == 'Signin'
+
+ def test_client_login(self, client_login):
+ """
+ Test login to client
+ """
+ title = client_login
+ assert title == 'QnABot Client'
+
diff --git a/.nightswatch/functional/test_2_import.py b/.nightswatch/functional/test_2_import.py
new file mode 100644
index 000000000..55423359e
--- /dev/null
+++ b/.nightswatch/functional/test_2_import.py
@@ -0,0 +1,190 @@
+######################################################################################################################
+# Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. #
+# #
+# Licensed under the Apache License, Version 2.0 (the "License"). You may not use this file except in compliance #
+# with the License. A copy of the License is located at #
+# #
+# http://www.apache.org/licenses/LICENSE-2.0 #
+# #
+# or in the 'license' file accompanying this file. This file is distributed on an 'AS IS' BASIS, WITHOUT WARRANTIES #
+# OR CONDITIONS OF ANY KIND, express or implied. See the License for the specific language governing permissions #
+# and limitations under the License. #
+######################################################################################################################
+
+import pytest
+import pathlib
+import time
+import json
+
+from helpers.website_model.menu_nav import MenuNav
+from helpers.website_model.dom_operator import DomOperator
+class TestImport:
+
+ def test_setup(self, designer_login, dom_operator: DomOperator):
+ qids = ['Import.001', 'Import.004', 'Import.006', 'Import.007']
+ menu = MenuNav(dom_operator)
+ edit_page = menu.open_edit_page()
+
+ for qid in qids:
+ if edit_page.check_question_exists_by_qid(qid):
+ edit_page.delete_question_by_qid(qid)
+
+ def test_designer_import_questions(self, designer_login, dom_operator: DomOperator):
+ """
+ Test that designer can import questions from the import page.
+ """
+ menu = MenuNav(dom_operator)
+ import_page = menu.open_import_page()
+ import_page.import_blog_examples()
+ edit_page = menu.open_edit_page()
+ bot_questions = edit_page.select_question_by_qid('Admin.001', 4).text
+
+ assert bot_questions == 'How do I modify Q and A Bot content'
+
+ def test_designer_import_questions_json(self, designer_login, dom_operator: DomOperator):
+ """
+ Test that designer can import questions from the import page using JSON format.
+
+ See: https://docs.aws.amazon.com/solutions/latest/qnabot-on-aws/importing-and-exporting-chatbot-answers.html
+ """
+ menu = MenuNav(dom_operator)
+ import_page = menu.open_import_page()
+
+ json_file = f'{pathlib.Path().resolve()}/question_bank/import_questions.json'
+ import_page.import_file(json_file)
+
+ edit_page = menu.open_edit_page()
+ edit_page.refresh_questions()
+
+ validation_file = open(json_file)
+ expected_question = json.load(validation_file)['qna'][0]
+ validation_file.close()
+
+ assert edit_page.check_question_exists_by_qid(expected_question['qid'])
+ assert edit_page.match_question_field_values(**expected_question)
+ # Need to clean up after test since the question is hidden in the DOM and can still be selected in other tests
+ edit_page.delete_question_by_qid(expected_question['qid'])
+
+ def test_designer_import_questions_xlsx(self, designer_login, dom_operator: DomOperator):
+ """
+ Test that designer can import questions from the import page using xlsx format.
+
+ See: https://docs.aws.amazon.com/solutions/latest/qnabot-on-aws/importing-and-exporting-chatbot-answers.html
+ """
+ qids = ['Import.002', 'Import.003']
+ menu = MenuNav(dom_operator)
+ edit_page = menu.open_edit_page()
+ for qid in qids:
+ if edit_page.check_question_exists_by_qid(qid):
+ edit_page.delete_question_by_qid(qid)
+
+ import_page = menu.open_import_page()
+ xlsx_file = f'{pathlib.Path().resolve()}/files/import-pass.xlsx'
+ import_page.import_file(xlsx_file)
+ edit_page = menu.open_edit_page()
+
+ bot_questions = edit_page.select_question_by_qid(qids[0], 4).text
+ assert bot_questions == 'How do I import questions in content designer?'
+
+ bot_questions = edit_page.select_question_by_qid(qids[1], 4).text
+ assert bot_questions == 'Can I import multiple answers when I import with excel?'
+
+ validation_file = open('./files/import-pass-expected.json')
+ expected_questions = json.load(validation_file)['qna']
+ validation_file.close()
+ for question in expected_questions:
+ assert edit_page.match_question_field_values(**question)
+
+ def test_designer_import_questions_xlsx_fail(self, designer_login, dom_operator: DomOperator):
+ """
+ Test that designer responds back with errors when questions cannot be imported using xlsx format.
+
+ See: https://docs.aws.amazon.com/solutions/latest/qnabot-on-aws/importing-and-exporting-chatbot-answers.html
+ """
+ menu = MenuNav(dom_operator)
+ import_page = menu.open_import_page()
+
+ xlsx_file = f'{pathlib.Path().resolve()}/files/import-fail.xlsx'
+ import_page.import_file(xlsx_file)
+ error = import_page.get_import_file_error()
+
+ assert 'Error Loading Content' in error
+ assert 'Warning: No questions found for QID: "NoQuestionWarning". The question will be skipped.' in error
+ assert 'Warning: No answer found for QID:"NoAnswerWarning". The question will be skipped.' in error
+ assert 'Warning: No QID found for line 4. The question will be skipped.' in error
+
+ def test_designer_import_questions_qna(self, designer_login, dom_operator: DomOperator):
+ """
+ Test that designer can import QNA type questions from the import page using JSON format.
+
+ See: https://docs.aws.amazon.com/solutions/latest/qnabot-on-aws/importing-and-exporting-chatbot-answers.html
+ """
+ menu = MenuNav(dom_operator)
+ import_page = menu.open_import_page()
+
+ json_file = f'{pathlib.Path().resolve()}/question_bank/import_questions_qna.json'
+ import_page.import_file(json_file)
+
+ edit_page = menu.open_edit_page()
+ edit_page.refresh_questions()
+
+ validation_file = open(json_file)
+ expected_question = json.load(validation_file)['qna'][0]
+ validation_file.close()
+
+ assert edit_page.check_question_exists_by_qid(expected_question['qid'])
+ assert edit_page.match_question_field_values(**expected_question)
+ # Need to clean up after test since the question is hidden in the DOM and can still be selected in other tests
+ edit_page.delete_question_by_qid(expected_question['qid'])
+
+ @pytest.mark.skip(reason='Bug in import page')
+ def test_designer_import_questions_quiz(self, designer_login, dom_operator: DomOperator):
+ pass
+
+ def test_designer_import_questions_slot(self, designer_login, dom_operator: DomOperator):
+ """
+ Test that designer can import slot type questions from the import page using JSON format.
+
+ See: https://docs.aws.amazon.com/solutions/latest/qnabot-on-aws/importing-and-exporting-chatbot-answers.html
+ """
+ menu = MenuNav(dom_operator)
+ import_page = menu.open_import_page()
+
+ json_file = f'{pathlib.Path().resolve()}/question_bank/import_questions_slot.json'
+ import_page.import_file(json_file)
+
+ edit_page = menu.open_edit_page()
+ edit_page.refresh_questions()
+
+ validation_file = open(json_file)
+ expected_question = json.load(validation_file)['qna'][0]
+ validation_file.close()
+
+ assert edit_page.check_question_exists_by_qid(expected_question['qid'])
+ assert edit_page.match_question_field_values(**expected_question)
+ # Need to clean up after test since the question is hidden in the DOM and can still be selected in other tests
+ edit_page.delete_question_by_qid(expected_question['qid'])
+
+ def test_designer_import_questions_text(self, designer_login, dom_operator: DomOperator):
+ """
+ Test that designer can import text type questions from the import page using JSON format.
+
+ See: https://docs.aws.amazon.com/solutions/latest/qnabot-on-aws/importing-and-exporting-chatbot-answers.html
+ """
+ menu = MenuNav(dom_operator)
+ import_page = menu.open_import_page()
+
+ json_file = f'{pathlib.Path().resolve()}/question_bank/import_questions_text.json'
+ import_page.import_file(json_file)
+
+ edit_page = menu.open_edit_page()
+ edit_page.refresh_questions()
+
+ validation_file = open(json_file)
+ expected_question = json.load(validation_file)['qna'][0]
+ validation_file.close()
+
+ assert edit_page.check_question_exists_by_qid(expected_question['qid'])
+ assert edit_page.match_question_field_values(**expected_question)
+ # Need to clean up after test since the question is hidden in the DOM and can still be selected in other tests
+ edit_page.delete_question_by_qid(expected_question['qid'])
diff --git a/.nightswatch/functional/test_embeddings.py b/.nightswatch/functional/test_embeddings.py
new file mode 100644
index 000000000..e78dee33f
--- /dev/null
+++ b/.nightswatch/functional/test_embeddings.py
@@ -0,0 +1,73 @@
+######################################################################################################################
+# Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. #
+# #
+# Licensed under the Apache License, Version 2.0 (the "License"). You may not use this file except in compliance #
+# with the License. A copy of the License is located at #
+# #
+# http://www.apache.org/licenses/LICENSE-2.0 #
+# #
+# or in the 'license' file accompanying this file. This file is distributed on an 'AS IS' BASIS, WITHOUT WARRANTIES #
+# OR CONDITIONS OF ANY KIND, express or implied. See the License for the specific language governing permissions #
+# and limitations under the License. #
+######################################################################################################################
+
+import pytest
+import time
+import json
+
+from helpers.cloud_watch_client import CloudWatchClient
+from helpers.website_model.menu_nav import MenuNav
+from helpers.website_model.dom_operator import DomOperator
+
+QUESTION_FILEPATH = './question_bank/embeddings_questions.json'
+
+# https://docs.aws.amazon.com/solutions/latest/qnabot-on-aws/semantic-question-matching.html
+@pytest.mark.skipif_embeddings_not_enabled()
+class TestEmbeddings:
+
+ @pytest.fixture(scope='class')
+ def loaded_questions(self) -> list[dict]:
+ question_file = open(QUESTION_FILEPATH)
+ data = json.load(question_file)
+ question_file.close()
+ return data['qna']
+
+ def __create_question(self, question: dict, edit_page):
+ qid = question['qid']
+ if edit_page.check_question_exists_by_qid(qid):
+ edit_page.delete_question_by_qid(qid)
+ edit_page.add_question(**question)
+
+ def __get_question_by_qid(self, qid, loaded_questions: list[dict]) -> dict:
+ return [q for q in loaded_questions if q['qid'] == qid][0]
+
+ def test_setup(self, designer_login, dom_operator: DomOperator):
+ """
+ Overrides deployment settings before running other tests.
+ """
+ menu = MenuNav(dom_operator)
+ settings_page = menu.open_settings_page()
+ settings_page.reset_settings()
+ assert 'Success' in settings_page.disable_llm()
+ assert 'Success' in settings_page.disable_filter()
+ assert 'Success' in settings_page.enable_embeddings()
+
+ def test_semantic_matching(self, designer_login, loaded_questions: list[dict], dom_operator: DomOperator, cw_client: CloudWatchClient):
+ """
+ Test that the bot can answer questions based on semantic matching.
+ """
+ qid = 'Embed.001'
+ question = self.__get_question_by_qid(qid, loaded_questions)
+
+ menu = MenuNav(dom_operator)
+ edit_page = menu.open_edit_page()
+
+ self.__create_question(question, edit_page)
+
+ chat_page = menu.open_chat_page()
+
+ chat_page.send_message('Where does the president live?')
+ answer = chat_page.get_messages()
+ assert question['a'] in answer
+ cw_client.print_fulfillment_lambda_logs()
+
diff --git a/.nightswatch/functional/test_export.py b/.nightswatch/functional/test_export.py
new file mode 100644
index 000000000..48d2c5ef4
--- /dev/null
+++ b/.nightswatch/functional/test_export.py
@@ -0,0 +1,29 @@
+######################################################################################################################
+# Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. #
+# #
+# Licensed under the Apache License, Version 2.0 (the "License"). You may not use this file except in compliance #
+# with the License. A copy of the License is located at #
+# #
+# http://www.apache.org/licenses/LICENSE-2.0 #
+# #
+# or in the 'license' file accompanying this file. This file is distributed on an 'AS IS' BASIS, WITHOUT WARRANTIES #
+# OR CONDITIONS OF ANY KIND, express or implied. See the License for the specific language governing permissions #
+# and limitations under the License. #
+######################################################################################################################
+
+import pytest
+
+from helpers.website_model.menu_nav import MenuNav
+from helpers.website_model.dom_operator import DomOperator
+
+class TestExport:
+# https://docs.aws.amazon.com/solutions/latest/qnabot-on-aws/importing-and-exporting-chatbot-answers.html
+
+ def test_designer_export_questions(self, designer_login, dom_operator: DomOperator):
+ """
+ Test export questions using the designer.
+ """
+ menu = MenuNav(dom_operator)
+ export_page = menu.open_export_page()
+ export_page.generate_export('export', 'Export')
+
diff --git a/.nightswatch/functional/test_kendra.py b/.nightswatch/functional/test_kendra.py
new file mode 100644
index 000000000..0a7aaa1a0
--- /dev/null
+++ b/.nightswatch/functional/test_kendra.py
@@ -0,0 +1,232 @@
+######################################################################################################################
+# Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. #
+# #
+# Licensed under the Apache License, Version 2.0 (the "License"). You may not use this file except in compliance #
+# with the License. A copy of the License is located at #
+# #
+# http://www.apache.org/licenses/LICENSE-2.0 #
+# #
+# or in the 'license' file accompanying this file. This file is distributed on an 'AS IS' BASIS, WITHOUT WARRANTIES #
+# OR CONDITIONS OF ANY KIND, express or implied. See the License for the specific language governing permissions #
+# and limitations under the License. #
+######################################################################################################################
+
+import pytest
+import json
+import time
+import os
+
+from helpers.kendra_client import KendraClient
+from helpers.cloud_watch_client import CloudWatchClient
+from helpers.website_model.menu_nav import MenuNav
+from helpers.website_model.dom_operator import DomOperator
+from helpers.website_model.chat_page import ChatPage
+
+region = os.environ.get('CURRENT_STACK_REGION')
+kendra_regions = ['us-east-1', 'us-west-2', 'ap-southeast-1', 'ap-southeast-2', 'ca-central-1', 'eu-west-1']
+unsupported_region_reason = 'Region Not Supported'
+
+QUESTION_FILEPATH = './question_bank/kendra_questions.json'
+
+KENDRA_ANSWER_MESSAGE = 'While I did not find an exact answer, these search results from Amazon Kendra might be helpful.'
+
+@pytest.mark.skipif(region not in kendra_regions, reason=unsupported_region_reason)
+@pytest.mark.skipif_kendra_not_enabled()
+class TestKendra:
+
+ @pytest.fixture(scope='class')
+ def loaded_questions(self) -> list[dict]:
+ question_file = open(QUESTION_FILEPATH, encoding='utf-8-sig')
+ data = json.load(question_file)
+ question_file.close()
+ return data['qna']
+
+ def __create_question(self, question: dict, edit_page):
+ qid = question['qid']
+ if edit_page.check_question_exists_by_qid(qid):
+ edit_page.delete_question_by_qid(qid)
+ edit_page.add_question(**question)
+
+ def __get_question_by_qid(self, qid, loaded_questions: list[dict]) -> dict:
+ return [q for q in loaded_questions if q['qid'] == qid][0]
+
+ def test_setup(self, designer_login, dom_operator: DomOperator):
+ """
+ Overrides deployment settings before running other tests.
+ """
+ menu = MenuNav(dom_operator)
+ settings_page = menu.open_settings_page()
+ settings_page.reset_settings()
+ assert 'Success' in settings_page.enable_filter()
+ assert 'Success' in settings_page.disable_embeddings()
+ assert 'Success' in settings_page.disable_llm()
+ assert 'Success' in settings_page.enable_kendra('https://developer.amazon.com/en-US/alexa,https://www.amazon.com/s?k=kindle', doc_count=1)
+
+ def test_sync_kendra_faq(self, designer_login, dom_operator: DomOperator):
+ """
+ Test that the FAQ is synced using the Kendra page sync button.
+ """
+ menu = MenuNav(dom_operator)
+ edit_page = menu.open_edit_page()
+ status = edit_page.sync_kendra_faq()
+ assert status == 'Success!'
+
+ def test_kendra_service_faq(self, kendra_client: KendraClient):
+ """
+ Test the FAQ is successfully created and active.
+ """
+ response = kendra_client.list_faqs()
+ faq_summary_items = response['FaqSummaryItems']
+ for faq_summary_item in faq_summary_items:
+ faq_name = faq_summary_item['Name']
+ faq_status = faq_summary_item['Status']
+
+ assert faq_name == 'qna-facts' and faq_status == 'ACTIVE'
+
+ def test_delete_kendra_faq(self, kendra_client: KendraClient):
+ """
+ Test the FAQ is successfully deleted.
+ """
+ response = kendra_client.list_faqs()
+ faq_summary_items = response['FaqSummaryItems']
+ for faq_summary_item in faq_summary_items:
+ faq_id = faq_summary_item['Id']
+
+ response = kendra_client.delete_faq_by_id(faq_id)
+ status = response['ResponseMetadata']['HTTPStatusCode']
+ assert status == 200
+
+ def test_start_crawling(self, designer_login, dom_operator: DomOperator):
+ """
+ Test that the web crawler is started.
+
+ Required for the next step.
+ """
+ menu = MenuNav(dom_operator)
+ kendra_page = menu.open_kendra_page()
+ status = kendra_page.index()
+
+ attempts = 1
+ max_attempts = 3
+ while 'SYNCING' not in status:
+ wait = 1 * 2 ** attempts
+ print(f'Current status is: {status}. Waiting {wait}ms after {attempts} unsuccessful attempts.')
+ time.sleep(wait)
+ status = kendra_page.index()
+ attempts += 1
+
+ if attempts == max_attempts:
+ break
+
+ assert 'SYNCING' in status
+
+ def test_kendra_data_sources_status(self, kendra_client: KendraClient):
+ """
+ Test that the data sources are successfully created and active.
+ """
+ timeout = 60
+ check_every = 30
+ elapsed_time = 0
+ status = ['INACTIVE']
+
+ while elapsed_time <= timeout:
+ response = kendra_client.list_data_sources()
+ status = [ summary_item['Status'] for summary_item in response['SummaryItems'] ]
+
+ if all(state == 'ACTIVE' for state in status):
+ break
+
+ time.sleep(check_every)
+ elapsed_time += check_every
+
+ assert all(state == 'ACTIVE' for state in status) == True
+
+ def test_kendra_data_sources_results(self, kendra_client: KendraClient):
+ """
+ Test that the data sources return results based on a query.
+ """
+ timeout = 600
+ check_every = 30
+ elapsed_time = 0
+ queries = ['Amazon Kindle', 'Alexa AND "custom skill"']
+ kendra_results = [0, 0]
+
+ while elapsed_time <= timeout:
+ kendra_results = [kendra_client.query(query)['TotalNumberOfResults'] for query in queries]
+
+ if all(result > 0 for result in kendra_results):
+ break
+
+ time.sleep(check_every)
+ elapsed_time += check_every
+
+ assert all(result > 0 for result in kendra_results) == True
+
+ def test_kendra_fallback(self, designer_login, dom_operator: DomOperator, cw_client: CloudWatchClient):
+ """
+ Test that the Kendra fallback is used when no answer is found.
+
+ See: https://github.com/aws-solutions/aws-qnabot/blob/main/docs/Kendra_Fallback_README.md
+ """
+ menu = MenuNav(dom_operator)
+ kendra_page = menu.open_kendra_page()
+ timeout = 300
+ check_every = 60
+ elapsed_time = 0
+ while elapsed_time <= timeout:
+ status = kendra_page.get_crawling_status()
+ if 'SYNCING' not in status:
+ break
+ time.sleep(check_every)
+ elapsed_time += check_every
+
+ chat_page = menu.open_chat_page()
+
+ chat_page.send_message('How can I publish Kindle books?')
+ answer = chat_page.get_messages()
+ assert KENDRA_ANSWER_MESSAGE in answer
+ assert 'Source Link:' in answer
+ cw_client.print_fulfillment_lambda_logs()
+
+ def test_kendra_redirect(self, designer_login, loaded_questions: list[dict], dom_operator: DomOperator, cw_client: CloudWatchClient):
+ """
+ Test the Kendra query contained in the qna is used instead of Kendra fallback when there is a question match.
+
+ See: https://github.com/aws-solutions/qnabot-on-aws/tree/main/docs/kendra_redirect
+ """
+ qid = 'Kendra.001'
+ question = self.__get_question_by_qid(qid, loaded_questions)
+
+ menu = MenuNav(dom_operator)
+ edit_page = menu.open_edit_page()
+
+ self.__create_question(question, edit_page)
+
+ chat_page = menu.open_chat_page()
+
+ chat_page.send_message(question['q'][0])
+ answer = chat_page.get_messages()
+ assert 'Alexa' in answer
+ cw_client.print_fulfillment_lambda_logs()
+
+ @pytest.mark.skipif_llm_not_enabled()
+ def test_kendra_llm_retrieval(self, designer_login, loaded_questions: list[dict], dom_operator: DomOperator, cw_client: CloudWatchClient):
+ """
+ Test the Kendra LLM retrieval. This test is meant to catch a bug with Kendra LLM retrieval where the message
+ sent to the LLM has too many tokens.
+
+ See: https://t.corp.amazon.com/P96661817/overview
+ """
+ start_time = time.time()
+ menu = MenuNav(dom_operator)
+ settings_page = menu.open_settings_page()
+ assert 'Success' in settings_page.enable_llm()
+ no_hits_response = settings_page.get_no_hits_response()
+
+ chat_page = menu.open_chat_page()
+
+ chat_page.send_message('publish kindle')
+ answer = chat_page.get_last_message_text()
+ assert no_hits_response not in answer
+ assert len(answer) > 0
+ cw_client.print_fulfillment_lambda_logs()
\ No newline at end of file
diff --git a/.nightswatch/functional/test_lambda_hooks.py b/.nightswatch/functional/test_lambda_hooks.py
new file mode 100644
index 000000000..e64ffddc7
--- /dev/null
+++ b/.nightswatch/functional/test_lambda_hooks.py
@@ -0,0 +1,79 @@
+######################################################################################################################
+# Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. #
+# #
+# Licensed under the Apache License, Version 2.0 (the "License"). You may not use this file except in compliance #
+# with the License. A copy of the License is located at #
+# #
+# http://www.apache.org/licenses/LICENSE-2.0 #
+# #
+# or in the 'license' file accompanying this file. This file is distributed on an 'AS IS' BASIS, WITHOUT WARRANTIES #
+# OR CONDITIONS OF ANY KIND, express or implied. See the License for the specific language governing permissions #
+# and limitations under the License. #
+######################################################################################################################
+
+import pytest
+from helpers.website_model.menu_nav import MenuNav
+from helpers.website_model.dom_operator import DomOperator
+from helpers.cloud_watch_client import CloudWatchClient
+
+
+class TestLambdaHooks:
+
+ def test_setup(self, designer_login, dom_operator: DomOperator):
+ """
+ Overrides deployment settings before running other tests.
+ """
+ menu = MenuNav(dom_operator)
+ settings_page = menu.open_settings_page()
+ settings_page.reset_settings()
+ assert 'Success' in settings_page.disable_embeddings()
+ assert 'Success' in settings_page.set_post_processing_lambda('')
+
+ def test_pre_processing_lambda_hooks(self, designer_login, dom_operator: DomOperator, cw_client: CloudWatchClient, lambda_hook_example_arn: str):
+ """
+ Test pre-process lambda hook is invoked and appended to answer correctly.
+
+ See: https://github.com/aws-solutions/qnabot-on-aws/issues/651
+ """
+ hook_question = 'How do I modify Q and A Bot content'
+
+ menu = MenuNav(dom_operator)
+ settings_page = menu.open_settings_page()
+
+ assert 'Success' in settings_page.set_pre_processing_lambda(lambda_hook_example_arn)
+
+ chat_page = menu.open_chat_page()
+
+ chat_page.send_message(hook_question)
+ answer = chat_page.get_last_message_text()
+ assert 'Use the Content Designer Question and Test tools to find your existing documents and edit them directly in the console.' in answer
+ cw_client.print_fulfillment_lambda_logs()
+
+ @pytest.mark.skipif_version_less_than('5.5.0')
+ def test_post_processing_lambda_hooks(self, designer_login, dom_operator: DomOperator, cw_client: CloudWatchClient, lambda_hook_example_arn: str):
+ """
+ Test post-process lambda hook is invoked and appended to answer correctly.
+ """
+ hook_question = 'How do I modify Q and A Bot content'
+
+ menu = MenuNav(dom_operator)
+ settings_page = menu.open_settings_page()
+
+ assert 'Success' in settings_page.set_post_processing_lambda(lambda_hook_example_arn)
+
+ chat_page = menu.open_chat_page()
+
+ chat_page.send_message(hook_question)
+ answer = chat_page.get_last_message_text()
+ assert 'Hi! This is your Custom Javascript Hook speaking!' in answer
+ cw_client.print_fulfillment_lambda_logs()
+
+ def test_cleanup(self, designer_login, dom_operator: DomOperator, ):
+ """
+ Removes lambda hook settings.
+ """
+ menu = MenuNav(dom_operator)
+ settings_page = menu.open_settings_page()
+
+ assert 'Success' in settings_page.set_pre_processing_lambda('')
+ assert 'Success' in settings_page.set_post_processing_lambda('')
diff --git a/.nightswatch/functional/test_llm.py b/.nightswatch/functional/test_llm.py
new file mode 100644
index 000000000..394d15914
--- /dev/null
+++ b/.nightswatch/functional/test_llm.py
@@ -0,0 +1,136 @@
+######################################################################################################################
+# Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. #
+# #
+# Licensed under the Apache License, Version 2.0 (the "License"). You may not use this file except in compliance #
+# with the License. A copy of the License is located at #
+# #
+# http://www.apache.org/licenses/LICENSE-2.0 #
+# #
+# or in the 'license' file accompanying this file. This file is distributed on an 'AS IS' BASIS, WITHOUT WARRANTIES #
+# OR CONDITIONS OF ANY KIND, express or implied. See the License for the specific language governing permissions #
+# and limitations under the License. #
+######################################################################################################################
+
+import pytest
+import os
+import json
+
+from helpers.cloud_watch_client import CloudWatchClient
+from helpers.website_model.menu_nav import MenuNav
+from helpers.website_model.dom_operator import DomOperator
+from helpers.website_model.chat_page import ChatPage
+
+QUESTION_FILEPATH = './question_bank/llm_questions.json'
+
+region = os.environ.get('CURRENT_STACK_REGION')
+g5_instance_regions = ['us-east-1', 'us-west-2', 'ap-northeast-2', 'ap-northeast-1', 'ap-southeast-2', 'ca-central-1', 'eu-central-1', 'eu-west-1', 'eu-west-2']
+unsupported_region_reason = 'Region Not Supported'
+
+@pytest.mark.skipif(region not in g5_instance_regions, reason=unsupported_region_reason)
+@pytest.mark.skipif_llm_not_enabled()
+class TestLlm:
+
+ @pytest.fixture(scope='class')
+ def loaded_questions(self) -> list[dict]:
+ question_file = open(QUESTION_FILEPATH, encoding='utf-8-sig')
+ data = json.load(question_file)
+ question_file.close()
+ return data['qna']
+
+ def __create_question(self, question: dict, edit_page):
+ qid = question['qid']
+ if edit_page.check_question_exists_by_qid(qid):
+ edit_page.delete_question_by_qid(qid)
+ edit_page.add_question(**question)
+
+ def __get_question_by_qid(self, qid, loaded_questions: list[dict]) -> dict:
+ return [q for q in loaded_questions if q['qid'] == qid][0]
+
+ def test_setup(self, designer_login, dom_operator: DomOperator, loaded_questions: list[dict]):
+ """
+ Overrides deployment settings and adds questions before running other tests.
+ """
+ menu = MenuNav(dom_operator)
+ settings_page = menu.open_settings_page()
+ settings_page.reset_settings()
+ assert 'Success' in settings_page.enable_llm()
+ assert 'Success' in settings_page.enable_multi_language_support()
+
+ import_page = menu.open_import_page()
+ import_page.import_language()
+
+ qid = 'LLM.001'
+ question = self.__get_question_by_qid(qid, loaded_questions)
+
+ menu = MenuNav(dom_operator)
+ edit_page = menu.open_edit_page()
+
+ self.__create_question(question, edit_page)
+
+ def test_disambiguation(self, client_login, dom_operator: DomOperator, cw_client: CloudWatchClient):
+ """
+ Test question disambiguation.
+ """
+
+ chat_page = ChatPage(dom_operator)
+
+ chat_page.send_message('Who was Humpty Dumpty?')
+ chat_page.send_message('Where did he sit?')
+ answer = chat_page.get_last_message_text()
+ assert 'LLM generated query' in answer
+ assert 'Humpty Dumpty' in answer
+ cw_client.print_fulfillment_lambda_logs()
+
+ @pytest.mark.skipif_version_less_than('5.5.0')
+ def test_ignore_utterances(self, designer_login, dom_operator: DomOperator, cw_client: CloudWatchClient):
+ """
+ Test that phrases in the ignored utterances list are not disambiguated when LLM_GENERATE_QUERY_ENABLE is set to true.
+
+ See: https://t.corp.amazon.com/V1083580664/overview
+ """
+ menu = MenuNav(dom_operator)
+ chat_page = menu.open_chat_page()
+
+ chat_page.send_message('Who was Humpty Dumpty?')
+ chat_page.send_message('Where did he sit?')
+ chat_page.send_message('help me')
+ answer = chat_page.get_last_message_text()
+ assert 'I am the QnA bot, ask me a question and I will try my best to answer it.' in answer
+ assert 'LLM generated query' not in answer
+
+ chat_page.send_positive_feedback()
+ answer = chat_page.get_last_message_text()
+ assert 'Thank you for your positive feedback on this answer, your feedback helps us continuously improve.' in answer
+ assert 'LLM generated query' not in answer
+ cw_client.print_fulfillment_lambda_logs()
+
+ def test_inference(self, designer_login, dom_operator: DomOperator, cw_client: CloudWatchClient):
+ """
+ Test LLM model can infer answers from information.
+ """
+ menu = MenuNav(dom_operator)
+ settings_page = menu.open_settings_page()
+ # This is needed since the LLM changes the question to an unrelated query
+ assert 'Success' in settings_page.disable_llm_disambiguation()
+
+ chat_page = menu.open_chat_page()
+
+ chat_page.send_message('Who was Humpty Dumpty?')
+
+ chat_page.send_message('Did Humpty Dumpty sit on a wall?')
+ answer = chat_page.get_last_message_text()
+ assert 'Yes' in answer
+
+ cw_client.print_fulfillment_lambda_logs()
+
+ def test_translation(self, client_login, dom_operator: DomOperator, cw_client: CloudWatchClient):
+ """
+ Test LLM answers are translated into the preferred language.
+ """
+
+ chat_page = ChatPage(dom_operator)
+
+ chat_page.send_message('OĂč Ă©tait assis Humpty Dumpty?')
+ answer = chat_page.get_last_message_text()
+ assert 'mur' in answer
+ cw_client.print_fulfillment_lambda_logs()
diff --git a/.nightswatch/functional/test_question_designer.py b/.nightswatch/functional/test_question_designer.py
new file mode 100644
index 000000000..7c1dbec33
--- /dev/null
+++ b/.nightswatch/functional/test_question_designer.py
@@ -0,0 +1,597 @@
+######################################################################################################################
+# Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. #
+# #
+# Licensed under the Apache License, Version 2.0 (the "License"). You may not use this file except in compliance #
+# with the License. A copy of the License is located at #
+# #
+# http://www.apache.org/licenses/LICENSE-2.0 #
+# #
+# or in the 'license' file accompanying this file. This file is distributed on an 'AS IS' BASIS, WITHOUT WARRANTIES #
+# OR CONDITIONS OF ANY KIND, express or implied. See the License for the specific language governing permissions #
+# and limitations under the License. #
+######################################################################################################################
+
+import pytest
+import json
+import time
+
+from helpers.website_model.menu_nav import MenuNav
+from helpers.website_model.dom_operator import DomOperator
+from helpers.lex_client import LexClient
+from helpers.translate_client import TranslateClient
+from helpers.cloud_watch_client import CloudWatchClient
+
+QUESTION_FILEPATH = './question_bank/question_designer_questions.json'
+
+class TestQuestionDesigner:
+
+ @pytest.fixture(scope='class')
+ def loaded_questions(self) -> list[dict]:
+ question_file = open(QUESTION_FILEPATH)
+ data = json.load(question_file)
+ question_file.close()
+ return data['qna']
+
+ def __create_question(self, question: dict, edit_page):
+ qid = question['qid']
+ if edit_page.check_question_exists_by_qid(qid):
+ edit_page.delete_question_by_qid(qid)
+ edit_page.add_question(**question)
+
+ def __get_question_by_qid(self, qid, loaded_questions: list[dict]) -> dict:
+ return [q for q in loaded_questions if q['qid'] == qid][0]
+
+ def test_setup(self, designer_login, dom_operator: DomOperator):
+ """
+ Overrides deployment settings before running other tests.
+ """
+ menu = MenuNav(dom_operator)
+ settings_page = menu.open_settings_page()
+ settings_page.reset_settings()
+ # Needs to be enabled, otherwise all questions fallback
+ assert 'Success' in settings_page.enable_kendra_fallback()
+ assert 'Success' in settings_page.disable_embeddings()
+ assert 'Success' in settings_page.disable_llm()
+ assert 'Success' in settings_page.enable_multi_language_support()
+
+ def test_create_question(self, designer_login, loaded_questions: list[dict], dom_operator: DomOperator, cw_client: CloudWatchClient):
+ """
+ Test creating a question in the question designer and available to the client.
+ """
+ qid = 'Designer.001'
+ question = self.__get_question_by_qid(qid, loaded_questions)
+
+ menu = MenuNav(dom_operator)
+ edit_page = menu.open_edit_page()
+
+ self.__create_question(question, edit_page)
+
+ chat_page = menu.open_chat_page()
+
+ chat_page.send_message(question['q'][0])
+ answer = chat_page.get_messages()
+ assert question['a'] in answer
+ cw_client.print_fulfillment_lambda_logs()
+
+ def test_update_question(self, designer_login, loaded_questions: list[dict], dom_operator: DomOperator, cw_client: CloudWatchClient):
+ """
+ Test question is updated using question designer.
+ """
+ qid = 'Designer.001'
+ question = self.__get_question_by_qid(qid, loaded_questions)
+
+ question['a'] = 'pancakes with maple syrup'
+
+ menu = MenuNav(dom_operator)
+ edit_page = menu.open_edit_page()
+
+ assert edit_page.check_question_exists_by_qid(qid)
+ edit_page.edit_question_by_qid(**question)
+
+ chat_page = menu.open_chat_page()
+
+ chat_page.send_message(question['q'][0])
+ answer = chat_page.get_messages()
+ assert question['a'] in answer
+ cw_client.print_fulfillment_lambda_logs()
+
+ def test_delete_question(self, designer_login, loaded_questions: list[dict], dom_operator: DomOperator):
+ """
+ Test question is deleted using question designer.
+ """
+ qid = 'Designer.001'
+ question = self.__get_question_by_qid(qid, loaded_questions)
+
+ menu = MenuNav(dom_operator)
+ edit_page = menu.open_edit_page()
+
+ assert edit_page.check_question_exists_by_qid(qid)
+ edit_page.delete_question_by_qid(qid)
+
+ chat_page = menu.open_chat_page()
+
+ chat_page.send_message(question['q'][0])
+ answer = chat_page.get_messages()
+ assert question['a'] not in answer
+
+ def test_multiple_utterances(self, designer_login, loaded_questions: list[dict], dom_operator: DomOperator):
+ """
+ Test question can contain multiple utterances.
+ """
+ qid = 'Designer.001'
+ question = self.__get_question_by_qid(qid, loaded_questions)
+
+ uterrances = question['q'] + ['What rots your teeth?', 'What do you hand out at Halloween?']
+
+ question['q'] = uterrances
+
+ menu = MenuNav(dom_operator)
+ edit_page = menu.open_edit_page()
+
+ self.__create_question(question, edit_page)
+
+ chat_page = menu.open_chat_page()
+
+ for utterance in question['q']:
+ chat_page.send_message(utterance)
+ answer = chat_page.get_messages()
+ assert question['a'] in answer
+
+ def test_create_quiz_question(self, designer_login, loaded_questions: list[dict], dom_operator: DomOperator, cw_client: CloudWatchClient):
+ """
+ Test quiz question creation in question designer.
+
+ See: https://catalog.us-east-1.prod.workshops.aws/workshops/20c56f9e-9c0a-4174-a661-9f40d9f063ac/en-US/qna/quiz
+ """
+ qids = ['Quiz.001','Quiz.002']
+
+ menu = MenuNav(dom_operator)
+ edit_page = menu.open_edit_page()
+
+ for qid in qids:
+ question = self.__get_question_by_qid(qid, loaded_questions)
+ self.__create_question(question, edit_page)
+
+ chat_page = menu.open_chat_page()
+
+ chat_page.send_message(f'Quiz start')
+ chat_page.send_message('A')
+ answer = chat_page.get_messages()
+ assert 'Thank you for taking the quiz!' in answer
+ cw_client.print_fulfillment_lambda_logs()
+
+ def test_lex_rebuild(self, designer_login, dom_operator: DomOperator):
+ """
+ Test lex rebuild.
+
+ Required for slot questions.
+ """
+ menu = MenuNav(dom_operator)
+ edit_page = menu.open_edit_page()
+ status = edit_page.rebuild_lex()
+ assert status == 'Success!'
+
+ def test_create_slot_question(self, designer_login, loaded_questions: list[dict], dom_operator: DomOperator, cw_client: CloudWatchClient):
+ """
+ Test create slot type question in question designer.
+
+ See: https://docs.aws.amazon.com/solutions/latest/qnabot-on-aws/configure-intent-and-slot-matching.html
+ """
+ qids = ['Slot.001','Slot.002','Slot.003']
+
+ menu = MenuNav(dom_operator)
+ edit_page = menu.open_edit_page()
+
+ for qid in qids:
+ question = self.__get_question_by_qid(qid, loaded_questions)
+ self.__create_question(question, edit_page)
+
+ edit_page.rebuild_lex()
+
+ chat_page = menu.open_chat_page()
+
+ reservation_responses = ['I want to book a flight', 'Jeff', '2023-07-06', 'Houston', 'Toronto', '8am', 'sure']
+ cancelled_reservation_responses = ['I want to book a flight', 'Jeff', 'Today', 'Houston', 'Toronto', '8am', 'N']
+
+ for response in reservation_responses:
+ chat_page.send_message(response)
+ answer = chat_page.get_messages()
+ assert 'Okay, I have confirmed your reservation.' in answer
+
+ for response in reservation_responses:
+ if response == '8am':
+ assert '0800' in answer
+ else:
+ assert response in answer
+
+ for response in cancelled_reservation_responses:
+ chat_page.send_message(response)
+
+ answer = chat_page.get_messages()
+ assert 'Okay, I have cancelled your reservation in progress.' in answer
+ cw_client.print_fulfillment_lambda_logs()
+
+ def test_slots_created_in_lex(self, designer_login, region: str, dom_operator: DomOperator, stack_name: str, cw_client: CloudWatchClient):
+ """
+ Test slot type created in lex.
+
+ See: https://docs.aws.amazon.com/solutions/latest/qnabot-on-aws/configure-intent-and-slot-matching.html
+ """
+ qids = ['Slot.001','Slot.002','Slot.003']
+
+ menu = MenuNav(dom_operator)
+ edit_page = menu.open_edit_page()
+
+ for qid in qids:
+ assert edit_page.check_question_exists_by_qid(qid)
+
+ bot_name = stack_name + '_QnaBot'
+ bot_locales = ['en_US', 'es_US', 'fr_CA']
+ slot_names = ['QID-SLOTTYPE-Slot_dot_002', 'QID-SLOTTYPE-Slot_dot_003']
+
+ lex_client = LexClient(region)
+ assert lex_client.bot_slot_type_names_exist_for_all_locales(bot_name, slot_names, bot_locales) is True
+ cw_client.print_fulfillment_lambda_logs()
+
+ def test_slots_are_translated(self, designer_login, dom_operator: DomOperator, translate_client: TranslateClient, cw_client: CloudWatchClient):
+ """
+ Test slots are translated in lex into multiple locales
+
+ See: https://docs.aws.amazon.com/solutions/latest/qnabot-on-aws/configure-intent-and-slot-matching.html
+ """
+ qids = ['Slot.001','Slot.002','Slot.003']
+
+ menu = MenuNav(dom_operator)
+ edit_page = menu.open_edit_page()
+
+ for qid in qids:
+ assert edit_page.check_question_exists_by_qid(qid)
+
+ english_confirmation_msg = '**Name**: Jeff - **Departing City:** Houston - **Destination**: Toronto'
+
+ chat_page = menu.open_chat_page()
+ chat_page.select_locale('fr_CA')
+
+ french_responses = ['Je souhaite réserver un vol', 'Jeff', '2023-07-06', 'Houston', 'Toronto', '8am', "Oui s'il vous plaßt"]
+
+ for response in french_responses:
+ chat_page.send_message(response)
+ answer = chat_page.get_messages()
+ assert translate_client.translate(english_confirmation_msg, 'fr') in answer
+
+ chat_page.select_locale('es_US')
+ spanish_responses = ['quiero reservar un vuelo', 'Jeff', '2023-07-06', 'Houston', 'Toronto', '8am', 'SĂ']
+
+ for response in spanish_responses:
+ chat_page.send_message(response)
+
+ answer = chat_page.get_messages()
+ assert translate_client.translate(english_confirmation_msg, 'es') in answer
+ cw_client.print_fulfillment_lambda_logs()
+
+ def test_slots_are_deleted(self, designer_login, region: str, dom_operator: DomOperator, stack_name: str):
+ """
+ Test slots are deleted in lex.
+
+ See: https://docs.aws.amazon.com/solutions/latest/qnabot-on-aws/configure-intent-and-slot-matching.html
+ """
+ qids = ['Slot.001','Slot.002','Slot.003']
+
+ menu = MenuNav(dom_operator)
+ edit_page = menu.open_edit_page()
+
+ for qid in qids:
+ if edit_page.check_question_exists_by_qid(qid):
+ print(f'Deleting {qid}')
+ edit_page.delete_question_by_qid(qid)
+
+ edit_page.rebuild_lex()
+
+ for qid in qids:
+ assert not edit_page.check_question_exists_by_qid(qid)
+
+ bot_name = stack_name + '_QnaBot'
+ bot_locales = ['en_US', 'es_US', 'fr_CA']
+ slot_names = ['QID-SLOTTYPE-Slot_dot_002', 'QID-SLOTTYPE-Slot_dot_003']
+
+ lex_client = LexClient(region)
+ assert lex_client.bot_slot_type_names_do_not_exist_for_all_locales(bot_name, slot_names, bot_locales) is True
+
+ def test_create_response_card(self, designer_login, loaded_questions: list[dict], dom_operator: DomOperator, cw_client: CloudWatchClient):
+ """
+ Test response card is created using question designer.
+
+ See: https://docs.aws.amazon.com/solutions/latest/qnabot-on-aws/adding-images-to-your-answers.html
+ """
+ qid = 'Card.001'
+ question = self.__get_question_by_qid(qid, loaded_questions)
+
+ menu = MenuNav(dom_operator)
+ edit_page = menu.open_edit_page()
+
+ self.__create_question(question, edit_page)
+
+ chat_page = menu.open_chat_page()
+
+ chat_page.send_message(question['q'][0])
+ assert chat_page.has_element_with_xpath(f'//img[contains(@src,"{question["r"]["imageUrl"]}")]')
+ for button in question["r"]["buttons"]:
+ assert chat_page.has_element_with_xpath(f'//button//span[contains(string(), "{button["text"]}")]')
+ cw_client.print_fulfillment_lambda_logs()
+
+ def test_question_topic(self, designer_login, loaded_questions: list[dict], dom_operator: DomOperator, cw_client: CloudWatchClient):
+ """
+ Test question responds with correct response using topics.
+
+ See: https://docs.aws.amazon.com/solutions/latest/qnabot-on-aws/using-topics-to-support-follow-up-questions-and-contextual-user-journeys.html
+ """
+ qids = ['Topic.001','Topic.002','Topic.003','Topic.004']
+ questions = [self.__get_question_by_qid(qid, loaded_questions) for qid in qids]
+
+ menu = MenuNav(dom_operator)
+ edit_page = menu.open_edit_page()
+
+ for question in questions:
+ self.__create_question(question, edit_page)
+ edit_page.refresh_questions()
+
+ chat_page = menu.open_chat_page()
+
+ for question in questions:
+ chat_page.send_message(question['q'][0])
+
+ answer = chat_page.get_messages()
+
+ for question in questions:
+ assert question['a'] in answer
+ cw_client.print_fulfillment_lambda_logs()
+
+ def extract_integer_from_string(self, string: str) -> int:
+ """
+ Helper function to extract the count of times the question has been asked.
+
+ Args:
+ string (str): String to extract integer from.
+
+ Returns:
+ int: Integer extracted from string.
+ """
+ words = string.split()
+ words.reverse()
+
+ for index, word in enumerate(words):
+ if word == 'times.':
+ return int(words[index + 1])
+
+ def test_response_handlebars(self, designer_login, loaded_questions: list[dict], dom_operator: DomOperator, cw_client: CloudWatchClient):
+ """
+ Test handlebars are evaluated correctly.
+
+ See: https://docs.aws.amazon.com/solutions/latest/qnabot-on-aws/integrating-handlebars-templates.html
+ """
+ qid = 'Handlebars.001'
+ question = self.__get_question_by_qid(qid, loaded_questions)
+
+ menu = MenuNav(dom_operator)
+ edit_page = menu.open_edit_page()
+
+ self.__create_question(question, edit_page)
+
+ chat_page = menu.open_chat_page()
+
+ chat_page.send_message(question['q'][0])
+ answer = chat_page.get_messages()
+ assert 'times' in answer
+
+ first_interaction_count = self.extract_integer_from_string(answer)
+
+ chat_page.send_message(question['q'][0])
+ answer = chat_page.get_messages()
+ second_interaction_count = self.extract_integer_from_string(answer)
+
+ assert first_interaction_count + 1 == second_interaction_count
+ cw_client.print_fulfillment_lambda_logs()
+
+ @pytest.mark.skipif_version_less_than('5.5.0')
+ def test_response_handlebars_getQuestion(self, designer_login, loaded_questions: list[dict], dom_operator: DomOperator, cw_client: CloudWatchClient):
+ """
+ Test handlebars getQuestion method returns the matched question.
+
+ See: https://github.com/aws-solutions/qnabot-on-aws/issues/397
+ """
+ qid = 'Handlebars.002'
+ question = self.__get_question_by_qid(qid, loaded_questions)
+
+ menu = MenuNav(dom_operator)
+ edit_page = menu.open_edit_page()
+
+ self.__create_question(question, edit_page)
+
+ chat_page = menu.open_chat_page()
+
+ chat_page.send_message(question['q'][0])
+ answer = chat_page.get_last_message_text()
+ assert 'It seems like you are asking about: How do I use handlebars to return a matched question?' in answer
+ cw_client.print_fulfillment_lambda_logs()
+
+ def test_filter(self, designer_login, loaded_questions: list[dict], dom_operator: DomOperator, cw_client: CloudWatchClient):
+ """
+ Test filter setting works correctly by not answering questions with provided answer if too many nouns are provided (75% matching when > 2)
+ and does not match when nouns do not match.
+
+ See: https://docs.aws.amazon.com/solutions/latest/qnabot-on-aws/keyword-filters-and-custom-dont-know-answers.html
+ """
+ qid = 'Topic.001'
+ question = self.__get_question_by_qid(qid, loaded_questions)
+
+ menu = MenuNav(dom_operator)
+ edit_page = menu.open_edit_page()
+
+ self.__create_question(question, edit_page)
+
+ settings_page = menu.open_settings_page()
+ settings_page.enable_filter()
+ settings_page.set_match_criteria('2<75%')
+
+ chat_page = menu.open_chat_page()
+
+ chat_page.send_message('What is Amazon Astro the robot that has Alexa?')
+ answer = chat_page.get_messages()
+ assert question['a'] not in answer
+
+ chat_page.send_message('What is Amazon astronomy?')
+ answer = chat_page.get_messages()
+ assert question['a'] not in answer
+
+ chat_page.send_message('What is Amazon Astro the robot?')
+ answer = chat_page.get_messages()
+ assert question['a'] in answer
+ cw_client.print_fulfillment_lambda_logs()
+
+ def test_elicit_response(self, designer_login, loaded_questions: list[dict], dom_operator: DomOperator, cw_client: CloudWatchClient):
+ """
+ Test response is elicited from client when prompted.
+
+ See: https://catalog.us-east-1.prod.workshops.aws/workshops/20c56f9e-9c0a-4174-a661-9f40d9f063ac/en-US/qna/elicit-response
+ """
+ qids = ['Elicit.001','Elicit.002']
+ questions = [self.__get_question_by_qid(qid, loaded_questions) for qid in qids]
+
+ menu = MenuNav(dom_operator)
+ edit_page = menu.open_edit_page()
+
+ for question in questions:
+ self.__create_question(question, edit_page)
+
+ chat_page = menu.open_chat_page()
+
+ name_responses = [questions[0]['q'][0], 'Jeff', 'Bezos', 'yes']
+ age_responses = [questions[1]['q'][0], '12', 'No']
+
+ for response in name_responses:
+ chat_page.send_message(response)
+
+ answer = chat_page.get_messages()
+ assert questions[0]['a'] in answer
+ assert 'Did I get your name right (Yes or No) Jeff Bezos?'
+
+ for response in age_responses:
+ chat_page.send_message(response)
+
+ answer = chat_page.get_messages()
+ assert 'Hello Jeff â What is your age in years?' in answer
+ assert 'Is 12 correct (Yes or No)?' in answer
+ cw_client.print_fulfillment_lambda_logs()
+
+ def test_question_branching(self, designer_login, loaded_questions: list[dict], dom_operator: DomOperator, cw_client: CloudWatchClient):
+ """
+ Test question branches to next question based on conditional age.
+
+ See: https://docs.aws.amazon.com/solutions/latest/qnabot-on-aws/configuring-the-chatbot-to-ask-the-questions-and-use-response-bots.html#advancing-and-branching-through-a-series-of-questions
+ """
+ qids = ['Elicit.003','Elicit.004']
+ edit_qid = 'Elicit.002'
+ questions = [self.__get_question_by_qid(qid, loaded_questions) for qid in qids]
+
+ menu = MenuNav(dom_operator)
+ edit_page = menu.open_edit_page()
+
+ for question in questions:
+ self.__create_question(question, edit_page)
+ edit_page.refresh_questions()
+ time.sleep(2)
+ assert edit_page.check_question_exists_by_qid(edit_qid)
+ edit_question = self.__get_question_by_qid(edit_qid, loaded_questions)
+
+ edit_question['conditionalChaining'] = '(SessionAttributes.age_of_user.Age< 18) ? "Under 18\" : "Over 18 answer"'
+
+ edit_page.edit_question_by_qid(**edit_question)
+
+ chat_page = menu.open_chat_page()
+
+ age_responses = ['ask my age', '12', 'Yes', 'ask my age', '20', 'Yes']
+
+ for response in age_responses:
+ chat_page.send_message(response)
+
+ answer = chat_page.get_messages()
+ assert questions[0]['a'] in answer
+ assert questions[1]['a'] in answer
+ cw_client.print_fulfillment_lambda_logs()
+
+ def test_rich_text(self, designer_login, loaded_questions: list[dict], dom_operator: DomOperator, cw_client: CloudWatchClient):
+ """
+ Answers are provided in rich text using markdown.
+
+ See: https://docs.aws.amazon.com/solutions/latest/aws-qnabot/displaying-rich-text-answers.html
+ """
+
+ qid = 'Markdown.001'
+ question = self.__get_question_by_qid(qid, loaded_questions)
+
+ menu = MenuNav(dom_operator)
+ edit_page = menu.open_edit_page()
+
+ self.__create_question(question, edit_page)
+
+ chat_page = menu.open_chat_page()
+
+ link_xpath = '//a[@href="https://www.markdownguide.org/cheat-sheet/" and contains(string(), "Markdown Cheat Sheet")]'
+ title_xpath = '//h1[contains(string(), "Markdown")]'
+ italics_xpath = '//em[contains(string(), "dynamic")]'
+ bold_xpath = '//strong[contains(string(), "bold text")]'
+ blockquote_xpath = '//blockquote[contains(string(), "blockquote")]'
+ ordered_list_xpath = '//ol//li[contains(string(), "First item")]'
+ horizontal_rule_xpath = '//div[@class="message-text"]//hr'
+ unordered_list_xpath = '//ul//li[contains(string(), "First item")]'
+ table_header_xpath = '//table//thead//tr//th[contains(string(), "Syntax")]'
+ code_xpath = '//code[contains(string(), "firstName")]'
+ checkbox_xpath = '//ul//li//input[@type="checkbox"]'
+ image_xpath = '//img[@src="https://github.com/aws-solutions/qnabot-on-aws/blob/main/assets/examples/photos/west%20coast%20grocery.jpg?raw=true" and @alt="West Coast Grocery"]'
+ iframe_xpath = '//iframe[@src="https://www.youtube.com/embed/OE4MrFx2XCs"]'
+
+ markdown_element_xpaths = [
+ link_xpath,
+ title_xpath,
+ italics_xpath,
+ bold_xpath,
+ blockquote_xpath,
+ ordered_list_xpath,
+ horizontal_rule_xpath,
+ unordered_list_xpath,
+ table_header_xpath,
+ code_xpath,
+ checkbox_xpath,
+ image_xpath,
+ iframe_xpath
+ ]
+
+ chat_page.send_message(question['q'][0])
+
+ for element_xpath in markdown_element_xpaths:
+ assert chat_page.has_element_with_xpath(element_xpath)
+ cw_client.print_fulfillment_lambda_logs()
+
+ def test_lambda_hooks(self, designer_login, dom_operator: DomOperator, cw_client: CloudWatchClient):
+ """
+ Test lambda hook is invoked and appended to answer correctly.
+
+ See: https://docs.aws.amazon.com/solutions/latest/qnabot-on-aws/specifying-lambda-hook-functions.html
+ """
+ hook_question = 'What are lambda hooks'
+
+ menu = MenuNav(dom_operator)
+ import_page = menu.open_import_page()
+ import_page.import_greeting_hook()
+
+ edit_page = menu.open_edit_page()
+ bot_questions = edit_page.select_question_by_qid('GreetingHookExample', 4).text
+
+ assert bot_questions == hook_question
+
+ chat_page = menu.open_chat_page()
+
+ chat_page.send_message(hook_question)
+ answer = chat_page.get_messages()
+ assert 'good afternoon' in answer or 'good morning' in answer or 'good evening' in answer
+ cw_client.print_fulfillment_lambda_logs()
diff --git a/.nightswatch/functional/test_routing.py b/.nightswatch/functional/test_routing.py
new file mode 100644
index 000000000..8e64e1a65
--- /dev/null
+++ b/.nightswatch/functional/test_routing.py
@@ -0,0 +1,164 @@
+######################################################################################################################
+# Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. #
+# #
+# Licensed under the Apache License, Version 2.0 (the "License"). You may not use this file except in compliance #
+# with the License. A copy of the License is located at #
+# #
+# http://www.apache.org/licenses/LICENSE-2.0 #
+# #
+# or in the 'license' file accompanying this file. This file is distributed on an 'AS IS' BASIS, WITHOUT WARRANTIES #
+# OR CONDITIONS OF ANY KIND, express or implied. See the License for the specific language governing permissions #
+# and limitations under the License. #
+######################################################################################################################
+
+import pytest
+import json
+import os
+
+from helpers.website_model.menu_nav import MenuNav
+from helpers.website_model.dom_operator import DomOperator
+from helpers.website_model.chat_page import ChatPage
+from helpers.lex_client import LexClient
+from helpers.iam_client import IamClient
+from helpers.cloud_watch_client import CloudWatchClient
+
+QUESTION_FILEPATH = './question_bank/routing_questions.json'
+QIDS = ['Routing.001', 'Routing.002', 'Routing.003']
+TEST_BOT_INTENT_FILES = ['./helpers/bot_intents/greetings.json','./helpers/bot_intents/get_attribute.json', './helpers/bot_intents/set_attribute.json']
+TEST_BOT_NAME = 'test_bot_routing'
+
+region = os.environ.get('CURRENT_STACK_REGION')
+lexv2_regions = [
+ 'us-east-1',
+ 'us-west-2',
+ 'af-south-1',
+ 'ap-northeast-2',
+ 'ap-southeast-1',
+ 'ap-southeast-2',
+ 'ap-northeast-1',
+ 'ca-central-1',
+ 'eu-central-1',
+ 'eu-west-1',
+ 'eu-west-2'
+]
+unsupported_region_reason = 'Region Not Supported'
+
+@pytest.mark.skipif(region not in lexv2_regions, reason=unsupported_region_reason)
+class TestRouting:
+ # https://catalog.us-east-1.prod.workshops.aws/workshops/20c56f9e-9c0a-4174-a661-9f40d9f063ac/en-US/qna/bot-routing
+ @pytest.fixture(scope='class')
+ def loaded_questions(self) -> list[dict]:
+ question_file = open(QUESTION_FILEPATH)
+ data = json.load(question_file)
+ question_file.close()
+ return data['qna']
+
+ def __create_question(self, question: dict, edit_page):
+ qid = question['qid']
+ if edit_page.check_question_exists_by_qid(qid):
+ edit_page.delete_question_by_qid(qid)
+ edit_page.add_question(**question)
+
+ def __get_question_by_qid(self, qid, loaded_questions: list[dict]) -> dict:
+ return [q for q in loaded_questions if q['qid'] == qid][0]
+
+ def test_create_test_bot(self, lex_client: LexClient, iam_client: IamClient):
+ """
+ Tests creation of a Lex V2 bot using boto client. Asserts the bot exists.
+
+ Required for next steps in test.
+ """
+ role_arn = iam_client.create_lexv2_role(TEST_BOT_NAME)
+ lex_client.create_test_bot(TEST_BOT_NAME, role_arn, TEST_BOT_INTENT_FILES, locales=['en_US'])
+ assert lex_client.check_bot_exists(TEST_BOT_NAME)
+
+ def test_setup(self, designer_login, loaded_questions: list[dict], dom_operator: DomOperator, lex_client: LexClient):
+ """
+ Creates test questions and asserts they all exist before continuing.
+ """
+ bot_id = lex_client.find_bot_id_from_bot_name(bot_name=TEST_BOT_NAME)
+
+ menu = MenuNav(dom_operator)
+
+ settings_page = menu.open_settings_page()
+ settings_page.reset_settings()
+ # Needs to be enabled, otherwise all questions fallback
+ assert 'Success' in settings_page.enable_kendra_fallback()
+ assert 'Success' in settings_page.disable_embeddings()
+ assert 'Success' in settings_page.disable_llm()
+
+ edit_page = menu.open_edit_page()
+
+ for qid in QIDS:
+ question = self.__get_question_by_qid(qid, loaded_questions)
+
+ if 'botRouting' in question:
+ if 'specialty_bot' in question['botRouting']:
+ question['botRouting']['specialty_bot'] = f'lexv2::{bot_id}/TSTALIASID/en_US'
+
+ self.__create_question(question, edit_page)
+
+ edit_page.refresh_questions()
+
+ for qid in QIDS:
+ assert edit_page.check_question_exists_by_qid(qid)
+
+
+ def test_bot_routing(self, client_login, loaded_questions: list[dict], dom_operator: DomOperator, cw_client: CloudWatchClient):
+ """
+ Tests the bot routes to the specialty bot.
+ """
+ qid = 'Routing.001'
+ question = self.__get_question_by_qid(qid, loaded_questions)
+
+ chat_page = ChatPage(dom_operator)
+
+ chat_page.send_message(question['q'][0])
+ chat_page.send_message('Hi')
+ answer = chat_page.get_messages()
+ assert question['a'] in answer
+ assert 'GREETINGS, I AM TEST BOT' in answer
+ cw_client.print_fulfillment_lambda_logs()
+
+ def test_pass_attribute_to_specialty_bot(self, client_login, loaded_questions: list[dict], dom_operator: DomOperator, cw_client: CloudWatchClient):
+ """
+ Tests the specialty bot has access to the session attributes.
+ """
+ qid = 'Routing.001'
+ question = self.__get_question_by_qid(qid, loaded_questions)
+
+ chat_page = ChatPage(dom_operator)
+
+ chat_page.send_message(question['q'][0])
+ chat_page.send_message('Do I have an attribute?')
+ answer = chat_page.get_messages()
+ assert 'TRUE - YOUR ATTRIBUTE IS CONFIGURED CORRECTLY' in answer
+ cw_client.print_fulfillment_lambda_logs()
+
+ @pytest.mark.skipif_version_less_than('5.5.0')
+ def test_attribute_received_from_specialty_bot_and_chaining(self, client_login, loaded_questions: list[dict], dom_operator: DomOperator, cw_client: CloudWatchClient):
+ """
+ Receives a session attribute from the specialty bot, exits the specialty bot, and executes document chaining using the session attribute.
+
+ See: https://github.com/aws-solutions/qnabot-on-aws/issues/508
+ """
+ qid = 'Routing.002'
+ question = self.__get_question_by_qid(qid, loaded_questions)
+
+ chat_page = ChatPage(dom_operator)
+
+ chat_page.send_message(question['q'][0])
+ chat_page.send_message('exit')
+ answer = chat_page.get_messages()
+ assert 'HERE IS A SESSION ATTRIBUTE' in answer
+ assert 'Welcome back to QnABot.' in answer
+ assert 'You just received a session attribute from test bot.' in answer
+ cw_client.print_fulfillment_lambda_logs()
+
+ def test_bot_cleanup(self, lex_client: LexClient, iam_client: IamClient):
+ """
+ Tests the bot and role is deleted correctly.
+ """
+ lex_client.delete_bot_if_exists(TEST_BOT_NAME)
+ iam_client.delete_role_if_exists(TEST_BOT_NAME)
+ assert not lex_client.check_bot_exists(TEST_BOT_NAME)
\ No newline at end of file
diff --git a/.nightswatch/functional/test_session_attribute.py b/.nightswatch/functional/test_session_attribute.py
new file mode 100644
index 000000000..2129a261c
--- /dev/null
+++ b/.nightswatch/functional/test_session_attribute.py
@@ -0,0 +1,112 @@
+######################################################################################################################
+# Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. #
+# #
+# Licensed under the Apache License, Version 2.0 (the "License"). You may not use this file except in compliance #
+# with the License. A copy of the License is located at #
+# #
+# http://www.apache.org/licenses/LICENSE-2.0 #
+# #
+# or in the 'license' file accompanying this file. This file is distributed on an 'AS IS' BASIS, WITHOUT WARRANTIES #
+# OR CONDITIONS OF ANY KIND, express or implied. See the License for the specific language governing permissions #
+# and limitations under the License. #
+######################################################################################################################
+
+import pytest
+import json
+import time
+
+from helpers.cloud_watch_client import CloudWatchClient
+from helpers.website_model.menu_nav import MenuNav
+from helpers.website_model.dom_operator import DomOperator
+from helpers.website_model.chat_page import ChatPage
+
+QUESTION_FILEPATH = './question_bank/session_attribute_questions.json'
+QIDS = ['Session.001','Session.002','Session.003','Session.004']
+
+class TestSessionAttribute():
+ # https://docs.aws.amazon.com/solutions/latest/qnabot-on-aws/setting-amazon-lex-session-attributes.html
+
+ @pytest.fixture(scope='class')
+ def loaded_questions(self) -> list[dict]:
+ question_file = open(QUESTION_FILEPATH)
+ data = json.load(question_file)
+ question_file.close()
+ return data['qna']
+
+ def __create_question(self, question: dict, edit_page):
+ qid = question['qid']
+ if edit_page.check_question_exists_by_qid(qid):
+ edit_page.delete_question_by_qid(qid)
+ edit_page.add_question(**question)
+
+ def __get_question_by_qid(self, qid, loaded_questions: list[dict]) -> dict:
+ return [q for q in loaded_questions if q['qid'] == qid][0]
+
+ def test_setup(self, designer_login, loaded_questions: list[dict], dom_operator: DomOperator):
+ """
+ Creates test questions and asserts they all exist before continuing.
+ """
+ menu = MenuNav(dom_operator)
+
+ settings_page = menu.open_settings_page()
+ settings_page.reset_settings()
+ # Needs to be enabled, otherwise all questions fallback
+ assert 'Success' in settings_page.enable_kendra_fallback()
+ assert 'Success' in settings_page.disable_embeddings()
+ assert 'Success' in settings_page.disable_llm()
+
+ edit_page = menu.open_edit_page()
+
+ for qid in QIDS:
+ question = self.__get_question_by_qid(qid, loaded_questions)
+ self.__create_question(question, edit_page)
+ edit_page.refresh_questions()
+
+ for qid in QIDS:
+ assert edit_page.check_question_exists_by_qid(qid)
+
+ def test_default_returned_when_attribute_not_set(self, client_login, loaded_questions: list[dict], dom_operator: DomOperator, cw_client: CloudWatchClient):
+ """
+ Tests that the default value is returned when the attribute is not set.
+ """
+ qid = 'Session.001'
+ question = self.__get_question_by_qid(qid, loaded_questions)
+
+ chat_page = ChatPage(dom_operator)
+
+ chat_page.send_message(question['q'][0])
+ answer = chat_page.get_messages()
+ assert 'default' in answer
+ cw_client.print_fulfillment_lambda_logs()
+
+ def test_set_session_attributes_using_ui(self, client_login, loaded_questions: list[dict], dom_operator: DomOperator, cw_client: CloudWatchClient):
+ """
+ Tests that the session attribute is set and can be created using the question designer.
+ """
+ qids = ['Session.002', 'Session.004']
+
+ questions = [self.__get_question_by_qid(qid, loaded_questions) for qid in qids]
+
+ chat_page = ChatPage(dom_operator)
+
+ chat_page.send_message(questions[0]['q'][0])
+ chat_page.send_message(questions[1]['q'][0])
+ answer = chat_page.get_messages()
+ assert 'Here is your session attribute: "Amazon"' in answer
+ cw_client.print_fulfillment_lambda_logs()
+
+ def test_set_session_attributes_using_handlebars(self, client_login, loaded_questions: list[dict], dom_operator: DomOperator, cw_client: CloudWatchClient):
+ """
+ Tests the session attribute can be set using handlebars.
+ """
+ qids = ['Session.003', 'Session.004']
+
+ questions = [self.__get_question_by_qid(qid, loaded_questions) for qid in qids]
+
+ chat_page = ChatPage(dom_operator)
+
+ chat_page.send_message(questions[0]['q'][0])
+ chat_page.send_message(questions[1]['q'][0])
+ answer = chat_page.get_messages()
+ assert 'Here is your session attribute: "AWS"' in answer
+ cw_client.print_fulfillment_lambda_logs()
diff --git a/.nightswatch/functional/test_settings.py b/.nightswatch/functional/test_settings.py
new file mode 100644
index 000000000..f89055f33
--- /dev/null
+++ b/.nightswatch/functional/test_settings.py
@@ -0,0 +1,121 @@
+######################################################################################################################
+# Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. #
+# #
+# Licensed under the Apache License, Version 2.0 (the "License"). You may not use this file except in compliance #
+# with the License. A copy of the License is located at #
+# #
+# http://www.apache.org/licenses/LICENSE-2.0 #
+# #
+# or in the 'license' file accompanying this file. This file is distributed on an 'AS IS' BASIS, WITHOUT WARRANTIES #
+# OR CONDITIONS OF ANY KIND, express or implied. See the License for the specific language governing permissions #
+# and limitations under the License. #
+######################################################################################################################
+
+import pytest
+import json
+
+from helpers.website_model.menu_nav import MenuNav
+from helpers.website_model.dom_operator import DomOperator
+
+QUESTION_FILEPATH = './question_bank/settings_questions.json'
+
+class TestSettings:
+
+ @pytest.fixture(scope='class')
+ def loaded_questions(self) -> list[dict]:
+ question_file = open(QUESTION_FILEPATH)
+ data = json.load(question_file)
+ question_file.close()
+ return data['qna']
+
+ def __create_question(self, question: dict, edit_page):
+ qid = question['qid']
+ if edit_page.check_question_exists_by_qid(qid):
+ edit_page.delete_question_by_qid(qid)
+ edit_page.add_question(**question)
+
+ def __get_question_by_qid(self, qid, loaded_questions: list[dict]) -> dict:
+ return [q for q in loaded_questions if q['qid'] == qid][0]
+
+ def test_custom_response(self, designer_login, loaded_questions: list[dict], dom_operator: DomOperator):
+ """
+ Tests the custom empty response setting can be overwritten.
+
+ See: https://docs.aws.amazon.com/solutions/latest/qnabot-on-aws/keyword-filters-and-custom-dont-know-answers.html
+ """
+ custom_empty_message = "Sorry, I don't know that"
+ edit_qid = 'CustomNoMatches'
+
+ menu = MenuNav(dom_operator)
+ edit_page = menu.open_edit_page()
+
+ assert edit_page.check_question_exists_by_qid(edit_qid)
+ edit_question = self.__get_question_by_qid(edit_qid, loaded_questions)
+
+ edit_question['a'] = custom_empty_message
+
+ edit_page.edit_question_by_qid(**edit_question)
+ settings_page = menu.open_settings_page()
+ settings_page.customize_empty_message(custom_empty_message)
+ settings_page.disable_kendra_fallback()
+
+ chat_page = menu.open_chat_page()
+ chat_page.send_message('Gobbledygook Eellogofusciouhipoppokunurious Anachronism')
+ answer = chat_page.get_messages()
+
+ assert custom_empty_message in answer
+
+ @pytest.mark.skip(reason="Not implemented")
+ def test_create_setting(self):
+ """
+ Tests the create setting feature.
+ """
+ pass
+
+ @pytest.mark.skip(reason="Not implemented")
+ def test_import_settings(self):
+ """
+ Tests the import settings feature.
+ """
+ pass
+
+ @pytest.mark.skip(reason="Not implemented")
+ def test_export_settings(self):
+ """
+ Tests the export settings feature.
+ """
+ pass
+
+ @pytest.mark.skip(reason="Not implemented")
+ def test_reset_settings(self):
+ """
+ Tests the reset settings feature.
+ """
+ pass
+
+ @pytest.mark.skip(reason="Not implemented")
+ def test_match_settings(self):
+ """
+ Tests the number of nouns must match setting can be set.
+
+ See: https://docs.aws.amazon.com/solutions/latest/qnabot-on-aws/modifying-configuration-settings.html
+ """
+ pass
+
+ @pytest.mark.skip(reason="Not implemented")
+ def test_pii_rejection(self):
+ """
+ Tests the PII rejection setting can be set.
+
+ See: https://w.amazon.com/bin/view/AWS/Solutions/SolutionsTeam/SolutionsImplementations/AWS_QnABot/Test_Plan/#HTC37.VerifyPIIrejectionfeature
+ """
+ pass
+
+ @pytest.mark.skip(reason="Not implemented")
+ def test_redaction(self):
+ """
+ Tests that custom terms are redacted in logs.
+
+ See: https://w.amazon.com/bin/view/AWS/Solutions/SolutionsTeam/SolutionsImplementations/AWS_QnABot/Test_Plan/#HTC38.Verifyredactionfeature-v5.0.1
+ """
+ pass
diff --git a/.nightswatch/functional/test_translate.py b/.nightswatch/functional/test_translate.py
new file mode 100644
index 000000000..605ba1c89
--- /dev/null
+++ b/.nightswatch/functional/test_translate.py
@@ -0,0 +1,198 @@
+######################################################################################################################
+# Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. #
+# #
+# Licensed under the Apache License, Version 2.0 (the "License"). You may not use this file except in compliance #
+# with the License. A copy of the License is located at #
+# #
+# http://www.apache.org/licenses/LICENSE-2.0 #
+# #
+# or in the 'license' file accompanying this file. This file is distributed on an 'AS IS' BASIS, WITHOUT WARRANTIES #
+# OR CONDITIONS OF ANY KIND, express or implied. See the License for the specific language governing permissions #
+# and limitations under the License. #
+######################################################################################################################
+
+import pytest
+import json
+import pathlib
+
+from helpers.website_model.dom_operator import DomOperator
+from helpers.website_model.menu_nav import MenuNav
+from helpers.website_model.chat_page import ChatPage
+from helpers.translate_client import TranslateClient
+from helpers.cloud_watch_client import CloudWatchClient
+
+QUESTION_FILEPATH = './question_bank/translate_questions.json'
+
+QNABOT_BLOG_QUESTION_TEXT = 'Can I backup Q and A Bot content'
+QNABOT_BLOG_ANSWER_TEXT = 'Yes. Use the Content Designer to export your content as a JSON file. Maintain this file in your version control system or S3 bucket. Use the Designer UI Import feature to restore content from the JSON file.'
+class TestTranslate:
+
+ @pytest.fixture(scope='class')
+ def loaded_questions(self) -> list[dict]:
+ question_file = open(QUESTION_FILEPATH)
+ data = json.load(question_file)
+ question_file.close()
+ return data['qna']
+
+ def __create_question(self, question: dict, edit_page):
+ qid = question['qid']
+ if edit_page.check_question_exists_by_qid(qid):
+ edit_page.delete_question_by_qid(qid)
+ edit_page.add_question(**question)
+
+ def __get_question_by_qid(self, qid, loaded_questions: list[dict]) -> dict:
+ return [q for q in loaded_questions if q['qid'] == qid][0]
+
+ def test_setup(self, designer_login, dom_operator: DomOperator, translate_client: TranslateClient):
+ """
+ Sets default settings before running tests.
+ """
+
+ translate_client.delete_all_terminologies()
+
+ menu = MenuNav(dom_operator)
+ settings_page = menu.open_settings_page()
+ settings_page.reset_settings()
+ # Needs to be enabled, otherwise all questions fallback
+ assert 'Success' in settings_page.enable_kendra_fallback()
+ assert 'Success' in settings_page.disable_embeddings()
+ assert 'Success' in settings_page.disable_llm()
+ assert 'Success' in settings_page.enable_multi_language_support()
+ assert 'Success' in settings_page.enable_custom_terminology()
+
+ def test_client_conversation_english(self, client_login, dom_operator: DomOperator, cw_client: CloudWatchClient):
+ """
+ Verifies the question works in English first before asking in other languages.
+
+ Prerequisite: Blog entry example must be imported first.
+ """
+ chat_page = ChatPage(dom_operator)
+
+ expected_response = QNABOT_BLOG_ANSWER_TEXT
+
+ call = QNABOT_BLOG_QUESTION_TEXT
+ chat_page.send_message(call)
+ answer = chat_page.get_messages()
+ assert expected_response in answer
+ cw_client.print_fulfillment_lambda_logs()
+
+ def test_client_conversation_multi(self, client_login, dom_operator: DomOperator, translate_client: TranslateClient, languages: list[str], cw_client: CloudWatchClient):
+ """
+ Test the same question is translated to other locales.
+ """
+ chat_page = ChatPage(dom_operator)
+
+ calls = [translate_client.translate(QNABOT_BLOG_QUESTION_TEXT, language) for language in languages]
+ expected_responses = [translate_client.translate(QNABOT_BLOG_ANSWER_TEXT, language) for language in languages]
+
+ chat_page.send_message(calls[0])
+ chat_page.send_message(calls[1])
+ answer = chat_page.get_messages()
+ assert expected_responses[0] in answer
+ assert expected_responses[1] in answer
+ cw_client.print_fulfillment_lambda_logs()
+
+ def test_custom_terminology(self, designer_login, loaded_questions: list[dict], dom_operator: DomOperator, translate_client: TranslateClient, cw_client: CloudWatchClient):
+ """
+ Test that custom terminology can be uploaded and does not get translated when asked in the other language.
+ """
+ menu = MenuNav(dom_operator)
+ custom_terminology = menu.open_custom_terminology()
+ terminology_file = f'{pathlib.Path().resolve()}/files/terms.csv'
+ custom_terminology.upload_file(terminology_file)
+
+ qid = 'Translate.001'
+ question = self.__get_question_by_qid(qid, loaded_questions)
+
+ edit_page = menu.open_edit_page()
+
+ self.__create_question(question, edit_page)
+
+ chat_page = menu.open_chat_page()
+
+ # French language currently has an issue with custom terminology see: https://t.corp.amazon.com/V998365774/overview
+ call = translate_client.translate(question['q'][0], 'es')
+ response = translate_client.translate(question['a'], 'es')
+
+ chat_page.send_message(call)
+ answer = chat_page.get_messages()
+ assert translate_client.has_terminology('terms')
+ assert response in answer
+ cw_client.print_fulfillment_lambda_logs()
+
+ @pytest.mark.skip(reason="Issue under review")
+ def test_custom_terminology_translates_to_specified_term(self, designer_login, loaded_questions: list[dict], dom_operator: DomOperator, cw_client: CloudWatchClient):
+ """
+ Test that custom terminology can be uploaded and translates based on custom terminology provided.
+
+ See: https://github.com/aws-solutions/qnabot-on-aws/issues/455
+ """
+ menu = MenuNav(dom_operator)
+
+ custom_terminology = menu.open_custom_terminology()
+ terminology_file = f'{pathlib.Path().resolve()}/files/EPCTerminology.csv'
+ custom_terminology.upload_file(terminology_file)
+
+ qid = 'Translate.003'
+ question = self.__get_question_by_qid(qid, loaded_questions)
+
+ edit_page = menu.open_edit_page()
+
+ self.__create_question(question, edit_page)
+
+ chat_page = menu.open_chat_page()
+
+ chat_page.send_message('TraducciĂłn de tarifas de prueba')
+ answer = chat_page.get_messages()
+ assert 'sin incurrir ningĂșn cargo' in answer
+ cw_client.print_fulfillment_lambda_logs()
+
+ def test_lang_handlebar(self, designer_login, loaded_questions: list[dict], dom_operator: DomOperator, cw_client: CloudWatchClient):
+ """
+ Test different answers are returned based on the current language using the lang handlebars.
+
+ See: https://aws.amazon.com/blogs/machine-learning/building-a-multilingual-question-and-answer-bot-with-amazon-lex/
+ """
+ qid = 'Translate.002'
+ question = self.__get_question_by_qid(qid, loaded_questions)
+
+ menu = MenuNav(dom_operator)
+ edit_page = menu.open_edit_page()
+
+ self.__create_question(question, edit_page)
+
+ chat_page = menu.open_chat_page()
+
+ chat_page.send_message('Why should you say mucho to your Hispanic friends?')
+ answer = chat_page.get_messages()
+ assert 'It means a lot to them' in answer
+
+ chat_page.send_message('ÂżPor quĂ© deberĂas decir mucho cuando hablas con tus amigos hispanos?')
+ answer = chat_page.get_messages()
+ assert 'Significa mucho para mĂ' in answer
+ cw_client.print_fulfillment_lambda_logs()
+
+ def test_lang_support(self, designer_login, loaded_questions: list[dict], dom_operator: DomOperator, cw_client: CloudWatchClient):
+ """
+ Test the language example can be imported and the client can select their language based on utterance.
+
+ See: https://aws.amazon.com/blogs/machine-learning/building-a-multilingual-question-and-answer-bot-with-amazon-lex/
+ """
+ qid = 'Translate.002'
+ question = self.__get_question_by_qid(qid, loaded_questions)
+
+ menu = MenuNav(dom_operator)
+ import_page = menu.open_import_page()
+ import_page.import_language()
+
+ edit_page = menu.open_edit_page()
+
+ self.__create_question(question, edit_page)
+
+ chat_page = menu.open_chat_page()
+
+ chat_page.send_message('Spanish')
+ chat_page.send_message('Why should you say mucho to your Hispanic friends?')
+ answer = chat_page.get_messages()
+ assert 'Significa mucho para mĂ' in answer
+ cw_client.print_fulfillment_lambda_logs()
diff --git a/.nightswatch/functional/test_tuning.py b/.nightswatch/functional/test_tuning.py
new file mode 100644
index 000000000..4c8aff318
--- /dev/null
+++ b/.nightswatch/functional/test_tuning.py
@@ -0,0 +1,62 @@
+######################################################################################################################
+# Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. #
+# #
+# Licensed under the Apache License, Version 2.0 (the "License"). You may not use this file except in compliance #
+# with the License. A copy of the License is located at #
+# #
+# http://www.apache.org/licenses/LICENSE-2.0 #
+# #
+# or in the 'license' file accompanying this file. This file is distributed on an 'AS IS' BASIS, WITHOUT WARRANTIES #
+# OR CONDITIONS OF ANY KIND, express or implied. See the License for the specific language governing permissions #
+# and limitations under the License. #
+######################################################################################################################
+
+import pytest
+import time
+
+from helpers.website_model.menu_nav import MenuNav
+from helpers.website_model.dom_operator import DomOperator
+
+class TestTuning:
+# https://docs.aws.amazon.com/solutions/latest/aws-qnabot/tuning-testing-and-troubleshooting.html
+
+ def test_test_all(self, designer_login, dom_operator: DomOperator):
+ """
+ Tests the test all functionality.
+ """
+ menu = MenuNav(dom_operator)
+ edit_page = menu.open_edit_page()
+ edit_page.select_test_all_tab()
+ report_status = edit_page.generate_test_report()
+ assert 'Completed' in report_status
+
+ def test_test_single(self, designer_login, dom_operator: DomOperator):
+ """
+ Tests the test single functionality.
+ """
+
+ # Import test must be successful for this question to be available
+ blog_question = 'What is Q and A Bot'
+
+ menu = MenuNav(dom_operator)
+ edit_page = menu.open_edit_page()
+ edit_page.select_test_tab()
+ edit_page.execute_test_query(blog_question)
+
+ top_question = edit_page.select_question_by_row_and_column(1, 4).text
+
+ # Wait for the query to finish
+ attempts = 1
+ max_attempts = 3
+ while blog_question not in top_question:
+ wait = 1 * 2 ** attempts
+ time.sleep(wait)
+ top_question = edit_page.select_question_by_row_and_column(1, 4).text
+ attempts += 1
+
+ if attempts == max_attempts:
+ break
+
+ assert blog_question in top_question
+ top_score = edit_page.select_question_by_row_and_column(1, 1).text
+ assert float(top_score) >= 1
diff --git a/.nightswatch/nightswatch_config.json b/.nightswatch/nightswatch_config.json
new file mode 100644
index 000000000..6262a3484
--- /dev/null
+++ b/.nightswatch/nightswatch_config.json
@@ -0,0 +1,14 @@
+{
+ "deployment": {
+ "SKIP_DEPLOY": false,
+ "SKIP_DESTROY": false,
+ "SKIP_CLEANUP": false,
+ "SKIP_PRE_DEPLOY": false,
+ "SKIP_PRE_UNDEPLOY": true,
+ "SKIP_UNDEPLOY": false,
+ "SKIP_POST_UNDEPLOY": false,
+ "SEQUENTIAL_DEPLOY": false,
+ "SEQUENTIAL_DEPLOY_WITH_DELAY_IN_SECONDS": 60
+ },
+ "functional": {}
+}
\ No newline at end of file
diff --git a/.nightswatch/requirements.txt b/.nightswatch/requirements.txt
new file mode 100644
index 000000000..773d2d0e1
--- /dev/null
+++ b/.nightswatch/requirements.txt
@@ -0,0 +1,4 @@
+pytest
+pytest-json
+boto3
+selenium==4.16
\ No newline at end of file
diff --git a/.nightswatch/scripts/delete_admin_users.py b/.nightswatch/scripts/delete_admin_users.py
new file mode 100644
index 000000000..051a78cce
--- /dev/null
+++ b/.nightswatch/scripts/delete_admin_users.py
@@ -0,0 +1,65 @@
+######################################################################################################################
+# Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. #
+# #
+# Licensed under the Apache License, Version 2.0 (the "License"). You may not use this file except in compliance #
+# with the License. A copy of the License is located at #
+# #
+# http://www.apache.org/licenses/LICENSE-2.0 #
+# #
+# or in the 'license' file accompanying this file. This file is distributed on an 'AS IS' BASIS, WITHOUT WARRANTIES #
+# OR CONDITIONS OF ANY KIND, express or implied. See the License for the specific language governing permissions #
+# and limitations under the License. #
+######################################################################################################################
+
+import boto3
+
+cloudformation_client = boto3.client('cloudformation', region_name=region)
+cognito_idp_client = boto3.client('cognito-idp', region_name=region)
+
+
+def get_user_pool_id():
+ response = cloudformation_client.list_stack_resources(
+ StackName=stack_name
+ )
+ response_more = cloudformation_client.list_stack_resources(
+ StackName=stack_name,
+ NextToken=response['NextToken']
+ )
+ for StackResourceSummary in response_more['StackResourceSummaries']:
+ if StackResourceSummary['LogicalResourceId'] == 'UserPool':
+ user_pool_id = StackResourceSummary['PhysicalResourceId']
+ return user_pool_id
+
+
+def get_original_admin_user():
+ response = cloudformation_client.describe_stacks(
+ StackName=stack_name
+ )
+ stacks = response['Stacks']
+ for stack in stacks:
+ parameters = stack['Parameters']
+ for parameter in parameters:
+ if parameter['ParameterKey'] == 'Username':
+ original_admin_user = parameter['ParameterValue']
+ return original_admin_user
+
+
+def delete_admin_users():
+ original_admin_user = get_original_admin_user()
+ user_pool_id = get_user_pool_id()
+ response = cognito_idp_client.list_users_in_group(
+ UserPoolId=user_pool_id,
+ GroupName='Admins'
+ )
+ for user in response['Users']:
+ if user['Username'] != original_admin_user:
+ adminusername = user['Username']
+ print(adminusername)
+ print('deleting this user')
+ cognito_idp_client.admin_delete_user(
+ UserPoolId=user_pool_id,
+ Username=adminusername
+ )
+
+
+delete_admin_users()
diff --git a/.nightswatch/scripts/delete_kendra_data_source.py b/.nightswatch/scripts/delete_kendra_data_source.py
new file mode 100644
index 000000000..885ec9585
--- /dev/null
+++ b/.nightswatch/scripts/delete_kendra_data_source.py
@@ -0,0 +1,50 @@
+######################################################################################################################
+# Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. #
+# #
+# Licensed under the Apache License, Version 2.0 (the "License"). You may not use this file except in compliance #
+# with the License. A copy of the License is located at #
+# #
+# http://www.apache.org/licenses/LICENSE-2.0 #
+# #
+# or in the 'license' file accompanying this file. This file is distributed on an 'AS IS' BASIS, WITHOUT WARRANTIES #
+# OR CONDITIONS OF ANY KIND, express or implied. See the License for the specific language governing permissions #
+# and limitations under the License. #
+######################################################################################################################
+
+import boto3
+
+kendra_regions = ['us-east-1', 'us-west-2', 'ap-southeast-1', 'ap-southeast-2', 'ca-central-1', 'eu-west-1']
+
+
+def get_kendra_index_id(kendra_client):
+ response = kendra_client.list_indices()
+ index_configuration_summary_items = response['IndexConfigurationSummaryItems']
+ for index_configuration_summary_item in index_configuration_summary_items:
+ if index_configuration_summary_item['Name'] == 'nightswatch':
+ kendra_index_id = index_configuration_summary_item['Id']
+ return kendra_index_id
+
+def delete_kendra_data_sources():
+ for kendra_region in kendra_regions:
+ kendra_client = boto3.client('kendra', region_name=kendra_region)
+ kendra_index_id = get_kendra_index_id(kendra_client)
+ if (kendra_index_id is None) or (kendra_index_id == 'None'):
+ print('kendra_index_id not found.')
+ else:
+ response = kendra_client.list_data_sources(
+ IndexId=kendra_index_id
+ )
+ summary_items = response['SummaryItems']
+ if summary_items:
+ for summary_item in summary_items:
+ data_source_id = summary_item['Id']
+ print(
+ kendra_region + ' -:Kendra Index ID:- ' + kendra_index_id + ' -:Data Source ID:- ' + data_source_id)
+ kendra_client.delete_data_source(
+ Id=data_source_id,
+ IndexId=kendra_index_id
+ )
+ print(kendra_region + ' -:Data Source ID - DELETED:- ' + data_source_id)
+
+
+delete_kendra_data_sources()
diff --git a/.nightswatch/scripts/delete_role.py b/.nightswatch/scripts/delete_role.py
new file mode 100644
index 000000000..152f831b0
--- /dev/null
+++ b/.nightswatch/scripts/delete_role.py
@@ -0,0 +1,40 @@
+######################################################################################################################
+# Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. #
+# #
+# Licensed under the Apache License, Version 2.0 (the "License"). You may not use this file except in compliance #
+# with the License. A copy of the License is located at #
+# #
+# http://www.apache.org/licenses/LICENSE-2.0 #
+# #
+# or in the 'license' file accompanying this file. This file is distributed on an 'AS IS' BASIS, WITHOUT WARRANTIES #
+# OR CONDITIONS OF ANY KIND, express or implied. See the License for the specific language governing permissions #
+# and limitations under the License. #
+######################################################################################################################
+
+import boto3
+import sys
+
+client = boto3.client('iam')
+
+def delete_role():
+ marker = None
+ while True:
+ paginator = client.get_paginator('list_roles')
+ response_iterator = paginator.paginate(
+ PaginationConfig={
+ 'PageSize': 10,
+ 'StartingToken': marker})
+ for page in response_iterator:
+ iam_roles = page['Roles']
+ for iam_role in iam_roles:
+ role_name = iam_role['RoleName']
+ if role_name.startswith('AWSServiceRoleForLexV2Bots_tCaT-qnabot'):
+ client.delete_service_linked_role(RoleName=role_name)
+ print(role_name + '----DELETED')
+ try:
+ marker = page['Marker']
+ print(marker)
+ except KeyError:
+ sys.exit()
+
+delete_role()
diff --git a/.nightswatch/scripts/delete_s3_bucket.py b/.nightswatch/scripts/delete_s3_bucket.py
new file mode 100644
index 000000000..281c0a6a6
--- /dev/null
+++ b/.nightswatch/scripts/delete_s3_bucket.py
@@ -0,0 +1,47 @@
+######################################################################################################################
+# Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. #
+# #
+# Licensed under the Apache License, Version 2.0 (the "License"). You may not use this file except in compliance #
+# with the License. A copy of the License is located at #
+# #
+# http://www.apache.org/licenses/LICENSE-2.0 #
+# #
+# or in the 'license' file accompanying this file. This file is distributed on an 'AS IS' BASIS, WITHOUT WARRANTIES #
+# OR CONDITIONS OF ANY KIND, express or implied. See the License for the specific language governing permissions #
+# and limitations under the License. #
+######################################################################################################################
+
+#!/usr/bin/env python3
+
+# Deletes S3 buckets created by stack deployment
+
+import logging
+import time
+
+import boto3
+from botocore.exceptions import ClientError
+
+s3 = boto3.resource("s3")
+
+bucket_name = ["tcat-qnabot"]
+print("buckets to be delete should starts with:")
+print(bucket_name)
+
+def delete_bucket():
+ for buckets in bucket_name:
+ try:
+ for bucket in s3.buckets.all():
+ if bucket.name.startswith(buckets):
+ print("the bucket exists!!------" + bucket.name)
+ s3_bucket = s3.Bucket(bucket.name)
+ s3_object = s3_bucket.object_versions.delete()
+ s3_bucket.objects.all().delete()
+ s3_bucket.delete()
+ print("bucket deleted:" + "----" + bucket.name)
+ time.sleep(5)
+ except ClientError as e:
+ logging.error(e)
+ return
+
+
+delete_bucket()
diff --git a/.nightswatch/scripts/post_undeploy.sh b/.nightswatch/scripts/post_undeploy.sh
new file mode 100644
index 000000000..ea9fc2bf5
--- /dev/null
+++ b/.nightswatch/scripts/post_undeploy.sh
@@ -0,0 +1,26 @@
+#!/bin/bash
+######################################################################################################################
+# Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. #
+# #
+# Licensed under the Apache License, Version 2.0 (the "License"). You may not use this file except in compliance #
+# with the License. A copy of the License is located at #
+# #
+# http://www.apache.org/licenses/LICENSE-2.0 #
+# #
+# or in the 'license' file accompanying this file. This file is distributed on an 'AS IS' BASIS, WITHOUT WARRANTIES #
+# OR CONDITIONS OF ANY KIND, express or implied. See the License for the specific language governing permissions #
+# and limitations under the License. #
+######################################################################################################################
+
+set -e -x
+
+python3 ${NIGHTSWATCH_TEST_DIR}/scripts/delete_s3_bucket.py
+sleep 20
+
+python3 ${NIGHTSWATCH_TEST_DIR}/scripts/delete_role.py
+sleep 10
+
+echo 'DELETING KENDRA DATA SOURCE:-------------------------------------------------------------'
+python3 ${NIGHTSWATCH_TEST_DIR}/scripts/delete_kendra_data_source.py
+sleep 10
+
diff --git a/.nightswatch/scripts/pre_deploy.sh b/.nightswatch/scripts/pre_deploy.sh
new file mode 100644
index 000000000..006285583
--- /dev/null
+++ b/.nightswatch/scripts/pre_deploy.sh
@@ -0,0 +1,21 @@
+#!/bin/bash
+######################################################################################################################
+# Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. #
+# #
+# Licensed under the Apache License, Version 2.0 (the "License"). You may not use this file except in compliance #
+# with the License. A copy of the License is located at #
+# #
+# http://www.apache.org/licenses/LICENSE-2.0 #
+# #
+# or in the 'license' file accompanying this file. This file is distributed on an 'AS IS' BASIS, WITHOUT WARRANTIES #
+# OR CONDITIONS OF ANY KIND, express or implied. See the License for the specific language governing permissions #
+# and limitations under the License. #
+######################################################################################################################
+
+nightswatch --destroy-stacks
+
+python3 ${NIGHTSWATCH_TEST_DIR}/scripts/delete_s3_bucket.py
+sleep 20
+
+python3 ${NIGHTSWATCH_TEST_DIR}/scripts/delete_role.py
+sleep 10
\ No newline at end of file
diff --git a/.nightswatch/scripts/pytest_jest-1.0.0-py3-none-any.whl b/.nightswatch/scripts/pytest_jest-1.0.0-py3-none-any.whl
new file mode 100644
index 000000000..383d8dd51
Binary files /dev/null and b/.nightswatch/scripts/pytest_jest-1.0.0-py3-none-any.whl differ
diff --git a/.nightswatch/scripts/run_regression_tests.sh b/.nightswatch/scripts/run_regression_tests.sh
new file mode 100644
index 000000000..51dddc071
--- /dev/null
+++ b/.nightswatch/scripts/run_regression_tests.sh
@@ -0,0 +1,62 @@
+#!/bin/bash
+######################################################################################################################
+# Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. #
+# #
+# Licensed under the Apache License, Version 2.0 (the "License"). You may not use this file except in compliance #
+# with the License. A copy of the License is located at #
+# #
+# http://www.apache.org/licenses/LICENSE-2.0 #
+# #
+# or in the 'license' file accompanying this file. This file is distributed on an 'AS IS' BASIS, WITHOUT WARRANTIES #
+# OR CONDITIONS OF ANY KIND, express or implied. See the License for the specific language governing permissions #
+# and limitations under the License. #
+######################################################################################################################
+
+set -e -x
+
+cd ${NIGHTSWATCH_TEST_DIR}/
+
+echo 'install pytest'
+pip install -U pytest
+pip install -r requirements.txt
+echo 'echo requirements installed'
+
+cd ${NIGHTSWATCH_TEST_DIR}/functional/
+
+aws configure list
+
+### Issue: Test are taking too long to run sequentially and tests fail when run in parallel when Selenium operating within the same browser instance.
+### Action: So running functional test in one random regions
+REGIONS=($(${NIGHTSWATCH_TEST_DIR}/scripts/utils/listregions.py yaml-filename=taskcat.yml | tr -d "[],'"))
+NUMBER_OF_REGIONS=${#REGIONS[@]}
+RANDOM_NUM=$(( $RANDOM % $NUMBER_OF_REGIONS ))
+echo "Regression test selected for region: ${REGIONS[RANDOM_NUM]}"
+
+function runRegressionTest {
+ export STACK_FILE_NAME=$1
+ regex="(.*)-(${REGIONS[RANDOM_NUM]})-cfnlogs.txt"
+ if [[ $STACK_FILE_NAME =~ $regex ]];
+ then
+ export CURRENT_STACK_NAME=${BASH_REMATCH[1]};
+ export CURRENT_STACK_REGION=${BASH_REMATCH[2]};
+
+ export RESULTS_FILE_NAME="${STACK_FILE_NAME/-cfnlogs.txt/-python-functional-logs.json}"
+ cd ${NIGHTSWATCH_TEST_DIR}/functional/
+ echo 'running for current stack region --- ' $CURRENT_STACK_REGION
+ echo 'current stack --- ' $CURRENT_STACK_NAME
+ echo $RESULTS_FILE_NAME
+ pwd
+ echo 'above is the location'
+
+ (EMAIL='test@example.com' pytest -vs --json=${NIGHTSWATCH_TEST_DIR}/functional/results/$RESULTS_FILE_NAME) &
+
+ sleep 120
+ fi
+}
+
+# For each of the stack from taskcat_outputs/ folder, run the regression tests.
+for file in ${NIGHTSWATCH_TEST_DIR}/*.txt
+ do
+ runRegressionTest ${file##*/}
+ done
+wait
diff --git a/.nightswatch/scripts/utils/listregions.py b/.nightswatch/scripts/utils/listregions.py
new file mode 100755
index 000000000..0c4d642ee
--- /dev/null
+++ b/.nightswatch/scripts/utils/listregions.py
@@ -0,0 +1,57 @@
+######################################################################################################################
+# Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. #
+# #
+# Licensed under the Apache License, Version 2.0 (the "License"). You may not use this file except in compliance #
+# with the License. A copy of the License is located at #
+# #
+# http://www.apache.org/licenses/LICENSE-2.0 #
+# #
+# or in the 'license' file accompanying this file. This file is distributed on an 'AS IS' BASIS, WITHOUT WARRANTIES #
+# OR CONDITIONS OF ANY KIND, express or implied. See the License for the specific language governing permissions #
+# and limitations under the License. #
+######################################################################################################################
+#!/usr/bin/env python3
+
+import os
+import yaml
+import sys
+
+NIGHTSWATCH_TEST_DIR = os.getenv('NIGHTSWATCH_TEST_DIR')
+
+def listregions(yaml_filename=None):
+ if not yaml_filename:
+ yaml_filename = 'taskcat.yml'
+ with open(f'{NIGHTSWATCH_TEST_DIR}/deployment/{yaml_filename}', "r") as taskcat_file:
+ output = yaml.safe_load(taskcat_file)
+ project_details = output["project"]
+ if "regions" in output["project"]:
+ print(project_details["regions"])
+ return project_details["regions"]
+ else:
+ testcase = output["tests"]
+ myregions = []
+ for tc in testcase:
+ for region in testcase[tc]["regions"]:
+ myregions.append(region)
+ print(myregions)
+ return myregions
+
+def invalid_usage():
+ error_message = """ Invalid Usage ::
+ Follow the example below
+ listregions.py
+ OR
+ listregions.py yaml-filename='taskcat1.yml'
+ """
+ raise error_message
+
+if __name__ == '__main__':
+ args = sys.argv[1:]
+ if not args:
+ listregions()
+ else:
+ kwargs = dict(x.split('=', 1) for x in args)
+ if not ('yaml-filename' in kwargs):
+ invalid_usage()
+ else:
+ listregions(kwargs['yaml-filename'])
\ No newline at end of file
diff --git a/.npmrc b/.npmrc
new file mode 100644
index 000000000..4fd021952
--- /dev/null
+++ b/.npmrc
@@ -0,0 +1 @@
+engine-strict=true
\ No newline at end of file
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 4cfdcf27b..90c368de3 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -5,6 +5,39 @@ All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
+## [5.5.0] - 2024-01-04
+### Added
+- Added Core-Language parameter to the QnABot deployment. This parameter allows the user to select during the deployment a core language which will be used by the OpenSearch language analyzers to look for question and answers. With this update, QnABot can now be deployed natively in 33 Languages with a more syntactical accuracy for matching questions and answers
+- Bot routing enhancements including passing initial utterance to specialty bot and receive session attributes from specialty bot ([issue #376](https://github.com/aws-solutions/qnabot-on-aws/issues/376)) - contributed by ([@bobpskier](https://github.com/bobpskier))
+- Improved error handling. Added custom error handling question to QnaUtility and some errors are appended to chat client message when ENABLE_DEBUG_RESPONSES is set to 'true'
+- Added 'PROTECTED_UTTERANCES' setting which allows the user to configure a comma-separated list of utterances that will be ignored by LLM query disambiguation and translation. This fixes a bug where feedback (thumbs up/thumbs down) and language selection would be disambiguated instead of triggering the respective workflow
+- Added 'getQuestion' handlebar that returns the original matched question without hard-coding ([issue #397](https://github.com/aws-solutions/qnabot-on-aws/issues/397))
+- Added functional test collection for verifying deployed QnABots
+- Added Service API Usage Tracking
+- Added deployment parameter to enable selection of opensearch instance type ([issue #599](https://github.com/aws-solutions/qnabot-on-aws/issues/599))
+
+### Updated
+- Migrated out of Bluebird promises to native promises
+- Migrated to AWS SDK for JavaScript v3
+- Upgraded to Webpack 5
+- Upgraded to Vue3
+- Upgraded to Vuetify 3
+- Upgraded to latest LLM Image
+- Code Quality improvements based on SonarQube analysis
+- Security patches for npm
+
+### Fixed
+- Fixed chaining not working when combined with bot routing ([issue #508](https://github.com/aws-solutions/qnabot-on-aws/issues/508)) - contributed by ([@bobpskier](https://github.com/bobpskier))
+- Fixed issue with chaining causing QnABot to become unresponsive when chaining rule evaluation fails. Improved error reporting when debugging is enabled.
+- Fixed issue preventing lambda hooks defined in the templates/extensions directory from being executed by the fulfillment lambda.
+- Fixed issue where LLM errors return 'no_hits' response instead of error message.
+- Fixed bug where positive feedback is not published to SNS.
+- Fixed content designer settings using different casing standard for boolean values ([issue #666](https://github.com/aws-solutions/qnabot-on-aws/issues/666))
+- Fixed inclusion of OpenSearch QnA results in text passages ([issue #669](https://github.com/aws-solutions/qnabot-on-aws/issues/669)) - contributed by ([@cristi-constantin](https://github.com/cristi-constantin))
+- Fixed issue where session attributes become undefined when translate isn't enabled.
+- Fixed issue where settings were being evaluated as strings instead of numbers. Settings that are saved as stings that represent positive, negative, whole, or decimal numbers will be parsed as numbers.
+- Fixed issue where kendra redirect does not use redirect query when users locale matches kendra index locale
+
## [5.4.5] - 2023-11-1
### Updated
- Security patch for browserify-sign
@@ -18,7 +51,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- Improved error handling
## [5.4.3] - 2023-10-13
- ### Fixed
+### Fixed
- Fixed issue where Alexa schema was not exporting the utterances list.
## [5.4.2] - 2023-09-30
diff --git a/Makefile b/Makefile
index b8c0b1d79..38ce6e49c 100644
--- a/Makefile
+++ b/Makefile
@@ -1,4 +1,4 @@
-TEMPLATES=$(shell for l in $$(ls ./templates | egrep -v "util|lib|README.md");do echo templates/$$l;done)
+TEMPLATES=$(shell for l in $$(ls ./templates | egrep -v "util|lib|README.md|jest.config.js|package.json|package-lock.json|node_modules|coverage|__tests__|.pytest_cache|.venv-test|pytest.ini|requirements.txt|requirements-test.txt");do echo templates/$$l;done)
All: ml_model assets templates lambda website make_directories
diff --git a/NOTICE.txt b/NOTICE.txt
index 429e50412..7888587f8 100644
--- a/NOTICE.txt
+++ b/NOTICE.txt
@@ -16,6 +16,39 @@ THIRD PARTY COMPONENTS
**********************
This software includes third party software subject to the following copyrights:
+@aws-sdk/client-api-gateway under the Apache License Version 2.0
+@aws-sdk/client-cognito-identity under the Apache License Version 2.0
+@aws-crypto/sha256-js under the Apache License Version 2.0
+@aws-sdk/client-api-gateway under the Apache License Version 2.0
+@aws-sdk/client-cloudformation under the Apache License Version 2.0
+@aws-sdk/client-cognito-identity under the Apache License Version 2.0
+@aws-sdk/client-cognito-identity-provider under the Apache License Version 2.0
+@aws-sdk/client-comprehend under the Apache License Version 2.0
+@aws-sdk/client-dynamodb under the Apache License Version 2.0
+@aws-sdk/client-elasticsearch-service under the Apache License Version 2.0
+@aws-sdk/client-firehose under the Apache License Version 2.0
+@aws-sdk/client-iam under the Apache License Version 2.0
+@aws-sdk/client-kendra under the Apache License Version 2.0
+@aws-sdk/client-kms under the Apache License Version 2.0
+@aws-sdk/client-lambda under the Apache License Version 2.0
+@aws-sdk/client-lex-model-building-service under the Apache License Version 2.0
+@aws-sdk/client-lex-models-v2 under the Apache License Version 2.0
+@aws-sdk/client-lex-runtime-service under the Apache License Version 2.0
+@aws-sdk/client-lex-runtime-v2 under the Apache License Version 2.0
+@aws-sdk/client-polly under the Apache License Version 2.0
+@aws-sdk/client-s3 under the Apache License Version 2.0
+@aws-sdk/client-sagemaker-runtime under the Apache License Version 2.0
+@aws-sdk/client-ssm under the Apache License Version 2.0
+@aws-sdk/client-sts under the Apache License Version 2.0
+@aws-sdk/client-translate under the Apache License Version 2.0
+@aws-sdk/credential-providers under the Apache License Version 2.0
+@aws-sdk/lib-dynamodb under the Apache License Version 2.0
+@aws-sdk/s3-request-presigner under the Apache License Version 2.0
+@aws-sdk/util-stream-node under the Apache License Version 2.0
+@smithy/node-http-handler under the Apache License Version
+@smithy/protocol-http under the Apache License Version 2.0
+@smithy/signature-v4 under the Apache License Version 2.0
+@smithy/util-retry under the Apache License Version 2.0
@babel/core under the Massachusetts Institute of Technology (MIT) license
@babel/plugin-transform-runtime under the Massachusetts Institute of Technology (MIT) license
@babel/preset-env under the Massachusetts Institute of Technology (MIT) license
@@ -26,27 +59,26 @@ This software includes third party software subject to the following copyrights:
ajv under the Massachusetts Institute of Technology (MIT) license
alexa-sdk under the Apache License Version 2.0
arrow under the Apache License Version 2.0
-astroid under the GNU Lesser General Public License v2 (LGPLv2)
async-mutex under the Massachusetts Institute of Technology (MIT) license
attrs under the Massachusetts Institute of Technology (MIT) license
autopep8 under the Massachusetts Institute of Technology (MIT) license
autosize under the Massachusetts Institute of Technology (MIT) license
aws-lex-web-ui under the Amazon Software License
aws-sdk under the Apache License Version 2.0
-aws4 the Massachusetts Institute of Technology (MIT) license
-axios the Massachusetts Institute of Technology (MIT) license
+aws-sdk-client-mock under the Massachusetts Institute of Technology (MIT) license
+aws-sdk-client-mock-jest under the Massachusetts Institute of Technology (MIT) license
+aws4 under the Massachusetts Institute of Technology (MIT) license
+axios under the Massachusetts Institute of Technology (MIT) license
babel-core under the Massachusetts Institute of Technology (MIT) license
babel-loader under the Massachusetts Institute of Technology (MIT) license
babel-plugin-syntax-flow under the Massachusetts Institute of Technology (MIT) license
babel-plugin-transform-flow-strip-types under the Massachusetts Institute of Technology (MIT) license
-babel-polyfill the Massachusetts Institute of Technology (MIT) license
+babel-polyfill under the Massachusetts Institute of Technology (MIT) license
babel-preset-env under the Massachusetts Institute of Technology (MIT) license
babel-preset-es2015 under the Massachusetts Institute of Technology (MIT) license
babel-preset-es2015-ie the Massachusetts Institute of Technology (MIT) license
beautifulsoup under Massachusetts Institute of Technology (MIT) License
beautifulsoup4 under the Massachusetts Institute of Technology (MIT) license
-black under the Massachusetts Institute of Technology (MIT) license
-bluebird under the Massachusetts Institute of Technology (MIT) license
body-parser under the Massachusetts Institute of Technology (MIT) license
boolean.py under BSD-2-Clause
boto3 under the Apache License Version 2.0
@@ -80,6 +112,7 @@ file-loader under the Massachusetts Institute of Technology (MIT) license
file-saver under the Massachusetts Institute of Technology (MIT) license
filelock under the Unlicense license
flake8 under the Massachusetts Institute of Technology (MIT) license
+h11 under the Massachusetts Institute of Technology (MIT) license
handlebars under the Massachusetts Institute of Technology (MIT) license
handlebars-loader under the Massachusetts Institute of Technology (MIT) license
highlight.js under BSD-3-Clause license
@@ -91,6 +124,7 @@ intercept-stdout under the Massachusetts Institute of Technology (MIT) license
iniconfig under the Massachusetts Institute of Technology (MIT) license
isort under the Massachusetts Institute of Technology (MIT) license
Jinja2 under the BSD License (BSD-3-Clause)
+jest under the Massachusetts Institute of Technology (MIT) license
jmespath under the Massachusetts Institute of Technology (MIT) license
js-cache under the Massachusetts Institute of Technology (MIT) license
jsdom under the Massachusetts Institute of Technology (MIT) license
@@ -124,8 +158,8 @@ mypy-extensions under the Massachusetts Institute of Technology (MIT) license
openapi-schema-validator under the BSD License
openapi-spec-validator under the Apache License Version 2.0
ora under the Massachusetts Institute of Technology (MIT) license
+outcome under the Apache License Version 2.0 and the the Massachusetts Institute of Technology (MIT) license
pathable under the Apache License Version 2.0
-pathspec under the Mozilla Public License 2.0 (MPL 2.0)
platformdirs under the Massachusetts Institute of Technology (MIT) license
pluggy under the Massachusetts Institute of Technology (MIT) license
progress-bar-webpack-plugin under the Massachusetts Institute of Technology (MIT) license
@@ -136,13 +170,14 @@ pug-runtime under the Massachusetts Institute of Technology (MIT) license
pycodestyle under the Massachusetts Institute of Technology (MIT) license
pycparser under the BSD License
pyflakes under the Massachusetts Institute of Technology (MIT) license
-pylint under the GNU General Public License v2 (GPLv2)
pyrsistent under the Massachusetts Institute of Technology (MIT) license
pytest under the Massachusetts Institute of Technology (MIT) license
pytest-cov under the Massachusetts Institute of Technology (MIT) license
pytest-env under the Massachusetts Institute of Technology (MIT) license
+pytest-json under the Massachusetts Institute of Technology (MIT) license
pytest-mock under the Massachusetts Institute of Technology (MIT) license
py-serializable under the Apache License Version 2.0
+PySocks under the BSD License
python-dateutil under the Apache License Version 2.0 and BSD License
pytz under the Massachusetts Institute of Technology (MIT) license
query-string under the Massachusetts Institute of Technology (MIT) license
@@ -156,12 +191,15 @@ read-excel-file under the Massachusetts Institute of Technology (MIT) license
recursive-readdir under the Massachusetts Institute of Technology (MIT) license
require-dir under the Massachusetts Institute of Technology (MIT) license
responses under the Apache License Version 2.0
+rfc3339-validator under the Massachusetts Institute of Technology (MIT) license
roboto-fontface under the Apache License Version 2.0
s3transfer under the Apache License Version 2.0
sass under the Massachusetts Institute of Technology (MIT) license
sass-loader under the Massachusetts Institute of Technology (MIT) license
+selenium under the Apache License Version 2.0
simple-encryptor under the Massachusetts Institute of Technology (MIT) license
slackify-markdown under the Massachusetts Institute of Technology (MIT) license
+sniffio under the Apache License Version 2.0 and the the Massachusetts Institute of Technology (MIT) license
soupsieve under the Massachusetts Institute of Technology (MIT) license
static-eval under the Massachusetts Institute of Technology (MIT) license
strip-ansi under the Massachusetts Institute of Technology (MIT) license
@@ -170,6 +208,8 @@ stylus under the Massachusetts Institute of Technology (MIT) license
stylus-loader under the Massachusetts Institute of Technology (MIT) license
tomli under the Massachusetts Institute of Technology (MIT) license
tomlkit under the Massachusetts Institute of Technology (MIT) license
+trio under the Apache License Version 2.0 and the the Massachusetts Institute of Technology (MIT) license
+trio-websocket under the Massachusetts Institute of Technology (MIT) license
types-PyYAML under the Apache License Version 2.0
types-python-dateutil under the Apache License Version 2.0
typing_extensions under Python Software Foundation License
@@ -196,4 +236,5 @@ webpack-merge under the Massachusetts Institute of Technology (MIT) license
webpack-s3-plugin under the Massachusetts Institute of Technology (MIT) license
websocket-client under the Apache License Version 2.0
Werkzeug under the BSD License
+wsproto under the Massachusetts Institute of Technology (MIT) license
xmltodict under the Massachusetts Institute of Technology (MIT) license
\ No newline at end of file
diff --git a/README.md b/README.md
index 355318f2a..6fc521045 100644
--- a/README.md
+++ b/README.md
@@ -28,11 +28,11 @@ The high-level process flow for the solution components deployed with the AWS Cl
4. The `Content Designer` [AWS Lambda](http://aws.amazon.com/lambda/) function saves the input in [Amazon OpenSearch Service](http://aws.amazon.com/opensearch-service/) in a questions bank index. If using [text embeddings](docs/semantic_matching_using_LLM_embeddings/README.md), these requests will first pass through a ML model hosted on [Amazon SageMaker](https://aws.amazon.com/sagemaker/) to generate embeddings before being saved into the question bank on OpenSearch.
-5. Users of the chatbot interact with Amazon Lex via the web client UI or [Amazon Connect](https://aws.amazon.com/connect/).
+5. Users of the chatbot interact with Amazon Lex via the web client UI, [Amazon Alexa](https://developer.amazon.com/en-US/alexa) or [Amazon Connect](https://aws.amazon.com/connect/).
-6. Amazon Lex forwards requests to the `Bot Fulfillment` AWS Lambda function. Users can also send requests to this Lambda function via [Amazon Alexa](https://developer.amazon.com/en-US/alexa) devices.
+6. Amazon Lex forwards requests to the `Bot Fulfillment` AWS Lambda function. Users can also send requests to this Lambda function via Amazon Alexa devices.
-7. The `Bot Fulfillment` AWS Lambda function takes the users input and uses [Amazon Comprehend](https://aws.amazon.com/comprehend/) and [Amazon Translate](https://aws.amazon.com/translate/) (if necessary) to translate non-English requests to English and then looks up the answer in in Amazon OpenSearch Service. If using LLM features such as [text generation](docs/LLM_Retrieval_and_generative_question_answering/README.md) and [text embeddings](docs/semantic_matching_using_LLM_embeddings/README.md), these requests will first pass through various ML models hosted on Amazon SageMaker to generate the search query and embeddings to compare with those saved in the question bank on OpenSearch.
+7. The `Bot Fulfillment` AWS Lambda function takes the users input and uses [Amazon Comprehend](https://aws.amazon.com/comprehend/) and [Amazon Translate](https://aws.amazon.com/translate/) (if necessary) to translate non-Native Language requests to the Native Language selected by the user during the deployment and then looks up the answer in in Amazon OpenSearch Service. If using LLM features such as [text generation](docs/LLM_Retrieval_and_generative_question_answering/README.md) and [text embeddings](docs/semantic_matching_using_LLM_embeddings/README.md), these requests will first pass through various ML models hosted on Amazon SageMaker to generate the search query and embeddings to compare with those saved in the question bank on OpenSearch.
8. If an [Amazon Kendra](https://aws.amazon.com/kendra/) index is [configured for fallback](docs/Kendra_Fallback_README.md), the `Bot Fulfillment` AWS Lambda function forwards the request to Kendra if no matches were returned from the OpenSearch question bank. The text generation LLM can optionally be used to create the search query and to synthesize a response given the returned document excerpts.
@@ -96,13 +96,186 @@ If you have an existing stack you can run the following to update your stack:
npm run update
```
+## Testing
+
+### Running Unit Tests
+
+To run unit tests execute the following command from the root folder:
+
+```shell
+npm test
+```
+
+To update the test snapshots when modifying the /website or /templates directory, execute the following command:
+
+```shell
+npm run test:update:snapshot
+```
+
+### Running Regression Tests
+
+**NOTE: Running regression tests will create, modify, and delete content and settings from the Content Designer. Only run regression tests on non-production bots where loss or modification of content and settings is acceptable.**
+
+This runs integration tests against a deployed QnABot deployment in your account. Before running the tests follow the above steps to build and deploy a version or deploy using the template from the QnABot landing page: [Launch QnABot](https://docs.aws.amazon.com/solutions/latest/qnabot-on-aws/step-1-launch-the-stack.html).
+
+
+
+1. Start from the /.nightswatch directory:
+```bash
+cd .nightswatch
+```
+
+2. Install the dependencies of the automated testing:
+
+```bash
+brew install python@3
+brew install geckodriver
+brew install --cask chromedriver
+pip3 install virtualenv
+```
+
+3. Set up a virtual environment for testing, and install the project dependencies into it.
+
+```bash
+python3 -m virtualenv venv
+source ./venv/bin/activate
+pip install -r requirements.txt
+```
+
+4. Ensure you are logged in to the AWS CLI.
+
+Set the following environment variables to point to the a QnA Bot deployment under test:
+
+```bash
+export CURRENT_STACK_REGION=''
+export CURRENT_STACK_NAME=''
+export EMAIL='
+```
+
+Optionally provide a username and password for an Admin user to test with. If these environment variables are not set then a default 'QnaAdmin' user will be created during the initial test. If you want to run a specific test then provide a username since the default user will only be created in the initial test.
+
+```bash
+export USER=''
+export PASSWORD=''
+```
+
+If you'd like to launch the browser while running tests then also set the below env variable:
+
+```bash
+export HEADLESS_BROWSER='false'
+```
+
+If you'd like to see to start and end time for each test:
+
+```bash
+export TIMESTAMPS='true'
+```
+
+5. The LLM and Kendra tests will only run if the deployed bot has these features enabled. Follow the steps in the Implementation Guide to enable these features to test them:
+ - LLM
+ - Set LLMApi to SAGEMAKER. For more information, please [Enabling LLM support](https://docs.aws.amazon.com/solutions/latest/qnabot-on-aws/enabling-llm-support.html). If stack update fails, check your quota for __ml.g5.12xlarge for endpoint__ usage as mentioned in the note of this article.
+ - Kendra
+ - Create an index and note the Index ID. For IAM role, you can create a custom new role for this from the dropdown. [Creating an index](https://docs.aws.amazon.com/kendra/latest/dg/create-index.html)
+ - Update deployed stack's parameter DefaultKendraIndexId with Index ID created in the previous step.
+
+6. Run the regression tests from within the test folder:
+
+```bash
+cd functional
+pytest -v
+```
+
+## Publishing
+Power users interested in releasing a custom QnABot can use the following instructions for publishing the deployment artifacts available to external users.
+
+Create an S3 bucket to host the templates from (see $DIST_OUTPUT_BUCKET below). You will also need regional buckets for each region your users will deploy from. The regional buckets must be named $DIST_OUTPUT_BUCKET-$AWS_REGION. You will need to provide appropriate access permissions to the buckets for your targeted users. Please refer to the below links for buckets security and access control best practices:
+- https://docs.aws.amazon.com/AmazonS3/latest/userguide/access-control-block-public-access.html#access-control-block-public-access-policy-status
+- https://docs.aws.amazon.com/AmazonS3/latest/userguide/access-control-best-practices.html
+- https://docs.aws.amazon.com/AmazonS3/latest/userguide/security.html
+
+**NOTE: All buckets must have versioning enabled, otherwise the stack will fail to deploy.**
+
+Set the following environment variables for your custom QnABot:
+
+```shell
+export DIST_OUTPUT_BUCKET=''
+export SOLUTION_NAME=''
+export VERSION=''
+export AWS_REGIONS=("us-east-1" "us-west-2" "ap-southeast-1" "ap-southeast-2" "ca-central-1" "eu-west-1" "ap-northeast-1" "eu-central-1" "eu-west-2" "ap-northeast-2")
+```
+
+The above variables will determine the bucket URL path where your bot will be hosted from. The AWS_REGIONS array is a list of all regions QnABot supports. The list can be modified as necessary if your bot version will not be deployed in certain regions.
+
+Run the following commands to upload the current local version to the specified bucket:
+```shell
+cd deployment
+./build-s3-dist.sh $DIST_OUTPUT_BUCKET $SOLUTION_NAME $VERSION
+aws s3 cp global-s3-assets/ s3://$DIST_OUTPUT_BUCKET/$SOLUTION_NAME/$VERSION/ --recursive --acl bucket-owner-full-control
+```
+
+Create S3 buckets for each region if they do not already exist. These buckets will need to be configured for public use:
+```shell
+for region in "${AWS_REGIONS[@]}";
+do
+ if aws s3api head-bucket --bucket "$DIST_OUTPUT_BUCKET-$region" 2>/dev/null
+ then
+ echo "Bucket exists: s3://$DIST_OUTPUT_BUCKET-$region"
+ else
+ aws s3api create-bucket --bucket "$DIST_OUTPUT_BUCKET-$region"
+ echo "Created bucket: s3://$DIST_OUTPUT_BUCKET-$region"
+ fi
+done
+```
+
+Run the below command for each region:
+```shell
+for region in "${AWS_REGIONS[@]}";
+do
+ if aws s3api head-bucket --bucket "$DIST_OUTPUT_BUCKET-$region" 2>/dev/null
+ then
+ aws s3 cp regional-s3-assets/ s3://$DIST_OUTPUT_BUCKET-$region/$SOLUTION_NAME/$VERSION/ --recursive --acl bucket-owner-full-control
+ else
+ echo "Bucket not found: s3://$DIST_OUTPUT_BUCKET-$region"
+ fi
+done
+
+```
+
+The template can be deployed from the following URL for all regions:
+```shell
+echo https://$DIST_OUTPUT_BUCKET.s3.amazonaws.com/$SOLUTION_NAME/$VERSION/qnabot-on-aws-main.template
+```
+
+### Publishing best practices
+1. Never overwrite a published artifact when it is available to external users. Increment the version to upload new artifacts and keep previous versions immutable. Enable bucket versioning to recover artifacts. You may also decide to have a 'latest' version which will always contain the latest version of your bot.
+1. After uploading the artifacts, deploy and test from the S3 URL before releasing to any external users.
+
+## Run Webpack in Development Mode
+
+In order to run Webpack in Development Mode, make sure to have the following
+- Existing deployment of QnABot on AWS
+
+Navigate to the root directory of QnABot (directory will be created once you have cloned this repo).
+
+```shell
+npm install
+```
+
+Next, assign the environment variable, `ASSET_BUCKET_NAME` located in package.json in the npm script `dev mode`. This is the name of the bucket QnABot loads ./website assets to and is usually named \-bucket-\.
+
+Once set up correctly, run
+```shell
+npm run dev-mode
+```
+
+This should set Webpack to development mode and upload assets in ./website/build to `ASSET_BUCKET_NAME`. This will also watch for any changes in ./website and reload assets into your bucket if the assets change.
+
## Designer UI Compatibility
Currently the only browsers supported are:
- Chrome
- Firefox
- We are currently working on adding Microsoft Edge support.
## Built With
@@ -126,14 +299,15 @@ As QnABot evolves over the years, it makes use of various services and functiona
_Note: **Deployable solution versions** refers to the ability to deploy the version of QnABot in their AWS accounts. **Actively supported versions** for QnABot is only available for the latest version of QnABot._
### Deployable Versions
-
+- [v5.5.0](https://github.com/aws-solutions/qnabot-on-aws/releases/tag/v5.5.0) - [Public](https://solutions-reference.s3.amazonaws.com/qnabot-on-aws/v5.5.0/qnabot-on-aws-main.template)/[VPC](https://solutions-reference.s3.amazonaws.com/qnabot-on-aws/v5.5.0/qnabot-on-aws-vpc.template)
+ - _Vue has been upgraded from Vue 2 to 3. We highly recommend to use or upgrade to this version due to Vue 2 reaching End of Life (EOL), which affects all previous versions of QnABot. For more information, see [below](#upcomingrecent-deprecations)._
- [v5.4.5](https://github.com/aws-solutions/qnabot-on-aws/releases/tag/v5.4.5) - [Public](https://solutions-reference.s3.amazonaws.com/qnabot-on-aws/v5.4.5/qnabot-on-aws-main.template)/[VPC](https://solutions-reference.s3.amazonaws.com/qnabot-on-aws/v5.4.5/qnabot-on-aws-vpc.template)
- - _For those upgrading from `v5.4.X` to later versions, if you are upgrading from a deployment with LLMApi set to SAGEMAKER then set this value to DISABLED before upgrading. After upgrading, return this value back to SAGEMAKER.._
+ - _For those upgrading from `v5.4.X` to later versions, if you are upgrading from a deployment with LLMApi set to SAGEMAKER then set this value to DISABLED before upgrading. After upgrading, return this value back to SAGEMAKER._
- [v5.4.4](https://github.com/aws-solutions/qnabot-on-aws/releases/tag/v5.4.4) - [Public](https://solutions-reference.s3.amazonaws.com/qnabot-on-aws/v5.4.4/qnabot-on-aws-main.template)/[VPC](https://solutions-reference.s3.amazonaws.com/qnabot-on-aws/v5.4.4/qnabot-on-aws-vpc.template)
- [v5.4.3](https://github.com/aws-solutions/qnabot-on-aws/releases/tag/v5.4.3) - [Public](https://solutions-reference.s3.amazonaws.com/qnabot-on-aws/v5.4.3/qnabot-on-aws-main.template)/[VPC](https://solutions-reference.s3.amazonaws.com/qnabot-on-aws/v5.4.3/qnabot-on-aws-vpc.template)
- - _We do not recommend to use this version due to a potential issue with the testall functionality which may introduce a high number of versions stored in the testall S3 bucket. Please use the latest version available or v5.4.4+_
+ - _We do not recommend to use this version due to a potential issue with the testall functionality which may introduce a high number of versions stored in the testall S3 bucket. Please use the latest version available._
- [v5.4.2](https://github.com/aws-solutions/qnabot-on-aws/releases/tag/v5.4.2) - [Public](https://solutions-reference.s3.amazonaws.com/qnabot-on-aws/v5.4.2/qnabot-on-aws-main.template)/[VPC](https://solutions-reference.s3.amazonaws.com/qnabot-on-aws/v5.4.2/qnabot-on-aws-vpc.template)
- - _We do not recommend to use this version due to a potential issue with the testall functionality which may introduce a high number of versions stored in the testall S3 bucket. Please use the latest version available or v5.4.4+_
+ - _We do not recommend to use this version due to a potential issue with the testall functionality which may introduce a high number of versions stored in the testall S3 bucket. Please use the latest version available._
- [v5.4.1](https://github.com/aws-solutions/qnabot-on-aws/releases/tag/v5.4.1) - [Public](https://solutions-reference.s3.amazonaws.com/qnabot-on-aws/v5.4.1/qnabot-on-aws-main.template)/[VPC](https://solutions-reference.s3.amazonaws.com/qnabot-on-aws/v5.4.1/qnabot-on-aws-vpc.template)
- [v5.4.0](https://github.com/aws-solutions/qnabot-on-aws/releases/tag/v5.4.0) - [Public](https://solutions-reference.s3.amazonaws.com/qnabot-on-aws/v5.4.0/qnabot-on-aws-main.template)/[VPC](https://solutions-reference.s3.amazonaws.com/qnabot-on-aws/v5.4.0/qnabot-on-aws-vpc.template)
- _Note: Lambda Runtimes have been updated this release. Solution now uses: [nodejs18 and python3.10]_
@@ -153,8 +327,10 @@ _Note: **Deployable solution versions** refers to the ability to deploy the vers
### Undeployable Versions
- All solutions less than `v5.2.1` are no longer deployable due to Lambda Runtime deprecations.
-- **Upcoming/Recent deprecations**
- - nodejs16 will enter [Phase 1 deprecation](https://docs.aws.amazon.com/lambda/latest/dg/lambda-runtimes.html#runtime-support-policy) on Mar 11, 2024.
+
+### Upcoming/Recent deprecations
+- nodejs16 will enter [Phase 1 deprecation](https://docs.aws.amazon.com/lambda/latest/dg/lambda-runtimes.html#runtime-support-policy) on Mar 11, 2024.
+- Vue 2 will reach [End of Life](https://v2.vuejs.org/lts/) (EOL) on December 31st, 2023.
### Why would a solution version no longer be deployable?
For QnABot, the most common reason is due to [AWS Lambda Runtimes being deprecated](https://docs.aws.amazon.com/lambda/latest/dg/lambda-runtimes.html#runtime-support-policy). When a Lambda runtime has been marked as deprecated, customers can no longer create new Lambda functions in their AWS account. This means that older versions of our solutions that make use of those runtimes will fail to deploy. This makes it hard for the community to provide support as we are unable to deploy a similar environment to investigate issues and reproduce bug reports.
diff --git a/bin/build.js b/bin/build.js
index 88ef8da6c..d83460854 100755
--- a/bin/build.js
+++ b/bin/build.js
@@ -14,9 +14,7 @@
process.env.AWS_PROFILE = require('../config.json').profile;
process.env.AWS_DEFAULT_REGION = require('../config.json').profile;
-const aws = require('aws-sdk');
const chalk = require('chalk');
-aws.config.region = require('../config.json').region;
const stringify = require('json-stringify-pretty-compact');
const check = require('./check');
const fs = require('fs').promises;
diff --git a/bin/check.js b/bin/check.js
index 9d690b033..19fb92603 100755
--- a/bin/check.js
+++ b/bin/check.js
@@ -17,12 +17,11 @@ const fs = require('fs').promises;
process.env.AWS_PROFILE = config.profile;
process.env.AWS_DEFAULT_REGION = config.profile;
-const aws = require('aws-sdk');
-aws.config.region = require('../config.json').region;
+const { CloudFormationClient, ValidateTemplateCommand, DescribeStacksCommand } = require('@aws-sdk/client-cloudformation');
+const { S3Client, PutObjectCommand } = require('@aws-sdk/client-s3');
const { region } = require('../config.json');
-
-const cf = new aws.CloudFormation();
-const s3 = new aws.S3();
+const cf = new CloudFormationClient({ region });
+const s3 = new S3Client({ region });
const name = require('./name');
module.exports = run;
@@ -77,19 +76,21 @@ async function run(stack, options = {}) {
const Key = `${prefix}/templates/${stack}.json`;
const TemplateURL = `https://${Bucket}.s3.${region}.amazonaws.com/${Key}`;
console.log(TemplateURL);
- await s3.putObject({ Bucket, Key, Body: template }).promise();
- return cf.validateTemplate({ TemplateURL }).promise();
+ const putCmd = new PutObjectCommand({Bucket, Key, Body:template});
+ await s3.send(putCmd);
+ const validateCmd = new ValidateTemplateCommand({ TemplateURL })
+ return cf.send(validateCmd);
}
- return cf.validateTemplate({
- TemplateBody: template,
- }).promise();
+ const validateCmd = new ValidateTemplateCommand({ TemplateBody:template })
+ return cf.send(validateCmd);
}
async function bootstrap() {
const outputs = {};
- const tmp = await cf.describeStacks({
- StackName: name('dev/bootstrap', {}),
- }).promise();
+ const describeCmd = new DescribeStacksCommand({
+ StackName:name("dev/bootstrap",{})
+ })
+ const tmp = await cf.send(describeCmd);
tmp.Stacks[0].Outputs.forEach((x) => outputs[x.OutputKey] = x.OutputValue);
return outputs;
diff --git a/bin/check_bucket_ownership.js b/bin/check_bucket_ownership.js
index f9aabf8cd..6b7a4040b 100755
--- a/bin/check_bucket_ownership.js
+++ b/bin/check_bucket_ownership.js
@@ -12,14 +12,17 @@
*********************************************************************************************************************/
const commander = require('commander');
-const aws = require('aws-sdk');
+const { region } = require('../config.json');
+const { S3Client, HeadBucketCommand } = require('@aws-sdk/client-s3');
+const { STSClient, GetCallerIdentityCommand } = require('@aws-sdk/client-sts');
async function getAccountId() {
let statusCode;
let account_id = '';
try {
- const sts = new aws.STS();
- const identity = await sts.getCallerIdentity().promise();
+ const sts = new STSClient({ region });
+ const command = new GetCallerIdentityCommand();
+ const identity = await sts.send(command);
account_id = identity.Account;
statusCode = 200;
} catch (error) {
@@ -42,8 +45,9 @@ async function checkBucketOwner(bucket) {
Bucket: bucket,
ExpectedBucketOwner: accountResp.account_id,
};
- const s3 = new aws.S3();
- await s3.headBucket(params).promise();
+ const s3 = new S3Client({ region });
+ const command = new HeadBucketCommand(params);
+ await s3.send(command);
console.info(`Bucket ownership validation for bucket ${bucket} passed`);
} catch (error) {
resp = { statusCode: error.statusCode };
diff --git a/bin/config.js b/bin/config.js
index 79800e327..606e827ec 100644
--- a/bin/config.js
+++ b/bin/config.js
@@ -17,8 +17,11 @@ module.exports = {
publicBucket: 'aws-bigdata-blog',
publicPrefix: 'artifacts/aws-ai-qna-bot',
devEmail: '',
+ ApprovedDomain: 'NONE',
+ Username: 'Admin',
devEncryption: 'ENCRYPTED',
devPublicOrPrivate: 'PRIVATE',
+ devLanguage: 'English',
namespace: 'dev',
LexBotVersion: 'LexV2 Only',
LexV2BotLocaleIds: 'en_US,es_US,fr_CA',
@@ -29,8 +32,17 @@ module.exports = {
buildType: 'Custom',
FulfillmentConcurrency: 1,
EmbeddingsApi: 'SAGEMAKER',
- QASummarizeApi: 'SAGEMAKER',
+ LLMApi: 'SAGEMAKER',
InstallLexResponseBots: true,
+ DefaultKendraIndexId: '',
+ devElasticSearchNodeCount: 1,
+ XraySetting: 'FALSE',
+ EmbeddingsLambdaArn : '',
+ LLMSagemakerInstanceType: 'ml.g5.12xlarge',
+ LLMLambdaArn: '',
+ ElasticSearchInstanceType: 'm6g.large.search',
+ VPCSubnetIdList: '',
+ VPCSecurityGroupIdList: '',
};
if (require.main === module) {
diff --git a/bin/exports.js b/bin/exports.js
index 7e8317126..5c6db5504 100755
--- a/bin/exports.js
+++ b/bin/exports.js
@@ -15,18 +15,19 @@ const config = require('../config.json');
process.env.AWS_PROFILE = config.profile;
process.env.AWS_DEFAULT_REGION = config.profile;
-const aws = require('aws-sdk');
-aws.config.region = require('../config.json').region;
+const { CloudFormationClient, ListExportsCommand, DescribeStacksCommand } = require('@aws-sdk/client-cloudformation');
+const region = require('../config.json').region;
const name = require('./name');
const launch = require('./launch');
const _ = require('lodash');
-const cf = new aws.CloudFormation();
+const cf = new CloudFormationClient({ region });
module.exports = _.memoize(async (stack, options = {}) => {
if (!stack) {
const exports = {};
- const listExportsData = await cf.listExports().promise();
+ const listExportsCmd = new ListExportsCommand();
+ const listExportsData = await cf.send(listExportsCmd);
listExportsData.Exports.forEach((exp) => exports[exp.Name] = exp.Value);
return exports;
}
@@ -41,9 +42,10 @@ module.exports = _.memoize(async (stack, options = {}) => {
next();
async function next() {
try {
- const stackResult = await cf.describeStacks({
+ const describeCmd = new DescribeStacksCommand({
StackName: name(stack, {}),
- }).promise();
+ });
+ const stackResult = await cf.send(describeCmd);
const stackStatus = stackResult.Stacks[0].StackStatus;
if (['CREATE_COMPLETE',
'UPDATE_COMPLETE',
@@ -65,7 +67,8 @@ module.exports = _.memoize(async (stack, options = {}) => {
} catch (x) {
if (x.message.match(/does not exist/)) {
await launch.sure(stack, { wait: true });
- const stackResult = await cf.describeStacks({ StackName: name(stack, {}) }).promise();
+ const describeCmd = new DescribeStacksCommand({ StackName: name(stack, {}) });
+ const stackResult = await cf.send(describeCmd);
res(stackResult);
} else {
throw x;
diff --git a/bin/launch.js b/bin/launch.js
index 10b55c859..32b88e85f 100755
--- a/bin/launch.js
+++ b/bin/launch.js
@@ -16,20 +16,21 @@ const config = require('../config.json');
process.env.AWS_PROFILE = config.profile;
process.env.AWS_DEFAULT_REGION = config.profile;
-const aws = require('aws-sdk');
-aws.config.region = require('../config.json').region;
+const { CloudFormationClient, CreateStackCommand, UpdateStackCommand, DescribeStacksCommand, DeleteStackCommand } = require('@aws-sdk/client-cloudformation');
+const { S3Client, PutObjectCommand } = require('@aws-sdk/client-s3');
+
const { region } = require('../config.json');
const _ = require('lodash');
const fs = require('fs');
-const cf = new aws.CloudFormation();
+const cf = new CloudFormationClient({ region });
const build = require('./build');
const check = require('./check');
const argv = require('commander');
const name = require('./name');
const wait = require('./wait');
-const s3 = new aws.S3();
+const s3 = new S3Client({ region });
if (require.main === module) {
const args = argv.version('1.0')
@@ -127,28 +128,32 @@ async function up(stack, options) {
let create;
if (Buffer.byteLength(template) < 51200) {
- create = await cf.createStack({
+ const createCmd = new CreateStackCommand({
StackName,
Capabilities: ['CAPABILITY_NAMED_IAM', 'CAPABILITY_AUTO_EXPAND'],
DisableRollback: true,
TemplateBody: template,
- }).promise();
+ });
+ create = await cf.send(createCmd);
} else {
const exp = await bootstrap();
const bucket = exp.Bucket;
const prefix = exp.Prefix;
const url = `https://${bucket}.s3.${region}.amazonaws.com/${prefix}/templates/${stack}.json`;
- await s3.putObject({
+ const params = {
Bucket: bucket,
Key: `${prefix}/templates/${stack}.json`,
Body: template,
- }).promise();
- create = await cf.createStack({
+ };
+ const putCmd = new PutObjectCommand(params)
+ await s3.send(putCmd);
+ const createCmd = new CreateStackCommand({
StackName,
Capabilities: ['CAPABILITY_NAMED_IAM', 'CAPABILITY_AUTO_EXPAND'],
DisableRollback: true,
TemplateURL: url,
- }).promise();
+ });
+ create = await cf.send(createCmd);
}
log(`stackname: ${StackName}`, options);
@@ -175,27 +180,33 @@ async function update(stack, options) {
const template = fs.readFileSync(`${__dirname}/../build/templates/${stack}.json`, 'utf-8');
let start;
if (Buffer.byteLength(template) < 51200) {
- start = await cf.updateStack({
+ const updateParams = {
StackName,
Capabilities: ['CAPABILITY_NAMED_IAM', 'CAPABILITY_AUTO_EXPAND'],
TemplateBody: template,
- }).promise();
+ };
+ const updateCmd = new UpdateStackCommand(updateParams);
+ start = await cf.send(updateCmd);
} else {
const exp = await bootstrap();
const bucket = exp.Bucket;
const prefix = exp.Prefix;
const url = `https://${bucket}.s3.${region}.amazonaws.com/${prefix}/templates/${stack}.json`;
console.log(url);
- await s3.putObject({
+ const params = {
Bucket: bucket,
Key: `${prefix}/templates/${stack}.json`,
Body: template,
- }).promise();
- start = await cf.updateStack({
+ };
+ const putCmd = new PutObjectCommand(params)
+ await s3.send(putCmd);
+ const updateParams = {
StackName,
Capabilities: ['CAPABILITY_NAMED_IAM', 'CAPABILITY_AUTO_EXPAND'],
TemplateURL: url,
- }).promise();
+ };
+ const updateCmd = new UpdateStackCommand(updateParams)
+ start = await cf.send(updateCmd);
}
const result = await start;
log(`stackname: ${StackName}`, options);
@@ -216,13 +227,15 @@ async function down(stack, options) {
return;
}
try {
- const down = await cf.describeStacks({
+ const describeCmd = new DescribeStacksCommand({
StackName,
- }).promise();
+ });
+ const down = await cf.send(describeCmd);
const id = down.Stacks[0].StackId;
- await cf.deleteStack({
+ const deleteCmd = new DeleteStackCommand({
StackName: id,
- }).promise();
+ });
+ await cf.send(deleteCmd);
if (options.wait) {
return wait(stack, {
Id: id,
@@ -242,7 +255,8 @@ async function sure(stack, options = {}) {
const StackName = options.stackName ? options.stackName : name(stack);
log(`making sure stack ${stack} is up`, options);
try {
- await cf.describeStacks({ StackName }).promise();
+ const describeCmd = new DescribeStacksCommand({ StackName });
+ await cf.send(describeCmd);
await wait(stack, { show: options.interactive && !options.silent });
log(`${stack} is up as ${StackName}`, options);
} catch (e) {
@@ -262,9 +276,10 @@ function log(message, options) {
async function bootstrap() {
const outputs = {};
- const tmp = await cf.describeStacks({
+ const describeCmd = new DescribeStacksCommand({
StackName: name('dev/bootstrap', {}),
- }).promise();
+ });
+ const tmp = await cf.send(describeCmd);
tmp.Stacks[0].Outputs.forEach((x) => outputs[x.OutputKey] = x.OutputValue);
return outputs;
}
diff --git a/bin/license.js b/bin/license.js
index 6575cd140..1c5880393 100755
--- a/bin/license.js
+++ b/bin/license.js
@@ -12,44 +12,55 @@
* and limitations under the License. *
*********************************************************************************************************************/
-const Promise = require('bluebird');
-const fs = Promise.promisifyAll(require('fs'));
-const readdir = Promise.promisify(require('recursive-readdir'));
+const fs = require('fs').promises;
+const util = require('util');
+const readdir = util.promisify(require('recursive-readdir'));
const files = readdir(`${__dirname}/../`)
- .filter((x) => !x.match(/.*node_modules.*/));
+ .then(files => files.filter((x) => !x.match(/.*node_modules.*/)));
-const jsfiles = files.filter((x) => x.match(/.*\.js$/)).tap((x) => console.log(`${x.length} js files`));
-const vuefiles = files.filter((x) => x.match(/.*\.vue$/)).tap((x) => console.log(`${x.length} vue files`));
+const jsfiles = files
+ .then(files => files.filter((x) => x.match(/.*\.js$/)))
+ .then(jsfiles => {
+ console.log(`${jsfiles.length} js files`);
+ return jsfiles;
+ });
+
+const vuefiles = files
+ .then(files => files.filter((x) => x.match(/.*\.vue$/)))
+ .then(vuefiles => {
+ console.log(`${vuefiles.length} vue files`);
+ return vuefiles;
+ });
-Promise.join(
- jsfiles.map(js),
- vuefiles.map(vue),
-).tap(() => console.log('done'));
+Promise.all([
+ jsfiles.then(jsfiles => Promise.all(jsfiles.map(js))),
+ vuefiles.then(vuefiles => Promise.all(vuefiles.map(vue))),
+]).then(() => console.log('done'));
-const license = fs.readFileAsync(`${__dirname}/license.txt`, 'utf8')
+const license = fs.readFile(`${__dirname}/license.txt`, 'utf8')
.then((file) => {
const tmp = file.split('\n');
return tmp.slice(0, tmp.length - 1);
});
function js(name) {
- const source = fs.readFileAsync(name, 'utf8').then((x) => x.split('\n'));
- Promise.join(source, license)
- .spread((file, license) => {
+ const source = fs.readFile(name, 'utf8').then((x) => x.split('\n'));
+ Promise.all([source, license])
+ .then(([file, license]) => {
const position = file[0].match('#!') ? 1 : 0;
if (!source[position + 1].match('Copyright 2017-2017')) {
- return fs.writeFileAsync(name, insert(file, license, position));
+ return fs.writeFile(name, insert(file, license, position));
}
});
}
function vue(name) {
- const source = fs.readFileAsync(name, 'utf8').then((x) => x.split('\n'));
- Promise.join(source, license)
- .spread((file, license) => {
+ const source = fs.readFile(name, 'utf8').then((x) => x.split('\n'));
+ Promise.all([source, license])
+ .then(([file, license]) => {
const position = file.findIndex((x) => x.match('